Skip to main content
  1. Posts/

The tunnel was up. The socket existed. Nothing was on the other end.

··1851 words·9 mins·
Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
Deleting ClaudeDeck - This article is part of a series.
Part 5: This Article
The daemon reported the tunnel as established. The local socket file was right there on disk with the right permissions. The target sat at `offline` forever. Meanwhile `connect to /home/nick/.config/herdr/sessions/main/herdr.sock port 0 failed` was being written to a stderr stream that nothing in my process ever read, several times a second, for as long as I left it running.
Tested with herdr 0.8.0 · macOS

HerdDeck drives agents on a second machine. My laptop has the Stream Deck and the daemon; the desktop runs herdr and the agents actually doing work. They are joined by ssh -N -L <local.sock>:<remote.sock> host, a Unix-domain socket forward, and the daemon speaks the same protocol to a forwarded socket as it does to a local one.

That symmetry is the entire reason remote support was cheap. It is also what made this bug possible, because “I have a socket” is a much weaker statement than it looks.

ssh -L binds before it connects
#

A local forward binds the listening end immediately. It does not, and cannot, verify that anything is listening on the far side, because nothing tries to reach the far side until you open a connection through it.

So the moment ssh -N -L starts, you have a socket file. It is a real socket. You can connect() to it. And when you do, ssh opens a channel, asks the remote sshd to reach the target path, the remote sshd fails, and ssh writes a message about it to its stderr and closes your channel.

My daemon’s definition of “established” was the existence of that file.

Every failure mode on the far side therefore looked identical and looked fine. Remote herdr not running: tunnel up, target offline. Wrong absolute path in the config: tunnel up, target offline. Session socket not created because that named session had never been started on the remote: tunnel up, target offline.

Three different problems, one symptom, and the symptom actively pointed away from the cause, because the tunnel was the part reporting healthy.

The error was there the whole time
#

The failures are asynchronous. ssh does not fail at spawn; it fails per channel, later, on stderr. I was reading stderr during spawn to catch startup errors, then dropping the stream on the floor once the process was up.

Everything the debugger needed was in the part I stopped reading.

Two changes. First, stderr is drained for the tunnel’s entire lifetime into a small per-target ring buffer, capped, so a chatty failure cannot grow without bound and the last few dozen lines are always available for diagnostics. Second, and more important, establishing a tunnel now ends by proving it:

bind local socket  ->  ping-probe THROUGH the socket  ->  declare established

The probe is a real protocol ping through the forwarded socket with a 4-second timeout. If it fails, the tunnel is not established, regardless of what the filesystem says, and the failure joins the retry path with the stderr tail attached to the error.

The observable difference is the whole point. Before: tunnel green, target offline indefinitely, zero clues. After: it fails within seconds with channel 1: open failed: connect failed and the full -L path mapping in the log, which names the wrong path directly.

ControlMaster pollution, in both directions
#

This one I would never have found by reading code, because the bug lived in the interaction between correct code and my personal SSH config.

Most developers have ControlMaster auto set. It multiplexes SSH sessions to the same host over one connection, which is why your second ssh to a box you are already on connects instantly. It is a good default and I have had it on for years.

A long-lived tunnel joining that multiplexing arrangement fails in two directions, and the second is nastier.

herddeck tunnelControlMasteryour shellherddeck tunnelControlMasteryour shelltunnel now depends ona connection it does not ownssh desktop (opens master)ssh -N -L (muxes onto master)exitControlPersist expires, tunnel dies
herddeck tunnelControlMasteryour shellherddeck tunnelControlMasteryour shelltunnel now depends ona connection it does not ownssh desktop (opens master)ssh -N -L (muxes onto master)exitControlPersist expires, tunnel dies

Direction one: the tunnel dies when an unrelated master connection closes, or when its ControlPersist timer expires. Your daemon’s connectivity is now coupled to whether you happen to have a terminal open.

Direction two: if the tunnel gets there first, it becomes the master. Every subsequent interactive ssh to that host attaches to a connection owned by a background daemon. Restart the daemon and you drop the shells of anyone attached to it.

The fix is to refuse to participate:

// Own the connection lifecycle: with the user's ControlMaster
// auto config the tunnel would otherwise mux onto (or become) a
// shared master and its forward would die with that master's
// ControlPersist timer (found live in Tier-1 testing).
"-o", "ControlMaster=no",
"-o", "ControlPath=none",
"-o", "ExitOnForwardFailure=yes",

A tunnel must own its connection. Borrowing one couples its lifetime to something the daemon does not control and cannot observe.

Not every failure deserves a retry
#

launchd starts the daemon at login, which on a laptop means before Wi-Fi has associated and well before a VPN is up. The first tunnel attempt fails, and in the original code that failure was permanent until I restarted the daemon by hand.

Retrying everything is the wrong correction. Retrying a wrong passphrase forever is how you get an account locked out, and retrying a host-key mismatch forever is how you sit in a loop ignoring a security warning. So failures get classified by what ssh said:

ssh stderr containsClassBehaviour
Permission deniedpermanentno retry, surfaced as auth
Host key verification failedpermanentno retry, surfaced as auth
Bad configurationpermanentno retry
Bad owner or permissionspermanentno retry
everything elsetransientcapped jittered retry, 15s to 10min

Connection refused, timeout, network unreachable and DNS resolution failures all default to transient, and DNS is the interesting one. Could not resolve hostname looks permanent and is completely ambiguous under a VPN that has not come up yet. The default is to retry, because the cost of retrying a genuinely dead hostname is a log line every ten minutes, and the cost of not retrying is a daemon that is dead until you notice.

The loud error fires once per target rather than once per retry, which is the difference between a diagnostic and a log flood.

localhost is a valid far side
#

The technique that made most of this testable, and the part I would steal if I were reading someone else’s post:

“Remote” here means the far side of an ssh streamlocal forward. It does not mean “a different machine”. Point the forward at localhost and you get a genuine SSH forward, with a genuine remote sshd, a genuine second herdr socket path, and genuine channel-open failures, on one laptop with no second machine and no network.

Wrong path, remote daemon down, socket missing, sshd refusing connections, first-connect-before-network: all of those reproduce against sshd on localhost. Only latency and actual link loss need real hardware.

One caveat about --remote, because the obvious guess is wrong
#

herdr has a --remote flag and it does not do what you would assume from the outside.

It makes the human terminal experience a thin client. It does not expose a local API socket that proxies the remote server. The local herdr-client.sock is a TUI attach endpoint, so a daemon cannot piggyback on it and needs its own forward.

There is a consequence to that which bit me later. herdr auto-syncs versions between client and server, and that sync fires on a human --remote attach. It never fires for the daemon’s forward. So a machine you rarely attach to by hand can quietly drift to an older protocol while the daemon happily keeps talking to it. Every target is therefore pinged and version-checked on connect and degraded to a warning state on mismatch, rather than assumed compatible.

When not to guess
#

I want to end on the one place in this system where the correct behaviour is to do nothing, because multi-target is what creates it.

Each herdr server tracks its own focused pane. With two targets connected, two panes can each legitimately report that the user is looking at them, and the daemon has no way to know which screen you are actually in front of.

The physical Enter key sends a keystroke to the focused agent. Guessing wrong there does not display something incorrect. It submits someone else’s prompt, to a different agent, on a different machine, with whatever half-written text was sitting in that pane.

getFocusedAgent(): AgentSnapshot | undefined {
  if (this.focused) {
    return this.agentsList.find((a) => sameAgent(a, this.focused));
  }
  const herdrFocused = this.agentsList.filter((a) => a.focused);
  // More than one target can each report a focused pane; only an
  // unambiguous single candidate is safe to act on.
  return herdrFocused.length === 1 ? herdrFocused[0] : undefined;
}

An explicit slot press always wins, because that is you telling it. Absent that, one candidate is a fallback and two candidates is nothing. The refusal has its own test, with the reasoning written into the test body, because it is the kind of behaviour a future refactor would happily “fix” into a [0].

Lessons
#

  • A bound socket is not a connected socket. ssh -L binds before anything reaches the far end, so the file existing proves only that ssh started.
  • End every “establish” with a probe that exercises the thing you are claiming works. Reachability is the claim, so make a round trip and let that be the definition.
  • If a process fails asynchronously on stderr, drain stderr for its whole life. Reading it only at startup means the exact bytes naming your bug are being written and discarded while you debug.
  • A long-lived tunnel must own its SSH connection. Muxing onto a user’s ControlMaster couples the daemon to a terminal, and winning the race to become the master is worse, because now your restart drops their shells.
  • Classify failures before retrying them. Auth and host-key failures should never retry; timeouts, refusals and DNS should, because DNS under a sleeping VPN is indistinguishable from DNS against a dead host.
  • Log the loud error once per target, not once per retry. A diagnostic that repeats every fifteen seconds is a log flood that people filter out.
  • localhost is a valid far side. Most of a remote-path test matrix runs against sshd on the same machine, and only latency and link loss need real hardware.
  • When ambiguity could take a destructive action, do nothing and test that you do nothing. Sending Enter to the wrong agent submits someone else’s prompt.

References
#

Deleting ClaudeDeck - This article is part of a series.
Part 5: This Article

Related

The ring was 1.00:1 against its background. The key read 35% at 65%.

··1541 words·8 mins
A key on my Stream Deck showed an agent with a context window 65% full. The ring around it displayed a little over a third of a circle, which anyone would read as 35%. Not blank, not obviously broken, no missing-data placeholder. Confidently wrong, by exactly the amount that inverts the decision you are making when you look at it. Deleting ClaudeDeck · Part 4 of 5 1 2 3 4 5 🧪 Tested with herdr 0.8.0 · macOS That is the hardware, live against five agents on a remote machine. The top row is one key per agent. The tint is that session’s lifecycle state, and the ring is how full its context window is. Two independent signals, one key.

launchctl unload returned 0. The daemon was still running. KeepAlive raced.

··1797 words·9 mins
`launchctl unload ~/Library/LaunchAgents/com.nickboy.claudedeck.plist` exited 0. Then `pgrep -f claudedeck-daemon` printed a fresh PID. Three seconds after the "unload succeeded" line. Spoiler: KeepAlive is a polling supervisor, not an event-driven one, and when you tell launchd to tear a job down, there is a window where the supervisor has already noticed the previous PID is gone and started a replacement. Building ClaudeDeck · Part 10 of 10 1 2 3 4 5 6 7 8 9 10 (One-paragraph grounding if launchd isn’t your daily driver: launchd is macOS’s init system, the equivalent of systemd on Linux or Windows Services on Windows. It boots PID 1, brings up daemons, restarts them when they crash. A LaunchAgent is a per-user launchd job, defined by an XML plist (property list) at ~/Library/LaunchAgents/<name>.plist. KeepAlive is one of the plist keys; set it to true and launchd will respawn the job whenever it exits. launchctl is the CLI you use to load, unload, and inspect those jobs. The Linux mental model: think systemctl driving systemd unit files. The Stream Deck plugin and its daemon are described in the TCC cdhash trap post if you want the project context.)

Every context ring read 10%. Two bugs, and fixing one hid the other.

··1570 words·8 mins
Each agent key on the Stream Deck draws a ring showing how full that session's context window is. Mine sat at 10% for days. Every session, regardless of activity, regardless of how long the agent had been grinding. I found the bug, fixed it, watched agents start reporting correct percentages every single turn, and the ring still said 10%. That was the good part, because it meant there were two. Deleting ClaudeDeck · Part 3 of 5 1 2 3 4 5 🧪 Tested with Claude Code 2.1.x · macOS Ten percent is a suspicious number. Not zero, which would say “nothing ever arrived”. Not a plausible-looking 37%, which would say “this works and your session is small”. Ten percent is round, and round numbers in a display that should be noisy mean the display is not reading anything.