Summary
After a laptop suspend/resume cycle, the first prompt sent in an already-open cmd session stalls: the spinner runs, no tokens appear for anywhere from ~5 seconds to several minutes, then the turn either completes (displayed as "Worked for Xm Ys") or just hangs until i killed the process. Later prompts work normally. The request is being written into a pooled keep-alive connection whose network path died during sleep. The client never detects the stall and never retries.
Expected Behavior
The stale connection gets thrown away (or the request gets retried on a fresh one),
and the prompt answers at normal speed.
Actual Behavior
The app writes the request into a keep-alive connection whose network path died while the laptop was asleep. TCP on both ends still looks alive (no RST from anywhere), so the request just sits in the kernel's send queue while retransmits grind away with exponential backoff. The app never notices, no timeout fires, no retry, no error.
Eventually one of the retransmits punches through the revived path and the turn completes ("Worked for 1m 57s"). Measured time-to-first-token across three suspend cycles: 3.2s / 23.2s / 46.9s, scaling with how long I slept. Baseline is ~0s.
Steps to reproduce the issue
- Open
cmd in a terminal and have a little conversation with it (this leaves a keep-alive connection to the API sitting in the pool).
- Suspend the laptop. 5 minutes is a reliable trigger.
- Wake up and immediately send a prompt.
- Watch the spinner run with 0 tokens for anywhere from ~5 seconds to several minutes.
Command Code Version
1.50.1
Operating System
Linux
Terminal/IDE
Ghostty, ran through Zed's integrated terminal tab.
Shell
fish
Session file (optional)
No response
Fix prompt (optional)
No response
Additional context
Went a bit overboard with instrumentation on this one, so here's the whole story for anyone investigating. Big caveat up front: this is one laptop, one home network, four trials, and I'm reading kernel counters off ss — so take my interpretations with a grain of salt. TL;DR at the bottom.
The setup
After a couple of "it hung again" episodes I wanted to try to catch one in the act, so before the next suspend I armed:
- Node inspector on the running
cmd process (SIGUSR1 → CDP websocket), so I could grab main-thread stacks while it was frozen
- A socket monitor logging
ss -tnpoie for that PID at 2 Hz, so I'd have a full TCP timeline across the suspend
- A winsize logger on the terminal's pts (5 samples/sec), to check the "stale window size" theory
- A little CDP probe script that pauses the runtime, grabs 14 stack frames +
process.stdout.columns/rows + active handles, then resumes
Specimen: command-code 1.50.1, Node v23.11.1, Arch Linux (zen 7.2.2), Hyprland/Wayland, Zed integrated terminal.
Trial 1 — short suspend: clean
Slept for ~8 seconds with a pooled connection up. Connection survived, request went out fine. First useful (if unsurprising) data point: short suspends seem to leave the path alive — it took a real sleep to trigger anything.
Trial 2 — 5-minute suspend: clean (and instructive)
Slept 5 minutes... and got instant TTFT. The monitor log suggested why: the pooled connection had already closed, roughly 50s after the last response, before I suspended. I sent the prompt a minute after waking, so the client opened a fresh connection — nothing stale to hit. So: the pool's idle lifetime after a response looks like ~55s (on my setup, at least), and the bug seems to need the connection to still be pooled at the moment of suspend.
Trial 3 — 5-minute suspend: HUNG (the interesting one)
Sent a quick prompt, suspended within that ~50s window, woke up, sent the real prompt — wedge. As far as I can reconstruct from the logs:
18:53:05 socket to Cloudflare edge ESTAB; response streams; pool goes idle
18:53:13 PRE-SUSPEND: ESTAB, Recv-Q 0, Send-Q 0, keepalive healthy ← precondition met
18:53:14 → 18:58:33 SUSPEND (319s)
18:58:33 resume. SAME socket (same local port, same inode 9627122) still ESTABLISHED.
My prompt (144 bytes) gets written into it... and appears to STICK in Send-Q.
18:58:33-38 Send-Q pinned at 144B, persist timer probing: 309ms → 477ms → 482ms backoff
18:59-19:00 retransmit grind continues
19:00:19 Send-Q finally drains — presumably a probe/retransmit punched through
19:00:40 journal finally records the turn (2m07s after I hit enter)
19:01:00 ss -i: bytes_retrans: 443 ≈ 3× the request size, RTT back to 43.9ms, streaming fine
The UI showed "Worked for 1m 57s". I never resized, never touched the pane, no errors anywhere.
What it was NOT (checked live, on the frozen process — this part I'm fairly confident in)
- Doesn't look like a renderer wedge: CDP stacks ×3 — main thread idles in
processTimers, not a single calculateLayout frame. Event loop seemed healthy, just ticking spinner timers.
- Doesn't look like a starving renderer:
Recv-Q stayed flat 0 the entire wedge — no bytes ever arrived from the server.
- Not a spin loop: CPU delta 10 ticks / 3s (~3% of one core).
- Not a stale-winsize thing:
stdout dims sane the whole time (53x60), SIGWINCH listeners normal.
- My reading: the app is sitting on an active
TCPSocketWrap with a pending read, waiting for a response that never comes, with the request stuck ~144 bytes deep in the kernel send queue. Happy to be corrected on the TCP details.
One honest caveat
The first time it recovered, recovery coincided with my CDP probing (each probe pauses/resumes the runtime, and a timer that expired during the pause fires in a burst on resume — which might abort+retry the request). I also can't rule out plain TCP backoff finally getting through on its own. So "what exactly ends the stall" is the one part I haven't nailed down, and my probing may have influenced it. It doesn't change the onset story, though — the request clearly lands in a zombie connection, and the app doesn't appear to notice.
Trial 4 — 5-minute suspend control: clean (and this might explain the intermittency)
Ran the identical experiment again. This time: instant TTFT. The monitor log shows the difference:
- Hang cycle: the zombie socket stayed silently
ESTABLISHED after resume → my request fell into it → minutes-long stall.
- Clean cycle: the zombie died within 12 seconds of resume — looks like an RST (a silent keepalive failure would take minutes of probe retries, not 12s), though I can't fully rule out something else closing it — and it died before I sent anything → fresh connection → instant answer.
My best guess: the bug is a race — if the dead path answers with an RST, the pool recovers instantly; if the zombie stays half-alive, the first request falls into the void. Same suspend duration, opposite outcomes. That might be why this reproduces for some people/cycles and not others, though n=2 here, so, grain of salt.
Bonus oddity: the journal
The hung turn leaves no journal entry until recovery. When it finally lands, the user message and the assistant entry share the exact same millisecond timestamp — 2m07s after the prompt was actually sent. So it looks like the client only persists the turn once the request gets through.
TL;DR
Suspend seems to kill the network path behind the pooled keep-alive connection while the TCP endpoint itself survives, and no RST arrives to tell anyone. The first post-resume request gets written into this zombie connection, stalls in Send-Q, and the app never notices — no timeout, no retry, no error. Recovery is whatever TCP retransmits (or an external kick like a terminal resize) manages on its own, which would explain the 5s-to-minutes TTFT inflation scaling with suspend length. If that's roughly right, any of these would fix it: drain the undici pool on D-Bus PrepareForSleep, detect the suspend via the CLOCK_BOOTTIME/MONOTONIC gap, or just retry-once when no response headers arrive within ~5s. Even if my mechanism reading is off, a "no response headers in N seconds → new connection" fallback seems like it would paper over the whole class of problem.
Summary
After a laptop suspend/resume cycle, the first prompt sent in an already-open
cmdsession stalls: the spinner runs, no tokens appear for anywhere from ~5 seconds to several minutes, then the turn either completes (displayed as "Worked for Xm Ys") or just hangs until i killed the process. Later prompts work normally. The request is being written into a pooled keep-alive connection whose network path died during sleep. The client never detects the stall and never retries.Expected Behavior
The stale connection gets thrown away (or the request gets retried on a fresh one),
and the prompt answers at normal speed.
Actual Behavior
The app writes the request into a keep-alive connection whose network path died while the laptop was asleep. TCP on both ends still looks alive (no RST from anywhere), so the request just sits in the kernel's send queue while retransmits grind away with exponential backoff. The app never notices, no timeout fires, no retry, no error.
Eventually one of the retransmits punches through the revived path and the turn completes ("Worked for 1m 57s"). Measured time-to-first-token across three suspend cycles: 3.2s / 23.2s / 46.9s, scaling with how long I slept. Baseline is ~0s.
Steps to reproduce the issue
cmdin a terminal and have a little conversation with it (this leaves a keep-alive connection to the API sitting in the pool).Command Code Version
1.50.1
Operating System
Linux
Terminal/IDE
Ghostty, ran through Zed's integrated terminal tab.
Shell
fish
Session file (optional)
No response
Fix prompt (optional)
No response
Additional context
Went a bit overboard with instrumentation on this one, so here's the whole story for anyone investigating. Big caveat up front: this is one laptop, one home network, four trials, and I'm reading kernel counters off
ss— so take my interpretations with a grain of salt. TL;DR at the bottom.The setup
After a couple of "it hung again" episodes I wanted to try to catch one in the act, so before the next suspend I armed:
cmdprocess (SIGUSR1 → CDP websocket), so I could grab main-thread stacks while it was frozenss -tnpoiefor that PID at 2 Hz, so I'd have a full TCP timeline across the suspendprocess.stdout.columns/rows+ active handles, then resumesSpecimen: command-code 1.50.1, Node v23.11.1, Arch Linux (zen 7.2.2), Hyprland/Wayland, Zed integrated terminal.
Trial 1 — short suspend: clean
Slept for ~8 seconds with a pooled connection up. Connection survived, request went out fine. First useful (if unsurprising) data point: short suspends seem to leave the path alive — it took a real sleep to trigger anything.
Trial 2 — 5-minute suspend: clean (and instructive)
Slept 5 minutes... and got instant TTFT. The monitor log suggested why: the pooled connection had already closed, roughly 50s after the last response, before I suspended. I sent the prompt a minute after waking, so the client opened a fresh connection — nothing stale to hit. So: the pool's idle lifetime after a response looks like ~55s (on my setup, at least), and the bug seems to need the connection to still be pooled at the moment of suspend.
Trial 3 — 5-minute suspend: HUNG (the interesting one)
Sent a quick prompt, suspended within that ~50s window, woke up, sent the real prompt — wedge. As far as I can reconstruct from the logs:
The UI showed "Worked for 1m 57s". I never resized, never touched the pane, no errors anywhere.
What it was NOT (checked live, on the frozen process — this part I'm fairly confident in)
processTimers, not a singlecalculateLayoutframe. Event loop seemed healthy, just ticking spinner timers.Recv-Qstayed flat 0 the entire wedge — no bytes ever arrived from the server.stdoutdims sane the whole time (53x60), SIGWINCH listeners normal.TCPSocketWrapwith a pending read, waiting for a response that never comes, with the request stuck ~144 bytes deep in the kernel send queue. Happy to be corrected on the TCP details.One honest caveat
The first time it recovered, recovery coincided with my CDP probing (each probe pauses/resumes the runtime, and a timer that expired during the pause fires in a burst on resume — which might abort+retry the request). I also can't rule out plain TCP backoff finally getting through on its own. So "what exactly ends the stall" is the one part I haven't nailed down, and my probing may have influenced it. It doesn't change the onset story, though — the request clearly lands in a zombie connection, and the app doesn't appear to notice.
Trial 4 — 5-minute suspend control: clean (and this might explain the intermittency)
Ran the identical experiment again. This time: instant TTFT. The monitor log shows the difference:
ESTABLISHEDafter resume → my request fell into it → minutes-long stall.My best guess: the bug is a race — if the dead path answers with an RST, the pool recovers instantly; if the zombie stays half-alive, the first request falls into the void. Same suspend duration, opposite outcomes. That might be why this reproduces for some people/cycles and not others, though n=2 here, so, grain of salt.
Bonus oddity: the journal
The hung turn leaves no journal entry until recovery. When it finally lands, the user message and the assistant entry share the exact same millisecond timestamp — 2m07s after the prompt was actually sent. So it looks like the client only persists the turn once the request gets through.
TL;DR
Suspend seems to kill the network path behind the pooled keep-alive connection while the TCP endpoint itself survives, and no RST arrives to tell anyone. The first post-resume request gets written into this zombie connection, stalls in
Send-Q, and the app never notices — no timeout, no retry, no error. Recovery is whatever TCP retransmits (or an external kick like a terminal resize) manages on its own, which would explain the 5s-to-minutes TTFT inflation scaling with suspend length. If that's roughly right, any of these would fix it: drain the undici pool on D-BusPrepareForSleep, detect the suspend via the CLOCK_BOOTTIME/MONOTONIC gap, or just retry-once when no response headers arrive within ~5s. Even if my mechanism reading is off, a "no response headers in N seconds → new connection" fallback seems like it would paper over the whole class of problem.