Skip to content

feat: connection-level keepalive + ConnectionClosingCallback - #4

Merged
skevetter merged 10 commits into
mainfrom
feat/connection-keepalive-closing-callback
May 25, 2026
Merged

feat: connection-level keepalive + ConnectionClosingCallback#4
skevetter merged 10 commits into
mainfrom
feat/connection-keepalive-closing-callback

Conversation

@skevetter

@skevetter skevetter commented May 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Two additions to fix a connection-cleanup bug in downstream consumers (devsy pkg/ssh/server) where stdin EOF doesn't propagate promptly through proxy chains (outer ssh → ProxyCommand → docker exec → in-container helper), leaving HandleConn stuck in for ch := range chans and all defers — including ConnectionCompleteCallback — never running.

1. Connection-level keep-alive (OpenSSH-faithful)

The keep-alive previously lived in session.handleRequests and only ran while a session was active. Between sessions (idle ControlMaster) nothing pinged the peer, so a dead transport went undetected.

It's now hoisted to a goroutine started by Server.HandleConn that runs for the lifetime of the transport. The probe-selection mirrors OpenSSH sshd's client_alive_check():

  • If ≥1 channel is open → send keepalive@openssh.com as a channel request on the oldest open channel.
  • Otherwise (idle ControlMaster, the bug scenario) → send as a global request on the transport.

After ClientAliveCountMax consecutive intervals with no inbound traffic from the peer, sshConn.Close() is called and HandleConn unblocks.

Internals:

  • openChannelSet (per-connection, stashed on ssh.Context) tracks accepted channels.
  • trackingNewChannel wraps every gossh.NewChannel so Accept() transparently registers; the per-channel reqs stream is also wrapped so close drives unregistration. No changes needed to existing user-supplied ChannelHandlers.
  • notePeerActivity (unexported) bumps lastReceived and resets the probe ticker on every inbound packet, matching OpenSSH's packet.c semantics where keep_alive_timeouts = 0 on any successfully-received packet.
  • NewChannelUnwrapper exported interface gives any handler that needs the underlying gossh.NewChannel (type-assert against a custom impl, etc.) a stable way to recover it.

The per-session keep-alive ticker driver is removed — the connection-level loop supersedes it. Client-initiated keepalive@openssh.com (via KeepAliveRequestHandler) is unchanged.

2. ConnectionClosingCallback

ConnectionClosingCallback func(ctx Context, conn *gossh.ServerConn)

Invoked synchronously the moment HandleConn observes the inbound channels stream close — before any defers fire and before sshConn.Wait(). Unlike ConnectionCompleteCallback, which is wrapped in a defer around Wait() and so may never fire on a stuck transport, this callback gives downstream callers a fast-path hook for cleanup.

The ssh.Context argument lets callers reach per-connection state stashed via ctx.SetValue directly — no sync.Map[*gossh.ServerConn] indirection needed.

Caveat documented on the type: channel handler goroutines spawned by HandleConn may still be live when the callback fires. For "all work done" semantics, use ConnectionCompleteCallback.

Behavior changes for existing consumers

Before After
Server-initiated keep-alive Channel request, per-session only Channel-or-global per OpenSSH client_alive_check, runs always
Dead transport detection Only while a session was active Always (idle ControlMaster handled)
Per-session keep-alive ticker One per session Removed (connection-level supersedes)
keepalive@openssh.com (server-initiated) wire form Always SSH_MSG_CHANNEL_REQUEST SSH_MSG_CHANNEL_REQUEST when channel open, SSH_MSG_GLOBAL_REQUEST otherwise
Public API Added ConnectionClosingCallback, NewChannelUnwrapper

Test additions

Test Verifies
TestConnectionClosingCallback Closing callback fires before complete callback (single ordered event channel)
TestConnectionKeepAliveClosesStalledConn Idle ControlMaster scenario — no session, stalled peer, transport teardown
TestConnectionKeepAliveClosesStalledConnWithOpenSession Same as above but with an active session (exercises channel-request probe path)
TestConnectionKeepAliveUsesChannelRequestWhenSessionOpen Probe arrives as channel request while a channel is open, falls back to global after close
TestConnectionKeepAlivePrunesClosedChannels Channel-close hook removes channels from the set before the next probe (asserts state before any tick fires)

Verification

  • go build ./... clean
  • CGO_ENABLED=1 go test -race -timeout 180s ./... — 34 tests pass, no flakes
  • golangci-lint run clean

Release

feat: commit at the head triggers release-please to cut v1.2.0 on merge to main. devsy can then bump go.mod to github.com/devsy-org/ssh@v1.2.0.

Summary by CodeRabbit

  • New Features

    • Added a synchronous connection-closing callback for faster per-connection cleanup.
  • Improvements

    • Enhanced connection-level keep-alive that tracks per-channel activity and prefers channel probes when available.
    • Streamlined per-session request handling and more reliable shutdown sequencing.
  • Tests

    • Added regression tests covering connection closing and keep-alive probe/teardown behavior.

Review Change Stack

Hoists the keep-alive ticker loop out of session.handleRequests into a
new connection-level goroutine in Server.HandleConn. It runs for the
lifetime of the transport (using sshConn.SendRequest with the
"keepalive@openssh.com" global request) rather than per-session, so a
dead transport is detected even when no session is active — e.g., an
idle ControlMaster after the outer ssh -O exit but before EOF
propagates through the proxy chain. After ClientAliveCountMax
consecutive intervals with no successful reply, sshConn is closed so
HandleConn's channel loop unblocks.

Also adds ConnectionClosingCallback, invoked synchronously the moment
the inbound channels stream closes (before any defers, before
sshConn.Wait()). Unlike ConnectionCompleteCallback this fires reliably
even when the transport is permanently stuck. The callback receives
the ssh.Context so per-connection state stashed via ctx.SetValue is
reachable directly, removing the need for downstream callers to
maintain a sync.Map keyed by *gossh.ServerConn.

The per-session keepalive ticker driver in session.handleRequests is
removed (the connection-level loop now drives the shared
ctx.KeepAlive()); request-handler reset via KeepAliveRequestHandler
still functions unchanged.
@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@skevetter, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 42 minutes and 8 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 09956402-7cb3-4f43-ab17-7d9dd7ba5c3f

📥 Commits

Reviewing files that changed from the base of the PR and between e16d653 and 8e95dba.

📒 Files selected for processing (4)
  • keepalive.go
  • server.go
  • server_test.go
  • session.go
📝 Walkthrough

Walkthrough

Keep-alive bookkeeping is moved to the transport level with open-channel tracking. A synchronous ConnectionClosingCallback is added and invoked when channel acceptance ends. Session-level keep-alive ticks are removed; connectionKeepAlive probes prefer channel requests, close stalled transports, and tests cover callback ordering and probe routing.

Changes

Keep-Alive Migration

Layer / File(s) Summary
Connection closing callback contract
ssh.go
ConnectionClosingCallback is exported as a function type receiving Context and *gossh.ServerConn, documented to fire synchronously when HandleConn observes channel stream closure.
Context key and keep-alive peer API
context.go, keepalive.go
Add contextKeyOpenChannels and SessionKeepAlive.NotePeerActivity() to record inbound activity and reset probe timing.
Open channel tracking and wrapper
server.go
Add openChannelSet, trackingNewChannel, and NewChannelUnwrapper to register accepted channels, remove them on stream close, forward per-channel request activity to Context keep-alive, and drain/reject in-flight requests during shutdown.
HandleConn integration & transport keep-alive
server.go
Store open-channel set in Context, start connectionKeepAlive goroutine (prefers channel requests, throttles probes, calls ServerRequestedKeepAliveCallback, resets on success, closes transport on timeout), and invoke Server.ConnectionClosingCallback synchronously after channel loop ends; handleRequests now notes peer activity for global requests.
Session request handler refactor
session.go
Remove per-session keep-alive tick/probe flow; rewrite handleRequests to a for req := range reqs loop, call NotePeerActivity() per request, and consistently defer winch closure on channel end.
Tests: connection keep-alive and callback ordering
server_test.go
Add TestConnectionClosingCallback, TestConnectionKeepAliveClosesStalledConn, TestConnectionKeepAliveUsesChannelRequestWhenSessionOpen, and TestConnectionKeepAlivePrunesClosedChannels to assert callback timing and keep-alive probe behavior.

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main changes: adding connection-level keepalive and a new ConnectionClosingCallback mechanism.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

skevetter added 7 commits May 25, 2026 15:26
…ility

Addresses review feedback on the connection-level keep-alive change:

- Add `defer keepAlive.Close()` in connectionKeepAlive so the underlying
  time.Ticker is stopped deterministically when the goroutine exits
  (previously leaked until GC).
- Race sshConn.SendRequest against time.After(interval) so a stalled
  transport can't pin the inner goroutine forever; the buffered replyCh
  ensures the inner goroutine never blocks on send after the parent
  moves on.
- Restore log output that the per-session driver previously emitted:
  failed SendRequest, per-request timeout, the keep-alive timeout-close
  decision, and sshConn.Close errors.
- Tighten ConnectionClosingCallback godoc: it only fires reliably on a
  stuck transport when ClientAliveInterval is set. Without keep-alive
  there is nothing to break the gossh channel range loop.
- Replace the field-level comment that duplicated the type doc with a
  pointer, and drop the HandleConn inline comment that named another
  callback's internals (rot risk).

Tests:
- TestConnectionClosingCallback now uses a single ordered event channel
  to assert closing fires strictly before complete.
- New TestConnectionKeepAliveClosesStalledConn dials with raw
  gossh.NewClientConn and drops incoming requests without replying,
  asserting ConnectionClosingCallback fires after roughly
  ClientAliveInterval * ClientAliveCountMax. This is the regression
  test for the original bug — without the connection-level keep-alive
  it would hang.
…ction

OpenSSH's sshd sends keepalive@openssh.com as a per-channel request when
at least one channel is open and as a global request otherwise (see
serverloop.c client_alive_check). The previous connection-level
implementation always sent a global request, which works with every
real client (OpenSSH, gossh, libssh, paramiko all handle both forms)
but diverged from sshd's wire behavior.

This change tracks open channels per connection via a small
openChannelSet stashed on the ssh.Context. Channel registration is
transparent: each gossh.NewChannel is wrapped before being passed to
the user-supplied ChannelHandler so Accept() registers the resulting
Channel without any handler changes. Channel removal is lazy — when a
keepalive SendRequest on a registered channel fails, that channel is
pruned and the call falls through to the global request path. gossh
exposes no Channel-close notification, so prune-on-failure is the
cleanest approach.

The keepalive loop now:
- picks an open channel and calls ch.SendRequest if one exists,
- prunes and falls back to sshConn.SendRequest on failure or empty set,
- still bounds the send with the existing replyCh / time.After race,
- still drives ctx.KeepAlive() for metrics and Reset on success,
- still closes sshConn on TimeIsUp.

Test added: TestConnectionKeepAliveUsesChannelRequestWhenSessionOpen
opens a raw gossh channel, observes channel-typed keepalives arriving
on the per-channel request stream (and zero on the global stream) for
~1s, then closes the channel and observes global-typed keepalives
arriving for ~1s.
Addresses five divergences from OpenSSH sshd surfaced during review.

Channel-close hook (closes #1 of review)
  Previously openChannelSet.remove fired only on a SendRequest failure,
  so a connection that opened and cleanly closed many channels
  accumulated dead entries forever. any() always returned slot 0 — the
  oldest, most-likely-dead channel — degrading the OpenSSH-mirror mode
  into "always probe a dead channel, then fall back to global" within
  one tick. trackingNewChannel.Accept now proxies the per-channel
  request stream through a buffered (16, matching gossh's chanSize)
  forwarder goroutine; when the upstream reqs channel closes, an
  onClose callback prunes the set.

Remove the time.After race (closes #2 of review)
  connectionKeepAlive used a replyCh + time.After(interval) race
  around SendRequest. A reply arriving at interval+epsilon left
  keepAlive.Reset() un-called, ticking the false-disconnect counter
  for live-but-slow clients. Removed the outer timeout entirely. The
  inFlight semaphore already prevents overlapping probes; TimeIsUp
  enforces the deadline at the next tick; sshConn.Close on TimeIsUp
  unblocks any hung SendRequest.

Reset on any inbound traffic (closes #3 of review)
  OpenSSH resets its client-alive counter on every received packet.
  Added SessionKeepAlive.NotePeerActivity — like Reset but without
  bumping the KeepAliveReplyReceived metric. Called from
  Server.handleRequests (global requests), session.handleRequests
  (per-session requests), and the per-channel request forwarder in
  trackingNewChannel. Resets the ticker so the next probe fires
  ClientAliveInterval after the most recent activity, which also
  closes #4 (probe suppression on recent traffic) for free.

Unwrap helper + ChannelHandlers doc (closes #5 of review)
  Added Unwrap() gossh.NewChannel on trackingNewChannel so downstream
  handlers that need the underlying type can recover it. Documented
  the wrapping on Server.ChannelHandlers.

Test added: TestConnectionKeepAlivePrunesClosedChannels opens three
channels, closes them, then asserts subsequent keepalives over a 1s
window are global-only (0 channel-typed, >=1 global). Without the
close hook, the dead channels in the set would force channel-typed
probes that fail and prune one-at-a-time over many intervals.
- Forwarder goroutine in trackingNewChannel.Accept now selects on
  ctx.Done(); on cancel it drains upstream reqs (replying false to
  WantReply) so the deferred onClose runs and gossh's sender doesn't
  leak. Previously a handler that abandoned wrapped would leak the
  forwarder forever once its 16-slot buffer filled.

- NotePeerActivity no longer bumps lastReceived. It still resets the
  ticker (suppressing redundant probes when traffic flows), but the
  dead-peer deadline used by TimeIsUp is now cleared only by replies
  to our own probes. A chatty-but-wedged peer (send-side stuck,
  recv-side streaming) is no longer falsely considered alive — this
  matches OpenSSH's client_alive_check, which counts unanswered
  server-initiated probes, not arbitrary inbound traffic.

- TestConnectionKeepAlivePrunesClosedChannels now actually exercises
  the close hook: interval bumped to 1s so we can poll the
  openChannelSet via context BEFORE the next keepalive tick. The
  previous timings allowed the old prune-on-failure path to pass the
  test without the hook.

- Exported NewChannelUnwrapper named interface so Unwrap() is
  discoverable via go doc. ChannelHandlers godoc updated.
Round-2 review surfaced an internal contradiction: NotePeerActivity
reset the ticker but did not bump lastReceived. A chatty inbound peer
would keep deferring the probe (via ticker.Reset) so TimeIsUp never
got consulted, defeating dead-peer detection. The doc comment also
misrepresented OpenSSH semantics.

OpenSSH's ssh_packet_read_poll_seqnr (packet.c) zeros
keep_alive_timeouts on every successfully-received packet of any
type, and serverloop.c defers the next probe on inbound traffic.
NotePeerActivity now matches: bumps lastReceived AND resets the
ticker on any inbound traffic. The Reset metric counter is still
reserved for actual probe replies.

Also:
- Removed unreachable if t.ctx != nil guard in trackingNewChannel.
  Construction site (HandleConn) always passes a non-nil ctx, and
  the prior fallback comment was wrong (a nil select case deadlocks
  on full buffer, doesn't degrade).
- Tightened TestConnectionKeepAlivePrunesClosedChannels: interval
  1s -> 3s, poll deadline 800ms -> 500ms. Added invariant assertion
  that no keepalive tick fires inside the assertion window so the
  test can't silently pass via the prune-on-failure fallback.
- Removed defer keepAlive.Close() in connectionKeepAlive. The
  SessionKeepAlive is referenced by other goroutines (handleRequests,
  per-channel forwarders, in-flight probes) that may still call
  NotePeerActivity/Reset after connectionKeepAlive returns. The closed
  guards prevented an immediate panic but the pattern was fragile.
  The ticker is unreferenced once HandleConn returns and GC reclaims
  it.

- ConnectionClosingCallback godoc now warns that channel handler
  goroutines may still be live when the callback fires; points to
  ConnectionCompleteCallback for all-work-done semantics.

- Unexported the context key for openChannelSet (was
  ContextKeyOpenChannels, now contextKeyOpenChannels) — the value
  type is unexported so the exported key was misleading.

- Trimmed NotePeerActivity godoc to drop the file-specific OpenSSH
  source citation while preserving the high-level semantic claim.
openChans is unconditionally created and stashed on ctx in HandleConn
before connectionKeepAlive can run, so the comma-ok and nil-check were
unreachable. Direct type assertion + dropping the guard simplifies
the probe-send sequence.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
keepalive.go (1)

74-100: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Keep generic peer activity separate from keepalive replies.

Line 84 updates lastReceived, and Line 99 uses that same field to enforce ClientAliveCountMax. Any inbound request therefore erases the missed-probe budget, so a peer can ignore every server keepalive and still stay connected by sending unrelated channel/global traffic. NotePeerActivity() should defer the next probe without resetting the state that TimeIsUp() uses to count unanswered probes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@keepalive.go` around lines 74 - 100, NotePeerActivity currently updates
lastReceived and therefore wipes the unanswered-probe count used by TimeIsUp;
change the logic so general inbound traffic only defers the next probe (call
ska.ticker.Reset) without touching the probe-response timestamp. Add a separate
field on SessionKeepAlive (e.g., lastKeepAliveResponse or lastProbeAck) that
TimeIsUp uses instead of lastReceived when computing clientAliveCountMax *
clientAliveInterval, and ensure the code path that processes actual keepalive
replies sets that new field. Update NotePeerActivity to stop modifying
lastReceived (or rename lastReceived to reflect probe responses) and update
TimeIsUp to reference the new probe-response field while leaving ticker.Reset
behavior unchanged.
server.go (1)

454-468: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stop the keep-alive ticker during connection teardown.

This leaves SessionKeepAlive running after HandleConn returns, but the same ctx is still handed to channel handlers and ConnectionClosingCallback, so the ticker stays reachable and can keep firing indefinitely for stuck handlers. Please close it as part of HandleConn cleanup instead of relying on it becoming unreachable.

Proposed fix
 	applyKeepAlive(ctx, srv.ClientAliveInterval, srv.ClientAliveCountMax)
+	defer ctx.KeepAlive().Close()
 	openChans := &openChannelSet{}
 	ctx.SetValue(contextKeyOpenChannels, openChans)

Also applies to: 523-531

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server.go` around lines 454 - 468, The connection keep-alive goroutine's done
channel (keepAliveDone) is currently closed with a defer here which may not run
at the correct time, leaving SessionKeepAlive tickers running; change the
teardown so the keep-alive ticker is explicitly stopped as part of HandleConn
cleanup: create keepAliveDone and start the goroutine via
srv.connectionKeepAlive(…, keepAliveDone) as now, but remove the defer
close(keepAliveDone) and instead close keepAliveDone during the connection
teardown path (e.g., at the end of HandleConn or inside the
ConnectionClosingCallback invoked there) so the goroutine stops
deterministically and SessionKeepAlive tickers are released. Ensure references
to keepAliveDone live long enough for handlers that need it and that closing it
is idempotent/safe for the goroutine reading it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@keepalive.go`:
- Around line 74-100: NotePeerActivity currently updates lastReceived and
therefore wipes the unanswered-probe count used by TimeIsUp; change the logic so
general inbound traffic only defers the next probe (call ska.ticker.Reset)
without touching the probe-response timestamp. Add a separate field on
SessionKeepAlive (e.g., lastKeepAliveResponse or lastProbeAck) that TimeIsUp
uses instead of lastReceived when computing clientAliveCountMax *
clientAliveInterval, and ensure the code path that processes actual keepalive
replies sets that new field. Update NotePeerActivity to stop modifying
lastReceived (or rename lastReceived to reflect probe responses) and update
TimeIsUp to reference the new probe-response field while leaving ticker.Reset
behavior unchanged.

In `@server.go`:
- Around line 454-468: The connection keep-alive goroutine's done channel
(keepAliveDone) is currently closed with a defer here which may not run at the
correct time, leaving SessionKeepAlive tickers running; change the teardown so
the keep-alive ticker is explicitly stopped as part of HandleConn cleanup:
create keepAliveDone and start the goroutine via srv.connectionKeepAlive(…,
keepAliveDone) as now, but remove the defer close(keepAliveDone) and instead
close keepAliveDone during the connection teardown path (e.g., at the end of
HandleConn or inside the ConnectionClosingCallback invoked there) so the
goroutine stops deterministically and SessionKeepAlive tickers are released.
Ensure references to keepAliveDone live long enough for handlers that need it
and that closing it is idempotent/safe for the goroutine reading it.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5c1b0980-48ab-4c39-9b9d-a65897dc8f91

📥 Commits

Reviewing files that changed from the base of the PR and between 15bc325 and e16d653.

📒 Files selected for processing (6)
  • context.go
  • keepalive.go
  • server.go
  • server_test.go
  • session.go
  • ssh.go

- New TestConnectionKeepAliveClosesStalledConnWithOpenSession exercises
  the channel-request path of dead-peer detection. Opens a session and
  has the client drain all request streams without replying; the server
  should still detect the stall via TimeIsUp and tear the transport
  down. Complements the existing no-session variant which exercises the
  global-request path.

- Unexported NotePeerActivity -> notePeerActivity. It is an internal
  hook driven by this package's request loops; external callers have no
  reason to invoke it. Doing this before v1.2.0 is cut avoids a v2
  break later.
@skevetter
skevetter force-pushed the feat/connection-keepalive-closing-callback branch from f80e595 to 1bd4e78 Compare May 25, 2026 21:28
Per independent review: rely on GC was option A; explicit Stop in
HandleConn's defer chain is option B. The ticker stays reachable
after HandleConn returns because ctx is held by spawned handler
goroutines and the per-channel request forwarder, so it kept firing
until those exited.

LIFO ensures close(keepAliveDone) runs first (signaling
connectionKeepAlive to return), then ctx.KeepAlive().Close() Stops
the ticker. All SessionKeepAlive methods are mutex-protected and
check closed before touching the ticker, so a straggler caller is
a safe no-op.
@skevetter
skevetter merged commit 7fa2baf into main May 25, 2026
6 checks passed
@skevetter
skevetter deleted the feat/connection-keepalive-closing-callback branch May 25, 2026 21:41
@devsy-app devsy-app Bot mentioned this pull request May 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant