get_history blocks for the whole turn when its try_lock probe races the turn task (TOCTOU on the agent mutex)
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:
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_lockfails → persisted fallback → reply in ~25 ms. Correct. - The request loop reaches
get_historyfirst →try_locksucceeds (guard immediately dropped byis_err()) →send_historycallslock().await→ blocks until the turn finishes. Thehistoryreply is written after the turn'sdone.
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):
send_messageon a session.- Immediately
get_historyon the same connection. - Repeat ~20 times against a session with a non-trivial history. Some fraction of the
historyreplies arrive only after the turn'sdone, withagent_lock=<turn length>insend_history prep.
Suggested fix
Make the probe and the lock the same operation, so the decision cannot go stale:
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-sdk1.1.0 client) - Same code on
mainat5f33d62(client_state.rs:125,client_state.rs:621,client_lifecycle.rs:3584)
Source: 1jehuang/jcode