Skip to content

feat(remote): wake a sleeping host (Wake-on-LAN) from input, banner and native magic packet - #439

Open
Randalix wants to merge 14 commits into
Ark0N:masterfrom
Randalix:feat/remote-host-wake
Open

Randalix wants to merge 14 commits into
Ark0N:masterfrom
Randalix:feat/remote-host-wake

Conversation

@Randalix

@Randalix Randalix commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

What this does

Adds Wake-on-LAN for remote (SSH) sessions, so a sleeping host can be woken instead of silently swallowing input.

A remote host is armed by either of two RemoteHost fields in remote-hosts.json:

  • wakeMac (new, the normal case) — a comma-separated MAC list. Codeman builds and sends the magic packet itself over dgram (UDP 9, broadcast), dependency-free.
  • wakeCommand (kept) — a single executable path, run without a shell (spawn(command, [], { stdio: 'ignore' })), for waking via a router, another machine, or a script. It takes precedence over wakeMac when both are set.

Two triggers, both gated on a real user action:

  • InputPOST /api/sessions/:id/input probes the host (bare TCP connect, throttled to one probe per 30 s per session, wake-enabled hosts only) and, if it is down, wakes it, calls reattachRemote(), and flushes buffered input in order.
  • Banner — an amber "host not reachable" banner with a Wake button, backed by GET /api/sessions/:id/reachability (probe only, never wakes) and POST /api/sessions/:id/wake. With no wake target configured, the button opens the existing host-config dialog pre-filled for that host.

Closes #433.

The two invariants

#1 — only user input or an explicit wake request may wake a host. The auto-reconnect watcher (COD-108), handleRemoteSessionDropped and boot recovery have no access to the registry. A wake there would re-wake the host seconds after every suspend, so it could never stay asleep — a worse bug than the one this fixes. This is asserted as a wiring guard (a src/ scan in test/remote-wake.test.ts), not a comment. GET …/reachability probes and never wakes (regression test: probe a sleeping fake host → the wake spy stays empty).

#2 — no ServerAliveInterval. Keepalives would move bytes into an otherwise idle connection every interval, which is exactly what the byte-threshold idle detector on the sleeping host must not read as activity. The probe (~200 B / 30 s) sits far below it and cannot wake anyone by SYN. The reasoning paragraph lives in docs/architecture-invariants.md, where you asked for it, so the next person does not "fix" the stalled pane by adding keepalives.

wakeCommand gating

The config dialog writes through PUT /api/remote-hosts/:id, which is already admin-only in multi-user mode (adminOnly, case-routes.ts:682) — same as the rest of the host config — so a non-admin cannot name an executable for the server to run. The schema accepts a single executable path only (no arguments, no $/backticks), and it is spawned with shell: false.

Pending buffer

  • Bounded at 4 KB per session, drop-oldest, with a log line ([RemoteWake] pending buffer cap reached for session … — oldest input dropped). Keeping the tail preserves what the user just typed; a silently unbounded buffer keyed on user input would be a memory leak.
  • Memory-only. It lives in the per-session registry state and goes away with the session — nothing is persisted. So if the wake fails and the host stays down, the keystrokes are held for the life of that session and then discarded. (Dropping them immediately was also defensible; this is the one I chose, so it is spelled out rather than left to inference.)
  • If a flush write fails part-way, the remaining chunks are retained in the same in-memory buffer, not dropped.
  • Send-and-wait blocks on the wake instead of buffering, because buffering would break the wait contract.

The dgram finding (in the code, not just the commit message)

setBroadcast() on an unbound socket throws EBADF on Linux, the following send fails with EACCES, and the function reports success — the magic packet never left the machine and it looked like "the host just did not come back". The order is load-bearing, so the broadcast flag is set inside the bind callback, with a ⚠️ comment at the call site (src/remote-wake.ts). The socket is injectable so a test asserts the bind → setBroadcast order (a real UDP broadcast in CI would be unwelcome). This is the bug the live test caught; unit tests with mocks would never have found it.

Testing

  • New: test/remote-wake.test.ts (decision table, single-flight, buffer order + drop-oldest, wiring guard, socket order), test/routes/session-remote-wake.test.ts (route behavior), test/sse-dispatch-table.test.ts (frontend SSE dispatch guard).
  • Extended: test/remote-hosts.test.ts (schema + rehydration), test/mocks/mock-session.ts.
  • tsc --noEmit, eslint, prettier, check:frontend-syntax, check:public-assets — clean.
  • Full npm test: 7303 passed, 15 skipped.
  • Live against a real sleeping machine: the magic packet brought the host back over SSH in ~9 s; POST /api/sessions/:id/wake returned {woke:true, reachable:true} in 12 s including reattach, and the durable remote tmux session — and the agent conversation — survived the suspend. The banner was verified in a real browser (appears, Wake → "Waking…" → gone in 10.0 s, no console errors).

test/quick-start.test.ts is red in my environment only: it binds 127.0.0.1:3100, which a local Docker container already holds (EADDRINUSE). Unrelated to this change — the port comes from TEST_PORT + 1 off 3099, which is why grepping the file for 3100 finds nothing. Flagging it in case the suite is not fully green for you either.

Review pass over the branch (commit 8dfc965d) found and fixed two things, both worth knowing since they are the silent-failure kind:

  • _onRemoteHostWaking / _onRemoteHostWakeFailed were defined in two frontend modules (panels-ui.js for the toast, host-wake-ui.js for the banner). Both mix into CodemanApp.prototype and host-wake-ui.js loads later, so the toast copy was silently shadowed — and a wake for a background session produced no notification at all. The handlers now live only in host-wake-ui.js.
  • appendBoundedPending dropped only whole chunks, so a single input value over the cap (one large paste is one input, up to 100 KB by the input schema) was kept in full — "bounded at 4 KB" held per chunk, not per session. The surviving chunk's head is now trimmed, code-point aware.

Both now have a guard: every SSE dispatch handler must be defined in exactly one module (the existing test only asserted one exists somewhere), and a test asserts a single oversized chunk is trimmed rather than kept.

No changeset, per your note.

A durable remote session survives SSH drops (COD-104/108), but nothing brought
the HOST back: after the remote machine suspended, the local tmux pane's ssh
child stalled silently and `send-keys` SUCCEEDS against it, so typed input
vanished with no error anywhere.

Add an optional per-host `wakeCommand` (Wake-on-LAN wrapper, e.g. whuff) that
the input route runs when a wake-enabled host is unreachable: input is buffered,
the host is woken, the pane is reattached, and the buffer is flushed in order.
Detection is a throttled bare TCP probe on wake-enabled hosts only, and only
REAL user input may wake a host - the auto-reconnect watcher and boot recovery
deliberately cannot, or the host would be re-woken seconds after every suspend
and could never stay asleep.
…sions

A session's remote block is persisted at launch time and recovery uses that
snapshot, so a wakeCommand added to remote-hosts.json afterwards never reached
an already-running session - not even across a Codeman restart (observed: the
live Hufflepuff session came back with no wakeCommand). Merge the host-level
field in on restore, with the host config authoritative.
… bulk delete

Self-review pass: the input-ladder's two 'buffer' branches were the same three
lines, and bulk delete left a session's (bounded, per-random-uuid) wake state
behind. Documents the design where the code refers to it - remote-sessions.md
section, the architecture invariant, and the CLAUDE.md key pattern.
…ke-on-LAN

The reactive wake (typing into a session whose host slept) left the state invisible:
nothing told the user the machine was asleep, and with no wake target configured
there was nothing to do about it. Adds:

- RemoteHost.wakeMac (comma-separated) - Codeman builds and broadcasts the magic
  packet itself (UDP port 9), so the common case needs no external script. The
  existing wakeCommand stays as the explicit override.
- GET /api/sessions/:id/reachability - probes (throttled, cached, and it never
  wakes) and reports HOW the host can be woken, or that nothing is configured.
- POST /api/sessions/:id/wake - wakes, waits, reattaches the pane and flushes
  buffered input; 400 with a routable message when no target is configured.
- The amber host-unreachable banner + its 'Wake' / 'Configure WoL' action, and a
  small config dialog that saves via PUT /api/remote-hosts/:id.
- RemoteWakeDeps.resolveRemote: host config is re-resolved for LIVE sessions
  (throttled + cached), so saving the dialog takes effect without a restart.
setBroadcast() on an unbound dgram socket throws EBADF on Linux and the following
send fails with EACCES, so the magic packet silently never left the machine — the
feature reported a wake that never happened. Caught by waking a real sleeping host
(a unit test with a real UDP broadcast would not be welcome in CI, so the socket is
injectable and the bind-before-setBroadcast ORDER is asserted).
A configured-but-broken target (host replaced NIC, command removed) had no way
out: the dialog hung off the 'no target configured' branch only, so the banner
would keep offering a Wake button that keeps failing.
…of tab switches

Reported as 'the tab shows no banner' while the host was verifiably unreachable: the
banner only started polling from selectSession, which RETURNS EARLY for the tab you
are already on (so a page loaded with the remote tab active never polled), and a
long-lived tab keeps running the JS it loaded — the feature was invisible to anyone
who did not switch tabs after the deploy.

The poller is now page-wide: one interval (created on init and on the first session
switch), re-targeted whenever the active session changes, plus a visibilitychange
wake-up. It no longer depends on any single selection path running.

Also adds test/sse-dispatch-table.test.ts: a static guard that every
[SSE_EVENTS.X, '_onFoo'] entry names an event constants.js defines AND a handler some
module defines. Both halves fail silently (a typo'd constant is an undefined table
key; a renamed handler just never runs), which is exactly how a new banner can never
appear with no error anywhere.
… input cap

Two findings from a final review pass over the wake-on-LAN feature.

`_onRemoteHostWaking` / `_onRemoteHostWakeFailed` were defined in BOTH
`panels-ui.js` (toasts) and `host-wake-ui.js` (banner). Both files mix into
`CodemanApp.prototype` and `host-wake-ui.js` loads later, so the panels-ui copies
were silently shadowed: the toast never fired, and a wake started for a BACKGROUND
session (input on a non-active tab) produced no notification at all, since the
banner handler only acts on the active session. The handlers now live only in
`host-wake-ui.js`, show the toast unconditionally, and update the banner when the
woken session is the active one.

`appendBoundedPending` dropped only WHOLE chunks, so a single input value over the
cap (one large paste is one `input` value, up to the 100 KB input schema) was kept
in full: "bounded at 4 KB" held per chunk, not per session, and nothing was logged.
The surviving chunk's head is now trimmed too, code-point aware so a multi-byte
character is never split into a replacement char.

Adds the guard that would have caught the first one: every SSE dispatch handler must
be defined in exactly ONE frontend module. The existing test only asserts a handler
EXISTS somewhere, which two modules both satisfy while one is shadowed.
Pressing Run on a remote case whose host was asleep failed with
`could not verify tmux on remote host 192.168.50.137: …` — an ssh error that
blames tmux for a machine that is merely suspended. The only wake paths were
typed input on an established session and the banner's Wake button, so OPENING a
session (the moment the user actually decides to use that host) had none.

`RemoteWakeRegistry.ensureHostAwake()` reuses the existing probe/wake/readiness
machinery for a host that has no session yet, and is wired into the two
user-initiated create paths: `POST /api/quick-start` for a remote case (before
the tmux prereq probe, which is what surfaced the misleading error) and
`POST /api/sessions` with `attachRemoteSession`. A host without a wake target is
not even probed, so its behavior and latency are byte-identical. The wake is
blocking — the caller gets the session or an error — but bounded by
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS (40 s) instead of the 90 s session default,
because the dashboard sits behind a reverse proxy whose default
`proxy_read_timeout` is 60 s: a longer wait would be cut off at the proxy while
the session was still being created. The budget has to cover the whole request
(40 s wake + 1.5 s probe + the tmux probe's own 15 s = 56.5 s worst case), which
is why it is 40 s and not 45. A timeout now says the host did not come back, and
an unreachable host without a wake target says so instead of pointing at tmux.

The wiring is deliberately in the HTTP ROUTE, never in the shared session
service: `cron-service.ts` builds sessions there with nobody waiting on the
answer, and a wake on that path would power the host on for every schedule —
the timer-driven re-wake invariant Ark0N#1 exists to prevent. Both halves are asserted
(importers of `remote-wake`, and `ensureHostAwake` having exactly one caller
file), so a future caller has to come through the guard test. A rejection from
the wake IO is caught too: a broken target must fail the wake, not the route.

`remote:hostWaking`/`remote:hostWakeFailed` now carry `forNewSession` for the
session-less case, where "input is queued" would be untrue; the toast then reads
"the session starts when it is back".

Live wake numbers are unchanged (this reuses the measured ~12 s S3 path); the
route behavior is covered by new tests in session-routes.test.ts with an injected
registry, so no test opens a real socket or ssh.
@Randalix

Copy link
Copy Markdown
Contributor Author

One more wake path — asking before I push it

I have one more commit for this branch (d0a5a583) and have not pushed it, since the PR is with you now. It is the same feature, so my instinct is that it belongs here rather than in a second PR — but it changes the behaviour of two existing routes, which is not something I want to slide in mid-review. Say the word and I push it; say "not now" and it stays local.

The gap

Waking was reachable from input and from the banner button, but not from opening a session — which is the moment you actually decide to use that host. POST /api/quick-start resolves a remote case and then probes tmux (checkRemoteTmuxAvailable), and on a suspended host that probe fails over ssh:

OPERATION_FAILED: could not verify tmux on remote host 192.168.50.137: ssh: connect to host …

i.e. an error that blames tmux for a machine that is merely asleep, with nothing in the log. POST /api/sessions + attachRemoteSession had no wake path at all.

What the commit does

  • RemoteWakeRegistry.ensureHostAwake() runs the existing probe → wake → wait-for-readiness machinery for a host that has no session yet (host-scoped state, single-flight per host, so a double click or two cases on one host send one packet), plus checkHostReachable(), which only asks and never wakes.
  • Wired into those two user-initiated routes only, and deliberately not in the shared session service: cron-service.ts creates sessions there with nobody waiting on the answer, so a wake on that path would power the host on for every schedule — the timer-driven re-wake invariant feat: add HTTP Basic Auth for web interface security #1 exists to prevent. Kept as wiring rather than prose: only session-routes.ts may import remote-wake, and ensureHostAwake has exactly one caller file (test/remote-wake.test.ts, which also keeps the existing importers guard). A rejection from the wake IO is caught, so a broken target fails the wake and not the route.
  • A host with no wake target is not probed at all'no-target' returns before the first TCP connect, so its behaviour and latency are byte-identical to before.
  • Blocking, with its own budget: 40 s (REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS) rather than the 90 s session default. The deployment this was built against serves the dashboard through a reverse proxy whose default proxy_read_timeout is 60 s, so a longer wait is cut off at the proxy while the session is still being created — the browser reports a failure for a session that exists. 40 s + 1.5 s probe + the tmux probe's own 15 s timeout = 56.5 s worst case, and a wake on this setup measures 9–12 s.
  • Failure is honest now: a wake that does not come back says the host did not come back, and an unreachable host without a target says … is not reachable, and this host has no wake-on-LAN target instead of pointing at tmux. (Both branches are regression-tested.)
  • remote:hostWaking / remote:hostWakeFailed carry a forNewSession flag in the session-less case, where the existing toast text ("input is queued") would be untrue — it reads "the session starts when it is back".
  • Docs in the same commit: docs/remote-sessions.md §Wake-on-LAN and docs/architecture-invariants.md. The invariant wording had to move from "only real user input or an explicit wake request may wake a host" to "an explicit request — input, the wake button, or the user's own create/attach — and never a timer, probe or list path", with cron-service.ts named as the reason the create wake lives in the route.

Verification

before now
quick-start on a remote case, host suspended aborted with the tmux error session created and attached in 9 s
second run, host awake no wake, 2 s
attachRemoteSession, host awake no wake path attaches, no wake

Test sessions were deleted afterwards, and the remote tmux sessions with them (an attempt against a discovered, non-owned session detaches and leaves it alone).

npm test: 7316 passed, 15 skipped. Same environmental test/quick-start.test.ts red as before (127.0.0.1:3100 is held by an unrelated container here), plus one boundary flake in test/qr-auth.test.ts on the exact 90 s grace boundary that is green in isolation — flagging both rather than reporting a clean run.

No changeset, same as before.

…ion too

Found by driving the real UI: with a MAC configured in remote-hosts.json, removing it
(here: to reach the "Configure WoL" dialog) changed nothing for a running session —
_effectiveRemote short-circuited on the session's own snapshot whenever that snapshot
HAD a target, so the resolver was only ever consulted in the one direction where the
feature was missing. The documented "host config is authoritative" promise therefore
failed in the direction a user can actually observe, and a wake target could live on
invisibly after being deleted from the config.

The resolver is now consulted on the TTL regardless, and wins for the wake fields in
both directions. Also adds a route test for the browser's real input shape: one POST
per keystroke, all buffered during a wake, replayed IN ORDER.
@Randalix
Randalix force-pushed the feat/remote-host-wake branch from 4a30f51 to 8dfc965 Compare September 15, 2026 21:29
…ession

refreshHostWakeBanner clears _hostWake before calling _hostWakeTick, so the
clear branch's `if (this._hostWake)` guard skipped the repaint: once the
banner had appeared for an unreachable remote session it stayed up on every
chat (local ones included) until a reload, and the 30s ticker never cleared
it either. Render unconditionally in that branch — _renderHostWakeBanner is
idempotent with a null state.

Reproduced in a real browser (Puppeteer, mobile viewport): state went null
but banner.hidden stayed false. Regression test added in
test/host-wake-banner.test.ts (red before, green after).
@Randalix

Randalix commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Updated the branch — it now carries three commits past the snapshot you have (8dfc965d). The d0a5a583 offer from the comment above is included here rather than kept local, so the branch is current as a whole.

What changed since 8dfc965d

d0a5a583 — wake on create/attach (the commit I described in the comment above). POST /api/quick-start and POST /api/sessions + attachRemoteSession now wake a sleeping host through ensureHostAwake() before probing tmux, instead of failing with an error that blames tmux. Wired into those two user-initiated routes only, never the shared session service (cron-service.ts is the reason). A host without a wake target is not probed at all. 40 s budget (reverse-proxy proxy_read_timeout is 60 s here). Docs in the same commit.

4a30f510 — host config is authoritative in BOTH directions. RemoteWakeRegistry._effectiveRemote returned the session snapshot as soon as it had any wake target and never re-read the host config, so a MAC deleted in the config kept living in a running session: the banner still offered "Wake" and the config dialog was therefore unreachable from the UI. It now always consults the host config on the 30 s TTL and wins both ways. Verified server-side: wakeConfigured flips live mac → none → mac with no session restart. Also added a route test for the concurrency surface — the browser sends one POST per keystroke, so inputs arriving during a wake must be buffered and re-delivered in order (test/routes/session-remote-wake.test.ts).

a7f74f37 — banner no longer sticks across sessions. Reported from a phone: once the "host not reachable" banner appeared it stayed up on every chat, local ones included, until a reload. Cause: refreshHostWakeBanner clears _hostWake before calling _hostWakeTick, but the tick's clear branch only re-rendered if (this._hostWake) — already null by then, so no repaint, and the 30 s ticker runs into the same silent branch. The clear branch now renders unconditionally; _renderHostWakeBanner is already idempotent with a null state. Reproduced in a real browser (Puppeteer, mobile viewport): state went null while banner.hidden stayed false; green after the fix. Regression test test/host-wake-banner.test.ts (red before, green after). This bug exists in the current PR head too, which is why it is here.

Verification

npm test: 7321 passed, 15 skipped. One red suite, unchanged and environmental: test/quick-start.test.ts (EADDRINUSE 127.0.0.1:3100 — an unrelated container holds the port on this machine, see #440). typecheck, lint, format:check, check:frontend-syntax, check:public-assets all clean.

Live on this deployment: create/attach wake 9–12 s measured; the banner fix verified end-to-end in a real browser.

@Ark0N

Ark0N commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Thanks for this, and for the live test against a real sleeping machine: the bind before setBroadcast ordering bug is exactly the kind of thing mocks never find, and having it documented at the call site with a test that asserts the order is the right outcome. The PR adds Wake-on-LAN for remote SSH hosts: a wakeMac (magic packet built and broadcast by Codeman) or a wakeCommand on a host, triggered from the input route, a new Wake button and banner, and from Run/Attach, with input buffered and replayed in order after the reattach.

I ran typecheck, lint, format:check, check:frontend-syntax and check:public-assets (all clean) and the full suite: 387 files, 7324 tests, 12 skipped, exit 0. test/quick-start.test.ts is green here, so that one really was your port 3100.

The invariant work is the part I want to call out as right: only an explicit user action can wake, and it is enforced by two source-tree wiring guards rather than a comment. I checked by hand that nothing in tmux-manager.ts, remote-reconnect.ts, the dropped-session handler, boot recovery or cron-service.ts can reach the registry.

Nothing blocking. These are the things I would like fixed, and I am happy to do the first three at merge time if you would rather not round-trip.

1. Wake state is dropped on two of roughly eight cleanup paths (src/web/routes/session-routes.ts:1353, :1371). remoteWake.drop() is called from the two delete routes only. cron-service.ts:683, admin-routes.ts:205, ralph-routes.ts:438, the three scheduled-run calls in server.ts and the two error paths in session-routes.ts all call cleanupSession() without it, so the entry survives, including up to 4 KB of the user's buffered keystrokes. The right home is WebServer.cleanupSession() (server.ts:1266), next to sessionWaits.cancelAll() at server.ts:1457. Related: _effectiveRemote() (src/remote-wake.ts:610) calls this._state(session.id) before the if (!session.remote) return, so every local session that posts input also gets an entry. Swapping those two lines keeps local sessions out of the map.

2. An oversized paste is head-trimmed and then executed as a fragment (src/remote-wake.ts:135). MAX_INPUT_LENGTH is 64 KB and one paste is one input value, so pasting 10 KB into a session whose host is asleep keeps only the last 4 KB, and the flush writes that fragment into the pane (with Enter, if the payload carries a carriage return). Keeping the tail is right for typing and wrong for a chunk that was never typed: I would drop an oversized chunk outright and say so in the log line. test/remote-wake.test.ts:80 pins the current behaviour, so it moves with the change.

3. The Wake button uses the 90 s budget your own comment rules out (src/web/routes/session-routes.ts:1629). ensureAwake(session, { force: true }) passes no timeoutMs, so it inherits the 90 s session default, while the create and attach paths pass the 40 s REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS for the reverse proxy's 60 s proxy_read_timeout (the reasoning at src/remote-wake.ts:50). The button is pressed from that same dashboard and holds the request open the same way, so behind such a proxy it is cut at 60 s and the banner reports failure for a wake that is still running. Pass the request budget here too.

4. An in-flight wake has no cancellation path and delays shutdown. waitUntilRemoteReady polls for up to 90 s on a timer nothing can cancel, and WebServer.stop() ends with await this.app.close(), which does not abort in-flight requests (I checked against the Fastify in this tree: a request started before close() ran to completion and close() did not resolve first). So a restart during a wake waits it out. This is the case sessionWaits.cancelEverything() exists for, comment and all, at server.ts:3344. A stop() on the registry that resolves in-flight wakes false, called from WebServer.stop(), closes it. No data loss: the state flush happens earlier in stop().

5. The banner promises queueing on a path that never queues (src/web/public/host-wake-ui.js:160, and the toast at :337). Both strings key off state.waking, which the Wake button sets. Browser keystrokes go over the WebSocket (_reliableSend in app.js:3071, {"t":"i"} frames in ws-routes.ts), which does not pass through the registry, so what the user typed went into the stalled pane and is gone. The normal sequence is: type into a sleeping host, characters vanish, banner appears, press Wake, banner says "input is queued until it is back" when nothing was. I am not asking for the WS path to be made wake-aware, that is the hot path CLAUDE.md protects and the banner is the right answer for browsers. Just wording that does not promise queueing for the button path, and one line in docs/remote-sessions.md saying the WS keystroke path is deliberately not wake-aware.

Smaller things, and I will take these at merge unless you are touching the branch anyway:

  • src/web/sse-events.ts:8 still says 158 constants and :17 still says "Remote auto-reconnect (3)"; both moved. Same counts in CLAUDE.md (158 events, "+ 32 modules", "sessions (34)"), the frontend load-order chain at CLAUDE.md:301 does not list host-wake-ui.js (it loads between session-ui.js and webview-tabs.js), the Infra row now reads remote-wake "(pure)" although the module uses dgram/net/child_process, and the rule paragraph at CLAUDE.md:220 describes only the input route and the button, not the create/attach wake that can block Run for 40 s. That paragraph is what the next person reads, so it is the one I care about.
  • deriveSseHint (src/web/server.ts:2308) has no remote: prefix, so the two new events reach every client in multi-user mode, and _onRemoteHostWaking toasts unconditionally with the host label. Same shape as the existing remote: events, so not something you introduced, but worth deciding now.
  • src/web/public/host-wake-ui.js:200 branches on the error message text (includes('No wake-on-LAN target')). errorCode is the stable half of the contract; the message is not.
  • docs/architecture-invariants.md picked up eight lines of unrelated Prettier markdown churn (*why* to _why_, table padding) in sections the PR does not touch. docs/ is not in the format glob, so this looks like an editor. Reverting those hunks makes the diff honest about what it changes.
  • In multi-user mode GET /api/remote-hosts returns [] to non-admins, so "Configure WoL" opens a dialog whose Save fails with "Remote host not found" rather than saying it is admin-only (host-wake-ui.js:243, :288).
  • src/remote-wake.ts:467 says the host wake writes into "the same waking slot the session flow uses". They are different keys, so a session wake and a create-path wake for the same host can run concurrently. Harmless, but the comment reads as a guarantee.
  • runRemoteWakeCommand's timeout (src/remote-wake.ts:670) kills the direct child only, not the process group, so a wake wrapper that forks can outlive the 10 s budget.

One process note: you asked in the comment whether to push d0a5a583 and then pushed it. It is the same feature and I am fine keeping it here, but it does change the behaviour of two existing routes, so next time hold it until I answer.

Fix 1 through 5 and I will merge. The rest I will fold in at merge time.

Review follow-up on the wake-on-LAN PR (five findings, all of them about the
state the feature keeps and the budgets it inherits):

- Wake state is dropped by `WebServer.cleanupSession` instead of the two delete
  routes, so it now goes with the session on EVERY cleanup path (cron, admin,
  scheduled-run teardown, error paths) instead of surviving with up to 4 KB of
  the user's buffered keystrokes. `registerSessionRoutes` returns the registry
  so the server can own its lifetime without the wake-capable code living in
  `server.ts`; the wiring guard is updated to allow that and gains a second
  assertion that `server.ts` calls nothing but `drop`/`stop` on it.
- `_effectiveRemote` returns before `_state`, so a LOCAL session no longer gets
  a wake-state entry — the input gate runs on every keystroke, so that entry
  used to be allocated for every session the user types in.
- An input chunk larger than the 4 KB cap is dropped OUTRIGHT instead of being
  head-trimmed and then written as a fragment: one paste is one `input` value
  and was never typed character by character, so its tail is a partial command
  the user never sent. The drop is logged.
- The manual wake button passes `REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS` (40 s)
  like the create/attach paths, instead of inheriting the 90 s session default
  that the dashboard's reverse proxy cuts off at 60 s.
- `RemoteWakeRegistry.stop()` aborts in-flight readiness polls (abortable
  sleep) and refuses new wakes, and `WebServer.stop()` calls it, so a restart
  during a wake no longer waits the poll out.
- The banner/toast wording keys off a new `queuedInput` flag on the two SSE
  events, which is true only when the server actually holds bytes: browser
  keystrokes travel over the WebSocket, which never passes through the
  registry, so the wake BUTTON must not promise queued input. The failed-wake
  path also stops pattern-matching the error message (it re-asks the
  reachability route) and the WoL dialog says "admin-only" instead of "host not
  found" for a non-admin in multi-user mode.
- `host-wake-ui.js` joins the documented load order (12.2) and gets its
  `@dependency`/`@loadorder` tags; the frontend module count is 33, not 32.
- `remote-wake` is not "(pure)" — the module uses `dgram`/`net`/`child_process`.
- SSE counts: 160 constants, and the category is "Remote auto-reconnect / wake
  (5)"; the route table's per-file counts are refreshed (sessions 37, cases 34).
- The CLAUDE.md wake rule now names the create/attach wake, the 40 s request
  budget, the whole-chunk paste drop, the registry's lifetime (drop on cleanup,
  stop on shutdown) and the deliberately non-wake-aware WebSocket keystroke
  path — that paragraph is what the next person reads.
- Reverted the eight lines of unrelated Prettier markdown churn in
  `docs/architecture-invariants.md` (docs/ is not in the format glob, so it was
  an editor): only the new wake paragraph remains in the diff.
@Randalix

Copy link
Copy Markdown
Contributor Author

Pushed acb8d4b0 (two commits on top of a7f74f37) — all five, plus most of the smaller items since I was touching the branch anyway.

1. Wake state lifetime. remoteWake.drop() now runs from WebServer.cleanupSession() (next to sessionWaits.cancelAll()), so it covers every cleanup path; the two delete-route calls are gone. That means registerSessionRoutes returns the registry so the server can own its lifetime, and therefore server.ts imports remote-wake — so I reworked the first wiring guard rather than quietly widening it: it now allows web/routes/session-routes.ts and web/server.ts, and a second guard asserts server.ts contains no remoteWake.wake( / ensureAwake( / ensureHostAwake( / handleInput( / checkReachable( / checkHostReachable( (with or without ?.) and does contain drop( / stop(. The import list alone would have been satisfied by the field's type, so the property you actually care about is now asserted directly. Both guards verified red against the old code.

_effectiveRemote() returns before _state(); stateCount() is the diagnostic that pins it (red before the swap).

2. Oversized paste. appendBoundedPending now returns the buffer untouched when the chunk exceeds the cap, and _enqueue logs the drop. tailWithinBytes is gone and the two tests that pinned the trim were replaced by ones that pin the drop (including the existing buffer being left alone).

3. Wake button budget. ensureAwake takes timeoutMs; the button passes REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS.

4. Cancellation. RemoteWakeRegistry.stop() sets a stopped flag, aborts an AbortController whose signal rides into waitUntilReady, and refuses new wakes; waitUntilRemoteReady checks it at the loop top and the interval sleep is abortable (delayOrAbort), so cancellation is immediate instead of "after the current 1.5 s". WebServer.stop() calls it next to sessionWaits.cancelEverything(). Tests: an in-flight ensureHostAwake resolves 'failed' after stop() (real readiness poll, fake probe/wake), a direct waitUntilRemoteReady abort test, and an already-aborted signal returning false without probing.

5. Wording. Both SSE events now carry queuedInput (true only when state.pending.length > 0), and the banner detail plus both toasts key off it: the button path says "waiting for the host to come back" / "did not wake up", never "input is queued". docs/remote-sessions.md states that the WebSocket keystroke path is deliberately not wake-aware.

Smaller ones taken: the failed-wake handler no longer pattern-matches the error string (it re-asks /reachability, which is the authority on wakeConfigured); the WoL dialog says "Wake-on-LAN configuration is admin-only" for a non-admin in multi-user mode, both up front and on save; the remote-wake.ts comment about "the same waking slot" now says the keys differ on purpose; counts corrected (160 SSE events, 33 frontend modules, host-wake-ui.js in the load order at 12.2 with its tags, remote-wake no longer "(pure)"); the CLAUDE.md rule paragraph names the create/attach wake, the 40 s request budget, the whole-chunk drop, the registry's lifetime and the non-wake-aware WS path; and the eight lines of Prettier markdown churn in docs/architecture-invariants.md are reverted — only the new paragraph is in the diff now.

Left for you (I did not want to decide these unilaterally):

  • deriveSseHint / the remote: prefix. Adding it would scope hostWaking/hostWakeFailed by sessionId where they have one, but the create-path variants carry no sessionId and there is no host→owner mapping (hosts are global config), so they would fail closed to admins and a non-admin creating a remote case would lose the toast. A correct fix needs an explicit owner on the create path — a new field, so I left it.
  • runRemoteWakeCommand kills the direct child only. Process-group kill means detached: true + process.kill(-pid), which changes how a wake wrapper is spawned; small, but a behaviour change I would rather you nod at.
  • The route table: approvals (4) and webviews (6) don't match a naive app.get|post|put|patch|delete count (1 and 3), and custom-model-routes.ts isn't in the enumeration at all. Pre-existing drift, so I only refreshed sessions (37) and cases (34) — same count that reproduces system (56) exactly. Revert those two if your method differs.

Verification. npm test: 7327 passed, 15 skipped. One red suite, unchanged and environmental: test/quick-start.test.ts (EADDRINUSE 127.0.0.1:3100, an unrelated container holds the port here — see #440). typecheck, lint, format:check, check:frontend-syntax, check:public-assets clean; CI green on the new head.

⚠️ No live curl pass this time, and I want to be explicit about why rather than imply one: this revision is built on the PR head, while this deployment's working tree carries unrelated local work, so a build/restart here would have reverted that. The fixes are state-lifetime and budget changes, pinned by tests; the wake path itself is byte-identical to the revision you already exercised. Say the word if you want a deployed run and I will do it from a clean checkout.

… wake fields

Own review pass over the PR:

- `_flush` took the chunk out of the buffer only AFTER awaiting the write. Input
  arriving during that await is enqueued (`waking` is still set, so it takes the
  buffer path), and the 4 KB cap then drops the OLDEST chunk — which is the one
  already on its way to the pane. The `shift()` that followed removed the NEXT
  chunk instead, so the drop-oldest bookkeeping silently lost a chunk that was
  never written, while the log line blamed the one that was. The chunk is now
  removed before the await and re-inserted at the FRONT on a failed write, so the
  order of the queue behind it is preserved. Regression test: a chunk enqueued
  during the first write of a full buffer must still reach the pane (red against
  the old order).
- `showCreateCaseModal()` reset the remote-host form fields but not the two new
  wake inputs, so one host's MAC/command carried over into the next host that
  form saved.
- The banner's pre-poll `wakeConfigured` labelled a command-only host as 'mac'.
  Nothing reads the distinction, but the field is documented as which path is
  configured, so it says the truth until the first poll corrects it.
- Stale `resolveRemote` comment ("only for sessions that have no usable target of
  their own"): after the host config became authoritative in both directions it is
  consulted on the TTL regardless.
@Randalix

Copy link
Copy Markdown
Contributor Author

Follow-up: I read the whole diff against master again (not just my own fixes) and found three more things. Pushed as 29984c63, so the head is 29984c63 now.

1. The flush could lose a chunk that was never written. _flush took the chunk out of the pending buffer only AFTER awaiting writeViaMux. Input arriving during that await is enqueued — a wake is still in flight, so handleInput takes the buffer path — and if the buffer is at the 4 KB cap, appendBoundedPending drops the OLDEST chunk, which is exactly the one already on its way to the pane. The shift() that followed then removed the NEXT chunk, so drop-oldest silently discarded a chunk that had never been delivered, and the log line blamed the one that had. The chunk is now removed before the await and re-inserted at the FRONT if the write fails (so the order behind it is preserved). Regression test: with a full buffer, a chunk enqueued during the first write must still reach the pane — red against the old ordering, green now. Narrow window (needs ~4 KB queued plus input during one tmux call), but it is the same class of failure this feature exists to eliminate.

2. showCreateCaseModal() reset the remote-host form but not the two new wake inputs. Open the Create Case modal for host A with a MAC, close it, open it for host B: the wake fields still held A's values, and the next host this form saved got A's MAC/command. Both ids are in the reset list now.

3. The banner's pre-poll wakeConfigured labelled a command-only host as 'mac'. Nothing reads the distinction today, but the field is documented as which path is configured, so it now derives the right one until the first poll corrects it. Also fixed a stale resolveRemote comment ("only for sessions that have no usable target of their own") that the both-directions change had outdated.

Verification unchanged in shape: npm test 7328 passed, 15 skipped; test/quick-start.test.ts still the only red suite and still the environmental EADDRINUSE 127.0.0.1:3100 (#440). typecheck, lint, format:check, check:frontend-syntax, check:public-assets clean.

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.

feat(remote): wake a sleeping host — banner + manual WoL, and buffer input until it is back

2 participants