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
+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. */