Skip to content

fix(session,server): lock-safe Session.Messages access + 409-busy guard (#3590)#3606

Draft
aheritier wants to merge 8 commits into
mainfrom
fix/3590-session-messages-locking
Draft

fix(session,server): lock-safe Session.Messages access + 409-busy guard (#3590)#3606
aheritier wants to merge 8 commits into
mainfrom
fix/3590-session-messages-locking

Conversation

@aheritier

@aheritier aheritier commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #3590Session.mu is documented to protect Messages, but several
hot paths read/iterated sess.Messages without the lock, racing with HTTP
AddMessage/compaction that mutate a live session. Risks: data races,
wrong SessionPosition in emitted events, and index-out-of-range panics if
compaction truncates concurrently.

What changed

Lock-safe accessors (pkg/session)

  • Session.AddMessage now returns the appended item's index, computed under
    the same write lock as the append (no TOCTOU) — callers no longer do a racy
    len(sess.Messages)-1.
  • Session.ItemCount() — locked len(Messages).
  • Session.MessagesSnapshot() — exported, deep-copying snapshot (mirrors the
    existing snapshotItems precedent: copy under lock, release, then act).

Call sites fixed

  • pkg/runtime/loop.go (steer/follow-up/initial UserMessage emission)
  • pkg/runtime/runtime.go (session-restore LastMessage reconstruction)
  • pkg/runtime/compactor/compactor.go (firstKeptSessionIndex boundary)
  • pkg/server/session_manager.go ForkSession — walks a locked snapshot
    instead of the live shared *Session.Messages
  • pkg/session/branch.go — branch/fork paths take the RLock like Clone

409-busy guard

AddMessage/UpdateMessage now reuse the existing activeRuntimes.streaming
mutex + ErrSessionBusy sentinel that RunSession already uses, held across
the full mutation
so a stream cannot start between the busy check and the
write. Coverage was extended to attached/TUI direct-RunStream ownership
(new app.WithStreamGuard), which previously bypassed the guard. Handlers map
ErrSessionBusy409 Conflict, mirroring runAgent.

Compaction snapshot consistency

Session.CompactionInput now returns the snapshot's own item count, threaded
through to the out-of-range sentinel so it always describes the compaction
snapshot rather than a later live length.

Testing

New -race regression tests across pkg/session, pkg/runtime,
pkg/runtime/compactor, pkg/server, pkg/app — covering concurrent
AddMessage/branch/fork vs mutation, EmitStartupInfo vs AddMessage, the
attached-stream 409 guard (blocking-store, both AddMessage and UpdateMessage),
and the compaction snapshot-count invariant. All verified to fail against the
pre-fix code and pass now.

  • task dev (build + full tests + lint + 19 custom cops): green
  • go test -race -count=2 ./pkg/session/... ./pkg/runtime/... ./pkg/server/... ./pkg/app/...: green
  • Validated in isolation (detached checkout), not just the merged workspace.

Notes

pkg/runtime/loop.go, runtime.go, and compactor.go read sess.Messages (or
len(sess.Messages)) directly in several hot paths, racing a concurrent
HTTP AddMessage or compaction on the same live session and risking a wrong
UserMessageEvent.SessionPosition or an index-out-of-range panic:

- appendSteerAndEmit and the follow-up injection path in loop.go now use
  AddMessage's returned index instead of a separate len(sess.Messages)-1
  read.
- The initial-turn UserMessage emission in loop.go and firstKeptSessionIndex
  in compactor.go now use sess.ItemCount() instead of len(sess.Messages).
- EmitStartupInfo's session-restore LastMessage reconstruction in
  runtime.go now iterates a MessagesSnapshot() instead of sess.Messages.

Refs #3590
InMemorySessionStore.GetSession returns the live, shared *Session pointer,
not a copy. userMessageOrdinalToItemIndex iterated s.Messages directly to
translate a user-message ordinal into an item index, racing a concurrent
AddMessage on that same session (e.g. the HTTP AddMessage handler racing a
TUI fork action) and risking an index computed against a torn read.

It now walks s.MessagesSnapshot() instead.

Refs #3590
session.Session.mu now makes AddMessage/branch/fork race-free against a
live RunStream, but a message injected or edited mid-stream (mid-tool-call
in particular) can still desynchronize the in-flight turn from what the
model/tools expect. As defense in depth on top of the locking, reject the
mutation outright while the session has an active stream.

The check reuses the same activeRuntimes.streaming lock RunSession already
uses to serialize concurrent RunSession calls (TryLock/Unlock), so it can
never race a stream that starts between the check and the mutation: both
require sm.mux, which AddMessage/UpdateMessage hold for their entire body.
The existing ErrSessionBusy sentinel (already mapped to 409 for runAgent)
is reused and now also returned by AddMessage/UpdateMessage; server.go maps
it to 409 Conflict for both endpoints.

Documents the two message endpoints (previously missing from the API
reference) and their new 409 behavior.

Refs #3590
…rship

AddMessage/UpdateMessage/RunSession detect an active stream via
activeRuntimes.streaming, but that lock was only ever acquired by
RunSession itself. Runtimes registered via AttachRuntime (the TUI's
--listen control plane and local recall coordinator) stream directly
through pkg/app.App.Run/Retry/RunWithMessage, which called
runtime.RunStream without ever touching that lock, so a REST
AddMessage/UpdateMessage (or a second RunSession) during a genuinely
active attached/TUI stream wrongly succeeded instead of 409ing.

AttachRuntime now returns the same lock (sync.Locker) RunSession and
AddMessage/UpdateMessage already TryLock. A new app.WithStreamGuard
option lets the App hold it for the duration of every direct RunStream
call, acquired before mutating the session and released only once the
stream ends, mirroring RunSession's own ordering. cmd/root wires this
option into both the --listen and local-recall paths.

Adds SessionManager and App-level regression tests: driving a real
attached stream through the returned lock (not through RunSession) now
gets AddMessage/UpdateMessage to correctly return ErrSessionBusy, and a
blocking-runtime App test pins that the lock is held for the stream's
actual duration, not just while scheduling it.

Refs #3590
…own snapshot count

gatherCompactionInput took a locked snapshot via Session.CompactionInput,
but firstKeptSessionIndex's out-of-range sentinel (the 'compact
everything, keep nothing' boundary) called sess.ItemCount() again
instead of reusing that snapshot's own length. ItemCount is lock-safe
on its own, but it can observe an AddMessage/ApplyCompaction that
landed after CompactionInput's snapshot was taken, so the sentinel
could describe a longer session than the one messages/sessIndices
actually cover, breaking the invariant that messages appended after
the snapshot stay in the kept tail.

CompactionInput now returns the snapshot's own item count alongside
messages and sessIndices; gatherCompactionInput and
firstKeptSessionIndex thread that count through instead of re-querying
the live session, so the sentinel always describes the SAME snapshot
the split index was computed against.

Adds a deterministic test (no goroutines/-race needed: this was a
logic error, not a data race) that gathers the compaction input, lets
a message race in afterwards, and asserts the out-of-range sentinel
still matches the original snapshot count rather than the now-longer
live session. Also replaces the now-obsolete
TestFirstKeptSessionIndexConcurrent (firstKeptSessionIndex no longer
touches the session) with TestGatherCompactionInputConcurrent, which
keeps -race coverage on gatherCompactionInput's own snapshot read.

Refs #3590
…tion

AddMessage/UpdateMessage TryLock activeRuntimes.streaming to reject a
mutation while a stream is active, but released the lock immediately
after the check, before performing the append/update. Holding sm.mux
for the rest of the method does not close the gap for attached
runtimes (see AttachRuntime): App.acquireStreamGuard only ever
acquires streaming, never sm.mux, so an attached stream could start
after the busy check passed but before/during the REST mutation.

Both methods now hold streaming via defer until the mutation (store
write, plus the in-memory session update for AddMessage) has fully
completed. TryLock stays non-blocking, so a busy REST call still
returns immediately instead of waiting on an in-progress attached
stream, and the attached guard's plain Lock() cannot deadlock against
it.

Adds a deterministic blocking-store regression test (mirroring the
reviewer's TestReview_AttachedGuardCannotStartBetweenBusyCheckAndMutation
probe) for both AddMessage and UpdateMessage: a fake store blocks
mid-write so the test can assert the attached-stream guard stays
blocked until the mutation returns. Confirmed both tests fail without
the defer fix and pass with it.

Refs #3590
TestApp_Run_HoldsStreamGuardForStreamDuration declares 'var guard
sync.Mutex' but the file never imported sync, so the package failed to
compile (pkg/app/app_test.go:169:12: undefined: sync). task dev and
the -race suite could not even build.

Refs #3590
@aheritier aheritier added area/api For features/issues/fixes related to the usage of the cagent API area/cli CLI commands, flags, output formatting area/docs Documentation changes area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection area/sessions For features/issues/fixes related to session lifecycle (resume, persistence, export) kind/fix PR fixes a bug (maps to fix:). Use on PRs only. labels Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api For features/issues/fixes related to the usage of the cagent API area/cli CLI commands, flags, output formatting area/docs Documentation changes area/runtime Runtime engine, agent loop execution, tool dispatch, loop detection area/sessions For features/issues/fixes related to session lifecycle (resume, persistence, export) kind/fix PR fixes a bug (maps to fix:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(session): lock-safe access to Session.Messages in hot paths + reject mutation during active stream

1 participant