fix(remote): close the wake-state leaks and the dishonest wake budget

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.
This commit is contained in:
Randalix
2026-09-16 20:44:39 +02:00
parent a7f74f374f
commit 7b947fa3f1
7 changed files with 338 additions and 74 deletions
+21 -1
View File
@@ -42,6 +42,7 @@ import { execSync } from 'node:child_process';
import { hostname as getHostname } from 'node:os';
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
import type { RemoteWakeRegistry } from '../remote-wake.js';
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
import { GLYPH, palette } from '../cli-style.js';
import { getHookSecret } from '../config/hook-secret.js';
@@ -268,6 +269,14 @@ export class WebServer extends EventEmitter {
private scheduledRuns: Map<string, ScheduledRun> = new Map();
/** Cron service (assigned in setupRoutes). */
private cronService!: CronService;
/**
* Wake-on-LAN registry, returned by `registerSessionRoutes`. Held for its LIFETIME
* only — `drop()` on session cleanup, `stop()` on shutdown. Waking from here would
* re-wake a host on every timer tick (the invariant `remote-wake.ts` documents), so
* the wiring guard in `test/remote-wake.test.ts` pins that this file calls nothing
* but `drop`/`stop` on it.
*/
private remoteWake: RemoteWakeRegistry | null = null;
private sse: SseStreamManager;
private store = getStore();
private tabLayouts!: TabLayoutService;
@@ -1065,7 +1074,9 @@ export class WebServer extends EventEmitter {
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);
registerSessionRoutes(this.app, ctx);
// The registry's lifetime is the server's: it drops per-session wake state on every
// cleanup path and resolves in-flight wakes on shutdown.
this.remoteWake = registerSessionRoutes(this.app, ctx);
registerRespawnRoutes(this.app, ctx);
registerRalphRoutes(this.app, ctx);
registerPlanRoutes(this.app, ctx);
@@ -1456,6 +1467,11 @@ export class WebServer extends EventEmitter {
sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId);
approvalInbox.resolveForSession(sessionId, 'session_ended');
// Wake state goes with the session on EVERY cleanup path (delete routes, the cron
// and admin paths, scheduled-run teardown, error paths) — that is why it lives here
// rather than in the two delete routes, where it left an entry behind, including up
// to 4 KB of the user's buffered keystrokes.
this.remoteWake?.drop(sessionId);
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
}
@@ -3343,6 +3359,10 @@ export class WebServer extends EventEmitter {
// response), so without this a 10-minute wait holds shutdown open.
sessionWaits.cancelEverything();
approvalInbox.stop();
// Same reason as `cancelEverything` above: an in-flight wake is awaited by a request,
// and `app.close()` (the last line of this method) does not abort in-flight requests —
// so without this a restart during a wake waits out the readiness poll.
this.remoteWake?.stop();
this.lastRecordedTokens.clear();