fix(stdb): drop frames on a closed connection #116

Merged
yuris merged 1 commit from yuris/closed-dispatch-guard into main 2026-08-22 13:57:06 +00:00 AGit
Owner

Closes #115.

A connection the consumer closed still decoded frames, and handling one wrote _state back to "connected" over "closed", re-persisted credentials to the save slot, re-emitted connected, and refilled the cache. Reachable on HTML5, where CloseConnection gates its whole body on STATE_CONNECTED and the unconditional transition is HAVE_WSLAY-only, so close() during the handshake never closes the socket and the server keeps pushing.

Guard at two layers. connection.dispatch returns early on a closed connection, which makes _state genuinely sticky at "closed" — the property M.state's own docstring and schedule_reconnect's terminal branch already asserted, and which #113 had to work around with a second conjunct. The EVENT_MESSAGE branch in init.lua drops earlier still, before the queue, the coroutine and the decode: the dispatch guard alone is downstream of the budgeted queue, and close() cancels the pump without restarting it, so every post-close frame was retained for the life of the page (~4MB per 200k frames with frame_budget set).

With _state sticky, both #113 send-path guards collapse to that single test, since closing implies _state == "closed" and the terminal-drop path sets _state without closing. conn.close writes conn rather than its argument so the two can never be split across objects.

The zombie test now pins the defect rather than its consequence: state stays "closed", no token or identity recorded, nothing written to storage, no event emitted, and a post-close EVENT_MESSAGE that would raise if it reached the decoder. Both guards mutation-checked — removing either fails exactly one assertion. 13 passed, 0 failed.

Wiki updated alongside: db:state()'s terminality is now documented in API-Reference and Usage-Guide.

Reviewed by a second agent; unhandled events for dropped frames were considered and declined (the tag already means "unknown ordinal").

Closes #115. A connection the consumer closed still decoded frames, and handling one wrote `_state` back to `"connected"` over `"closed"`, re-persisted credentials to the save slot, re-emitted `connected`, and refilled the cache. Reachable on HTML5, where `CloseConnection` gates its whole body on `STATE_CONNECTED` and the unconditional transition is `HAVE_WSLAY`-only, so `close()` during the handshake never closes the socket and the server keeps pushing. **Guard at two layers.** `connection.dispatch` returns early on a closed connection, which makes `_state` genuinely sticky at `"closed"` — the property `M.state`'s own docstring and `schedule_reconnect`'s terminal branch already asserted, and which #113 had to work around with a second conjunct. The `EVENT_MESSAGE` branch in `init.lua` drops earlier still, before the queue, the coroutine and the decode: the dispatch guard alone is downstream of the budgeted queue, and `close()` cancels the pump without restarting it, so every post-close frame was retained for the life of the page (~4MB per 200k frames with `frame_budget` set). With `_state` sticky, both #113 send-path guards collapse to that single test, since `closing` implies `_state == "closed"` and the terminal-drop path sets `_state` without `closing`. `conn.close` writes `conn` rather than its argument so the two can never be split across objects. The zombie test now pins the defect rather than its consequence: state stays `"closed"`, no token or identity recorded, nothing written to storage, no event emitted, and a post-close `EVENT_MESSAGE` that would raise if it reached the decoder. Both guards mutation-checked — removing either fails exactly one assertion. 13 passed, 0 failed. Wiki updated alongside: `db:state()`'s terminality is now documented in API-Reference and Usage-Guide. Reviewed by a second agent; `unhandled` events for dropped frames were considered and declined (the tag already means "unknown ordinal").
Drop frames on a closed connection (#115)
All checks were successful
ci / ci (pull_request) Successful in 22s
conformance / conformance (pull_request) Successful in 3m1s
7b3fcc1995
A connection closed by the consumer still decoded frames, and decoding one
wrote _state back to "connected", re-persisted credentials, re-emitted
"connected", and refilled the cache. Reachable on HTML5, where close() during
the handshake is a no-op in extension-websocket.

A closed guard in connection.dispatch makes _state genuinely sticky at
"closed", which is what its own docstring and schedule_reconnect's terminal
branch already claimed. The two send-path guards in init.lua collapse from
`closing or _state == "closed"` to the sticky test alone.
yuris changed title from Drop frames on a closed connection (#115) to Drop frames on a closed connection 2026-08-22 13:36:14 +00:00
Drop post-close frames at the transport too (#115 review)
All checks were successful
ci / ci (pull_request) Successful in 1m30s
conformance / conformance (pull_request) Successful in 2m50s
8b94eeae79
Review of #116 found the guard in connection.dispatch is downstream of the
budgeted queue: close() cancels the pump and nothing restarts it, so with a
frame_budget every frame the HTML5 zombie socket delivers after close() was
retained for the life of the page (~4MB per 200k frames, measured). The
EVENT_MESSAGE branch now drops before the queue, the coroutine and the decode.

Also from the review: the docstring said a closed connection "decodes nothing",
which handle_frame contradicts - it decodes, then dispatch discards. Reworded
to "handles nothing" and pointed at the transport guard. conn.close writes conn
rather than its `c` argument, so closing and _state cannot be split across two
objects now that the send guards test _state alone.

The zombie test gained the re-persist and identity symptoms it was missing, and
a transport-level case that raises if the frame reaches the decoder. Both
guards mutation-checked: removing either fails exactly one assertion.
yuris force-pushed yuris/closed-dispatch-guard from 8b94eeae79
All checks were successful
ci / ci (pull_request) Successful in 1m30s
conformance / conformance (pull_request) Successful in 2m50s
to 2406a35299
All checks were successful
ci / ci (pull_request) Successful in 1m28s
conformance / conformance (pull_request) Successful in 2m22s
2026-08-22 13:56:37 +00:00
Compare
yuris changed title from Drop frames on a closed connection to fix(stdb): drop frames on a closed connection 2026-08-22 13:56:57 +00:00
yuris merged commit 50e3d9d1a0 into main 2026-08-22 13:57:06 +00:00
yuris referenced this pull request from a commit 2026-08-22 13:58:37 +00:00
Sign in to join this conversation.
No description provided.