Event feed connector: the run loop and tier-2 driver (2/3) - #705
Conversation
There was a problem hiding this comment.
Pull request overview
Adds the experimental Go Event Feed reference connector, deterministic conformance infrastructure, WebSocket transport, and checkpoint persistence.
Changes:
- Implements the push/poll state machine, recovery, deduplication, and checkpointing.
- Adds real and fake transports plus tier-2/tier-3 conformance tests.
- Documents the experimental API and architecture exception.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 54 out of 55 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
AGENTS.md |
Registers Event Feed infrastructure. |
CONTRIBUTING.md |
Documents conformance verification. |
go/README.md |
Adds Event Feed usage guidance. |
go/go.mod |
Adds WebSocket dependency. |
go/go.sum |
Locks WebSocket dependency. |
eventfeed/backoff.go |
Implements retry timing. |
eventfeed/backoff_test.go |
Tests retry timing. |
eventfeed/cable.go |
Implements cable framing. |
eventfeed/cable_test.go |
Tests cable framing. |
eventfeed/catchup.go |
Implements catch-up and streaming. |
eventfeed/catchup_test.go |
Tests catch-up behavior. |
eventfeed/checkpoint.go |
Defines checkpoint contracts. |
eventfeed/clock.go |
Implements production timers. |
eventfeed/clock_test.go |
Tests production timers. |
eventfeed/connector.go |
Defines the public connector. |
eventfeed/connector_test.go |
Tests connector construction. |
eventfeed/continuation.go |
Validates continuation URLs. |
eventfeed/dedupe.go |
Implements delivered-ID deduplication. |
eventfeed/dedupe_test.go |
Tests deduplication. |
eventfeed/digest.go |
Implements filter digests. |
eventfeed/digest_test.go |
Tests shared digest vectors. |
eventfeed/doc.go |
Documents the package contract. |
eventfeed/errors.go |
Defines terminal errors. |
eventfeed/errors_test.go |
Tests error taxonomy. |
eventfeed/event.go |
Defines feed events. |
eventfeed/event_test.go |
Tests event decoding. |
eventfeed/export_test.go |
Exposes test-only hooks. |
eventfeed/filestore.go |
Implements file checkpoints. |
eventfeed/filestore_test.go |
Tests file checkpoints. |
eventfeed/filters.go |
Defines filter validation. |
eventfeed/filters_test.go |
Tests filter validation. |
eventfeed/loop.go |
Implements connector lifecycle. |
eventfeed/loop_test.go |
Tests lifecycle behavior. |
eventfeed/reconnect_test.go |
Tests reconnect and staleness. |
eventfeed/recovery.go |
Implements poll recovery. |
eventfeed/recovery_test.go |
Tests recovery paths. |
eventfeed/scenario_conformance_test.go |
Replays conformance fixtures. |
eventfeed/scenario_fixture_test.go |
Decodes fixture contracts. |
eventfeed/scenario_harness_test.go |
Provides scenario harnessing. |
eventfeed/scenario_selftest_test.go |
Tests driver strictness. |
eventfeed/seams.go |
Defines connector seams. |
eventfeed/transport.go |
Enforces cable URL policy. |
eventfeed/transport_contract_test.go |
Defines transport contract tests. |
eventfeed/transport_test.go |
Tests cable URL policy. |
eventfeed/websocket_transport.go |
Implements WebSocket transport. |
eventfeed/websocket_transport_test.go |
Tests real WebSocket behavior. |
eventfeed/feedtest/clock.go |
Adds deterministic virtual time. |
eventfeed/feedtest/clock_test.go |
Tests virtual time. |
eventfeed/feedtest/minter.go |
Adds scripted ticket minting. |
eventfeed/feedtest/minter_test.go |
Tests scripted minting. |
eventfeed/feedtest/polls.go |
Adds scripted polling. |
eventfeed/feedtest/polls_test.go |
Tests scripted polling. |
eventfeed/feedtest/store.go |
Adds scripted checkpoints. |
eventfeed/feedtest/transport.go |
Adds scripted cable transport. |
eventfeed/feedtest/transport_test.go |
Tests scripted transport. |
Suppressed comments (1)
go/pkg/basecamp/eventfeed/filestore.go:230
- The rename is atomic but not durable without syncing the staged file and parent directory. After a system crash, the first checkpoint file can disappear; the next
Loadthen reports Missing and starts at the present, which can skip history rather than merely replay from an older position. SinceSaveand the package advertise durable checkpointing, sync the file before rename and the directory after rename, or stop claiming crash durability and avoid treating disappearance as a safe present entry.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ceb8398f4c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 55 changed files in this pull request and generated no new comments.
Suppressed comments (4)
go/pkg/basecamp/eventfeed/websocket_transport.go:73
- The offered subprotocol is not verified after the handshake. coder/websocket v1.8.15 accepts a 101 response with an empty
Sec-WebSocket-Protocol, so this can return a connection even thoughactioncable-v1-jsonwas never negotiated, contrary to theCableTransportcontract. Checkconn.Subprotocol()and reject/close a missing selection; add a server case that intentionally selects none.
go/pkg/basecamp/eventfeed/catchup.go:302 - This parks an already-observed overflow until the poll returns. SPEC §23 requires semantic signals at the first consumer-context opportunity after the condition arises (with “before the next save” only as the outer bound), so a stalled poll can postpone the handler forever even though this goroutine has received the dropping frame. Dispatch the overflow immediately here; an Accept disposition can continue awaiting the poll, while Terminate should cancel the attempt/poll.
} else if over {
// The buffer, not the socket: the call is unaffected and is
// awaited to completion, and the drop's disposition runs
// before this page's position moves anything durable.
l.deferred = &deferredFrame{item: item, overflow: true}
if l.hooks.frameDeferred != nil {
l.hooks.frameDeferred(true)
}
r := <-done
return r.page, false, r.err
go/pkg/basecamp/eventfeed/websocket_transport.go:73
checkCableURLexplicitly accepts case-insensitive schemes (the new unit test includesWSS://), but this passes that original spelling to coder/websocket. In v1.8.15 its handshake switch recognizes only lowercasews/wss, so a URL accepted by policy fails as a transient dial and is retried indefinitely. Normalize only the scheme before dialing, while leaving the ticket-bearing remainder unchanged.
This issue also appears on line 70 of the same file.
go/pkg/basecamp/eventfeed/websocket_transport.go:183
- This synchronous graceful close can block teardown for several seconds: coder/websocket v1.8.15 waits up to 5 seconds to write the close frame and another 5 seconds for the peer response. Because
disposecalls this before cancelling the attempt, caller cancellation,Connector.Close, terminal outcomes, and reconnects can all stall on a live peer that ignores the handshake. Use a bounded teardown strategy that preserves the required close frame without letting the library's full handshake timeout delay the universal Closed edge.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 55 changed files in this pull request and generated 3 comments.
Suppressed comments (1)
go/pkg/basecamp/eventfeed/catchup.go:504
- A live event admitted by
drainScanis stranded when the buffer was empty at the start of this iteration:batchstays empty, so this returns even though the scan just repopulatedl.buffer. Streaming never drains that buffer, delaying the event until a later repair walk and allowingcaught_up(and a held save) to happen first. Continue the loop when the scan added events.
batch := l.buffer.take()
if out, done := l.drainScan(at, &budget); done {
return out, true
}
if len(batch) == 0 {
return cycleOutcome{}, false
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6eb58e6010
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Round-2 note on the suppressed comments, since three of them are load-bearing and one changed the design. catchup.go:302 (overflow parked until the poll returns) — taken, and it reframed the visible finding above it. Rather than add a third dispatch site I deleted the overflow deferral outright; catchup.go:504 (live event stranded by drainScan) — real, and a defect introduced by my own round-1 change: websocket_transport.go:73 ×2 and :183 — all three taken and in progress: the negotiated subprotocol is never verified after the handshake, a Thanks for putting the sharp ones in the suppressed block; they've been the most useful part of both rounds. |
|
Closing out the remaining suppressed comments from round 2 (02fec5f). Two fixed, one declined with evidence. Unverified subprotocol — real, fixed. Confirmed in the library rather than assumed: Unbounded graceful close — real, fixed. Case-insensitive scheme — not a defect, declined. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 55 changed files in this pull request and generated no new comments.
Suppressed comments (3)
go/pkg/basecamp/eventfeed/catchup.go:158
- A socket outcome deferred while this poll was in flight is skipped when the poll itself fails. For terminal poll branches,
recoverPollcallsdisposeAttempt, which clearsl.deferred; for retryable failures, the deferred frame can remain undispatched through arbitrarily many retries. In particular, aninvalid_event_stream_commandobserved during CatchingUp can be replaced bypoll_failed/authorization_failedor delayed indefinitely, despite SPEC §23 requiring protocol-fatal to terminate from every socket-open state. Dispatch the already-observed socket outcome here before applying poll recovery; a failed poll has no successful page boundary left to finish.
if p.err != nil {
step, out, done := l.recoverPoll(at, cursor, p.err)
go/pkg/basecamp/eventfeed/transport.go:40
- Checking
u.Hostdoes not ensure that the URL has a hostname. For example,wss://:443/cablehasHost == ":443"but an emptyHostname(), so it passes the policy check and is classified as a transient dial failure instead of terminalinvalid_cable_url, causing repeated re-mints/dials for a structurally unusable URL.
go/pkg/basecamp/eventfeed/cable.go:250 - The tier-2 push-event schema requires all nine keys, including presence-bearing
visible_to_clients, and SPEC §23 treats a correlated message missing a required event key as an invalid frame. Omitting this field from the presence checks accepts both an absent value and JSONnull, exposing a nil value on a push event instead of taking the socket-failure recovery edge.
{"creator_id", p.CreatorID != nil},
{"recording_id", p.RecordingID != nil},
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 02fec5ff1f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 55 changed files in this pull request and generated no new comments.
Suppressed comments (1)
go/pkg/basecamp/eventfeed/catchup.go:160
- A deferred socket outcome is skipped when the in-flight poll returns an error.
recoverPollmay retry, increment authorization failures, or terminate, and disposal then clearsl.deferred; this can even swallow an already-observedinvalid_event_stream_commandinstead of producingprotocol_fatal. Since no page succeeded, dispatch the deferred outcome before classifying the poll error (the finish-page ordering only applies to successful pages).
if p.err != nil {
step, out, done := l.recoverPoll(at, cursor, p.err)
if done {
return out, "", true
Holding two findings open deliberately: the in-flight-poll mechanism has drawn four roundsTwo round-4 findings — the suspendable bound in The ledger on one mechanism, in order:
Each fix was principled and each was verified, but four rounds of edge-findings on one structure is evidence about the structure. What they all orbit: the state machine both awaits a poll seam call and services the frame queue during it, parking one out-of-band frame in a single The concurrency is not optional — fixtures 01 and 19 require a live frame admitted after
I lean (a) plus a note, because the deferral that remains is the one SPEC explicitly sanctions (transition 21's deferred consumption) and (b) risks the delivery-ordering guarantees that nine fixtures and the save-ordering invariant pin. But it is a judgment call about this PR's core, so it goes to a human rather than to me. The other three round-4 findings (synchronous cancellation on |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a9103dd83a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 55 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
go/pkg/basecamp/eventfeed/loop.go:152
ctxis the attempt context, which is a child ofrunCtx.Connector.Close(or caller cancellation) therefore cancels the pump immediately, before the state machine can reachdisposeand callconn.Close. The default WebSocket read may abort the socket on that cancellation, so normal shutdown can still produce the abrupt disconnect that the close-before-cancel ordering is meant to prevent. Give the pump a cancellation scope that is canceled only by disposal after the socket close; HTTP seam calls can continue using the attempt context.
go/README.md:615- This says the connector performs no wire I/O, but the package directly performs the sanctioned WebSocket cable dial. That contradicts both the implementation and the architecture rule. Limit the claim to HTTP requests and explicitly name the cable dial exception.
**Experimental: the Layer-1 seam adapters have not landed yet.** The connector performs
no wire I/O of its own — every HTTP exchange reaches the wire through a seam backed by
a generated operation — and the adapters that build those seams over the generated
`CreateStreamTicket` and `PollEvents` operations are still to come. Until they do, a
consumer must supply the `TicketMinter` and `PollSource` implementations itself, and the
exported surface may still change as they land.
go/pkg/basecamp/eventfeed/loop.go:638
- Cancellation is not re-checked after
Load. A custom store that honors the supplied context can returncontext.Canceledwhen the caller cancels or callsClose, and this path then yieldscheckpoint_loadeven though cancellation is documented to end iteration cleanly. Match the mint/poll paths by givingrunCtxcancellation precedence over the load error.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 93a3d5a457
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
93a3d5a to
308df85
Compare
…as the bug
Five of this round's eight findings taken; the notable decline is recorded
at the end.
The continuation validator had the defect validateConfig was cured of two
commits ago, on its other consumer: checkContinuation canonicalized the
server-supplied URL first, and CanonicalOrigin's ToLower rewrites every
invalid byte to U+FFFD — so a continuation host carrying a raw 0xff
collapsed to the same canonical form as a configured origin that
legitimately contains the replacement character (valid UTF-8, so
construction accepts it), and §8 same-origin validation equated two
distinct byte strings on the authenticated path. The raw bytes are refused
before the lossy step; red proof: the collapsed URL was accepted against a
U+FFFD base.
Two waits treated a staleness firing as a verdict where §23 says it is a
wake. In awaitSupersededPoll, the stale=false arm returned superseded on an
authoritative expiry — but a read error does not reset staleness, so the
PRE-EXISTING window, mostly spent when the failure was deferred, abandoned
the in-flight poll almost immediately: a failure landing 7s into a 7.5s
window granted the poll 0.5s of the grace phase §23 measures from the
deferral ("wakes may be early or late, and the deadline is what decides").
The arm now latches through graceWake exactly as the stale caller does, and
the deadline alone ends the wait — red proof: teardown on the old firing,
green: a wake, then teardown only when virtual time reaches
deferral + EVENT_FEED_STALE_AFTER. In waitPollRetry, a select with both
poll-retry and staleness ready could take the retry and start another Poll
on a socket whose expired window was already evidence — and a blocking call
then defers the stale verdict and buys a fresh grace phase, overrunning
§23's published bound ("detection window + grace phase... nothing waits
longer than their sum"). The retry arm now re-checks staleness before
polling; the red proof parks the run goroutine in an overflow handler while
both timers fire, and 17/40 rounds issued the second poll.
Transition 6 was announced late: newLiveConn opens the socket, arms
staleness and starts the pump — the transition's own definition — but the
state stayed Connecting until awaitConfirmation, so Observer.Connected ran
with AwaitingWelcome's timer set {handshake-deadline, staleness} against an
announced state whose set is {handshake-deadline} alone. The announcement
now precedes the callback; red proof: Connected observed "connecting".
Wait could report quiescence for a run that had started: the single-shot
claim was an atomic CompareAndSwap taken BEFORE the mutex section that
publishes runDone, and the claim is observable in that window — a second
consumption yields the usage terminal — while Wait still read nil and
returned. The claim now lives in the same critical section as the
publication, so Wait runs wholly before it (no run to await) or wholly
after runDone exists. No red proof discriminates this one honestly: the
window was two adjacent statements with no seam between them, and a hook
would exist for nothing else — the same account the terminal-claim fix gave
in an earlier round. The existing single-shot and Wait suites hold the
contract on both sides of the change.
AGENTS.md's architecture row still called this package foundations-only
with the run loop pending; this branch is where that stops being true, so
the row now names foundations, the run loop, and the tier-2 driver, with
the Layer-1 adapters and the other SDKs still pending.
Declined: terminating on a post-confirmation reject_subscription. The state
machine draws transition 12 solely from AwaitingConfirmation; §23's "always
terminal — first attempt or reconnect" pins which ATTEMPT a rejection lands
on, not a state-independent verdict. Action Cable rejects only from the
subscribe callback and the connector subscribes exactly once per socket, so
a post-confirmation rejection is an unsolicited frame — and honoring it
would hand one such frame the connector's most severe verdict, ZERO
reconnects, which is precisely what the pre-subscribe gate was added to
prevent in an earlier round. The default arm's liveness-only comment says
this on purpose.
…nt-feed-go-connector * origin/event-feed-foundations: Make the fake transport honour the oversize sentinel it mirrors Police the endpoints a discovered issuer names, not only the issuer (#810) Refuse redirects on the signed download hop in every SDK (#809) Quiet known-noise CodeQL alerts without losing coverage (#807) Judge the advertised OAuth issuer's address, not just its spelling (#804) Let Go's raw GET retry loop see the Retry-After it already parses (#796) Deflake three tests that raced a wall clock, and gate the class that produced two of them (#794) SPEC §6: decide which statuses honour Retry-After, and how each loop composes it (#793) Pin the conformance runners' fixture reads, and give CI a leg that can see them break (#791) Report an anonymous embed the timestamp walk cannot resolve, instead of skipping it (#790)
|
@codex review |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 25 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
go/pkg/basecamp/eventfeed/scenario_conformance_test.go:730
- This boundary is captured too late. For example,
expectPolladvances the pointer and then releases its response; the connector can reach and leave the scripted buffer occupancy beforeexpectPollreturns, after whichfromexcludes that transition and this rendezvous times out despite compliant behavior. Capture the occupancy-history index atomically when the pointer advances onto theexpectBufferedstep, not when its executor is eventually scheduled.
from := len(d.h.occupancyHistory)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a466f3896f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
All five of this round's findings taken — four expired-verdict races in the
run loop's family, and a conformance-driver fidelity hole.
The dial result could outrun the handshake deadline: both ready, the select
random, and accepting the dial installed the pump, armed staleness, and
announced Connected for an attempt whose window had already closed —
transition 7 bypassed by a coin flip. The success path now drains the
deadline before transition 6, as a drain rather than a Stop-probe because
an unfired deadline must keep running to welcome. The red proof fires the
deadline from inside Dial, sequencing it strictly before every dial result,
so any Connected at all is a violation: 1/100 rounds pre-fix, 0 after.
The subscribe write's bounded wait selected only {written, close, phase
deadline}, on the recorded premise that the phase deadline is always the
tighter bound. It is not, twice: staleness (7.5s) undercuts even the
default 10s handshake deadline on an immediate first welcome, and a
duplicate welcome resends under the confirmation deadline, which is
configurable to anything — a dead socket sat blocked in the write
arbitrarily past the expiry rows 9/15 say tears it down. The wait now
carries the staleness case like every other socket-open wait; red proof:
a stalled write plus a fired window produced no StaleConnection within the
watchdog.
Streaming could deliver past a latched expiry: a frame received AFTER the
window fired does not reset it — staleHolder.arm latches, which is §23's
authoritative-firing rule working — but the fired timer and that frame were
then both ready, and the frame arm delivered first. The arm now re-checks
the verdict before delivering, with evaluate as the arbiter: a frame the
pump received first moved the generation and delivers as ever, a latched
expiry takes transition 25 with the frame discarded to the reconnect walk.
Red proof, rounds-driven off a collector parked mid-delivery: 17/40 rounds
delivered event 102 past the latched expiry.
A throttled Retry-After of zero was read as absence. SPEC §6 fixes the
adapter mapping — a retryable outcome whose last response carried a parsed
Retry-After maps to throttled(retry_after) whatever its status, one without
maps to transient — so PollThrottled always carries a server-directed wait,
zero included, waited exactly and cap-exempt. pollRetryDelay is now
kind-keyed; a parsed zero stays zero instead of drawing up to 60s of local
jitter (the red run drew 315ms), and a negative value clamps rather than
arming a negative timer.
The tier-2 driver's arrival-strict lookahead judged expectBuffered against
GLOBAL occupancy history, so occupancy reached and left in a long-departed
era satisfied a pending expectBuffered and let the scan read through to a
following expectCheckpoint — an out-of-order save accepted by the very
instrument that exists to reject it. History is now scoped to an era
boundary captured whenever the step pointer moves (enterStep and the
await advance), and expectBuffered's own rendezvous scans from the same
boundary, which also closes the reached-and-left-before-runStep flake its
run-time capture had. Red proof: the scan judged an arriving save against
step 3 (expectCheckpoint) on stale history. Every committed fixture still
passes under the scoped scan.
…nt-feed-go-connector * origin/event-feed-foundations: Fix the filters-clone comment that stated the opposite of the code Event feed: a policy reason never echoes what the server wrote
|
@codex review |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 27 changed files in this pull request and generated 4 comments.
Suppressed comments (1)
go/pkg/basecamp/eventfeed/scenario_conformance_test.go:1077
- The poll lane has the same status-keyed mapping error: a 429/503 without a parsed header is emitted as
PollThrottledwith zero. With the newpollRetryDelaybranch, that becomes an immediate retry loop instead of transient jitter. SPEC.md:682-687 requiresPollThrottledonly when a usable parsedRetry-Afteris present, regardless of status.
kind := eventfeed.PollTransient
if status == 429 || status == 503 {
kind = eventfeed.PollThrottled
}
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 86ec2cd547
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…o ask it
Four follow-ups taken, one of them a correction to this branch's own
previous round — stated plainly: the round-4 throttled-zero change overshot,
and Copilot caught it.
The two §6 texts compose; they do not conflict. The seam mapping is
presence-keyed — throttled iff the last response carried a PARSED
Retry-After — but "parsed" is defined by §6's parsing algorithm, and that
algorithm cannot yield zero: step 1 requires an integer > 0, step 2 returns
max(0, date − now) only when > 0, and the rounding rationale says why —
"zero is read as 'no usable value' and drops the request onto the local
backoff curve". So a conformant adapter's throttled ALWAYS carries a
positive value, the original value gate honored every one of them exactly,
and keying the delay on the kind bought nothing while turning a
nonconforming adapter's throttled-with-zero into a zero-delay poll-retry —
a tight loop against a server that is throttling the caller. The red run
shows it literally: "poll-retry armed for 0s". pollRetryDelay is reverted
to the value gate with the reconciliation written above it; the round-4
test asserting the wrong contract is replaced by one asserting the jitter
fallthrough. What WAS wrong is the seam doc, which still described both
throttled kinds by status ("429/503") — the exact status-keyed mapping the
§6 sentence exists to forbid — and both docs now state the presence-keyed
definition with the always-positive consequence.
The tier-2 driver had the same status-keying in the flesh, on both lanes: a
schema-valid 500/502/504 carrying Retry-After became transient and its
delay never reached classifyMintFailure's floor, while a bare 429 became a
throttled zero. Both outcome mappers now classify retryable statuses by
presence of the parsed value. And the driver's Retry-After parse was
integer-only — a valid RFC 7231 HTTP-date failed the scenario outright
(red: strconv.Atoi rejecting the date), zero and negatives became
durations. retryAfterFrom now implements §6 against the harness's virtual
clock — integer > 0; else HTTP-date with the sub-second remainder rounded
UP; else undefined — returning presence separately from the value.
The round-3 grace-phase fix also overshot, in the other direction: an
authoritative firing late in the phase re-armed graceWake for a FULL
window, so a firing 1ms short of the deadline pushed the only remaining
wake almost a whole window past it — the wait ended nearly two windows
after the deferral instead of one. graceWake now takes its wake distance
and every caller passes the remainder to its deadline (the full window at
the deferral itself, where they coincide). The new red proof pins the upper
bound the round-3 test could not see: advance to 1ms short, fire, advance
the last 1ms, and demand the teardown there — red sat parked until the
watchdog; green ends at the deadline exactly.
…nt-feed-go-connector * origin/event-feed-foundations: Event feed: the redactor renders configured text or a placeholder, never the server's
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 67e908aa19
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… lost to an announcement All seven of this round's findings taken — four in the loop's established families, three in the README, each verified against the code first. A superseded poll is now cancelled AT the grace deadline, in time and not merely in control flow. Disposal closes the socket gracefully before it cancels — deliberately, so the peer sees a close frame — but a peer that never acknowledges holds that close for the transport's full grace budget, and the abandoned seam call (carrying the caller's bearer) stayed live through it, stalling reconnection past §23's detection-plus-grace bound. The deadline branch cancels the attempt context itself; the disposal that follows finds its own cancel a no-op. The red proof captures the pump's read context and parks the teardown's close: pre-fix the context was still live at the park. AwaitingConfirmation's frame arm gains the latched-expiry drain the Streaming arm got last round — the same shape for a worse consequence: a frame received after the window fired could win the select and either admit a correlated message to the loop-wide buffer, which survives the teardown and resurfaces after reconnect, or hand a confirmation a live catch-up on a socket whose verdict was already in. Rounds red: 11/40 announced Confirmed past a latched expiry. Two Close-outranks-the-announcement gaps, both on the far side of observer callbacks that are documented Close sites. outcomeFailed announced Backoff and asked the product clock for a reconnect timer with runCtx already cancelled by a Close inside Disconnected or StaleConnection; finishDrain continued past CaughtUp into dispatchDeferred and stream, which re-arms the repair cadence through the same product seam and announces Streaming. Both now take the Closed edge first — a host clock that blocks in NewTimer would otherwise keep the iteration and Wait from ever reaching Closed. Red proofs: the state ledger carried "backoff" (and "streaming") after Close returned; green goes straight to closed. Three README corrections, each checked against the code. The terminal-vs- continuable section implied every continuable outcome waits a timer; rejected positions do not — reenterWalk re-polls a replacement cursor immediately on the same socket, and the text now says which failure continues how. The unwrap claim promised a generated error behind every poll_failed; the connector's own verdicts carry none (a 200 whose page has no position), so the claim is now conditional. And the consumer-surface section stated "Close stops the feed" without the lifecycle this branch actually ships: Close only cancels and can return with a save still in flight, Wait is the quiescence point, and a replacement connector opened straight after Close races the prior run's last save — the README now says so where the store guidance lives.
|
@codex review |
…nt-feed-go-connector * origin/event-feed-foundations: Fix three comments still stating rules the code has since outgrown
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.
Suppressed comments (2)
go/pkg/basecamp/eventfeed/catchup.go:373
- Dispatch an already-parked outcome before scanning the socket again. When the in-flight poll returns a successful page with
Next,pollPagemay already have stored a protocol-fatal disconnect inl.deferred;socketCheckcan then consume the pump's later read error/closed channel and end the cycle as a retryable failure before the next iteration reachesprobeFatal. That turns the requiredTerminal(protocol_fatal)into Backoff and loses the first outcome's ordering.
if out, done := l.socketCheck(at); done {
return out, "", true
}
cursor = Cursor{PageURL: page.Next}
go/pkg/basecamp/eventfeed/catchup.go:530
- The fixed grace deadline is not actually enforced for ordinary deferred frames/read errors.
graceWakeis armed only for a deferred staleness verdict, so a frame reset just beforedeadlinecan move the next staleness firing almost a full window later;awaitSupersededPollchecks the deadline only after a wake and therefore remains blocked past it. This contradicts the new SPEC.md:3235-3243 requirement that the grace phase be measured from deferral and be immune to frame resets. Please provide a deadline wake for every deferral (without letting it be mistaken for a new staleness verdict) and cover a reset immediately before the deadline.
if stale {
// The firing this branch consumed was the wait's own wake source,
// and an authoritative expiry latches — so without this there is
// nothing left to wake the grace phase at all, and the bounded wait
// becomes unbounded again. Armed BEFORE the hook, so a test that
// rendezvouses on the deferral cannot advance past a wake that is
// not yet armed. The distance is the remainder to the deadline —
// here, at the deferral itself, the full window.
at.lc.stale.graceWake(l.cfg.staleAfter)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 86dd27a4af
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…nt-feed-go-connector * origin/event-feed-foundations: Event feed: save through a symlinked store path instead of replacing it Event feed: an invalid frame limit fails closed, not open into no limit # Conflicts: # go/pkg/basecamp/eventfeed/seams.go
resolveStorePath's Lstat-error branch intentionally returns the unresolved path with no error — the comment above it says why: the open or rename that follows reports the same failure with better context, where a resolve-time error would name a step the caller never asked about. nilerr cannot read the comment; the annotation repeats it. Foundations carries the same line un-annotated and will want the same fix.
…nt-feed-go-connector * origin/event-feed-foundations: Annotate resolveStorePath's deliberate nilerr fall-through
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 93c714ca1f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 27 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
go/pkg/basecamp/eventfeed/scenario_conformance_test.go:1031
- The driver attaches the parsed header before classifying the status, so scripted 401/403 (and unrecoverable 404/422) mint errors carry
RetryAfter. This contradicts the §23 mapping that only retryable outcomes carry that value and makes tier 2 encode the same unauthorized-backoff bug as the connector. AssignRetryAfteronly in the retryable default branch.
retryAfter, present := d.retryAfterFrom(respond.Headers)
mintErr := &eventfeed.MintError{RetryAfter: retryAfter, Err: fmt.Errorf("mint responded %d", *respond.Status)}
switch *respond.Status {
case 401, 403:
mintErr.Kind = eventfeed.MintUnauthorized
…dy race All six of this round's findings taken. The three dispatch frame arms — AwaitingConfirmation, Streaming, and the poll-retry wait — now let the Closed edge win the both-ready race Close itself creates: cancellation makes the conforming ReadFrame return its cancellation error, the pump hands it off, and the frame case dispatching it reported the consumer's own Close to Observer.Disconnected as a socket failure, after Close had returned. Rounds red: 17/40 fired Disconnected(context canceled) post-Close. A panic in host code — an observer, the signal handler, a checkpoint store, the consumer's range body, all of which run on the run goroutine — unwound with only the run context cancelled: no deferred path closed the CableConn, and a compliant conn is only required to unblock reads on CLOSE, so under an outer recovery the socket stayed open with its pump parked and a later Connector.Close could not reach it (cancelRun cleared on unwind). runCycle now re-disposes the live attempt inside a recover and re-panics; disposal is idempotent, so paths that already disposed are unharmed, and phase timers held as locals are deliberately not chased — unfired, they fire into nothing. Red: the socket stayed open and the staleness timer stayed armed through a recovered panic. The repair-poll arm gains the family's expired-verdict drain: entering the walk made an already-rendered stale verdict look in-flight — pollPage deferred it into a grace phase, and the poll's page could be accepted first — where transition 25 fires directly from every socket-open state. Rounds red: repair polls issued on an expired socket. Both unauthorized paths forwarded a seam-supplied RetryAfter into the reconnect floor. The SPEC's loop table names `unauthorized` "a kind carrying no retry_after" and row 4 says the rest: "the backoff draw alone governs". Both arms drop the field; red on each showed the 5-minute floor verbatim. The tier-2 driver's redirect leg now runs the SHIPPED per-hop predicate. Fixture 30's description promised a sentinel listener the harness never binds, and the driver classified the scripted 302 by an ad-hoc origin comparison — synthesizing the refusal rather than exercising the decision, and turning an unreducible hostile Location into a SCENARIO error instead of a refusal. redirectRefusalFrom now classifies through checkContinuation (exported to the driver as a test shim), so the decision tier 2 pins is the one that ships. What tier 2 cannot see — an adapter's HTTP client auto-following inside one seam call — is below the poll seam by construction, and pretending otherwise was the finding: the fixture's description now claims exactly what the tier verifies (the refusal decision, the continuation terminal, the closed socket, no further seam calls) and names Layer-1 adapter conformance as the owner of the zero- egress property, where the pending adapter PR's sentinel can actually observe a request. The description edit is the only fixture change, one line, and the fixture gate stays green.
|
@codex review |
|
Codex Review: Didn't find any major issues. 🎉 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
go/pkg/basecamp/eventfeed/catchup.go:530
- The fixed grace deadline is only given an immutable wake when the deferred outcome is staleness. For a deferred disconnect/invalid frame, a later frame can reset the ordinary staleness timer near
deadline; therearmedcase wakes before the deadline, then the next wake is delayed untilframeTime + staleAfter, so the abandoned poll can outlive the one-window grace phase. This contradicts SPEC.md:3235-3243 and #758’s accepted “immune to frame resets” criterion. The existingTestGracePhaseIsImmuneToFrameResetsusesstaleDeferralHarness, so it cannot exercise this branch. Please add equivalent coverage for a frame-based deferral and provide a fixed deadline wake for that path without allowingsocketCheckto replace the parked disconnect reason with staleness.
if stale {
// The firing this branch consumed was the wait's own wake source,
// and an authoritative expiry latches — so without this there is
// nothing left to wake the grace phase at all, and the bounded wait
// becomes unbounded again. Armed BEFORE the hook, so a test that
// rendezvouses on the deferral cannot advance past a wake that is
// not yet armed. The distance is the remainder to the deadline —
// here, at the deferral itself, the full window.
at.lc.stale.graceWake(l.cfg.staleAfter)
The Go reference implementation of the SPEC.md §23 Event Feed connector — BC3's
account-wide event feed over Action Cable push plus polling catch-up — and the
tier-2 conformance driver. All 22 fixtures pass (23 with #778's addition).
Layer 2 only: the connector reaches the wire through
TicketMinter/PollSourceseams and one sanctioned cable dial (AGENTS.md Hard Rule 2). TheLayer-1 adapters are deferred to G1b, so the package is experimental and a
consumer supplies the seams today.
What is here
connector.go(New, the options,Events,Close),loop.go(states,transitions, timers, live buffer),
catchup.go(the poll walk, its pageboundary, the drain),
recovery.go(the 400/409/410 matrix), and the tier-2driver with its fixture model, harness and self-tests.
The foundations — seams, wire types, transports, filters, checkpoint identity,
the file store, dedupe, backoff, clock, cable codec,
feedtest/— are #777.Rebase note
The 29 commits were re-cut, not rebased. Rebasing onto #777 was attempted
and abandoned: 8 of them touch only foundation files and would replay empty, and
21 of the remaining 22 mix both halves, so every one conflicts against the four
fixes #777 applies. Resolving 22 interleaved conflicts by hand is a worse
guarantee than re-cutting, which is byte-exact by construction — the foundation
paths come from #777's tip, these 17 from the pre-split head, verified with an
empty
git diffagainst both.The five fixes on top
Blocker 2 — a poll page carrying no position is malformed
The walk took
page.Positionon trust, and an empty one silently skippedhistory in two different ways.
Position-resume:
acceptPosition("")setsl.position = "", andentryCursorselects onl.position != ""— so it does not preserve the oldcursor, it falls through to a bare present entry. The feed resumes at the
server's head with everything between skipped, reporting nothing.
Present-class is worse.
helduses""as its sentinel for "the final entrywas not present-class", so an empty position is not saved as empty — it
collapses into the sentinel, the
held != ""guard skipsacceptPositionandsaveCheckpointoutright, andcaught_upannounces anyway. The position isdiscarded, with the drain's deliveries already handed to the consumer.
Refused before delivery, counter resets, and every mutation. That placement is
what the mutation check exercises: moving the guard after the delivery loop
fails three of four subtests on
delivered ids = [101], want [].Blocker 6 — narrow the promise, and make Wait the quiescence point
Close's doc claimed "no seam call and no delivery can begin after Close hasreturned". That is not true and cannot be made true: the run goroutine checks
its context at each dispatch point and then acts, so a Close landing in between
cannot stop the call from starting — only from starting on a live context.
Closing that window means holding a lock across arbitrary host code, trading a
benign race for a deadlock reachable from any callback.
The one effect that outlives the process is the checkpoint save, and this PR
deliberately does not gate it. An accepted page's position saves even when
Close lands first — its events were already delivered, and dropping the write
would silently re-deliver them — and the save runs under a context detached
from the run's cancellation (
context.WithoutCancel: values kept, cancellationdropped), because a store that honors its
ctxis compliant and wouldotherwise lose the position Close raced. A save decided just before Close can
land just after it; that is intended, not residual.
Ordering a second connector over the same store is therefore the consumer's
to do, and
Waitis the tool: it blocks until the run goroutine has exited, sono save can be in flight — by construction, not by a narrow window. Await the
iterator's termination, or
Wait, before opening a second connector over thesame checkpoint store. (An earlier revision ordered saves through a
durableGateclaimed inside Close and tracked the unclosable [claim, write]residual in #784; the gate is gone,
Waitreplaces it, and #784 is closed withit.)
Also: a cancelled checkpoint load is no longer
Terminal(checkpoint_load).Blocker 7 — observers see origins only
#777's redactor applied to
Observer.Gapand bothCatchUpStartedsites. Anaccepted 410 latches the server's resume URL as reconnect state, so the
reconnect announces its walk carrying it — redacting
Gapalone would have leftthe identical URL leaving through a different callback one reconnect later.
Observer.Disconnectedis redacted, and this paragraph used to say theopposite. The original reasoning — an error is opaque text, and stripping a
credential out of arbitrary text means modelling the credential, which §23's
"opaque bearer" contract forbids — argued for leaving it alone. A later round
found a ticket reaching the callback through the raw seam read error, which no
seam obligation can repair from the connector's side, so both arguments now go
through closed vocabularies:
observableDisconnectReasonmaps everyunrecognized peer reason to
"other", andobservableSocketErrorreduces everycause to one the connector owns, degrading anything unrecognized to
errSocketFailed."Reduces" is load-bearing and was the last hole: matching a sentinel is not the
same as being one, so a seam returning
fmt.Errorf("read %s: %w", cableURL, context.Canceled)matched a recognized arm while its text carried the ticket.Every arm now returns the connector's own value rather than its argument;
errors.Isstill matches, and the wrapper does not survive.The seam obligation remains on
Dial,ReadFrameandWriteFrame— it is whatkeeps a preserved typed error safe — but the connector no longer depends on it
being honored.
#763 — staleness arms at socket open
Observer.Connectedfired between the socket opening and the window thatmeasures silence on it. Whatever a host's callback spent was time the window
never counted. Observed from inside the callback, which is the only place the
ordering is visible.
#760 — an occupied deferral slot no longer blinds the drain's fatal scan
drainScanreturned the moment it found the slot occupied, on the reasoningthat everything queued "arrived behind this one". That is a claim about arrival
order, where §23's carve-out is a claim about which verdict governs. With
any non-fatal outcome parked ahead of it the scan looked at nothing — and the
budget it runs under is
pumpDepth+1, sized indrain's own comment to reachevery frame the pump had already read. An occupied slot spent none of it.
The planned fix was a bounded deferral queue carved out of
pumpDepth; thatturned out to be unnecessary. Only one deferral is ever dispatched —
pumpExited,dispatchDisconnectand the invalid-frame teardown all end thecycle — so a queue would be a buffer sized for a delivery that cannot happen.
The scan keeps the first outcome and discards the rest as it passes them, which
removes the capacity question entirely: no share of
pumpDepth, no channelresize, no change to the published memory bound, no fixture sweep.
The new subtest needs both halves of the two around it: deferring during the
entry poll occupies the slot, and queuing the fatal frame mid-drain puts it
where only the scan can find it. Queued earlier, the ownership cut consumes it
first — which is how the first draft passed against un-fixed code.
On #758/#759: I could not reproduce the missing-wake-source hang. Every path
leaves a wake. The overshoot is real (close to two staleness windows) and
provably cannot exceed two, so the bound is documented in place rather than
patched, and carried to bc3 as an open §23 contract question.
Verification
Pristine worktree, one pass, clean tree before and after:
go build/go vet/-race -count=1/-count=5— passmake go-lint0 issues;gosecon the CI-pinned v2.23.0 (hash-verified) 0 issuesmake check— exit 0TestWalkFailureBetweenPagesboth subtests — the invariant that killed thereviewers' one-liner survives
-race: 0 failures, 0 data races, re-earnedbecause this PR rewrites the files that wait
Kill-matrix correction
Row 15's claim was inherited from the family README and is wrong; #778
corrects it. Tier 2 cannot prove zero egress to a foreign redirect target,
because the driver is the seam and manufactures the verdict. That is a
Layer-1 property, tracked for G1b.