connection: settle handle-free requests when the socket dies #47
No reviewers
Labels
No labels
area:defold
area:docs
area:infra
area:protocol
area:testing
kind:bug
kind:chore
phase:breadth
phase:core
phase:hardening
phase:slice
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
yuris/spacetimedb-defold!47
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/41-callback-loss"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #41. Also fixes a worse bug found while auditing
c.pending: two concurrent one-off queries delivered the wrong rows to the wrong consumer.The reported bug
PR #38 made a dying socket settle every in-flight
Callhandle withconnection lost. Procedures and one-off queries got no such treatment because they have no handle — theirc.pendingentries were dropped when the socket was replaced and the callback was never invoked. A consumer who disabled a button until a procedure returned left it disabled forever, with nothing reported anywhere.The rule now implemented is: the moment a request stops being answerable, its outcome reaches at least one surface the caller already holds — the
CallorSubscriptionhandle if it has one, the(err, ...)callback if it does not.A socket "going away" is three paths, not one
The first cut of this PR put the sweep in
set_send, which is reached only fromopen(). That covered a socket being replaced and missed a socket being retired:conn.closesetsclosing = trueandschedule_reconnectbails on "closing or not reconnect_enabled", so ondb:close(), or on any disconnect withreconnect = false, the sweep never ran at all and #41's own motivating scenario reproduced verbatim:The sweep is now
M.settle_pending(c, replay), called fromset_sendwithreplay = true, and fromconn.closeand the terminal branch ofschedule_reconnectwithreplay = false. Same three paths, measured after:Purely additive:
M.settle_pendingis new, no existing signature or behaviour changed.The exclusion set is "answered by the replay that is about to happen"
Only one of the three exempt kinds is contingent on a replay, so the list is split in two rather than special-cased on a string:
reducer— exempt on both paths. Its outcome is on theCallsettled in the same sweep, and the legacy callback's no-fire shape is frozen (#38 asserts it).resubscribe_allnever re-issues a reducer call, so a replay has nothing to do with it either way.unsubscribe— exempt on both paths. The set leftquery_setswhen the drop was issued and the server's copy died with the socket, so the drop took effect. That is a statement about the socket dying, not about what replaces it; and the set is out ofquery_sets, so no replay could resurrect it.subscribe— exempt only underreplay. "resubscribe_allre-issues the set under its original id, so the rows come back" is a claim about the future, and it is the only one. After a terminal close nothing re-issues anything, so the silence becomes the lie instead of the report being one.Still exclusion rather than a whitelist, so a request kind added later is reported without anyone remembering either list exists.
A raising callback no longer costs the other callers their answer
pendingis detached before the sweep begins, so a callback that raised out of the loop left every later caller unrecoverable — entries already gone, no later sweep able to find them. Four procedures in flight, the first callback raising: 1 of 4 callbacks reached, 0 entries left,set_sendnever completed.Each entry now settles under
pcalland the first error is re-raised once the sweep is complete: 4 of 4 reached, and the error still propagates. Considered and rejected:Re-raising also keeps the behaviour a single raising callback already had, where the error propagated out of
set_sendanyway; the change is strictly that the other callers get told first. Structural work is ordered before the sweep —c.sendis swapped,closingis set, the socket is disconnected — so a raising callback cannot leave a socket open or a transport unswapped. Later errors are dropped: Lua 5.1 carries one error value, and the first is the one whose cause is still live.Reentrancy is unchanged and covered: a callback that calls
db:close()or issues a new request mid-sweep still lets the sweep finish, and the re-issued request survives it.Three correlated handlers were missing the
p.kindcheck their siblings enforceInitialSubscriptionandTransactionUpdateguarded onp.kind;subscription_applied,SubscriptionErrorandProcedureResulttook whatever was parked under thatrequest_id. AProcedureResultbearing a reducer's id fired the frozen(err, update)callback with'not a TransactionUpdate'asupdateand consumed theCall's entry, orphaning it so no later settle could reach it andis_pending()lied for good.Only reachable if the server misroutes an id, but it is the exact bug class this PR set out to fix. One clause each — except
SubscriptionError, which takessubscribeorunsubscribe: a live 2.8.2 server answersUnsubscribeMultiwithSubscriptionErrorfor an unknownquery_idand for a double unsubscribe, both already covered by a test. Narrowing it tosubscribealone leaks the entry and swallows the unsubscribe callback.An unmatched
OneOffQueryResponsewas the one handler that could emit nothingProcedureResultemits whether or not it matched, and an unknown tag emitsunhandled, so this was the only correlated handler that could consume a whole frame silently — every misroute, unknown echo, uppercase echo and double answer vanished, in a file whose documented posture is that failures are loud or absent. The emit moved outsideif p then, carryingmessage_idand amatchedflag asone_off_result/one_off_failed.That also subsumes the case-fold question. Deleting the fold is now safe rather than merely tidy: an uppercase echo surfaces immediately as
matched = falseinstead of hanging a consumer.The design decision, and why the other option was rejected
Option 2 (settle the plain callbacks) over Option 1 (give procedures and one-off queries handles). Option 1 would freeze two more public types into 1.0 to carry an outcome that
(err, value, msg)and(err, tables)already express — both requests are one-shot with exactly one result. The rule is "every terminal outcome reaches at least one surface the caller holds", not "every request grows a handle".call_reducer's legacy callback still does not fire, and #38's assertion is unchanged and passing. The earlier phrasing of why — "the asymmetry is between mechanisms, not between what a caller can find out" — does not survive testing and has been corrected in the file and the README. A caller who used only the legacy callback and discarded the returnedCalllearns nothing about their specific request, only that the connection dropped, through a connection-level event that names no request. The honest statement is that the reducer's outcome is reachable, on a surface the same call already returned; reachable is not delivered, and that difference is the whole point of #41. The freeze is still right — firing the callback with a nilupdatewould break consumers — so the README now says to keep theCallif you need to know about your own request.The bug found by the audit
one_off_querykeyedc.pendingonc.last_one_off— "the query sent last". With two in flight at once:Now keyed on the echoed
message_id;c.last_one_offis deleted.Verified on the wire, since the new correlation depends on it:
[0,0,1,42]and[0,0,0,255]in flight on one socket came back as"0000012a"and"000000ff"—%02xper byte, lowercase, no prefix. Independently re-verified more broadly during review:[222,173,190,239]echoesdeadbeef(all letters, still lowercase), it ishex::encodeof exactly the bytes sent at any length, a failing query echoes its own id too, andmessage_idarrives as a string in every case.Also
conn.wswas assigned ininit.luaand read nowhere instdb/ordev/. Deleted.unsubscribed,procedure_result/procedure_failed,one_off_result/one_off_failed,unhandled).Left alone, with evidence:
message_id— live 2.8.2 answers both one-offs sent under the same id, and the second write would overwrite the first's entry. Not reachable through the public API:allocate_request_idis monotonic and never reset, including across reconnects (verified: two one-offs get ids 1 and 2 and park separately). The unmatched path above now reports it if a server ever does echo a duplicate.request_id>= 2^32 aliases the one-off key — verified:4294967295->ffffffff,4294967296->00000000, aliasing id 0. Needs 4.3e9 requests on one connection, and the general form (request_id above u32 on the wire) is pre-existing across every request kind.type(msg.message_id) == "string"guard — dead for absence, becausedecode_serveralready raisesOneOffQueryResponse missing required field "message_id"first, correctly loud. It earns its keep for a non-string: a numericmessage_idpasses decode and now emitsone_off_resultwithmatched = falseinstead of vanishing. Kept.Verification
6 passed, 0 failed;stylua --checkexit 0; conformance PASS twice back-to-back on isolated port 4500 with a privateSTDB_STATE_DIR, no #39 phase-2 flake.Mutation-tested, 16 mutants, 16/16 die:
close()no longer settlesclose() reaches a procedure's callback: expected connection lost, got unsetreconnect = falsebail no longer settlesreconnect = false settles on the socket's death: expected connection lost, got unsetsubscribealways exempt)a subscribe IS told, because no replay is coming: expected connection lost, got unsetsubscribenever exempt)a subscribe is not failed: resubscribe_all re-issues the same set: expected false, got truereducerdropped from the exclusion setcallback for the dead socket never fires: expected false, got trueunsubscribedropped from the exclusion setnor an unsubscribe: the set was dropped when the drop was issued: expected false, got truepcallremoved from the sweepand every other caller is settled anyway, not abandoned mid-sweep: expected 4, got 1the raised error still reaches whoever asked for the close: expected error, call succeededthe first one, unwrapped exactly as the callback raised it: expected raised by callback 1, got raised by callback 4and its handle's on_error: expected connection lost, got unsetis_endedguard dropped, so an ended set is resurrecteda terminal close leaves it ended rather than re-marking it failed: expected ended, got errorProcedureResultkind guard removeda ProcedureResult carrying a reducer's id does not fire the reducer callback: expected false, got truesubscription_appliedkind guard removedan applied frame carrying a procedure's id leaves the procedure alone: expected unset, got nilSubscriptionErrornarrowed tosubscribeonlyan error answering an unsubscribe still reaches its callback: expected true, got falseif pa response nobody is waiting for is still emitted: expected 1, got 0matchedhardcoded trueflagged as matching no request: expected false, got trueThe seven mutants from the first round of this PR (settle removed,
c.pendingcleared after the sweep instead of detached first, one-off send key made constant, response side ignoring the echoedmessage_id,ProcedureResultnot clearing its entry,OneOffQueryResponsenot clearing its entry) still die with their original messages. The one that previously survived — case-folding the one-off key — no longer applies: the fold is deleted, and the unmatched path is what makes an uppercase echo observable.Review — automated reviewer, posted for the record
Verdict: request changes
Closes #41: partially — reconnect path only; issue #41's own motivating harm still reproduces on the close path
The one-off correlation fix is correct and its wire premise independently re-verified (and holds more broadly than the author tested). The #41 fix is right in mechanism but incomplete in reach: the sweep fires only when a socket is REPLACED, never when one is RETIRED. 2 blockers, 4 should-fixes, 4 nits. Blockers 1+2 share one ~15-line additive fix.
Sanity
c.pendingkind enumerationconnection.lua:300 send_query_setconnection.lua:318 legacy M.subscribeconnection.lua:379 unsubscribeconnection.lua:405 call_reducerconnection.lua:423 call_procedureconnection.lua:461 one_off_queryBlockers
Blocker 1 — db:close() and reconnect=false never sweep at all
stdb/init.lua:258-263,stdb/init.lua:206,stdb/init.lua:218,stdb/connection.lua:562Problem: set_send is the sweep's only caller and is only reached from open(). conn.close sets closing=true and disconnects; schedule_reconnect bails on 'closing or not reconnect_enabled'. So on a terminal close, or any disconnect with reconnect=false, the sweep never executes.
Evidence:
Fix: Extract the sweep into M.settle_pending(c, replay) and call it from conn.close too. The exclusion set is really 'answered by the replay that is about to happen', so it must depend on whether one follows: answered = ANSWERED_ELSEWHERE[p.kind] and (replay or p.kind ~= 'subscribe'). Purely additive, no API change.
Blocker 2 — one raising settled callback abandons every later settle, unrecoverably
stdb/connection.lua:570-577,stdb/connection.lua:568Problem: Four procedures in flight, first callback raises: only 1 of 4 callbacks reached, set_send did not complete, and the other three callers are unrecoverable because pending was already detached at :568 — no later sweep can find them.
Evidence:
Fix: pcall each callback inside the sweep, save the first error, re-raise after the loop completes. Every caller is told and the error still propagates loudly.
should fix
[
{
"id": 3,
"title": "three handlers skip the p.kind check their siblings enforce",
"refs": [
"stdb/connection.lua:655",
"stdb/connection.lua:757",
"stdb/connection.lua:810"
],
"problem": "InitialSubscription (:622) and TransactionUpdate (:702/:713) guard on p.kind; subscription_applied, SubscriptionError and ProcedureResult take whatever is parked under that request_id.",
"evidence": "A ProcedureResult bearing a reducer's request_id: legacy reducer callback fired: true (received update = 'not a TransactionUpdate'); Call state: pending; pending entry consumed: 0",
"consequences": [
"the frozen (err, update) callback is handed a non-TransactionUpdate as update",
"the Call is orphaned — its entry is gone, so set_send can never settle it and is_pending() lies forever"
],
"reachability": "pre-existing; only reachable if the server misroutes an id, since request ids are unique per connection across all kinds. But it is the exact bug class this PR set out to fix, in the file this PR is auditing.",
"fix": "One clause each: p.kind == 'procedure' at :810; p.kind == 'subscribe' or 'unsubscribe' at :655; p.kind == 'subscribe' at :757."
},
{
"id": 4,
"title": "an unmatched OneOffQueryResponse reports nothing at all",
"refs": [
"stdb/connection.lua:780-794"
],
"problem": "The only correlated handler that can consume a frame and emit nothing. ProcedureResult:817 emits unconditionally whether or not p was found; handle_frame:833 emits 'unhandled' for an unknown tag. So every misroute, unknown echo, uppercase echo or double-answer vanishes without trace — in a file whose documented posture is that failures are loud or absent.",
"evidence": "events emitted for an id nobody sent: 0; original query still parked: 1",
"fix": "Mirror ProcedureResult: move the emit outside the 'if p then', carrying message_id and a matched flag.",
"note": "This subsumes the case-fold question entirely — with a loud unmatched path an uppercase echo surfaces immediately instead of hanging a consumer."
},
{
"id": 5,
"title": "README overclaims the settle",
"refs": [
"README.md:389-391",
"README.md:787-788"
],
"problem": "'it does fire, with ("connection lost"), when the socket dies under them' is unconditionally false on the close path (blocker 1). Same for both changed table rows.",
"fix": "Either fix blocker 1 so the prose becomes true, or scope it to 'when the socket dies and the SDK reconnects'. Also add the reachable-vs-delivered distinction, and note that a consumer relying solely on call_reducer's callback should keep the returned Call."
},
{
"id": 6,
"title": "sweep docblock restates the PR body",
"refs": [
"stdb/connection.lua:506-556"
],
"problem": "~30 lines of rationale in-file. Worth trimming to the invariant plus the exclusion table — the argument belongs in the PR, the invariant belongs in the file.",
"severity_note": "style/maintenance, lowest of the should-fixes"
}
]
nits
[
{
"title": "duplicate message_id silently overwrites",
"detail": "Live 2.8.2 answers BOTH one-offs sent under the same message_id, each echoing the same id (my wire TEST B). In the SDK the second write at :461 overwrites the first's entry: caller 1's callback dropped, caller 1's rows delivered to caller 2, second response finds nothing. Not reachable via the public API — allocate_request_id (:45-49) is monotonic and never reset, including across reconnects — but the server does not dedupe."
},
{
"title": "request_id >= 2^32 aliases the one-off key",
"detail": "The key is 4 bytes, so request_id 4294967296 produces oneoff:00000000, aliasing id 0. Verified: ids 4294967295/4294967296 -> keys oneoff:ffffffff, oneoff:00000000. Needs 4.3e9 requests on one connection. The general form (request_id above u32 on the wire) is pre-existing across every request kind."
},
{
"title": "type(msg.message_id) == 'string' guard is dead for absence",
"detail": "protocol.decode_server already raises 'OneOffQueryResponse missing required field "message_id"' before the handler runs — correctly loud. The guard earns its keep only for a non-string, e.g. a numeric message_id, which passes decode and is correctly ignored."
},
{
"title": "conn.ws is assigned and never read",
"detail": "init.lua:212 assigns it; grep finds no reader anywhere in stdb/ or dev/. Dead as written."
}
]
wire verification
{
"method": "own isolated SpacetimeDB 2.8.2 on port 4400, raw Node WebSocket client, v1.json.spacetimedb subprotocol",
"result": "CONFIRMED, and more broadly than the PR tested",
"observations": [
"sent [0,0,1,42] -> echo "0000012a"; sent [0,0,0,255] -> echo "000000ff"; sent [222,173,190,239] -> echo "deadbeef"",
"all lowercase: true; all match /^[0-9a-f]{8}$/: true; three concurrent one-offs all answered",
"deadbeef is the decisive case — all letters, echoed lowercase",
"it is hex::encode of EXACTLY the bytes sent, any length: [1]->"01", []->"", 16 bytes->32 chars",
"a FAILING query echoes its own id too, so the error path is correlated correctly",
"message_id arrives as typeof string in every case"
],
"conclusion": "the re-key at :461/:783 is right and the big-endian arithmetic at :456-459 matches the echo"
}
reentrancy
{
"issues_new_request": {
"sweep_continues": true,
"pending_consistent": true,
"later_skipped": false
},
"calls_close": {
"sweep_continues": true,
"detail": "all 3 of 3 callbacks fired",
"pending_consistent": true,
"later_skipped": false,
"judgment": "correct — each caller deserves its answer even after someone asked to close"
},
"raises": {
"sweep_continues": false,
"pending_consistent": "detached, entries lost",
"later_skipped": "YES, unrecoverably",
"finding": "blocker 2 — the reviewer's instinct was correct"
}
}
frozen contract judgment
{
"verdict": "the asymmetry is the right thing to freeze, but the stated justification does not survive testing",
"tested_claim": "'between mechanisms, not between what a caller can find out' is FALSE as written — a caller who used only call_reducer's legacy callback and discarded the returned Call learns nothing about their specific request, only that the connection dropped, via a connection-level event that names no request",
"defensible_restatement": "the reducer's outcome is REACHABLE, on a surface the same call already returned. Reachable is not delivered, and that difference is the whole point of #41.",
"freeze_is_still_correct": "firing the legacy callback with a nil update would break consumers, and test_connection.lua:202 guards a real contract",
"option_1_rejection": "also right — two new public types to carry what (err, ...) already says, on the PR that freezes the API, is a bad trade"
}