Replace per-message heartbeat timeout with a watchdog#395
Merged
Conversation
Every inbound frame cleared and re-armed a setTimeout to track the inactivity deadline, so a busy session allocated a timer and closure per message. Track the last inbound timestamp instead and let a single interval per session compare elapsed wall time against the deadline. Comparing Date.now() rather than counting interval executions keeps this safe under browser timer throttling: a delayed or suspended timer can only postpone detection of a dead connection, never report a heartbeat as missed while messages keep arriving.
The tests asserted the connection was still alive 1ms before the inactivity deadline. The suite runs with shouldAdvanceTime, so the fake clock also moves with real time, and on a contended runner it crossed that 1ms margin and fired the watchdog early. Use the existing heartbeat and disconnect grace helpers instead, which leaves a full heartbeat interval of slack on either side of the deadline. Both tests still fail if an inbound message stops refreshing the timestamp.
jackyzha0
approved these changes
Jul 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
SessionConnectedtracked its inactivity deadline by clearing and re-arming asetTimeouton every valid inbound frame, insideupdateBookkeeping(). That means a timer and a closure allocated per message. On high-throughput browser sessions this is significant allocation churn, and each cleared timer becomes garbage whose callback capturesthis, briefly retaining the session graph. Browser profiling on a downstream consumer attributed hundreds of megabytes to this path.Note that even an idle session churns here, since heartbeat traffic alone re-arms the timeout every
heartbeatIntervalMs.The per-message timeout dates to #302, which moved away from an interval plus a miss counter because browser background throttling made counting interval executions unreliable. This keeps that fix while dropping the allocation.
What changed
The session now records a
lastInboundAttimestamp on each inbound frame and runs a single interval for its lifetime that closes the connection onceDate.now() - lastInboundAtpasses the deadline. Inbound messages no longer allocate any timer. Detection stays correct under browser throttling because it compares actual elapsed wall time instead of counting interval executions, so a delayed or suspended timer can only postpone noticing a dead connection, never invent a missed heartbeat.Reviewer pointers:
startMissingHeartbeatTimeout()is renamed tostartHeartbeatWatchdog(), since it no longer arms a timeout. Both call sites are intransitions.ts. Teardown moved to a privateclearHeartbeatWatchdog(), mirroring the existingclearRehandshakeTimer(), and is still called from_handleStateExit().conn.close(). An interval would otherwise keep ticking and re-closing while the connection's close event propagates asynchronously; the old timeout fired exactly once, and this preserves that.One behavioral difference
Detection is now quantized to the check interval. A dead connection is noticed between
deadlineMsanddeadlineMs + heartbeatIntervalMsafter the last frame, rather than exactly atdeadlineMs. With the defaults (1s interval, 2 heartbeats until dead) that is 2s to 3s instead of exactly 2s. This is inherent to polling, and erring late is the safe direction.If the extra timer per session is ever a concern at high session counts, the natural follow-up is one transport-level watchdog scanning every session's
lastInboundAt. That was deliberately left out here to keep the change small.Testing
The
heartbeatsdescribe block had no coverage of the death path itself, so this adds two tests: the connection closes after silence, and a message arriving just under the deadline keeps pushing the deadline out. I confirmed both are meaningful by removing thelastInboundAtupdate and watching them fail.Full suite passes (770 passed, 1 skipped), and
npm run checkis clean. Since this changes timing behavior, I also ran the four timing-sensitive suites (disconnects,transport,stateMachine,cleanup) 12 times consecutively with no flakes.The second commit fixes the two new tests, which failed on the Ubuntu runner in the first push. They originally asserted the session was still alive 1ms before the deadline. The suite runs with
shouldAdvanceTime, so the fake clock also moves with real time, and on a contended runner that 1ms margin was crossed and the watchdog fired before the assertion. They now use the existingadvanceFakeTimersByHeartbeatandadvanceFakeTimersByDisconnectGracehelpers, which leave a full heartbeat interval of slack on either side of the deadline, matching how the rest of the suite handles heartbeat timing. This was purely a test bug; production behavior is unchanged, and both tests still fail if an inbound message stops refreshing the timestamp.Versioning
No wire format change, so a client on either version interoperates with a server on the other. Bumped to 0.220.1.
Worth a second opinion on the API checkbox:
SessionConnectedis exported as a type from./transport, so thestartMissingHeartbeatTimeout()rename is technically visible to anyone who typed against it and called that method. Nothing outside the state machine has any reason to call it, so I left the box unchecked, but say the word and I will restore the old name or keep it as an alias.