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
…nt-feed-go-connector * origin/event-feed-foundations: Negotiate the subprotocol case-sensitively
|
@codex review |
|
Codex Review: Didn't find any major issues. Nice work! 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". |
| l.disposeAttempt(at, nil) | ||
| return walkStep{}, cycleOutcome{kind: outcomeTerminal, term: &TerminalError{ | ||
| Reason: ReasonInvalidContinuation, | ||
| Msg: "the poll refused a cross-origin redirect", |
| if page.Next == "" { | ||
| return cycleOutcome{}, held, false | ||
| } | ||
| // Transition 21 from inside the walk: the page boundary is where a | ||
| // socket that died — or went stale — during the previous seam call is | ||
| // observed. Following `next` on a dead socket would walk the whole | ||
| // frozen head before noticing, delaying the reconnect cycle by the | ||
| // length of the walk. | ||
| if out, done := l.socketCheck(at); done { | ||
| return out, "", true | ||
| } | ||
| cursor = Cursor{PageURL: page.Next} |
…nt-feed-go-connector * origin/event-feed-foundations: Event feed: pin the write path's peer-close interleaving as a tripwire Event feed: Save refuses what Load could never detect Event feed: a server selecting an unoffered subprotocol is policy, not weather
|
@codex review |
|
Codex Review: Didn't find any major issues. Nice work! 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 1 comment.
Suppressed comments (1)
go/pkg/basecamp/eventfeed/scenario_conformance_test.go:1029
RetryAfteris populated before the status is classified, so 401/403 and 404/422 responses produceunauthorized/unrecoverableseam errors carrying a retry delay. SPEC.md:3087 and 3568-3574 defineretry_afteronly forthrottledand explicitly sayunauthorizedcarries none. Assign the field only in the throttled branch (and update the contradictory comment above) so the tier-2 adapter model matches the seam contract.
mintErr := &eventfeed.MintError{RetryAfter: retryAfter, Err: fmt.Errorf("mint responded %d", *respond.Status)}
| // Where it is exposed, stated exactly, because the obvious guess is wrong: | ||
| // a dial failure does NOT reach Observer.Disconnected. There is no socket | ||
| // yet, so there is no teardown to report. A DialPolicy failure becomes | ||
| // Terminal(invalid_cable_url) and is yielded as the iteration's terminal | ||
| // error — the consumer's own error value, which is a stronger exposure | ||
| // than a callback, not a weaker one. Every other kind takes transition 7 | ||
| // to backoff, where the connector reports the classification and drops the | ||
| // cause entirely. So the obligation above is not softened by the callback | ||
| // never firing; it is what keeps a credential out of the error a caller | ||
| // receives from Events. |
|
@codex review |
…nt-feed-go-connector * origin/event-feed-foundations: Event feed: the cable transport never consults a proxy Event feed: a positive repair interval stays positive after jitter Event feed: the store gates its keys on the way in, and its bytes on the way down # Conflicts: # go/pkg/basecamp/eventfeed/backoff_test.go
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)
go/pkg/basecamp/eventfeed/catchup.go:370
- Dispatch the parked socket outcome before checking staleness here.
pollPagereplaces the staleness timer with the grace wake and latchesexpired; if delivery,PageDelivered, or the checkpoint save lasts until that wake fires,socketCheckconsumes it as a new stale verdict before the next loop iteration reachesdispatchDeferred. On a page withNext, this can turn an already-deferredinvalid_event_stream_commandinto a reconnect instead ofTerminal(protocol_fatal)(and similarly misreport other deferred outcomes). Probe and dispatch the deferral at this page boundary before running the ordinary socket check.
if out, done := l.socketCheck(at); done {
…nt-feed-go-connector * origin/event-feed-foundations: Skip the directory sync on Windows, and drop a helper the lint flagged
|
@codex review |
…nt-feed-go-connector * origin/event-feed-foundations: Compare the refused selection exactly too
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 (5)
go/pkg/basecamp/eventfeed/catchup.go:631
- The result arm bypasses the fixed grace deadline. If the poll result and deadline wake are both ready, Go may select this arm and accept the page even though the deadline has elapsed, making the published abandonment bound nondeterministic. Recheck the clock in this arm so the deadline wins regardless of select ordering.
case r := <-done:
return pollAttempt{page: r.page, err: r.err}
go/pkg/basecamp/eventfeed/scenario_conformance_test.go:1032
- This attaches
RetryAfterto 401/403 outcomes, but §23 definesMintUnauthorizedas carrying no retry-after; the driver is supposed to model the Layer-1 seam contract. Parse and assign the header only for the retryable default branch (which also avoids attaching it to terminal 404/422 outcomes).
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
go/pkg/basecamp/eventfeed/scenario_conformance_test.go:1019
- This comment contradicts the §23 contract: unauthorized mint outcomes carry no retry-after, so row 4 must use only the local backoff draw. Update it alongside the classification fix to avoid documenting the opposite behavior.
// drop it at another. Unauthorized keeps a parsed value too — row 4 floors
// the below-threshold reconnect delay on it.
go/pkg/basecamp/eventfeed/connector.go:598
- The “safe from anywhere else” claim is too broad.
Waitalso self-deadlocks when called from a host seam method the run is awaiting (for exampleTicketMinter.Mint,PollSource.Poll, or a checkpoint-store call), even though those are not consumer callbacks andPollmay run on another goroutine. Document that callers must use an independent coordinator rather than any code the connector is waiting to return.
// It is not callable from a consumer callback. Every callback — an observer, a
// signal handler, the loop body — runs ON the run goroutine, so waiting for
// that goroutine from inside one waits for itself. Close is the call that is
// safe from anywhere; this is the one that is safe from anywhere ELSE.
SPEC.md:3029
- This normative restriction is narrower than the actual deadlock boundary. A host seam method can call
wait()while the run is awaiting that method, creating the same cycle even though it is not a consumer callback. Define the rule in terms of any execution the connector is waiting to return so all SDKs document the same safe usage.
`wait()` is not callable from a consumer callback, for the reason `close()` does not wait.
|
@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 1 comment.
Suppressed comments (4)
go/pkg/basecamp/eventfeed/catchup.go:523
- This frame arm does not recheck the staleness timer after
selectchooses a ready frame. If the deadline fired first but the frame became ready too,staleHolderlatches the expiry, yet this path admits the frame into the loop-wide buffer before the next iteration observes staleness; that event can survive teardown and be delivered after reconnect.loop.go:1136-1155explicitly guards the identical race inawaitConfirmation. Apply the same authoritative-expiry arbitration here before callingadmitDuringPoll.
case item, ok := <-at.lc.frames:
if !ok {
l.deferred = &deferredFrame{closed: true}
} else {
handled, out, ended := l.admitDuringPoll(at, item)
go/pkg/basecamp/eventfeed/catchup.go:792
- When
pollPagealready consumed and parked an authoritative staleness expiry, the new grace timer is not ready here, so this probe falls through toadmissionPass. A queued event received after the expiry is then admitted before the next loop iteration dispatches the deferred stale outcome, allowing it to survive reconnect. Skip normal admission when a stale outcome is already deferred (while still preserving the protocol-fatal scan that outranks it).
staleTimer, staleGen := at.lc.stale.current()
select {
case <-staleTimer.C():
if age, ok := at.lc.stale.evaluate(staleGen); ok {
l.disposeAttempt(at, nil)
if l.cfg.observer.StaleConnection != nil {
l.cfg.observer.StaleConnection(age)
}
l.observeDisconnected("", errStaleConnection)
return cycleOutcome{kind: outcomeFailed}, true
}
default:
}
return l.admissionPass(at)
go/pkg/basecamp/eventfeed/catchup.go:1005
fatalScanalso admits ordinary live events even when the occupied deferral is an authoritative staleness expiry. On a final poll page, those post-expiry events are added duringdrainand immediately replayed as “already-admitted” buffered events before staleness is dispatched, contrary to §23's requirement that a stale socket be detected before further live-socket delivery. With a deferred stale verdict, this scan should retain only the protocol-fatal override and discard other newly dequeued frames.
for *budget > 0 {
select {
case item, ok := <-at.lc.frames:
if !ok {
l.deferForDrain(&deferredFrame{closed: true})
return cycleOutcome{}, false
}
*budget--
handled, out, ended := l.admitDuringPoll(at, item)
switch {
case ended:
return out, true
case handled:
default:
if f, ok := protocolFatalFrame(item); ok {
return l.terminateProtocolFatal(at, f), true
}
l.deferForDrain(&deferredFrame{item: item})
}
go/pkg/basecamp/eventfeed/scenario_conformance_test.go:1032
- This driver attaches a parsed
Retry-Afterto unauthorized and unrecoverable mint errors, although the seam contract permits it only on retryable transient/throttled outcomes;loop.go:1066-1070also explicitly states that unauthorized carries none. That makes tier 2 manufacture an impossible Layer-1 result. AssignRetryAfteronly in the retryable default branch and correct the contradictory comment.
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
| // ONE piece is still to land, and it is what keeps the package unusable | ||
| // against the live API: the Layer-1 adapters over the generated | ||
| // CreateStreamTicket and PollEvents operations that back the TicketMinter and | ||
| // PollSource seams. Until they exist, the seams have no production | ||
| // implementation and a host must supply its own. Two obligations ride on |
…nt-feed-go-connector * origin/event-feed-foundations: Event feed: three store identities that could still drift, closed
|
@codex review |
|
Codex Review: Didn't find any major issues. Bravo. 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". |
…nt-feed-go-connector * origin/event-feed-foundations: Event feed: say why the raw read failure is not the dial path, and pin it
|
@codex review |
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)
go/pkg/basecamp/eventfeed/catchup.go:995
- Once the deferral slot is occupied, later frames still pass through
admitDuringPoll. A correlated message queued after a deferred disconnect or staleness expiry is therefore added to the loop-wide buffer (and may even trigger overflow), survives that attempt's teardown, and is delivered after reconnect. This breaks the stated stop-at-the-deferred-receive ordering; after the first deferred outcome, the scan should only look for the protocol-fatal carve-out and discard every other later item.
handled, out, ended := l.admitDuringPoll(at, item)
|
Codex Review: Didn't find any major issues. Keep them coming! 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". |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ecea7a3460
ℹ️ 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".
| for { | ||
| staleTimer, staleGen := at.lc.stale.current() | ||
| select { | ||
| case werr := <-written: |
There was a problem hiding this comment.
Check staleness before accepting a subscribe write
When WriteFrame completes while the staleness timer is already ready, this select can take written first without evaluating the fired window. A write error is then reported as the socket failure instead of firing StaleConnection, while a successful first subscribe proceeds to allocate a confirmation timer and enter AwaitingConfirmation before the authoritative staleness edge from SPEC.md transitions 9/15 is handled; a blocking custom Clock.NewTimer can delay that teardown indefinitely. Re-probe and evaluate staleTimer in this arm before dispatching the write result.
Useful? React with 👍 / 👎.
|
Disposition for Copilot's suppressed comment on the exact-head round (catchup.go:995, "later frames still pass through admitDuringPoll after the deferral slot is occupied"): declined. The "stop at the deferred receive" sentence it cites governs which OUTCOME is reported — the first parked socket outcome is the one dispatched, preserving arrival order for the cycle-ending verdict — not a freeze on admissions. The scan's continuation past an occupied slot is the point of the #760 fix: a protocol-fatal frame the pump had already read must govern even when a recoverable outcome is parked ahead of it, and ending the scan at the slot is exactly the hole that fix closed. Admitting correlated events behind the parked outcome is the live buffer doing its stated job (they are the in-flight stragglers it exists to carry, bounded by liveBufferCapacity with overflow dispatched at drop time), and delivery after reconnect is covered by the loop-wide dedupe lane, so a straggler that also arrives through the next attempt's catch-up cannot double-deliver. The proposed remedy — discard every non-fatal item after the first deferral — would turn received events into silent data loss to enforce an ordering the contract does not state. |
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.