Blog

The SSH tunnel survives the Mac going to sleep, yet nothing flows

After the Mac sleeps, the tunnel stops working, yet everything looks normal: the ssh process is still there, the local port still listens. What died is the underlying TCP connection, closed by the network during sleep, and SSH only finds out at the first write. That gap between appearance and reality is the whole failure, and two lines of configuration repair it.

Why is the process alive and the tunnel dead?

Because nobody told SSH. An idle TCP connection exchanges nothing: as long as no byte moves, neither end can know the other is gone. While the Mac sleeps, the world moves on without it: the server eventually closes the silent session, a NAT box forgets the mapping that carried it, a firewall drops the entry from its table. None of that produces any signal towards a sleeping machine.

TCP was built for exactly this ambiguity: an idle connection is indistinguishable from a dead one, by design, because telling them apart would require sending traffic. Keepalive probes exist precisely because the protocol cannot answer "is the other side still there?" without asking over the wire.

On wake, the ssh process resumes exactly where it stopped, holding a connection descriptor that no longer connects anything. It will only notice by trying to write to it, which is precisely the moment you use the tunnel. Hence the impression of an "unstable" tunnel: it is not unstable, it has been dead since wake and nothing told it so.

The Mac falls asleepthe TCP connection goes silentDuring sleepserver, NAT or firewall close itOn wakeprocess alive, port listeningFirst usethe write fails, finally visible
The timeline of the failure: the symptom appears long after its cause.

The test that tells the truth

Checking the local port proves nothing, and that is the central trap of this failure: nc -z localhost 5433 answers "succeeded" as soon as the process listens, even when the path behind it is dead. The proof is in the anatomy of 'connect failed: Connection refused', where that check succeeds while the connection fails.

The honest test makes a full round trip. Open a real connection through the tunnel, with a short timeout, and watch what the ssh process says at the same moment:

psql "host=localhost port=5433 connect_timeout=3" -c 'select 1'

Three outcomes. The query answers: the tunnel is truly alive. It fails immediately while the tunnel prints channel open failed: the SSH session is alive but the target stopped answering. It hangs, then times out while the tunnel says nothing: the SSH session itself is dead, and that is the sleep case.

ServerAliveInterval: what it repairs, and what it does not

By default, SSH never tests its own connection. Two lines change that:

Host *
  ServerAliveInterval 30
  ServerAliveCountMax 3

Every 30 seconds of silence, the client sends a probe; after 3 unanswered probes, it gives up and exits. That is half the work: a dead session is now detected in at most a minute and a half, instead of staying alive-looking forever. A process that dies is an honest state: your next command fails plainly instead of hanging.

What this setting does not do: reopen the tunnel. SSH notices the death, it does not organise the succession. It does not keep the connection alive through sleep either: no probe travels while the machine sleeps, and that is fine. Preventing sleep with caffeinate is not a fix for this failure, it is its denial: you do not handle waking up by abolishing sleep, you just pay for it in battery.

Reopening is the half that remains. Kestro reopens tunnels when the machine wakes, precisely because a live process proves nothing, and the guide to tunnels that keep dropping gives the full manual version, retry loop included.

What remains

The exact delay between wake and detection depends on the setting and on when the next probe leaves; we have not published a measured figure yet, it will come with its method. The same mechanism explains why this failure feels random: it depends on how long the machine slept, and on which piece of equipment between you and the server gave up first. There is also the network that changes on wake, from home Wi-Fi to the office: the connection is then dead for another reason, but the remedy is the same, detect fast and reopen. And if your tunnel dies with no sleep involved, mid-session, the symptom is close but the trail is different: start with 'bind: Address already in use' if reopening fails, or with the exact message the process prints.