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
File diff suppressed because one or more lines are too long
+110 -36
View File
@@ -113,44 +113,32 @@ export function decideRemoteInputAction(args: {
}
/**
* Append `data` to the pending buffer, dropping the OLDEST bytes when the cap is
* exceeded. Returns the resulting buffer. Pure.
* Append `data` to the pending buffer, dropping the OLDEST whole chunks when the cap
* is exceeded. Returns the resulting buffer — the SAME array reference when the chunk
* was rejected, so the caller can tell the two apart. Pure.
*
* A single chunk can itself exceed the cap (one large paste is one `input` value),
* so after whole chunks are dropped the surviving chunk's HEAD is trimmed too —
* otherwise "bounded at 4 KB" would hold only per chunk, not per session. The trim
* is code-point aware, so it never emits a broken multi-byte character.
* A chunk LARGER than the cap is dropped outright rather than trimmed: one paste is
* one `input` value, and it was never typed character by character, so delivering its
* tail would execute a fragment of it (with the trailing carriage return, if the paste
* had one) — a partial command the user never sent. Keeping the tail is right for
* typing, where the newest bytes are the ones the user just produced, and wrong for a
* chunk that arrived whole.
*/
export function appendBoundedPending(
pending: string[],
data: string,
maxBytes = REMOTE_WAKE_PENDING_MAX_BYTES
): string[] {
if (Buffer.byteLength(data) > maxBytes) return pending;
const next = [...pending, data];
let total = next.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
while (next.length > 1 && total > maxBytes) {
total -= Buffer.byteLength(next[0]);
next.shift();
}
if (next.length === 1) next[0] = tailWithinBytes(next[0], maxBytes);
return next;
}
/** Keep only the trailing part of `value` that fits in `maxBytes` UTF-8 bytes. Pure. */
function tailWithinBytes(value: string, maxBytes: number): string {
if (Buffer.byteLength(value) <= maxBytes) return value;
const chars = [...value];
let total = 0;
let start = chars.length;
while (start > 0) {
const size = Buffer.byteLength(chars[start - 1]);
if (total + size > maxBytes) break;
total += size;
start--;
}
return chars.slice(start).join('');
}
/** The remote fields the wake flow needs. Structurally satisfied by `SessionRemote`. */
export interface WakeableRemote {
wakeCommand?: string;
@@ -240,7 +228,7 @@ export interface RemoteWakeDeps {
/** Run the resolved wake target (magic packet or host command). Resolves false on failure. */
wake(target: NonNullable<WakeTarget>): Promise<boolean>;
/** Poll until the woken host accepts connections again. */
waitUntilReady(remote: WakeableRemote, opts?: { timeoutMs?: number }): Promise<boolean>;
waitUntilReady(remote: WakeableRemote, opts?: { timeoutMs?: number; signal?: AbortSignal }): Promise<boolean>;
/** Sleep helper (injected for tests). */
delay(ms: number): Promise<void>;
/** Notify the COD-108 watcher so an exhausted backoff is reset. */
@@ -319,6 +307,14 @@ interface WakeState {
*/
export class RemoteWakeRegistry {
private readonly states = new Map<string, WakeState>();
/**
* Aborted by {@link stop} on shutdown. Every in-flight readiness poll is holding an
* HTTP request open (the wake route blocks on it), and Fastify's `close()` waits for
* in-flight requests — so without this a restart during a wake sits out the full 90 s
* budget. The same problem `sessionWaits.cancelEverything()` exists for.
*/
private readonly shutdown = new AbortController();
private stopped = false;
constructor(private readonly deps: RemoteWakeDeps) {}
@@ -327,6 +323,19 @@ export class RemoteWakeRegistry {
this.states.delete(sessionId);
}
/**
* Resolve every in-flight wake as failed and refuse new ones (server shutdown).
*
* Called from `WebServer.stop()`: an in-flight wake is awaited by a request, and the
* server's own `app.close()` does not abort in-flight requests, so shutdown would wait
* out the poll. Nothing is lost by failing them — the state flush happens earlier in
* `stop()`, and the process is going away.
*/
stop(): void {
this.stopped = true;
this.shutdown.abort();
}
/** Whether a wake is currently in flight (diagnostics/tests). */
isWaking(sessionId: string): boolean {
return this.states.get(sessionId)?.waking != null;
@@ -339,6 +348,15 @@ export class RemoteWakeRegistry {
return state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
}
/**
* Number of keys with wake state (diagnostics/tests). Pins that a LOCAL session never
* gets an entry: the input gate runs on every keystroke, so an entry per local session
* would be a map the size of the session list, swept only on cleanup.
*/
stateCount(): number {
return this.states.size;
}
/** Whether this session's host has any wake path configured at all. */
async hasWakeTarget(session: WakeableSession): Promise<boolean> {
return resolveWakeTarget(await this._effectiveRemote(session)) !== null;
@@ -412,7 +430,8 @@ export class RemoteWakeRegistry {
* send-and-wait path, where the HTTP response stays open anyway and buffering
* would break the wait contract.
*/
async ensureAwake(session: WakeableSession, opts: { force?: boolean } = {}): Promise<boolean> {
async ensureAwake(session: WakeableSession, opts: { force?: boolean; timeoutMs?: number } = {}): Promise<boolean> {
if (this.stopped) return false;
const remote = await this._effectiveRemote(session);
if (!remote || !resolveWakeTarget(remote)) return true;
const state = this._state(session.id);
@@ -423,7 +442,10 @@ export class RemoteWakeRegistry {
state.reachable = await this.deps.probe(remote);
}
if (state.reachable) return true;
return this.wake(session);
// The manual button is pressed from the SAME dashboard the create/attach paths are,
// so it holds its request open under the same reverse proxy — it needs the request
// budget, not the 90 s session default (see REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS).
return this.wake(session, { timeoutMs: opts.timeoutMs });
}
/**
@@ -452,6 +474,7 @@ export class RemoteWakeRegistry {
*/
async ensureHostAwake(remote: WakeableRemote, opts: { timeoutMs?: number } = {}): Promise<HostWakeOutcome> {
if (!resolveWakeTarget(remote)) return 'no-target';
if (this.stopped) return 'failed';
const state = this._state(hostWakeKey(remote.hostId));
if (state.waking) return (await state.waking) ? 'ready' : 'failed';
@@ -463,9 +486,12 @@ export class RemoteWakeRegistry {
}
/**
* Single-flight wake for a host with no session (see {@link ensureHostAwake}).
* A write into the same `waking` slot the session flow uses, so the two can never
* run two readiness polls against one host from the same key space.
* Single-flight wake for a host with no session (see {@link ensureHostAwake}). Uses the
* same single-flight `waking` slot the session flow uses — but a DIFFERENT key
* (`host:<id>` vs the session id), so a session wake and a create-path wake for the same
* host are two independent flows rather than one shared poll. Harmless (both are
* user-initiated and the host only wakes once), and keying them together would mean a
* create request joining an unrelated session's wake and inheriting its budget.
*/
private async wakeHost(remote: WakeableRemote, opts: { timeoutMs?: number }): Promise<boolean> {
const state = this._state(hostWakeKey(remote.hostId));
@@ -491,7 +517,8 @@ export class RemoteWakeRegistry {
* Single-flight wake: probe-free (the caller already knows the host is down),
* run the wake command, poll for readiness, reattach the pane, flush the buffer.
*/
async wake(session: WakeableSession): Promise<boolean> {
async wake(session: WakeableSession, opts: { timeoutMs?: number } = {}): Promise<boolean> {
if (this.stopped) return false;
const remote = await this._effectiveRemote(session);
const target = resolveWakeTarget(remote);
if (!remote || !target) return true;
@@ -501,7 +528,7 @@ export class RemoteWakeRegistry {
state.waking = (async (): Promise<boolean> => {
const id = session.id;
try {
const ready = await this._wakeAndWait(remote, state, { sessionId: id });
const ready = await this._wakeAndWait(remote, state, { sessionId: id, timeoutMs: opts.timeoutMs });
if (!ready) return false;
const reattached = await session.reattachRemote();
@@ -546,10 +573,17 @@ export class RemoteWakeRegistry {
// No `sessionId` for a create-path wake: the toast handler is then the only one
// that acts (a banner for a session that does not exist yet would have no target),
// which is exactly the `forNewSession` distinction the UI renders.
//
// `queuedInput` is true only on the typing path, where bytes are actually held for
// this session. The wake BUTTON and the send-and-wait path hold nothing, so a UI
// that keyed "input is queued until it is back" off "a wake is running" would promise
// something the user can disprove by typing (browser keystrokes go over the
// WebSocket, which never passes through this registry).
this.deps.broadcast?.('remote:hostWaking', {
...(opts.sessionId ? { sessionId: opts.sessionId } : { forNewSession: true }),
hostId: remote.hostId,
label: remote.label,
queuedInput: state.pending.length > 0,
});
this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind} ${forWhat}`);
@@ -560,7 +594,10 @@ export class RemoteWakeRegistry {
);
}
const ready = await this.deps.waitUntilReady(remote, { timeoutMs: opts.timeoutMs });
const ready = await this.deps.waitUntilReady(remote, {
timeoutMs: opts.timeoutMs,
signal: this.shutdown.signal,
});
if (!ready) {
this.deps.log?.(
`[RemoteWake] ${remote.label} did not come back — ${opts.forNewSession ? 'the session was not started' : 'input stays buffered'}`
@@ -569,6 +606,7 @@ export class RemoteWakeRegistry {
...(opts.sessionId ? { sessionId: opts.sessionId } : { forNewSession: true }),
hostId: remote.hostId,
label: remote.label,
queuedInput: state.pending.length > 0,
});
state.probedAt = 0;
state.reachable = undefined;
@@ -607,8 +645,12 @@ export class RemoteWakeRegistry {
* promise failing in the one direction a user can observe.
*/
private async _effectiveRemote(session: WakeableSession): Promise<WakeableRemote | undefined> {
// The local-session return comes FIRST, before `_state`: this runs on every input
// chunk (`hasWakeTarget` gates the route), so allocating state here would put an
// entry in the map for every local session the user types in — sessions the feature
// can never apply to, and whose pending buffers would then have to be swept.
if (!session.remote) return undefined;
const state = this._state(session.id);
if (!session.remote) return state.resolvedRemote;
if (!this.deps.resolveRemote) return state.resolvedRemote ?? session.remote;
if (state.resolvedAt !== 0 && Date.now() - state.resolvedAt < REMOTE_WAKE_RESOLVE_TTL_MS) {
return state.resolvedRemote ?? session.remote;
@@ -627,12 +669,22 @@ export class RemoteWakeRegistry {
private _enqueue(sessionId: string, data: string): void {
const state = this._state(sessionId);
const next = appendBoundedPending(state.pending, data);
if (next === state.pending) {
// Oversized chunk: dropped whole (see `appendBoundedPending`), so the buffer is
// untouched and nothing is delivered as a fragment. Logged because the user's
// paste is gone — the 200 the route returns cannot say so.
this.deps.log?.(
`[RemoteWake] dropped a ${Buffer.byteLength(data)}-byte input chunk for session ${sessionId} — over the ${REMOTE_WAKE_PENDING_MAX_BYTES}-byte wake buffer, and a truncated paste must not be delivered as a fragment`
);
return;
}
const before = state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
state.pending = appendBoundedPending(state.pending, data);
const after = state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
const after = next.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
if (before + Buffer.byteLength(data) > after) {
this.deps.log?.(`[RemoteWake] pending buffer cap reached for session ${sessionId} — oldest input dropped`);
}
state.pending = next;
}
private async _flush(state: WakeState, session: WakeableSession): Promise<void> {
@@ -785,7 +837,12 @@ export type WakeSocketFactory = () => WakeSocket;
/** Poll the host until it accepts connections again, or the bound is hit. */
export async function waitUntilRemoteReady(
remote: WakeableRemote,
opts: { intervalMs?: number; timeoutMs?: number; probe?: (remote: WakeableRemote) => Promise<boolean> } = {}
opts: {
intervalMs?: number;
timeoutMs?: number;
signal?: AbortSignal;
probe?: (remote: WakeableRemote) => Promise<boolean>;
} = {}
): Promise<boolean> {
const intervalMs = opts.intervalMs ?? REMOTE_WAKE_READY_INTERVAL_MS;
const timeoutMs = opts.timeoutMs ?? REMOTE_WAKE_READY_TIMEOUT_MS;
@@ -794,12 +851,29 @@ export async function waitUntilRemoteReady(
// Probe immediately: WoL from a warm S3 is fast (~7.5 s measured on this setup),
// and the first poll is what turns "just woke" into a sub-interval response.
for (;;) {
if (opts.signal?.aborted) return false;
if (await probe(remote)) return true;
if (Date.now() + intervalMs > deadline) return false;
await delay(intervalMs);
// Abortable sleep, so a shutdown does not wait out the current interval either.
await delayOrAbort(intervalMs, opts.signal);
}
}
/** `delay`, but it also ends the moment `signal` aborts (so cancellation is immediate). */
function delayOrAbort(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) return delay(ms);
if (signal.aborted) return Promise.resolve();
return new Promise((resolve) => {
const done = (): void => {
clearTimeout(timer);
signal.removeEventListener('abort', done);
resolve();
};
const timer = setTimeout(done, ms);
signal.addEventListener('abort', done, { once: true });
});
}
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
/** Production wiring: all IO defaults, overridable for tests. */
+49 -6
View File
@@ -23,6 +23,9 @@
* keeps the banner in sync while a wake is running.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class, this.sessions, this.activeSessionId, showToast)
* @dependency constants.js (SSE_EVENTS — the remote:hostWaking / remote:hostWakeFailed names)
* @loadorder 12.2 — loaded after session-ui.js, before webview-tabs.js
*/
const HOST_WAKE_POLL_MS = 30_000;
@@ -45,6 +48,12 @@ Object.assign(CodemanApp.prototype, {
label: '',
/** True between clicking Wake and the answer coming back. */
waking: false,
/**
* True only when the server is actually holding bytes for this session (the typing
* path buffers them). Browser keystrokes go over the WebSocket, which never passes
* through the wake registry — so the Wake BUTTON must not claim input is queued.
*/
queuedInput: false,
/** Set when the last wake attempt or poll failed. */
error: '',
};
@@ -157,7 +166,9 @@ Object.assign(CodemanApp.prototype, {
}
if (detail) {
detail.textContent = state.waking
? 'input is queued until it is back'
? state.queuedInput
? 'input is queued until it is back'
: 'waiting for the host to come back'
: hasTarget
? `ssh ${state.host}`
: 'no wake-on-LAN configured';
@@ -187,6 +198,10 @@ Object.assign(CodemanApp.prototype, {
if (!state || !state.sessionId) return;
const sessionId = state.sessionId;
state.waking = true;
// The button path holds nothing: whatever the user typed went into the stalled pane
// over the WebSocket and is gone. Saying otherwise is a promise the next keystroke
// disproves.
state.queuedInput = false;
state.error = '';
this._renderHostWakeBanner();
try {
@@ -195,10 +210,13 @@ Object.assign(CodemanApp.prototype, {
if (this._hostWake !== state || state.sessionId !== sessionId) return;
state.waking = false;
if (!data.success) {
// Most likely: no wake target configured after all (the route is the authority).
// The ROUTE is the authority on whether a target is configured, so ask it again
// (`/reachability` reports `wakeConfigured`) rather than pattern-matching the
// error message: the message is prose, and the code is generic (`INVALID_INPUT`
// covers "Not a remote session" too).
state.error = data.error || 'Wake failed';
if (String(data.error || '').includes('No wake-on-LAN target')) state.wakeConfigured = 'none';
this._renderHostWakeBanner();
await this._pollHostReachability(true);
return;
}
state.reachable = data.data.reachable !== false;
@@ -217,6 +235,16 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Why the host could not be read. In multi-user mode `GET /api/remote-hosts` returns
* `[]` to a non-admin, so "Remote host not found" would blame a config the user simply
* is not allowed to see — the save is admin-only, and that is what it should say.
*/
_wakeConfigUnavailableMessage() {
const me = window.__codemanUser || {};
return me.multiUser && me.role !== 'admin' ? 'Wake-on-LAN configuration is admin-only' : 'Remote host not found';
},
/** Open the small WoL dialog for the banner's host, pre-filled from the host config. */
async openWakeConfigDialog() {
const state = this._hostWake;
@@ -247,6 +275,9 @@ Object.assign(CodemanApp.prototype, {
if (host && this._wakeConfigHostId === hostId) {
mac.value = host.wakeMac || '';
command.value = host.wakeCommand || '';
} else if (!host && this._wakeConfigHostId === hostId && status) {
// Say it up front rather than only when Save fails.
status.textContent = this._wakeConfigUnavailableMessage();
}
} catch {
/* The form is already usable from the session payload. */
@@ -289,7 +320,7 @@ Object.assign(CodemanApp.prototype, {
const listData = await listRes.json();
const hosts = listData.success ? listData.data : [];
const host = Array.isArray(hosts) ? hosts.find((item) => item.id === hostId) : null;
if (!host) throw new Error('Remote host not found');
if (!host) throw new Error(this._wakeConfigUnavailableMessage());
// PUT takes the whole host (schema-validated), so send back everything we know and
// only replace the wake fields. `undefined` drops the key entirely.
const payload = {
@@ -331,16 +362,24 @@ Object.assign(CodemanApp.prototype, {
// A create-path wake (the user pressed Run / Attach) has no session yet, so
// nothing is queued behind it — the wording has to say what actually happens.
const forNewSession = Boolean(data && data.forNewSession);
// Only the typing path buffers bytes; the wake button and the send-and-wait path
// hold none, and a browser keystroke never reaches the registry at all.
const queuedInput = Boolean(data && data.queuedInput);
// Long enough to cover the wake + attach (~10s measured on a warm S3), and it
// is replaced by `remote:sessionReconnected` the moment the pane is back.
this.showToast(
forNewSession ? `Waking ${label} … the session starts when it is back` : `Waking ${label} … input is queued`,
forNewSession
? `Waking ${label} … the session starts when it is back`
: queuedInput
? `Waking ${label} … input is queued`
: `Waking ${label} … waiting for it to come back`,
'info',
{ duration: 12000 }
);
const state = this._hostWake;
if (!state || !data || state.sessionId !== data.sessionId) return;
state.waking = true;
state.queuedInput = queuedInput;
state.error = '';
if (data.label) state.label = data.label;
this._renderHostWakeBanner();
@@ -350,16 +389,20 @@ Object.assign(CodemanApp.prototype, {
_onRemoteHostWakeFailed(data) {
const label = data && data.label ? data.label : 'Remote host';
const forNewSession = Boolean(data && data.forNewSession);
const queuedInput = Boolean(data && data.queuedInput);
this.showToast(
forNewSession
? `${label} did not wake up — no session was started`
: `${label} did not wake up — queued input is still held`,
: queuedInput
? `${label} did not wake up — queued input is still held`
: `${label} did not wake up`,
'error',
{ duration: 15000 }
);
const state = this._hostWake;
if (!state || !data || state.sessionId !== data.sessionId) return;
state.waking = false;
state.queuedInput = queuedInput;
state.error = 'timeout';
state.reachable = false;
this._renderHostWakeBanner();
+16 -4
View File
@@ -843,7 +843,7 @@ export function registerSessionRoutes(
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort,
/** Test seam: inject a registry with fake IO instead of the real TCP/WoL probes. */
options: { remoteWake?: RemoteWakeRegistry } = {}
): void {
): RemoteWakeRegistry {
// Wake-on-LAN for sleeping remote hosts (see remote-wake.ts). One registry per
// route registration (= one web server) — the same shape as the process-wide
// `sessionWaits` singleton, but without the global.
@@ -1350,7 +1350,8 @@ export function registerSessionRoutes(
}
const session = findSessionOrFail(ctx, id, req);
remoteWake.drop(session.id);
// Wake state is dropped by `cleanupSession` itself (server.ts), on EVERY cleanup
// path — not here: the scheduled-run and admin paths clean up without this route.
await ctx.cleanupSession(session.id, killMux, 'user_delete');
return {};
});
@@ -1368,7 +1369,6 @@ export function registerSessionRoutes(
for (const id of sessionIds) {
if (ctx.sessions.has(id)) {
remoteWake.drop(id);
await ctx.cleanupSession(id, true, 'user_bulk_delete');
killed++;
}
@@ -1626,7 +1626,13 @@ export function registerSessionRoutes(
'No wake-on-LAN target configured for this host (set a MAC address or a wake command)'
);
}
const woke = await remoteWake.ensureAwake(session, { force: true });
// The button is pressed from the SAME dashboard the create/attach paths are, under
// the same reverse proxy — so it holds the request open the same way and needs the
// same request budget, not the 90 s session default (see remote-wake.ts).
const woke = await remoteWake.ensureAwake(session, {
force: true,
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
});
return {
success: true,
data: {
@@ -4898,4 +4904,10 @@ export function registerSessionRoutes(
return { path: filepath, filename };
});
// Returned so the server can own the registry's LIFETIME (drop state when a session is
// cleaned up on any of its paths, resolve in-flight wakes on shutdown). The wake-CAPABLE
// code stays here: `test/remote-wake.test.ts` pins that `server.ts` calls nothing but
// `drop`/`stop` on this handle, so no timer path can reach a wake through it.
return remoteWake;
}
+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();
+116 -15
View File
@@ -20,10 +20,12 @@ import {
RemoteWakeRegistry,
appendBoundedPending,
buildMagicPacket,
createDefaultRemoteWakeDeps,
decideRemoteInputAction,
parseMacList,
resolveWakeTarget,
sendWakePackets,
waitUntilRemoteReady,
wakeConfigured,
REMOTE_WAKE_PENDING_MAX_BYTES,
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
@@ -77,21 +79,20 @@ describe('appendBoundedPending', () => {
expect(appendBoundedPending([big], 'newest')).toEqual(['newest']);
});
it('trims a single oversized chunk to the cap, keeping its TAIL', () => {
// One large paste is one input value, so the cap has to hold WITHIN a chunk too
// (otherwise "bounded at 4 KB" would only be true per chunk, not per session).
it('drops an oversized chunk outright instead of delivering a fragment of it', () => {
// One large paste is one input value and was never typed character by character, so its
// tail is not "what the user just typed" — writing it into the pane would run a partial
// command (with the paste's trailing carriage return, if it had one).
const huge = 'y'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES + 100);
const result = appendBoundedPending([], huge);
expect(result).toEqual(['y'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES)]);
expect(result[0].length).toBe(REMOTE_WAKE_PENDING_MAX_BYTES);
expect(appendBoundedPending([], huge)).toEqual([]);
// The bytes already queued are left alone, not replaced by the fragment.
expect(appendBoundedPending(['typed'], huge)).toEqual(['typed']);
});
it('trims a multi-byte tail without splitting a character', () => {
it('measures the cap in UTF-8 bytes, so a multi-byte paste is dropped too', () => {
const cap = 10;
const value = 'ä'.repeat(8); // 2 bytes each → 16 bytes
const result = appendBoundedPending([], value, cap);
expect(Buffer.byteLength(result[0])).toBeLessThanOrEqual(cap);
expect(result[0]).toBe('ä'.repeat(5)); // 10 bytes, no U+FFFD
const value = 'ä'.repeat(8); // 2 bytes each → 16 bytes > cap
expect(appendBoundedPending([], value, cap)).toEqual([]);
});
});
@@ -460,6 +461,37 @@ describe('RemoteWakeRegistry', () => {
expect(h.registry.pendingBytes('sess-1')).toBe(0);
expect(h.registry.isWaking('sess-1')).toBe(false);
});
it('never keys the state map on a local session', async () => {
// `hasWakeTarget` runs on EVERY input chunk (it is the route's gate), so allocating
// state before the `!session.remote` return would put an entry — and later a pending
// buffer — in the map for every local session the user types in.
const h = harness();
const local: WakeableSession = {
id: 'local-1',
remote: undefined,
reattachRemote: h.reattachRemote,
writeViaMux: h.writeViaMux,
};
expect(await h.registry.hasWakeTarget(local)).toBe(false);
expect(await h.registry.wakeConfigured(local)).toBe('none');
expect(h.registry.stateCount()).toBe(0);
expect(h.probe).not.toHaveBeenCalled();
});
it('ensureAwake hands the caller’s budget to the readiness poll (the wake button’s case)', async () => {
// The button is pressed from the same dashboard as Run/Attach, so it must not inherit
// the 90 s session default and get cut off by the proxy's 60 s read timeout.
const h = harness();
h.probe.mockResolvedValue(false);
await expect(
h.registry.ensureAwake(h.session, { force: true, timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS })
).resolves.toBe(true);
expect(h.waitUntilReady).toHaveBeenCalledWith(remote, {
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
signal: expect.any(AbortSignal),
});
});
});
// ========== Host-scoped wake (session create/attach) ==========
@@ -499,6 +531,8 @@ describe('RemoteWakeRegistry — host-scoped wake for a request that waits on it
// 60 s, so a create-path wake must not inherit the 90 s session default.
expect(h.waitUntilReady).toHaveBeenCalledWith(hostRemote, {
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
// The shutdown signal rides along so `WebServer.stop()` can end the poll.
signal: expect.any(AbortSignal),
});
expect(h.events).toContain('remote:hostWaking');
});
@@ -552,6 +586,49 @@ describe('RemoteWakeRegistry — host-scoped wake for a request that waits on it
await expect(h.registry.ensureHostAwake(hostRemote)).resolves.toBe('failed');
});
it('stop() resolves an in-flight wake as failed, so shutdown cannot wait it out', async () => {
// `WebServer.stop()` ends with `app.close()`, which does not abort in-flight requests:
// without this the shutdown sits out the whole readiness poll. Real `waitUntilReady`
// (abortable sleep) with fake probe/wake, which is the shape of a restart mid-wake.
const registry = new RemoteWakeRegistry(
createDefaultRemoteWakeDeps({ probe: async () => false, wake: async () => true, log: () => {} })
);
const pending = registry.ensureHostAwake(hostRemote, { timeoutMs: 60_000 });
await vi.waitFor(() => expect(registry.isWaking('host:hufflepuff')).toBe(true));
registry.stop();
await expect(pending).resolves.toBe('failed');
// ... and nothing new starts afterwards.
await expect(registry.ensureHostAwake(hostRemote)).resolves.toBe('failed');
});
});
describe('waitUntilRemoteReady', () => {
const remote: WakeableRemote = { hostId: 'h', label: 'H', host: '10.0.0.9' };
it('ends on abort instead of waiting out the current interval', async () => {
const controller = new AbortController();
const started = Date.now();
const pending = waitUntilRemoteReady(remote, {
intervalMs: 1_000,
timeoutMs: 60_000,
probe: async () => false,
signal: controller.signal,
});
setTimeout(() => controller.abort(), 10);
await expect(pending).resolves.toBe(false);
expect(Date.now() - started).toBeLessThan(1_000);
});
it('returns false immediately when the signal is already aborted', async () => {
const controller = new AbortController();
controller.abort();
const probe = vi.fn(async () => true);
await expect(waitUntilRemoteReady(remote, { probe, signal: controller.signal })).resolves.toBe(false);
expect(probe).not.toHaveBeenCalled();
});
});
// ========== Wiring guard ==========
@@ -572,11 +649,14 @@ function walkTs(dir: string): string[] {
}
describe('wake wiring guard', () => {
it('only the input route may reach the wake registry', () => {
it('only the route module and the server may import remote-wake', () => {
// The auto-reconnect watcher (tmux-manager.ts), the server's dropped-session
// handler and any boot-recovery path must NOT import remote-wake: waking there
// re-wakes the host seconds after each suspend. Asserted, not commented.
const allowed = new Set([join('web', 'routes', 'session-routes.ts')]);
// handler and any boot-recovery path must NOT be able to WAKE a host: waking there
// re-wakes the host seconds after each suspend. `web/server.ts` is allowed to hold
// the registry for its LIFETIME only (`drop()` on session cleanup, `stop()` on
// shutdown) — the test below pins that it never calls a waking method, which is the
// property this import list is an approximation of.
const allowed = new Set([join('web', 'routes', 'session-routes.ts'), join('web', 'server.ts')]);
const importers = walkTs(SRC)
.filter((full) => /from\s+['"][^'"]*remote-wake(\.js)?['"]/.test(readFileSync(full, 'utf-8')))
.map((full) => relative(SRC, full));
@@ -584,6 +664,27 @@ describe('wake wiring guard', () => {
expect(importers.sort()).toEqual([...allowed].sort());
});
it('the server only ever calls drop/stop on the registry — never a waking method', () => {
// `server.ts` holds the registry because `cleanupSession` and `stop()` need it, and
// those run on timers and shutdown paths. Any wake-capable call from this file is the
// exact failure invariant #1 exists to prevent, so it is asserted here rather than
// left to the import check above (which the field's type alone would satisfy).
const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8');
for (const method of [
'wake',
'ensureAwake',
'ensureHostAwake',
'handleInput',
'checkReachable',
'checkHostReachable',
]) {
expect(server).not.toContain(`remoteWake.${method}(`);
expect(server).not.toContain(`remoteWake?.${method}(`);
}
expect(server).toContain('remoteWake?.drop(');
expect(server).toContain('remoteWake?.stop(');
});
it('wakes a host for a create/attach request ONLY from the HTTP route', () => {
// The create-path wake (`ensureHostAwake`) is a USER request, so it belongs to the
// HTTP route. `cron-service.ts` builds sessions through the shared service with
+2
View File
@@ -1950,6 +1950,8 @@ describe('session-routes', () => {
// cut the request at 60 s while the session was still being built.
expect(wakeWaitUntilReady).toHaveBeenCalledWith(expect.objectContaining({ hostId: 'hufflepuff' }), {
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
// The shutdown signal rides along so `WebServer.stop()` can end the poll.
signal: expect.any(AbortSignal),
});
} finally {
startShell.mockRestore();