Repository navigation
High CPU usage (300%+) caused by keepAliveLoop goroutine leak and nil pointer panic in handleWebsocket #608
Description
Description
When running chisel server in a Docker container, CPU usage spikes to 300%+ after client sessions disconnect. The process consumes excessive CPU even with no active tunnels, and eventually becomes unresponsive.
Environment
- Chisel server: v1.11.8
- Chisel client: v1.11.7 (version mismatch observed)
- Running in Docker container
- Reverse tunnel enabled (
-R) - Reverse proxies:
R:2333=>22
Observed Behavior
Top output:
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
3630019 root 20 0 1270408 16980 7048 S 344.0 0.1 8:15 bin
Goroutine stack traces show:
- Multiple
keepAliveLoopgoroutines stuck in[runnable]state, spinning onssh.(*mux).SendRequest - Nil pointer dereference panic in
handleWebsocketatserver_handler.go:94
Logs show:
- Clients rapidly reconnecting with exponential backoff (sessions [Feature Request] add X-Forwarded-For header #4 through Add proxy support #14 in ~7 minutes)
- Version mismatch warnings on every connection
Root Cause Analysis
Bug 1: keepAliveLoop goroutine leak (High)
share/tunnel/tunnel.go:178-193 — The keepAliveLoop function has no context.Context parameter and can only exit when sshConn.SendRequest returns an error:
func (t *Tunnel) keepAliveLoop(sshConn ssh.Conn) {
for {
time.Sleep(t.Config.KeepAlive) // no context awareness
_, b, err := sshConn.SendRequest("ping", true, nil)
if err != nil {
break
}
if len(b) > 0 && !bytes.Equal(b, []byte("pong")) {
t.Debugf("strange ping response")
break
}
}
//close ssh connection on abnormal ping
sshConn.Close()
}When an SSH connection dies:
- The goroutine may be sleeping in
time.Sleepand won't detect the failure until the nextSendRequestcall (delay up toKeepAliveinterval) SendRequeston a closed mux returns immediately without blocking — the goroutine enters a rapid cycle of:sleep → wake → SendRequest(immediate error) → break → Close → exit- Multiple such goroutines from disconnected sessions remain in
[runnable]state simultaneously, causing the Go scheduler to consume excessive CPU managing transitions
With rapid client reconnect cycles (observed in logs), new keepAliveLoop goroutines are spawned on each BindSSH call before the previous ones have exited, causing goroutine accumulation.
Bug 2: Nil pointer dereference panic (Critical)
server/server_handler.go:94 — When the SSH connection closes before the client sends a "config" request, the reqs channel is closed. Reading from a closed channel returns the zero value (nil for *ssh.Request):
select {
case r = <-reqs: // r = nil when channel is closed
case <-time.After(10 * time.Second):
...
}
if r.Type != "config" { // nil pointer dereference → panic!This panic kills the handleWebsocket handler, leaving associated goroutines (including keepAliveLoop) orphaned with no parent context to clean them up, further contributing to the goroutine leak.
Suggested Fixes
| Priority | Fix | Location |
|---|---|---|
| Critical | Add nil check: if r == nil { return } after receiving from reqs channel |
server_handler.go:84-88 |
| High | Pass context.Context to keepAliveLoop, replace time.Sleep with context-aware select + time.After |
tunnel.go:178-193 |
| High | Ensure session disconnect explicitly cancels all spawned goroutines | server_handler.go, tunnel.go |
Reproduction
- Run chisel server with reverse tunnel:
chisel server -R --reverse - Connect a client with version mismatch (1.11.7 client → 1.11.8 server)
- Disconnect the client (network interruption or manual stop)
- Observe CPU usage climb as
keepAliveLoopgoroutines accumulate - Reconnect repeatedly to accelerate the issue
Activity
KiyoNetcat commentedon Jul 17, 2026
i've observed this behavior outside of docker as well; specifically, i'm using the version distributed by nixos/nixpkgs:
chisel is being invoked with the following parameters:
logs from chisel server are showing this for me:
looking back at cpu usage for my server, i noticed it started spiking at right about the same time the second set of connections occured here
fix(server): don't panic when client drops before config request
1.12: reliability & security pass (#609)
jpillora commentedon Jul 17, 2026
v1.12.0-rc1 fixes both bugs reported here: the keepalive loop now exits when the connection dies (and pings time out via
CHISEL_PING_TIMEOUT, so no more spinning goroutines), and the nil-pointer panic inhandleWebsocketis guarded — a client disconnecting before its config request no longer panics the handler (with a regression test).Please try out https://github.com/jpillora/chisel/releases/tag/v1.12.0-rc1 and report any issues in #610
ojura commentedon Aug 29, 2026
The high CPU isn't a goroutine leak, and it isn't in chisel's code. It's a bug in
golang.org/x/crypto/sshv0.52.0, which is the exact version v1.11.8 pins.v0.52.0 added a drain step at the top of
(*mux).SendRequest, before the message goes out:The only way out is
default:, and that needsm.globalResponsesto be empty and open. When theSSH transport dies,
mux.loop()runsclose(m.globalResponses). Receiving from a closed channelalways succeeds right away, so
default:never gets picked and the loop runs forever.So
keepAliveLoopsleeps its 25s, callsSendRequest("ping", true, nil), and never comes back out.The drain loop sits before
sendMessage, so the write that would have failed on the dead socketnever happens, which means
if err != nil { break }never fires andsshConn.Close()never runs.v0.51.0 doesn't have the drain loop at all, and v0.53.0 fixed it by checking the receive
(
case _, ok := <-m.globalResponses:withif !ok { break drain }). v0.52.0 is the only brokenone, and it's what v1.11.8 depends on.
On a live 1.11.8 server here (x86-64 Linux, reverse tunnel, two dead sessions), perf puts 88% of
8048 samples in one stack:
and the compiled loop only tests the first return value of
selectnbrecv:CPU sits at 79929s over 42106s elapsed, so a flat 1.9 cores, one per dead session, and it never
moves. The spinning threads make zero syscalls (
/proc/<tid>/syscallreadsrunning), which rulesout scheduler overhead and confirms nothing is being written. Only six fds are open: stdio, the log,
the listen socket, epoll, eventfd. No connection sockets left, every session long gone, CPU still
never drops.
runtime.emptyalso shows up as a hot leaf at 22%, which happens becausechanrecvcalls
empty(c)twice on the closed-channel path but only once when the channel is open and empty.Two things in the report above are off.
SendRequeston a closed mux doesn't return immediatelywith an error, it doesn't return at all, so the goroutines aren't cycling through sleep, wake,
error, exit. Each one is stuck inside a single
SendRequestcall for the rest of the process'slife. And the CPU isn't the scheduler moving runnable goroutines around, it's one real infinite loop
per dead session, each sitting on a core at 100%. That changes the fix, because making
time.Sleepcontext-aware won't help a goroutine that never gets back to the sleep.
v1.12.0-rc3 does fix this, but it's the bump to x/crypto v0.54.0 doing the work, not the ping
timeout. On v0.52.0 the timeout wrapper wouldn't have been enough:
If
SendRequestnever returns, the timeout letskeepAliveLoopexit but the goroutine it justspawned keeps spinning. The core stays pegged, and now nothing in the stack even points at the
keepalive loop. That only matters if any of this gets backported to 1.11.x without the dependency
bump, but worth knowing.
Last thing: v1.11.8 is still the latest stable and it's the version distros package (nixpkgs, per the comment
above). Anyone on it loses a core for good on every dropped session until they restart. Master and
the v1.12.0 pre-releases already have x/crypto v0.54.0. Could you release a v1.11.9 with just that
one-line bump? It'd fix the stable line without waiting for v1.12.0 to settle.
RDODCP-914: Chisel Sync with Upstream and dependency Updates (#60)
!18047 merge bump-chisel-tunnel-1.12.0 into main
jpillora commentedon Oct 11, 2026
Closed in e588d21, released in v1.12.0.
This updates
x/cryptofrom v0.52.0 to v0.54.0, fixing the SSHSendRequestinfinite loop, and guards the nil config request inhandleWebsocket. It also adds keepalive timeouts and exits the keepalive loop when the connection closes. Thanks @ojura for pinpointing the dependency bug; the dependency update is what fixes the CPU spin.