Description
Follow-up from #6661 (fixed in PR #6665). PromptChannelGuard::drop (crates/zeph-acp/src/agent/turn.rs) writes the receiver it holds back into entry.output_rx unconditionally, keyed only by session_id, looked up fresh via self.state.sessions.lock().get(&self.session_id) at drop time.
If a session is reloaded/resumed mid-turn — do_load_session (session.rs:440) or do_resume_session (session.rs:828) both insert() a fresh SessionEntry over the same SessionId — the guard's Drop will still find an entry under that id and overwrite its (new, live) output_rx with the old, now-dead receiver from the turn that was in flight before the reload. The reloaded session then ends up draining a receiver whose sender belongs to an agent loop that no longer exists.
This is a widening of a pre-existing behavior (the original success-path restore had the same unconditional write), not a new bug introduced by #6661's fix — but #6661 widens the exposure window because the abort/cancel path now also restores, and that window is asynchronous with respect to when .abort() is called, giving more opportunity for a do_load_session/do_resume_session race to land inside it.
Expected Behavior
PromptChannelGuard::drop should only restore into entry.output_rx if the entry it finds is still the same session generation/instance the guard was originally constructed against — not any entry that merely shares the same SessionId string.
Suggested Fix Direction
Add a generation counter (or an Arc/pointer identity check) to SessionEntry, captured by the guard at construction time, and compared before writing back in Drop. If the generation doesn't match, skip the restore (the reloaded session already owns a fresh, valid output_rx).
Environment
Logs / Evidence
Found during independent adversarial critique of PR #6665 (finding M2). See PR #6665 review discussion for the full analysis.
Description
Follow-up from #6661 (fixed in PR #6665).
PromptChannelGuard::drop(crates/zeph-acp/src/agent/turn.rs) writes the receiver it holds back intoentry.output_rxunconditionally, keyed only bysession_id, looked up fresh viaself.state.sessions.lock().get(&self.session_id)at drop time.If a session is reloaded/resumed mid-turn —
do_load_session(session.rs:440) ordo_resume_session(session.rs:828) bothinsert()a freshSessionEntryover the sameSessionId— the guard'sDropwill still find an entry under that id and overwrite its (new, live)output_rxwith the old, now-dead receiver from the turn that was in flight before the reload. The reloaded session then ends up draining a receiver whose sender belongs to an agent loop that no longer exists.This is a widening of a pre-existing behavior (the original success-path restore had the same unconditional write), not a new bug introduced by #6661's fix — but #6661 widens the exposure window because the abort/cancel path now also restores, and that window is asynchronous with respect to when
.abort()is called, giving more opportunity for ado_load_session/do_resume_sessionrace to land inside it.Expected Behavior
PromptChannelGuard::dropshould only restore intoentry.output_rxif the entry it finds is still the same session generation/instance the guard was originally constructed against — not any entry that merely shares the sameSessionIdstring.Suggested Fix Direction
Add a generation counter (or an
Arc/pointer identity check) toSessionEntry, captured by the guard at construction time, and compared before writing back inDrop. If the generation doesn't match, skip the restore (the reloaded session already owns a fresh, validoutput_rx).Environment
Logs / Evidence
Found during independent adversarial critique of PR #6665 (finding M2). See PR #6665 review discussion for the full analysis.