mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29: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
|
* Append `data` to the pending buffer, dropping the OLDEST whole chunks when the cap
|
||||||
* exceeded. Returns the resulting buffer. Pure.
|
* 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),
|
* A chunk LARGER than the cap is dropped outright rather than trimmed: one paste is
|
||||||
* so after whole chunks are dropped the surviving chunk's HEAD is trimmed too —
|
* one `input` value, and it was never typed character by character, so delivering its
|
||||||
* otherwise "bounded at 4 KB" would hold only per chunk, not per session. The trim
|
* tail would execute a fragment of it (with the trailing carriage return, if the paste
|
||||||
* is code-point aware, so it never emits a broken multi-byte character.
|
* 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(
|
export function appendBoundedPending(
|
||||||
pending: string[],
|
pending: string[],
|
||||||
data: string,
|
data: string,
|
||||||
maxBytes = REMOTE_WAKE_PENDING_MAX_BYTES
|
maxBytes = REMOTE_WAKE_PENDING_MAX_BYTES
|
||||||
): string[] {
|
): string[] {
|
||||||
|
if (Buffer.byteLength(data) > maxBytes) return pending;
|
||||||
const next = [...pending, data];
|
const next = [...pending, data];
|
||||||
let total = next.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
|
let total = next.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
|
||||||
while (next.length > 1 && total > maxBytes) {
|
while (next.length > 1 && total > maxBytes) {
|
||||||
total -= Buffer.byteLength(next[0]);
|
total -= Buffer.byteLength(next[0]);
|
||||||
next.shift();
|
next.shift();
|
||||||
}
|
}
|
||||||
if (next.length === 1) next[0] = tailWithinBytes(next[0], maxBytes);
|
|
||||||
return next;
|
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`. */
|
/** The remote fields the wake flow needs. Structurally satisfied by `SessionRemote`. */
|
||||||
export interface WakeableRemote {
|
export interface WakeableRemote {
|
||||||
wakeCommand?: string;
|
wakeCommand?: string;
|
||||||
@@ -240,7 +228,7 @@ export interface RemoteWakeDeps {
|
|||||||
/** Run the resolved wake target (magic packet or host command). Resolves false on failure. */
|
/** Run the resolved wake target (magic packet or host command). Resolves false on failure. */
|
||||||
wake(target: NonNullable<WakeTarget>): Promise<boolean>;
|
wake(target: NonNullable<WakeTarget>): Promise<boolean>;
|
||||||
/** Poll until the woken host accepts connections again. */
|
/** 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). */
|
/** Sleep helper (injected for tests). */
|
||||||
delay(ms: number): Promise<void>;
|
delay(ms: number): Promise<void>;
|
||||||
/** Notify the COD-108 watcher so an exhausted backoff is reset. */
|
/** Notify the COD-108 watcher so an exhausted backoff is reset. */
|
||||||
@@ -319,6 +307,14 @@ interface WakeState {
|
|||||||
*/
|
*/
|
||||||
export class RemoteWakeRegistry {
|
export class RemoteWakeRegistry {
|
||||||
private readonly states = new Map<string, WakeState>();
|
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) {}
|
constructor(private readonly deps: RemoteWakeDeps) {}
|
||||||
|
|
||||||
@@ -327,6 +323,19 @@ export class RemoteWakeRegistry {
|
|||||||
this.states.delete(sessionId);
|
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). */
|
/** Whether a wake is currently in flight (diagnostics/tests). */
|
||||||
isWaking(sessionId: string): boolean {
|
isWaking(sessionId: string): boolean {
|
||||||
return this.states.get(sessionId)?.waking != null;
|
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);
|
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. */
|
/** Whether this session's host has any wake path configured at all. */
|
||||||
async hasWakeTarget(session: WakeableSession): Promise<boolean> {
|
async hasWakeTarget(session: WakeableSession): Promise<boolean> {
|
||||||
return resolveWakeTarget(await this._effectiveRemote(session)) !== null;
|
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
|
* send-and-wait path, where the HTTP response stays open anyway and buffering
|
||||||
* would break the wait contract.
|
* 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);
|
const remote = await this._effectiveRemote(session);
|
||||||
if (!remote || !resolveWakeTarget(remote)) return true;
|
if (!remote || !resolveWakeTarget(remote)) return true;
|
||||||
const state = this._state(session.id);
|
const state = this._state(session.id);
|
||||||
@@ -423,7 +442,10 @@ export class RemoteWakeRegistry {
|
|||||||
state.reachable = await this.deps.probe(remote);
|
state.reachable = await this.deps.probe(remote);
|
||||||
}
|
}
|
||||||
if (state.reachable) return true;
|
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> {
|
async ensureHostAwake(remote: WakeableRemote, opts: { timeoutMs?: number } = {}): Promise<HostWakeOutcome> {
|
||||||
if (!resolveWakeTarget(remote)) return 'no-target';
|
if (!resolveWakeTarget(remote)) return 'no-target';
|
||||||
|
if (this.stopped) return 'failed';
|
||||||
const state = this._state(hostWakeKey(remote.hostId));
|
const state = this._state(hostWakeKey(remote.hostId));
|
||||||
if (state.waking) return (await state.waking) ? 'ready' : 'failed';
|
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}).
|
* Single-flight wake for a host with no session (see {@link ensureHostAwake}). Uses the
|
||||||
* A write into the same `waking` slot the session flow uses, so the two can never
|
* same single-flight `waking` slot the session flow uses — but a DIFFERENT key
|
||||||
* run two readiness polls against one host from the same key space.
|
* (`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> {
|
private async wakeHost(remote: WakeableRemote, opts: { timeoutMs?: number }): Promise<boolean> {
|
||||||
const state = this._state(hostWakeKey(remote.hostId));
|
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),
|
* 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.
|
* 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 remote = await this._effectiveRemote(session);
|
||||||
const target = resolveWakeTarget(remote);
|
const target = resolveWakeTarget(remote);
|
||||||
if (!remote || !target) return true;
|
if (!remote || !target) return true;
|
||||||
@@ -501,7 +528,7 @@ export class RemoteWakeRegistry {
|
|||||||
state.waking = (async (): Promise<boolean> => {
|
state.waking = (async (): Promise<boolean> => {
|
||||||
const id = session.id;
|
const id = session.id;
|
||||||
try {
|
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;
|
if (!ready) return false;
|
||||||
|
|
||||||
const reattached = await session.reattachRemote();
|
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
|
// 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),
|
// 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.
|
// 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', {
|
this.deps.broadcast?.('remote:hostWaking', {
|
||||||
...(opts.sessionId ? { sessionId: opts.sessionId } : { forNewSession: true }),
|
...(opts.sessionId ? { sessionId: opts.sessionId } : { forNewSession: true }),
|
||||||
hostId: remote.hostId,
|
hostId: remote.hostId,
|
||||||
label: remote.label,
|
label: remote.label,
|
||||||
|
queuedInput: state.pending.length > 0,
|
||||||
});
|
});
|
||||||
this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind} ${forWhat}`);
|
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) {
|
if (!ready) {
|
||||||
this.deps.log?.(
|
this.deps.log?.(
|
||||||
`[RemoteWake] ${remote.label} did not come back — ${opts.forNewSession ? 'the session was not started' : 'input stays buffered'}`
|
`[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 }),
|
...(opts.sessionId ? { sessionId: opts.sessionId } : { forNewSession: true }),
|
||||||
hostId: remote.hostId,
|
hostId: remote.hostId,
|
||||||
label: remote.label,
|
label: remote.label,
|
||||||
|
queuedInput: state.pending.length > 0,
|
||||||
});
|
});
|
||||||
state.probedAt = 0;
|
state.probedAt = 0;
|
||||||
state.reachable = undefined;
|
state.reachable = undefined;
|
||||||
@@ -607,8 +645,12 @@ export class RemoteWakeRegistry {
|
|||||||
* promise failing in the one direction a user can observe.
|
* promise failing in the one direction a user can observe.
|
||||||
*/
|
*/
|
||||||
private async _effectiveRemote(session: WakeableSession): Promise<WakeableRemote | undefined> {
|
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);
|
const state = this._state(session.id);
|
||||||
if (!session.remote) return state.resolvedRemote;
|
|
||||||
if (!this.deps.resolveRemote) return state.resolvedRemote ?? session.remote;
|
if (!this.deps.resolveRemote) return state.resolvedRemote ?? session.remote;
|
||||||
if (state.resolvedAt !== 0 && Date.now() - state.resolvedAt < REMOTE_WAKE_RESOLVE_TTL_MS) {
|
if (state.resolvedAt !== 0 && Date.now() - state.resolvedAt < REMOTE_WAKE_RESOLVE_TTL_MS) {
|
||||||
return state.resolvedRemote ?? session.remote;
|
return state.resolvedRemote ?? session.remote;
|
||||||
@@ -627,12 +669,22 @@ export class RemoteWakeRegistry {
|
|||||||
|
|
||||||
private _enqueue(sessionId: string, data: string): void {
|
private _enqueue(sessionId: string, data: string): void {
|
||||||
const state = this._state(sessionId);
|
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);
|
const before = state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
|
||||||
state.pending = appendBoundedPending(state.pending, data);
|
const after = next.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
|
||||||
const after = state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
|
|
||||||
if (before + Buffer.byteLength(data) > after) {
|
if (before + Buffer.byteLength(data) > after) {
|
||||||
this.deps.log?.(`[RemoteWake] pending buffer cap reached for session ${sessionId} — oldest input dropped`);
|
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> {
|
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. */
|
/** Poll the host until it accepts connections again, or the bound is hit. */
|
||||||
export async function waitUntilRemoteReady(
|
export async function waitUntilRemoteReady(
|
||||||
remote: WakeableRemote,
|
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> {
|
): Promise<boolean> {
|
||||||
const intervalMs = opts.intervalMs ?? REMOTE_WAKE_READY_INTERVAL_MS;
|
const intervalMs = opts.intervalMs ?? REMOTE_WAKE_READY_INTERVAL_MS;
|
||||||
const timeoutMs = opts.timeoutMs ?? REMOTE_WAKE_READY_TIMEOUT_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),
|
// 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.
|
// and the first poll is what turns "just woke" into a sub-interval response.
|
||||||
for (;;) {
|
for (;;) {
|
||||||
|
if (opts.signal?.aborted) return false;
|
||||||
if (await probe(remote)) return true;
|
if (await probe(remote)) return true;
|
||||||
if (Date.now() + intervalMs > deadline) return false;
|
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));
|
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
/** Production wiring: all IO defaults, overridable for tests. */
|
/** Production wiring: all IO defaults, overridable for tests. */
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
* keeps the banner in sync while a wake is running.
|
* keeps the banner in sync while a wake is running.
|
||||||
*
|
*
|
||||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
* @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;
|
const HOST_WAKE_POLL_MS = 30_000;
|
||||||
@@ -45,6 +48,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
label: '',
|
label: '',
|
||||||
/** True between clicking Wake and the answer coming back. */
|
/** True between clicking Wake and the answer coming back. */
|
||||||
waking: false,
|
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. */
|
/** Set when the last wake attempt or poll failed. */
|
||||||
error: '',
|
error: '',
|
||||||
};
|
};
|
||||||
@@ -157,7 +166,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
if (detail) {
|
if (detail) {
|
||||||
detail.textContent = state.waking
|
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
|
: hasTarget
|
||||||
? `ssh ${state.host}`
|
? `ssh ${state.host}`
|
||||||
: 'no wake-on-LAN configured';
|
: 'no wake-on-LAN configured';
|
||||||
@@ -187,6 +198,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!state || !state.sessionId) return;
|
if (!state || !state.sessionId) return;
|
||||||
const sessionId = state.sessionId;
|
const sessionId = state.sessionId;
|
||||||
state.waking = true;
|
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 = '';
|
state.error = '';
|
||||||
this._renderHostWakeBanner();
|
this._renderHostWakeBanner();
|
||||||
try {
|
try {
|
||||||
@@ -195,10 +210,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (this._hostWake !== state || state.sessionId !== sessionId) return;
|
if (this._hostWake !== state || state.sessionId !== sessionId) return;
|
||||||
state.waking = false;
|
state.waking = false;
|
||||||
if (!data.success) {
|
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';
|
state.error = data.error || 'Wake failed';
|
||||||
if (String(data.error || '').includes('No wake-on-LAN target')) state.wakeConfigured = 'none';
|
|
||||||
this._renderHostWakeBanner();
|
this._renderHostWakeBanner();
|
||||||
|
await this._pollHostReachability(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.reachable = data.data.reachable !== false;
|
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. */
|
/** Open the small WoL dialog for the banner's host, pre-filled from the host config. */
|
||||||
async openWakeConfigDialog() {
|
async openWakeConfigDialog() {
|
||||||
const state = this._hostWake;
|
const state = this._hostWake;
|
||||||
@@ -247,6 +275,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (host && this._wakeConfigHostId === hostId) {
|
if (host && this._wakeConfigHostId === hostId) {
|
||||||
mac.value = host.wakeMac || '';
|
mac.value = host.wakeMac || '';
|
||||||
command.value = host.wakeCommand || '';
|
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 {
|
} catch {
|
||||||
/* The form is already usable from the session payload. */
|
/* The form is already usable from the session payload. */
|
||||||
@@ -289,7 +320,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const listData = await listRes.json();
|
const listData = await listRes.json();
|
||||||
const hosts = listData.success ? listData.data : [];
|
const hosts = listData.success ? listData.data : [];
|
||||||
const host = Array.isArray(hosts) ? hosts.find((item) => item.id === hostId) : null;
|
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
|
// PUT takes the whole host (schema-validated), so send back everything we know and
|
||||||
// only replace the wake fields. `undefined` drops the key entirely.
|
// only replace the wake fields. `undefined` drops the key entirely.
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -331,16 +362,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// A create-path wake (the user pressed Run / Attach) has no session yet, so
|
// 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.
|
// nothing is queued behind it — the wording has to say what actually happens.
|
||||||
const forNewSession = Boolean(data && data.forNewSession);
|
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
|
// 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.
|
// is replaced by `remote:sessionReconnected` the moment the pane is back.
|
||||||
this.showToast(
|
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',
|
'info',
|
||||||
{ duration: 12000 }
|
{ duration: 12000 }
|
||||||
);
|
);
|
||||||
const state = this._hostWake;
|
const state = this._hostWake;
|
||||||
if (!state || !data || state.sessionId !== data.sessionId) return;
|
if (!state || !data || state.sessionId !== data.sessionId) return;
|
||||||
state.waking = true;
|
state.waking = true;
|
||||||
|
state.queuedInput = queuedInput;
|
||||||
state.error = '';
|
state.error = '';
|
||||||
if (data.label) state.label = data.label;
|
if (data.label) state.label = data.label;
|
||||||
this._renderHostWakeBanner();
|
this._renderHostWakeBanner();
|
||||||
@@ -350,16 +389,20 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_onRemoteHostWakeFailed(data) {
|
_onRemoteHostWakeFailed(data) {
|
||||||
const label = data && data.label ? data.label : 'Remote host';
|
const label = data && data.label ? data.label : 'Remote host';
|
||||||
const forNewSession = Boolean(data && data.forNewSession);
|
const forNewSession = Boolean(data && data.forNewSession);
|
||||||
|
const queuedInput = Boolean(data && data.queuedInput);
|
||||||
this.showToast(
|
this.showToast(
|
||||||
forNewSession
|
forNewSession
|
||||||
? `${label} did not wake up — no session was started`
|
? `${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',
|
'error',
|
||||||
{ duration: 15000 }
|
{ duration: 15000 }
|
||||||
);
|
);
|
||||||
const state = this._hostWake;
|
const state = this._hostWake;
|
||||||
if (!state || !data || state.sessionId !== data.sessionId) return;
|
if (!state || !data || state.sessionId !== data.sessionId) return;
|
||||||
state.waking = false;
|
state.waking = false;
|
||||||
|
state.queuedInput = queuedInput;
|
||||||
state.error = 'timeout';
|
state.error = 'timeout';
|
||||||
state.reachable = false;
|
state.reachable = false;
|
||||||
this._renderHostWakeBanner();
|
this._renderHostWakeBanner();
|
||||||
|
|||||||
@@ -843,7 +843,7 @@ export function registerSessionRoutes(
|
|||||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort,
|
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort,
|
||||||
/** Test seam: inject a registry with fake IO instead of the real TCP/WoL probes. */
|
/** Test seam: inject a registry with fake IO instead of the real TCP/WoL probes. */
|
||||||
options: { remoteWake?: RemoteWakeRegistry } = {}
|
options: { remoteWake?: RemoteWakeRegistry } = {}
|
||||||
): void {
|
): RemoteWakeRegistry {
|
||||||
// Wake-on-LAN for sleeping remote hosts (see remote-wake.ts). One registry per
|
// 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
|
// route registration (= one web server) — the same shape as the process-wide
|
||||||
// `sessionWaits` singleton, but without the global.
|
// `sessionWaits` singleton, but without the global.
|
||||||
@@ -1350,7 +1350,8 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = findSessionOrFail(ctx, id, req);
|
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');
|
await ctx.cleanupSession(session.id, killMux, 'user_delete');
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
@@ -1368,7 +1369,6 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
for (const id of sessionIds) {
|
for (const id of sessionIds) {
|
||||||
if (ctx.sessions.has(id)) {
|
if (ctx.sessions.has(id)) {
|
||||||
remoteWake.drop(id);
|
|
||||||
await ctx.cleanupSession(id, true, 'user_bulk_delete');
|
await ctx.cleanupSession(id, true, 'user_bulk_delete');
|
||||||
killed++;
|
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)'
|
'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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -4898,4 +4904,10 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
return { path: filepath, filename };
|
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 { hostname as getHostname } from 'node:os';
|
||||||
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
|
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
|
||||||
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.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 { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
|
||||||
import { GLYPH, palette } from '../cli-style.js';
|
import { GLYPH, palette } from '../cli-style.js';
|
||||||
import { getHookSecret } from '../config/hook-secret.js';
|
import { getHookSecret } from '../config/hook-secret.js';
|
||||||
@@ -268,6 +269,14 @@ export class WebServer extends EventEmitter {
|
|||||||
private scheduledRuns: Map<string, ScheduledRun> = new Map();
|
private scheduledRuns: Map<string, ScheduledRun> = new Map();
|
||||||
/** Cron service (assigned in setupRoutes). */
|
/** Cron service (assigned in setupRoutes). */
|
||||||
private cronService!: CronService;
|
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 sse: SseStreamManager;
|
||||||
private store = getStore();
|
private store = getStore();
|
||||||
private tabLayouts!: TabLayoutService;
|
private tabLayouts!: TabLayoutService;
|
||||||
@@ -1065,7 +1074,9 @@ export class WebServer extends EventEmitter {
|
|||||||
registerStatusTelemetryRoutes(this.app, ctx);
|
registerStatusTelemetryRoutes(this.app, ctx);
|
||||||
registerSystemRoutes(this.app, ctx);
|
registerSystemRoutes(this.app, ctx);
|
||||||
registerCaseRoutes(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);
|
registerRespawnRoutes(this.app, ctx);
|
||||||
registerRalphRoutes(this.app, ctx);
|
registerRalphRoutes(this.app, ctx);
|
||||||
registerPlanRoutes(this.app, ctx);
|
registerPlanRoutes(this.app, ctx);
|
||||||
@@ -1456,6 +1467,11 @@ export class WebServer extends EventEmitter {
|
|||||||
sessionWaits.notifySignal(sessionId, 'exit');
|
sessionWaits.notifySignal(sessionId, 'exit');
|
||||||
sessionWaits.cancelAll(sessionId);
|
sessionWaits.cancelAll(sessionId);
|
||||||
approvalInbox.resolveForSession(sessionId, 'session_ended');
|
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 });
|
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.
|
// response), so without this a 10-minute wait holds shutdown open.
|
||||||
sessionWaits.cancelEverything();
|
sessionWaits.cancelEverything();
|
||||||
approvalInbox.stop();
|
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();
|
this.lastRecordedTokens.clear();
|
||||||
|
|
||||||
|
|||||||
+116
-15
@@ -20,10 +20,12 @@ import {
|
|||||||
RemoteWakeRegistry,
|
RemoteWakeRegistry,
|
||||||
appendBoundedPending,
|
appendBoundedPending,
|
||||||
buildMagicPacket,
|
buildMagicPacket,
|
||||||
|
createDefaultRemoteWakeDeps,
|
||||||
decideRemoteInputAction,
|
decideRemoteInputAction,
|
||||||
parseMacList,
|
parseMacList,
|
||||||
resolveWakeTarget,
|
resolveWakeTarget,
|
||||||
sendWakePackets,
|
sendWakePackets,
|
||||||
|
waitUntilRemoteReady,
|
||||||
wakeConfigured,
|
wakeConfigured,
|
||||||
REMOTE_WAKE_PENDING_MAX_BYTES,
|
REMOTE_WAKE_PENDING_MAX_BYTES,
|
||||||
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||||
@@ -77,21 +79,20 @@ describe('appendBoundedPending', () => {
|
|||||||
expect(appendBoundedPending([big], 'newest')).toEqual(['newest']);
|
expect(appendBoundedPending([big], 'newest')).toEqual(['newest']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('trims a single oversized chunk to the cap, keeping its TAIL', () => {
|
it('drops an oversized chunk outright instead of delivering a fragment of it', () => {
|
||||||
// One large paste is one input value, so the cap has to hold WITHIN a chunk too
|
// One large paste is one input value and was never typed character by character, so its
|
||||||
// (otherwise "bounded at 4 KB" would only be true per chunk, not per session).
|
// 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 huge = 'y'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES + 100);
|
||||||
const result = appendBoundedPending([], huge);
|
expect(appendBoundedPending([], huge)).toEqual([]);
|
||||||
expect(result).toEqual(['y'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES)]);
|
// The bytes already queued are left alone, not replaced by the fragment.
|
||||||
expect(result[0].length).toBe(REMOTE_WAKE_PENDING_MAX_BYTES);
|
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 cap = 10;
|
||||||
const value = 'ä'.repeat(8); // 2 bytes each → 16 bytes
|
const value = 'ä'.repeat(8); // 2 bytes each → 16 bytes > cap
|
||||||
const result = appendBoundedPending([], value, cap);
|
expect(appendBoundedPending([], value, cap)).toEqual([]);
|
||||||
expect(Buffer.byteLength(result[0])).toBeLessThanOrEqual(cap);
|
|
||||||
expect(result[0]).toBe('ä'.repeat(5)); // 10 bytes, no U+FFFD
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -460,6 +461,37 @@ describe('RemoteWakeRegistry', () => {
|
|||||||
expect(h.registry.pendingBytes('sess-1')).toBe(0);
|
expect(h.registry.pendingBytes('sess-1')).toBe(0);
|
||||||
expect(h.registry.isWaking('sess-1')).toBe(false);
|
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) ==========
|
// ========== 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.
|
// 60 s, so a create-path wake must not inherit the 90 s session default.
|
||||||
expect(h.waitUntilReady).toHaveBeenCalledWith(hostRemote, {
|
expect(h.waitUntilReady).toHaveBeenCalledWith(hostRemote, {
|
||||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
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');
|
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');
|
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 ==========
|
// ========== Wiring guard ==========
|
||||||
@@ -572,11 +649,14 @@ function walkTs(dir: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('wake wiring guard', () => {
|
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
|
// 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
|
// handler and any boot-recovery path must NOT be able to WAKE a host: waking there
|
||||||
// re-wakes the host seconds after each suspend. Asserted, not commented.
|
// re-wakes the host seconds after each suspend. `web/server.ts` is allowed to hold
|
||||||
const allowed = new Set([join('web', 'routes', 'session-routes.ts')]);
|
// 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)
|
const importers = walkTs(SRC)
|
||||||
.filter((full) => /from\s+['"][^'"]*remote-wake(\.js)?['"]/.test(readFileSync(full, 'utf-8')))
|
.filter((full) => /from\s+['"][^'"]*remote-wake(\.js)?['"]/.test(readFileSync(full, 'utf-8')))
|
||||||
.map((full) => relative(SRC, full));
|
.map((full) => relative(SRC, full));
|
||||||
@@ -584,6 +664,27 @@ describe('wake wiring guard', () => {
|
|||||||
expect(importers.sort()).toEqual([...allowed].sort());
|
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', () => {
|
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
|
// 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
|
// 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.
|
// cut the request at 60 s while the session was still being built.
|
||||||
expect(wakeWaitUntilReady).toHaveBeenCalledWith(expect.objectContaining({ hostId: 'hufflepuff' }), {
|
expect(wakeWaitUntilReady).toHaveBeenCalledWith(expect.objectContaining({ hostId: 'hufflepuff' }), {
|
||||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||||
|
// The shutdown signal rides along so `WebServer.stop()` can end the poll.
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
startShell.mockRestore();
|
startShell.mockRestore();
|
||||||
|
|||||||
Reference in New Issue
Block a user