mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
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:
File diff suppressed because one or more lines are too long
+110
-36
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user