Repository navigation
Description
Summary
The /v1/events resume contract — "a subscriber that missed events is told so, rather than silently given a stream with a hole in it" — breaks in two shapes on the same code path, so a resumable stream can be silently incomplete or silently mispositioned:
- BUG-A (bus): an explicit
since=0resume never reports a gap, even after the retained history has been trimmed. The gap check inSubscribeFromis gated onsince > 0, so the one resume position that asks for everything from the start is the one position that can lose events 1..N with nogapsignal. - BUG-B (daemon): a malformed
since/Last-Event-IDis silently coerced to one of two opposite positions.handleEventsdiscards thestrconv.ParseUinterror, so a syntax error behaves exactly likesince=0(full retained replay) and an out-of-range value behaves likesince=MaxUint64(no replay, no gap, live-only) — neither is rejected and neither is signalled.
Evidence (verified on main d39e6111)
BUG-A
pkgs/mcpx/internal/events/events.go (sha256 370a935a…):
- The package's promise,
New(:~155): the history "is bounded… a subscriber gone longer than the history covers is told so rather than silently given a gap." SubscribeFrom's own doc (:206-208): "gap is true when since predates the retained history, so the subscriber knows it missed something and can resynchronise instead of trusting a stream with a hole in it."- The implementation (:218) is narrower than both docs:
if since > 0 && len(h) > 0 && h[0].Seq > since+1 {
gap = true
}- The daemon deliberately distinguishes position zero from absent (
internal/daemon/server.go:1264-1265): "Present-but-zero replays everything retained; absent is live only", and emits the in-bandevent: gapframe (:1277-1280) only whenSubscribeFromreturnsgap=true. - So: daemon has published more than
eventHistory(default 1024) events, a client connects with?since=0(or an SSE reconnect sendsLast-Event-ID: 0), events 1..(first retained − 1) are gone, the replay starts mid-history, and no gap frame is emitted. The existing tests pin the neighbouring cases but not this one:TestAGapBeyondHistoryIsReportedRatherThanHiddenusessince=1;TestAReplayLargerThanTheSubscriberBufferMissesNothingusessince=0with everything still retained (h[0].Seq == 1, where the uniform formulah[0].Seq > since+1also yields no gap).
Premise exercised this run (faithful mirror of the gap condition + retained()/trimming, probe-2026-10-28-laneA-eventsgap/premise.py in the fleet's hidden files): keep=3, seq 1..10 → retained [8,9,10]; CTRL since=1 → gap=true; CTRL since=0 with nothing trimmed → gap=false, all replayed; BUG since=0 after trim → replay [8,9,10], gap=false — events 1..7 silently missing.
BUG-B
pkgs/mcpx/internal/daemon/server.go:1262-1265:
raw := firstNonEmptyStr(r.Header.Get("Last-Event-ID"), q.Get("since"))
since, _ := strconv.ParseUint(raw, 10, 64)
// Present-but-zero replays everything retained; absent is live only.
sub, gap := s.Events.SubscribeFrom(f, since, raw != "")The error is discarded. Per the installed Go stdlib source (strconv/atoi.go, ParseUint: a syntax error returns 0, a range error returns maxVal):
since value |
effective position | result |
|---|---|---|
banana, -1 |
0 (syntax error, discarded) | full retained replay — as if the client asked from the start |
99999999999999999999999999 |
MaxUint64 (range error, discarded) | no replay, no gap — live-only forever, client believes it is caught up at a position that can never exist |
Neither outcome is a 400, a gap frame, or a log line. Last-Event-ID is normally server-generated, but since is a documented query parameter of this endpoint (handler comment, server.go:~1243), so hand-written clients — the ones most likely to send a malformed value — get one of two silently opposite streams.
Disclosure
A Go probe covering BUG-A (probe_lanea_test.go, same hidden-files folder) was prepared but not executed: the laptop was unreachable this run (SSH tunnel closes immediately — the ongoing outage, cf. #1667) and verification probes are never compiled on the sandbox. Triage should run the probe first and narrow this issue if either traced verdict differs. BUG-A is a line-trace + premise-mirror verification; BUG-B additionally rests on the Go stdlib source read this run.
Why it matters
The gap signal is the only mechanism a /v1/events consumer has to know it must resynchronise instead of trusting the stream. BUG-A removes it for exactly the fresh-start resume (new client, reset cursor, Last-Event-ID: 0) once a busy daemon has rolled its 1024-event history — elicitation and server-lifecycle consumers then act on a partial history believing it complete. BUG-B makes a client bug (a bad cursor value) undetectable from either side: the daemon cannot log it and the client cannot distinguish the response from a legitimate one.
Acceptance criteria
-
SubscribeFrom(f, 0, true)on a bus whose retained history starts at seq > 1 returnsgap=true; with nothing trimmed it still returnsgap=false(existing tests keep passing, plus a new regression test for the trimmed zero case). -
GET /v1/events?since=0after the daemon's history has rolled emits the in-bandevent: gapframe. - A malformed
since/Last-Event-ID(non-numeric, negative, or out of uint64 range) is rejected (e.g. 400) or otherwise explicitly signalled — never silently treated as 0 or MaxUint64; a test pins each malformed class. - The
SubscribeFromdoc comment and the implementation agree on whengapis set, for since = 0, since inside the retained window, and since beyond it.
Filed by the fleet issue creator (lane A) with file:line evidence, premise log, and a prepared-but-unexecuted Go probe; see the agent note for the packet/state details.
Activity
dezren39 commentedon Oct 28, 2026
🤖 Agent note · filed
32 remaining items
dezren39 commentedon Oct 31, 2026
🤖 Agent note · parked
dezren39 commentedon Oct 31, 2026
🤖 Agent note · picked up
fix(mcpx): events resume — since=0 gap after trim + 400 on malformed …
dezren39 commentedon Oct 31, 2026
🤖 Agent note · done