Raised by daybreak on #705. Confirmed against the current head.
The rule
SPEC §23 "Cable Protocol Details" is unconditional:
Subscribe is sent on each welcome received.
Not "on the first welcome". The sentence directly above it anticipates exactly
this case — "The server absorbs identical retransmits and rejects different
ones" — which is why the subscribe command is built byte-identically on every
send.
What the connector does
handleLiveFrame's post-confirmation dispatch drops it
(go/pkg/basecamp/eventfeed/catchup.go, the default arm):
default:
// welcome, ping, unknown types, and a post-confirmation confirm or
// reject: liveness only — the pump already reset staleness.
return cycleOutcome{}, false
So a welcome arriving in CatchingUp, Draining, or Streaming updates liveness
and nothing else. An Action Cable server that re-issues welcome on the same
socket — after its own connection-state reset — leaves the connector believing
it holds a subscription the server no longer has. The feed then goes silent
with a healthy socket underneath it: pings keep staleness satisfied, so nothing
tears it down, and only the repair poll's 60s cadence recovers any events at
all. Live delivery is dead until the socket happens to drop.
Why it is not a one-line fix
The obvious patch — write the subscribe frame from that arm — reintroduces a
defect this PR already fixed once. CableConn.WriteFrame may block, and the
handshake path deliberately writes subscribe on its own goroutine against a
deadline for that reason (loop.go, transition 8). A synchronous write here
runs on the run goroutine, inside frame dispatch, with no deadline and nothing
to cancel it: a peer whose receive window has shut would hang the feed until
Connector.Close, which is the "defeated deadline" failure the bounded write
exists to prevent.
So the fix needs the same bounded-write discipline in a place that currently
has none, and a decision the inventory does not answer: whether a
post-confirmation welcome also re-arms confirmation-deadline and returns
the state machine to AwaitingConfirmation (transition 8's shape), or only
retransmits while staying in its current state. The first is a new edge from
three states; the second is a write with no state change. §23 numbers neither.
Acceptance criteria
- A
welcome in CatchingUp, Draining, and Streaming each results in the
byte-identical subscribe command being written — one test per state, since
the three reach handleLiveFrame by different paths.
- The write is bounded the way transition 8's is: a stalled
WriteFrame must
not hold the run goroutine past a deadline, and must be cancellable by
teardown.
- The state/timer answer is written into §23 — either a numbered edge back to
AwaitingConfirmation with confirmation-deadline re-armed, or an explicit
statement that the retransmit changes neither state nor the timer set. The
per-state exact-timer assertions make this observable, so it cannot be left
implicit.
- A tier-2 fixture covers it, since this is cross-SDK behavior and the other
five drivers will need the same case.
Raised by daybreak on #705. Confirmed against the current head.
The rule
SPEC §23 "Cable Protocol Details" is unconditional:
Not "on the first welcome". The sentence directly above it anticipates exactly
this case — "The server absorbs identical retransmits and rejects different
ones" — which is why the subscribe command is built byte-identically on every
send.
What the connector does
handleLiveFrame's post-confirmation dispatch drops it(
go/pkg/basecamp/eventfeed/catchup.go, thedefaultarm):default: // welcome, ping, unknown types, and a post-confirmation confirm or // reject: liveness only — the pump already reset staleness. return cycleOutcome{}, falseSo a
welcomearriving in CatchingUp, Draining, or Streaming updates livenessand nothing else. An Action Cable server that re-issues
welcomeon the samesocket — after its own connection-state reset — leaves the connector believing
it holds a subscription the server no longer has. The feed then goes silent
with a healthy socket underneath it: pings keep staleness satisfied, so nothing
tears it down, and only the repair poll's 60s cadence recovers any events at
all. Live delivery is dead until the socket happens to drop.
Why it is not a one-line fix
The obvious patch — write the subscribe frame from that arm — reintroduces a
defect this PR already fixed once.
CableConn.WriteFramemay block, and thehandshake path deliberately writes subscribe on its own goroutine against a
deadline for that reason (
loop.go, transition 8). A synchronous write hereruns on the run goroutine, inside frame dispatch, with no deadline and nothing
to cancel it: a peer whose receive window has shut would hang the feed until
Connector.Close, which is the "defeated deadline" failure the bounded writeexists to prevent.
So the fix needs the same bounded-write discipline in a place that currently
has none, and a decision the inventory does not answer: whether a
post-confirmation
welcomealso re-armsconfirmation-deadlineand returnsthe state machine to AwaitingConfirmation (transition 8's shape), or only
retransmits while staying in its current state. The first is a new edge from
three states; the second is a write with no state change. §23 numbers neither.
Acceptance criteria
welcomein CatchingUp, Draining, and Streaming each results in thebyte-identical subscribe command being written — one test per state, since
the three reach
handleLiveFrameby different paths.WriteFramemustnot hold the run goroutine past a deadline, and must be cancellable by
teardown.
AwaitingConfirmation with
confirmation-deadlinere-armed, or an explicitstatement that the retransmit changes neither state nor the timer set. The
per-state exact-timer assertions make this observable, so it cannot be left
implicit.
five drivers will need the same case.