Repository navigation
bug(server): WatchSandbox can hand out a resume cursor past an event it never delivered or reported #4829
Description
User Story
I use OpenShell's WatchSandbox to follow sandbox logs and platform events together, through the SDK watch helpers that reconnect from the highest cursor received. I implemented the loss-aware resume contract in #3209 and the TypeScript (#3778), Go (#3662) and Python (#3711) helpers. During review of #3209, @varshaprasad96 found two ways the replay can give a client a cursor that skips an event it never received. We agreed they predate #3209 but only become visible through its resume contract, and I said I would file this follow-up for them. The SDK helpers and the planned E2E coverage depend on them being fixed.
Problem Statement
When a watch follows both logs and platform events, its replay comes from two buffers that share one cursor space. A client that receives cursor N assumes every retained event up to N was either delivered or reported, and reconnects with resume_after_cursor = N. Two cases on main (39d95b42) break that assumption.
1. Tail depths differ between sources. This is deterministic and happens with the defaults.
The initial tail takes up to log_tail_lines logs (default 200) and event_tail platform events (default 0) independently. The batch can then contain logs on both sides of a platform event that the platform tail left out. For example, with history log 1, platform event 2, log 3 and the default depths, the client receives logs 1 and 3 but not event 2, with no warning. Its cursor is now past event 2, so resuming will never deliver it.
2. The two buffers are read at different moments. This is a race.
Both the initial tail and the resume replay read the log buffer and the platform buffer under separate locks (resume: crates/openshell-server/src/grpc/sandbox.rs:2128-2142). An event published between the two reads can be left out:
- Read the log replay. It ends at cursor 9.
- Log 10 is published, then platform event 11.
- Read the platform replay. It includes 11.
- The client receives 11 and disconnects before log 10 arrives live.
- The client resumes after 11. Log 10 is still retained but is never delivered.
In both cases the client gets neither a SandboxStreamWarning nor OUT_OF_RANGE.
Impact / Why This Matters
- The resume contract promises that every gap is either replayed or reported. These cases lose events silently.
- Case 1 needs no race: any watch following both sources with the default
event_tailcan hit it once a sandbox has platform events between log lines. - The SDK helpers reconnect automatically from the highest cursor received, so callers can't detect the gap or work around it.
- Workaround: none that keeps resume. A caller can avoid
resume_after_cursorand restart from a full tail after every disconnect, but it still can't tell what it missed.
Acceptance Criteria
- When a watch follows both sources, every event between the oldest and newest cursor in the initial batch is delivered. If events must be held back to guarantee this, a
SandboxStreamWarningreports how many, per source. - An event published while the gateway builds the initial tail or the resume replay is either delivered in that batch or reported. It never arrives live after a higher cursor from the batch.
- A client that reconnects from the highest cursor it received never misses a retained event below that cursor without a warning or
OUT_OF_RANGE. - Single-source watches (for example
openshell logs --tail N) return the same events as today. - Regression tests cover differing tail depths (including equal depths over differently positioned histories) and a publish between the two buffer reads on both the initial and resume paths.
- The
WatchSandboxfield docs anddocs/observability/accessing-logs.mdxexplain when fewer events than requested are returned and what the warning means.
Reproduction Steps
Case 1:
- Create a sandbox and let it produce a log line, then a platform event (for example a policy update or a lifecycle change), then another log line.
- Call
WatchSandboxfor it withfollow_logsandfollow_eventsset andlog_tail_linesandevent_tailleft at their defaults. - The initial batch contains both log lines but not the platform event between them, and no warning.
Case 2 is a race between gateway tasks, so it can't be triggered reliably from the CLI. Forcing a publish between the two buffer reads in a unit test shows the loss deterministically. #3613 adds tests for both cases.
Environment
- OpenShell: built from source,
mainat39d95b42. The code path is unchanged since fix(api): make WatchSandbox loss-aware and resumable #3209 merged. - OS: Omarchy (Arch Linux), kernel 7.2.5, x86_64
- Deployment: found through source review and unit tests. The behavior is in the gateway's
WatchSandboxreplay, so it applies to any gateway and compute driver.
Logs
None. Neither case produces a log line or a warning, which is the problem.
Activity
letv1nnn commentedon Oct 22, 2026
Assessment: case 1 reproduced on
main; case 2 confirmed in source, not reproduced. This was run from the issue author's account, so it is added evidence, not an independent disposition. A maintainer decides acceptance.Case 1 (default tail depths), reproduced. A temporary unit test on
main(39d95b42), next to the existing watch tests incrates/openshell-server/src/grpc/sandbox.rs, seeds log (cursor 1), platform event (cursor 2), log (cursor 3), then callshandle_watch_sandboxwithfollow_logsandfollow_eventsset and both tail depths left at their defaults (log_tail_linesfalls back to 200,event_tailstays 0):mainlog@1,log@3. No warning.main23744fee)SandboxStreamWarning(1 log line withheld), thenlog@3On #3613 the delivered window no longer spans the platform event the shallower tail left out. The withheld log is disclosed in a warning instead of silently placed behind it.
Case 2 (publish between the two buffer reads): confirmed in source, not reproduced. On
mainthe resume path reads the two buses under separate locks (grpc/sandbox.rs:2128-2142), and the initial tail does the same. Forcing the interleaving needs a test seam between the reads. #3613 addssnapshot_after/snapshot_tail, which take both reads under the cursor-space lock, plus tests for it.Duplicates and ownership: this is the follow-up promised in #3209 (comment of 2026-09-17), split out of the stale-closed #3055, which was never accepted. Fix in #3613 by the issue author. No other open issue covers it.
Next human decision: whether to accept this issue, which #3613 needs in order to pass
PR Issue Acceptance.