#1284·jcode

get_history blocks for the whole turn when its try_lock probe races the turn task (TOCTOU on the agent mutex)

Author: guyb1Created Sep 16, 2026Updated Sep 16, 2026

Summary

handle_get_history (crates/jcode-app-core/src/server/client_state.rs) decides between the fast persisted-snapshot path and the full send_history path with a try_lock() probe on the agent mutex, then drops that guard and has send_history re-acquire the mutex with lock().await:

rust
if agent.try_lock().is_err() {
    send_history_from_persisted_session(...)   // no lock, ~25 ms
    ...
    return Ok(());
}
send_history(...)                              // inside: let agent_guard = agent.lock().await;

process_message_streaming_mpsc holds that same mutex for the entire turn (let mut agent = agent.lock().await; ... run_once_streaming_mpsc(...).await).

So when a client sends message immediately followed by get_history (the documented pattern for a harness-API client that needs a history baseline for the turn it just started), the outcome depends on which task reaches the mutex first:

  • The spawned turn task locks first → try_lock fails → persisted fallback → reply in ~25 ms. Correct.
  • The request loop reaches get_history first → try_lock succeeds (guard immediately dropped by is_err()) → send_history calls lock().awaitblocks until the turn finishes. The history reply is written after the turn's done.

That is a TOCTOU on the probe: "not busy" is only true for the instant of the probe, and the code then takes a blocking lock as though the probe were still valid.

Impact

The get_history reply arrives after the turn's terminal done. A client that uses get_history as a "frames after this reply are mine" barrier (the OneCLI supervisor does exactly this, to fence out frames from concurrent daemon-started turns) misclassifies every frame of its own turn, including the terminal, and waits forever. Observed in production three times over three days on v0.81.1; each affected turn had actually completed in 8 to 15 s.

Measured rate from the daemon logs of the affected sessions: 3 of 18 turns took the blocked path (~17%). The daemon logs it as:

SERVER_REQUEST_HANDLER_STALLED ... request_kind=get_history ... message_id=14 ... elapsed_ms=2001
...
[TIMING] send_history prep: ... agent_lock=14495ms ...
[TIMING] handle_get_history: session=..., send_history=14500ms, ...

Even without a barrier-style client, a get_history that silently takes as long as the turn is surprising for any UI (the TUI's own history refresh would sit behind a long tool run).

Reproduction (deterministic enough)

Any harness-API client, v0.81.1 or current main (the probe and the blocking lock are still there at client_state.rs:125 and :621):

  1. send_message on a session.
  2. Immediately get_history on the same connection.
  3. Repeat ~20 times against a session with a non-trivial history. Some fraction of the history replies arrive only after the turn's done, with agent_lock=<turn length> in send_history prep.

Suggested fix

Make the probe and the lock the same operation, so the decision cannot go stale:

rust
match agent.try_lock() {
    Ok(guard) => send_history_with_guard(guard, ...).await,   // pass the guard down
    Err(_)    => send_history_from_persisted_session(...).await,
}

Or, if send_history must keep its own signature, replace the inner agent.lock().await with a bounded tokio::time::timeout(Duration::from_millis(50), agent.lock()) and fall back to the persisted snapshot on Err(Elapsed).

Either way get_history should never block for the duration of a turn; the persisted snapshot exists precisely for the busy case.

Environment

  • jcode v0.81.1 (cae6d2a57), Linux aarch64, harness API over the Unix socket (@1jehuang/jcode-sdk 1.1.0 client)
  • Same code on main at 5f33d62 (client_state.rs:125, client_state.rs:621, client_lifecycle.rs:3584)