Skip to content

(remote): push liveness over a persistent watch channel - #241

Merged
devsuitup merged 2 commits into
mainfrom
feat/remote-watch-channel
Sep 9, 2026
Merged

(remote): push liveness over a persistent watch channel#241
devsuitup merged 2 commits into
mainfrom
feat/remote-watch-channel

Conversation

@devsuitup

Copy link
Copy Markdown
Owner

Closes #240.

A remote session's state could be five minutes stale, and lowering the poll
interval only multiplies handshakes — the OpenSSH shipped with Windows has no
ControlMaster, so every cycle is a full connect plus authentication, measured
at 416–585 ms whatever the command.

This adds a persistent push channel per host alongside the periodic cycle. The
cycle stays exactly as it was: it remains the reconciliation pass, and the only
thing covering a host the channel cannot watch.

Shape

remote-watch.js — one long-lived ssh -tt child per alias running
inotifywait, one line per filesystem event, restarting on exit with the same
backoff remote-index.js already uses for host failures (hoisted and shared,
not a second policy).

Events are told apart at the source: .claude/sessions means a descriptor was
rewritten, .claude/projects means a transcript grew. Paths are checked with
the transport's existing isSafeRelPath; anything else is dropped rather than
guessed at.

remote-index.js gains refreshHostNow(alias) — the narrowest possible entry
point, guarded so an event-driven refresh and the periodic cycle can never run
on the same host at once. The signal lands in the existing notify throttle; no
second path to the renderer.

Verified against the live host, with a real session

The whole chain was run against the declared host — not only unit-tested. A
real Claude session was started on it in tmux and asked a question, with the
shipped buildSshArgs and parseWatchLine driving the channel:

+4s   session  3610858.json                            (descriptor rewritten on submit)
+5s   project  -tmp-sb-live/7eaba321-….jsonl  ×9        (transcript growing)
+11s  project  ×2
+13s  project  ×4
+14s  project  ×4
+14s  session  3610858.json                            (descriptor rewritten on idle)

The liveness signal arrives at once instead of waiting up to five minutes.

Orphans: measured twice, zero left behind. -tt is load-bearing — without a
TTY the remote inotifywait survives the local ssh being killed, one orphan per
restart, forever. A stdin-guard wrapper was tried and still leaked. With two
backgrounded watchers behind a wait, -tt still takes the whole process group
down.

Parser against real traffic: transcript and descriptor paths parse;
inotifywait's own startup chatter and any ../ traversal are dropped. Note
that with -tt the remote stderr merges into stdout, so that chatter does reach
the parser — it is dropped, by design.

One correction on top of the original commit

The coalescing window was one second. Since every coalesced signal opens a full
ssh cycle, a continuously writing session would have driven roughly one
connection per second — 3600 an hour against a host that gets 12 today, the
opposite of the point. It is now 15 s. The leading edge still fires at once, so
an idle-to-active transition is immediate; sustained activity costs at most four
connections a minute.

Degradation

inotifywait missing on a host is not a host failure: the channel marks that
host unwatchable, logs it once, and stops retrying. The periodic cycle already
covers it. inotify-tools was installed on the declared host for this work
(4.23.9.0); no other host is assumed to have it.

Mutation proofs

-tt dropped; the unwatchable guard removed; the coalescing gate disabled; the
path check removed; the backoff replaced with a linear formula; the
in-flight guard removed. Each turns its own test red and nothing else. The
backoff test had to chain three exits — a single exit cannot tell exponential
from linear at the first failure.

task check: 1184 pass, 0 fail, 8 pre-existing skips, lint 0 errors.

The periodic mirror pull (floored at 60s) stays as the reconciliation
path, but a live host no longer waits up to 5 minutes to show up: one
ssh -tt inotifywait child per alias now pushes a coalesced signal into
remoteIndexer.refreshHostNow(), the same refresh path the timer takes,
narrowed to the one host that changed. -tt is required to avoid
leaking an orphaned inotifywait on the remote host once the local ssh
is killed. A host missing inotifywait is marked unwatchable after one
attempt and left to the periodic cycle, never retried in a loop.
Every coalesced signal opens a full ssh cycle for the host. At a one-second
window a continuously writing remote session would drive roughly one connection
per second — 3600 handshakes an hour against a host that gets 12 today, the
opposite of what the watch channel is for.

The leading edge still fires at once, so an idle-to-active transition is
visible immediately; sustained activity now costs at most four connections a
minute.
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.

perf(remote): liveness is up to five minutes stale, and polling harder only multiplies handshakes

1 participant