mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
feat(remote): wake a sleeping host from user input (Wake-on-LAN)
A durable remote session survives SSH drops (COD-104/108), but nothing brought the HOST back: after the remote machine suspended, the local tmux pane's ssh child stalled silently and `send-keys` SUCCEEDS against it, so typed input vanished with no error anywhere. Add an optional per-host `wakeCommand` (Wake-on-LAN wrapper, e.g. whuff) that the input route runs when a wake-enabled host is unreachable: input is buffered, the host is woken, the pane is reattached, and the buffer is flushed in order. Detection is a throttled bare TCP probe on wake-enabled hosts only, and only REAL user input may wake a host - the auto-reconnect watcher and boot recovery deliberately cannot, or the host would be re-woken seconds after every suspend and could never stay asleep.
This commit is contained in:
@@ -549,6 +549,9 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi
|
||||
port: host.port,
|
||||
remotePath: remoteCase.remotePath,
|
||||
commands: host.commands,
|
||||
// Wake-on-LAN command travels with the session so the input route can wake a
|
||||
// sleeping host without a second config read (see remote-wake.ts).
|
||||
wakeCommand: host.wakeCommand,
|
||||
// COD-105 — the COD-104 launch path creates the remote session, so we own it
|
||||
// (an explicit kill may propagate a remote kill-session). Discovered+attached
|
||||
// sessions go through `toAttachedSessionRemote` with `owned: false`.
|
||||
@@ -587,6 +590,9 @@ export function toAttachedSessionRemote(
|
||||
port: host.port,
|
||||
remotePath,
|
||||
commands: host.commands,
|
||||
// An attached session can be woken exactly the same way — the identity of the
|
||||
// creator does not change whether the host is asleep.
|
||||
wakeCommand: host.wakeCommand,
|
||||
// Discovered + attached — another Codeman created it. Detach-not-kill.
|
||||
owned: false,
|
||||
remoteSessionName,
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* @fileoverview Wake a SLEEPING remote host from user input (user-triggered Wake-on-LAN).
|
||||
*
|
||||
* A durable remote session survives SSH drops (COD-104) and auto-reconnects
|
||||
* (COD-108), but nothing brings the HOST back: if the remote machine suspended,
|
||||
* the local tmux pane's `ssh` child stalls silently. `tmux send-keys` then
|
||||
* SUCCEEDS against a pane that will never deliver the bytes, so typed input is
|
||||
* lost with no error anywhere — the failure this module exists to close.
|
||||
*
|
||||
* Design (deliberately narrow, see docs/remote-sessions.md §Wake-on-LAN):
|
||||
* - ONLY real user input wakes a host. The auto-reconnect watcher and
|
||||
* boot-recovery must never wake one, or a host would be re-woken ~45 s after
|
||||
* each suspend and could never stay asleep (the "keepalive pings a sleeping
|
||||
* host" failure already solved for a different consumer by
|
||||
* `hufflepuff-mcp-lazy`).
|
||||
* - Detection is a cheap TCP connect to the SSH port (no auth, no ssh client,
|
||||
* a few hundred bytes — below any meaningful activity threshold), throttled
|
||||
* per session. No SSH keepalive is added to the launch command: keepalives
|
||||
* would move bytes into an otherwise idle connection every interval, which is
|
||||
* exactly the "an open pipe keeps the host awake" bug the remote-side idle
|
||||
* detector was rewritten to avoid.
|
||||
* - While a wake is in flight, input is BUFFERED and flushed in order once the
|
||||
* pane is reattached, so the user's first characters after a long pause are
|
||||
* not the ones that get eaten.
|
||||
*
|
||||
* The pure decisions and the IO are separated so the decision table can be
|
||||
* unit-tested without tmux, ssh, or a real host.
|
||||
*
|
||||
* @module remote-wake
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import net from 'node:net';
|
||||
|
||||
/** Minimum spacing between two reachability probes for the same session. */
|
||||
export const REMOTE_WAKE_PROBE_MIN_INTERVAL_MS = 30_000;
|
||||
/** TCP-connect timeout for a reachability probe (host awake ≈ a few ms). */
|
||||
export const REMOTE_WAKE_PROBE_TIMEOUT_MS = 1_500;
|
||||
/** Poll spacing while waiting for a woken host to accept SSH again. */
|
||||
export const REMOTE_WAKE_READY_INTERVAL_MS = 1_500;
|
||||
/** Bounded wait for the host to come back after the wake command ran. */
|
||||
export const REMOTE_WAKE_READY_TIMEOUT_MS = 90_000;
|
||||
/** The wake command itself must not hang the wake flow. */
|
||||
export const REMOTE_WAKE_COMMAND_TIMEOUT_MS = 10_000;
|
||||
/**
|
||||
* Settle time between respawning the ssh pane and flushing buffered input: the
|
||||
* respawned `ssh` needs a moment to run `tmux -L codeman-remote … -A` and attach,
|
||||
* and bytes written into a still-connecting pane land in nothing.
|
||||
*/
|
||||
export const REMOTE_WAKE_ATTACH_SETTLE_MS = 1_500;
|
||||
/**
|
||||
* Cap on buffered input per session while a host is being woken. 4 KB is a lot
|
||||
* of typing for a ~10 s wake; beyond it the OLDEST bytes are dropped (keeping the
|
||||
* tail preserves what the user just typed, and a silently unbounded buffer would
|
||||
* be a memory leak keyed on user input).
|
||||
*/
|
||||
export const REMOTE_WAKE_PENDING_MAX_BYTES = 4096;
|
||||
/** Default SSH port used when the host config has no explicit `port`. */
|
||||
export const DEFAULT_SSH_PORT = 22;
|
||||
|
||||
/** What the input path should do with a chunk of user input. Pure. */
|
||||
export type RemoteInputAction = 'deliver' | 'probe' | 'buffer';
|
||||
|
||||
/**
|
||||
* The caller-facing outcome of {@link RemoteWakeRegistry.handleInput}: either the
|
||||
* caller writes the bytes as usual, or the registry took ownership of them.
|
||||
*/
|
||||
export type RemoteInputOutcome = 'deliver' | 'buffered';
|
||||
|
||||
/**
|
||||
* Decide what to do with an input chunk on an input route. Mirrors
|
||||
* {@link RemoteWakeRegistry.handleInput} so the throttle table has exactly ONE
|
||||
* definition and is unit-testable:
|
||||
*
|
||||
* - a wake already in flight → buffer (the flush owns delivery),
|
||||
* - no wake command configured → deliver (feature off, today's behavior),
|
||||
* - the last probe said "down" → buffer (no second probe; re-probing a known
|
||||
* sleeping host on every keystroke would add seconds of latency per character),
|
||||
* - never probed / throttle window elapsed → probe,
|
||||
* - probed "up" inside the window → deliver.
|
||||
*
|
||||
* Pure — no clock, no IO.
|
||||
*/
|
||||
export function decideRemoteInputAction(args: {
|
||||
hasWakeCommand: boolean;
|
||||
waking: boolean;
|
||||
probeAgeMs: number;
|
||||
lastReachable?: boolean;
|
||||
minProbeIntervalMs?: number;
|
||||
}): RemoteInputAction {
|
||||
if (args.waking) return 'buffer';
|
||||
if (!args.hasWakeCommand) return 'deliver';
|
||||
if (args.lastReachable === false) return 'buffer';
|
||||
const interval = args.minProbeIntervalMs ?? REMOTE_WAKE_PROBE_MIN_INTERVAL_MS;
|
||||
if (args.probeAgeMs >= interval) return 'probe';
|
||||
return 'deliver';
|
||||
}
|
||||
|
||||
/**
|
||||
* Append `data` to the pending buffer, dropping the OLDEST bytes when the cap is
|
||||
* exceeded. Returns the resulting buffer. Pure.
|
||||
*/
|
||||
export function appendBoundedPending(
|
||||
pending: string[],
|
||||
data: string,
|
||||
maxBytes = REMOTE_WAKE_PENDING_MAX_BYTES
|
||||
): string[] {
|
||||
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();
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The remote fields the wake flow needs. Structurally satisfied by `SessionRemote`. */
|
||||
export interface WakeableRemote {
|
||||
wakeCommand?: string;
|
||||
hostId: string;
|
||||
label: string;
|
||||
host: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of `Session` the wake flow uses — an interface rather than the
|
||||
* concrete class so the registry is testable without a tmux server.
|
||||
*/
|
||||
export interface WakeableSession {
|
||||
readonly id: string;
|
||||
readonly remote: WakeableRemote | undefined;
|
||||
/** COD-108 reattach: respawns the local ssh pane, idempotently attaching the durable remote tmux. */
|
||||
reattachRemote(): Promise<boolean>;
|
||||
/** Write bytes to the session's pane. */
|
||||
writeViaMux(data: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Injected IO so the registry holds no direct dependency on ssh/net/child_process in tests. */
|
||||
export interface RemoteWakeDeps {
|
||||
/** Cheap reachability probe. Must resolve false (never throw) for a sleeping host. */
|
||||
probe(remote: WakeableRemote): Promise<boolean>;
|
||||
/** Run the host's wake command. Resolves false when it fails to run. */
|
||||
wake(command: string): Promise<boolean>;
|
||||
/** Poll until the woken host accepts connections again. */
|
||||
waitUntilReady(remote: WakeableRemote): Promise<boolean>;
|
||||
/** Sleep helper (injected for tests). */
|
||||
delay(ms: number): Promise<void>;
|
||||
/** Notify the COD-108 watcher so an exhausted backoff is reset. */
|
||||
noteReconnected?(sessionId: string, success: boolean): void;
|
||||
/** SSE broadcast. */
|
||||
broadcast?(
|
||||
event: 'remote:hostWaking' | 'remote:hostWakeFailed' | 'remote:sessionReconnected',
|
||||
payload: Record<string, unknown>
|
||||
): void;
|
||||
/** Structured diagnostics. */
|
||||
log?(message: string): void;
|
||||
}
|
||||
|
||||
/** Per-session wake bookkeeping. */
|
||||
interface WakeState {
|
||||
probedAt: number;
|
||||
reachable?: boolean;
|
||||
waking: Promise<boolean> | null;
|
||||
pending: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session wake state + single-flight wake flow.
|
||||
*
|
||||
* One instance per web server (module singleton in the routes file, like the
|
||||
* signal-wait registry). State is keyed by session id and dropped with the
|
||||
* session.
|
||||
*/
|
||||
export class RemoteWakeRegistry {
|
||||
private readonly states = new Map<string, WakeState>();
|
||||
|
||||
constructor(private readonly deps: RemoteWakeDeps) {}
|
||||
|
||||
/** Drop a session's state (session closed/killed). The pending buffer goes with it. */
|
||||
drop(sessionId: string): void {
|
||||
this.states.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Whether a wake is currently in flight (diagnostics/tests). */
|
||||
isWaking(sessionId: string): boolean {
|
||||
return this.states.get(sessionId)?.waking != null;
|
||||
}
|
||||
|
||||
/** Buffered input bytes for a session (diagnostics/tests). */
|
||||
pendingBytes(sessionId: string): number {
|
||||
const state = this.states.get(sessionId);
|
||||
if (!state) return 0;
|
||||
return state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide + act for one input chunk.
|
||||
*
|
||||
* `'deliver'` means the caller writes it as usual (today's path, zero added
|
||||
* cost). `'buffered'` means the registry took ownership of the bytes: it either
|
||||
* queued them behind an in-flight wake or started a wake, and will flush them
|
||||
* in order once the pane is reattached.
|
||||
*/
|
||||
async handleInput(session: WakeableSession, data: string): Promise<RemoteInputOutcome> {
|
||||
const remote = session.remote;
|
||||
const state = this._state(session.id);
|
||||
const action = decideRemoteInputAction({
|
||||
hasWakeCommand: Boolean(remote?.wakeCommand),
|
||||
waking: state.waking != null,
|
||||
probeAgeMs: Date.now() - state.probedAt,
|
||||
lastReachable: state.reachable,
|
||||
});
|
||||
|
||||
if (action === 'deliver') return 'deliver';
|
||||
if (action === 'buffer') {
|
||||
// A buffered decision with no wake in flight (the wake failed and the state
|
||||
// was reset, or the very first input of a session in the throttle window)
|
||||
// must still drive a wake, or the bytes would sit in the buffer forever.
|
||||
if (state.waking == null && remote?.wakeCommand) {
|
||||
this._enqueue(session.id, data);
|
||||
void this.wake(session);
|
||||
return 'buffered';
|
||||
}
|
||||
this._enqueue(session.id, data);
|
||||
return 'buffered';
|
||||
}
|
||||
|
||||
// action === 'probe' — the throttle window elapsed, so one TCP connect is owed.
|
||||
state.probedAt = Date.now();
|
||||
state.reachable = remote ? await this.deps.probe(remote) : true;
|
||||
if (state.reachable) return 'deliver';
|
||||
|
||||
this._enqueue(session.id, data);
|
||||
void this.wake(session);
|
||||
return 'buffered';
|
||||
}
|
||||
|
||||
/**
|
||||
* Block until the host is reachable and the pane is reattached — the
|
||||
* send-and-wait path, where the HTTP response stays open anyway and buffering
|
||||
* would break the wait contract.
|
||||
*/
|
||||
async ensureAwake(session: WakeableSession): Promise<boolean> {
|
||||
const remote = session.remote;
|
||||
if (!remote?.wakeCommand) return true;
|
||||
const state = this._state(session.id);
|
||||
if (state.reachable !== false && Date.now() - state.probedAt >= REMOTE_WAKE_PROBE_MIN_INTERVAL_MS) {
|
||||
state.probedAt = Date.now();
|
||||
state.reachable = await this.deps.probe(remote);
|
||||
}
|
||||
if (state.reachable) return true;
|
||||
return this.wake(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const remote = session.remote;
|
||||
if (!remote?.wakeCommand) return true;
|
||||
const state = this._state(session.id);
|
||||
if (state.waking) return state.waking;
|
||||
|
||||
state.waking = (async (): Promise<boolean> => {
|
||||
const id = session.id;
|
||||
try {
|
||||
this.deps.broadcast?.('remote:hostWaking', { sessionId: id, hostId: remote.hostId, label: remote.label });
|
||||
this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) for session ${id}`);
|
||||
|
||||
const woke = await this.deps.wake(remote.wakeCommand as string);
|
||||
if (!woke) this.deps.log?.(`[RemoteWake] wake command failed for ${remote.label}: ${remote.wakeCommand}`);
|
||||
|
||||
const ready = await this.deps.waitUntilReady(remote);
|
||||
if (!ready) {
|
||||
this.deps.log?.(`[RemoteWake] ${remote.label} did not come back — input stays buffered`);
|
||||
this.deps.broadcast?.('remote:hostWakeFailed', { sessionId: id, hostId: remote.hostId, label: remote.label });
|
||||
// Reset the probe state so the NEXT user input probes and retries
|
||||
// instead of trusting a stale "down" verdict forever.
|
||||
state.probedAt = 0;
|
||||
state.reachable = undefined;
|
||||
return false;
|
||||
}
|
||||
|
||||
state.reachable = true;
|
||||
state.probedAt = Date.now();
|
||||
const reattached = await session.reattachRemote();
|
||||
if (!reattached) {
|
||||
this.deps.log?.(`[RemoteWake] ${remote.label} is up but the pane could not be reattached`);
|
||||
return false;
|
||||
}
|
||||
// The reset also clears an EXHAUSTED COD-108 backoff, which otherwise
|
||||
// never fires again for this session (see remote-reconnect.ts).
|
||||
this.deps.noteReconnected?.(id, true);
|
||||
this.deps.broadcast?.('remote:sessionReconnected', { sessionId: id });
|
||||
this.deps.log?.(`[RemoteWake] ${remote.label} reattached for session ${id}`);
|
||||
|
||||
await this.deps.delay(REMOTE_WAKE_ATTACH_SETTLE_MS);
|
||||
await this._flush(state, session);
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.deps.log?.(`[RemoteWake] unexpected failure: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return false;
|
||||
} finally {
|
||||
state.waking = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return state.waking;
|
||||
}
|
||||
|
||||
private _state(sessionId: string): WakeState {
|
||||
let state = this.states.get(sessionId);
|
||||
if (!state) {
|
||||
state = { probedAt: 0, reachable: undefined, waking: null, pending: [] };
|
||||
this.states.set(sessionId, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private _enqueue(sessionId: string, data: string): void {
|
||||
const state = this._state(sessionId);
|
||||
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);
|
||||
if (before + Buffer.byteLength(data) > after) {
|
||||
this.deps.log?.(`[RemoteWake] pending buffer cap reached for session ${sessionId} — oldest input dropped`);
|
||||
}
|
||||
}
|
||||
|
||||
private async _flush(state: WakeState, session: WakeableSession): Promise<void> {
|
||||
while (state.pending.length > 0) {
|
||||
const chunk = state.pending[0];
|
||||
const ok = await session.writeViaMux(chunk).catch(() => false);
|
||||
if (!ok) {
|
||||
this.deps.log?.(
|
||||
`[RemoteWake] flush failed for session ${session.id} — ${state.pending.length} chunk(s) retained`
|
||||
);
|
||||
return;
|
||||
}
|
||||
state.pending.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Default IO ==========
|
||||
|
||||
/**
|
||||
* Cheap reachability probe: a bare TCP connect to the SSH port.
|
||||
*
|
||||
* Deliberately NOT an `ssh … true` probe: that opens a full session (auth,
|
||||
* remote log, process) every throttle window for a question a SYN already
|
||||
* answers. Any byte count it does move is a few hundred bytes per probe, far
|
||||
* below the remote idle detector's traffic threshold, so probing cannot keep a
|
||||
* host awake.
|
||||
*/
|
||||
export function probeRemoteHostReachable(
|
||||
remote: WakeableRemote,
|
||||
timeoutMs = REMOTE_WAKE_PROBE_TIMEOUT_MS
|
||||
): Promise<boolean> {
|
||||
const port = remote.port ?? DEFAULT_SSH_PORT;
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (value: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(value);
|
||||
};
|
||||
const socket = net.connect({ host: remote.host, port });
|
||||
socket.setTimeout(timeoutMs, () => finish(false));
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a host's wake command (e.g. a Wake-on-LAN wrapper script). No shell — the
|
||||
* value is a single executable path, so nothing in it can be interpreted.
|
||||
* Resolves false on any failure (missing binary, non-zero exit, timeout) rather
|
||||
* than throwing: a broken wake command must not break the input route.
|
||||
*/
|
||||
export function runRemoteWakeCommand(command: string, timeoutMs = REMOTE_WAKE_COMMAND_TIMEOUT_MS): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (value: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(command, [], { stdio: 'ignore' });
|
||||
} catch {
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
finish(false);
|
||||
}, timeoutMs);
|
||||
child.once('error', () => {
|
||||
clearTimeout(timer);
|
||||
finish(false);
|
||||
});
|
||||
child.once('exit', (code) => {
|
||||
clearTimeout(timer);
|
||||
finish(code === 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 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> } = {}
|
||||
): Promise<boolean> {
|
||||
const intervalMs = opts.intervalMs ?? REMOTE_WAKE_READY_INTERVAL_MS;
|
||||
const timeoutMs = opts.timeoutMs ?? REMOTE_WAKE_READY_TIMEOUT_MS;
|
||||
const probe = opts.probe ?? probeRemoteHostReachable;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
// 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 (await probe(remote)) return true;
|
||||
if (Date.now() + intervalMs > deadline) return false;
|
||||
await delay(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** Production wiring: all IO defaults, overridable for tests. */
|
||||
export function createDefaultRemoteWakeDeps(overrides: Partial<RemoteWakeDeps> = {}): RemoteWakeDeps {
|
||||
return {
|
||||
probe: probeRemoteHostReachable,
|
||||
wake: runRemoteWakeCommand,
|
||||
waitUntilReady: (remote) => waitUntilRemoteReady(remote),
|
||||
delay,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -97,6 +97,15 @@ export interface RemoteHost extends RemoteSshOptions {
|
||||
username: string;
|
||||
port?: number;
|
||||
commands?: Partial<Record<RemoteCommandMode, string>>;
|
||||
/**
|
||||
* Optional Wake-on-LAN command that powers this host on from SLEEP (e.g. a
|
||||
* wrapper script like `/home/joe/bin/whuff`). Absent = no wake support and
|
||||
* today's behavior exactly. Executed WITHOUT a shell (a single executable
|
||||
* path, never a command line), only from user input on a session whose host
|
||||
* is unreachable — never from the auto-reconnect/boot-recovery path, which
|
||||
* would re-wake a host seconds after each suspend.
|
||||
*/
|
||||
wakeCommand?: string;
|
||||
}
|
||||
|
||||
export interface RemoteCase {
|
||||
@@ -137,6 +146,11 @@ export interface SessionRemote extends RemoteSshOptions {
|
||||
* session was created elsewhere. Only meaningful when `owned === false`.
|
||||
*/
|
||||
remoteSessionName?: string;
|
||||
/**
|
||||
* Wake-on-LAN command carried over from the host config (see `RemoteHost.wakeCommand`)
|
||||
* so the input route can wake a sleeping host without re-reading the host list.
|
||||
*/
|
||||
wakeCommand?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -219,6 +219,8 @@ const _SSE_HANDLER_MAP = [
|
||||
// Remote auto-reconnect (COD-108)
|
||||
[SSE_EVENTS.REMOTE_SESSION_RECONNECTED, '_onRemoteSessionReconnected'],
|
||||
[SSE_EVENTS.REMOTE_RECONNECT_EXHAUSTED, '_onRemoteReconnectExhausted'],
|
||||
[SSE_EVENTS.REMOTE_HOST_WAKING, '_onRemoteHostWaking'],
|
||||
[SSE_EVENTS.REMOTE_HOST_WAKE_FAILED, '_onRemoteHostWakeFailed'],
|
||||
|
||||
// Ralph
|
||||
[SSE_EVENTS.SESSION_RALPH_LOOP_UPDATE, '_onRalphLoopUpdate'],
|
||||
|
||||
@@ -1057,6 +1057,9 @@ const SSE_EVENTS = {
|
||||
REMOTE_SESSION_DROPPED: 'remote:sessionDropped',
|
||||
REMOTE_SESSION_RECONNECTED: 'remote:sessionReconnected',
|
||||
REMOTE_RECONNECT_EXHAUSTED: 'remote:reconnectExhausted',
|
||||
// Wake-on-LAN from user input on a sleeping remote host
|
||||
REMOTE_HOST_WAKING: 'remote:hostWaking',
|
||||
REMOTE_HOST_WAKE_FAILED: 'remote:hostWakeFailed',
|
||||
|
||||
// Ralph
|
||||
SESSION_RALPH_LOOP_UPDATE: 'session:ralphLoopUpdate',
|
||||
|
||||
@@ -116,6 +116,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
|
||||
// Wake-on-LAN from user input on a sleeping remote host (see remote-wake.ts).
|
||||
_onRemoteHostWaking(data) {
|
||||
const label = data && data.label ? data.label : 'Remote host';
|
||||
// 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(`Waking ${label} … input is queued`, 'info', { duration: 12000 });
|
||||
},
|
||||
|
||||
_onRemoteHostWakeFailed(data) {
|
||||
const label = data && data.label ? data.label : 'Remote host';
|
||||
this.showToast(`${label} did not wake up — queued input is still held`, 'error', { duration: 15000 });
|
||||
},
|
||||
|
||||
|
||||
// Bash tools
|
||||
_onBashToolStart(data) {
|
||||
this.handleBashToolStart(data.sessionId, data.tool);
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
type WaitSignal,
|
||||
type SignalWaitResult,
|
||||
} from '../session-wait-registry.js';
|
||||
import { RemoteWakeRegistry, createDefaultRemoteWakeDeps } from '../../remote-wake.js';
|
||||
import { clampWaitMs, MAX_BUFFER_SCAN_BYTES } from '../../config/agent-wait.js';
|
||||
import {
|
||||
autoConfigureRalph,
|
||||
@@ -816,8 +817,32 @@ export function resolveOmpConfigForCreate(
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
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. */
|
||||
options: { remoteWake?: RemoteWakeRegistry } = {}
|
||||
): void {
|
||||
// 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.
|
||||
//
|
||||
// ⚠️ The ONLY caller that may wake a host is the input route below. The
|
||||
// auto-reconnect watcher and boot recovery deliberately have no access to this
|
||||
// registry: waking there would re-wake the host seconds after every suspend, so
|
||||
// it could never stay asleep.
|
||||
const remoteWake =
|
||||
options.remoteWake ??
|
||||
new RemoteWakeRegistry(
|
||||
createDefaultRemoteWakeDeps({
|
||||
noteReconnected: (sessionId, success) => {
|
||||
// Duck-typed exactly like server.ts: TmuxManager owns the COD-108 backoff
|
||||
// state, and the port interface does not expose it.
|
||||
const mux = ctx.mux as unknown as { noteRemoteReconnect?: (id: string, ok: boolean) => void };
|
||||
mux.noteRemoteReconnect?.(sessionId, success);
|
||||
},
|
||||
broadcast: (event, payload) => ctx.broadcast(event, payload),
|
||||
log: (message) => console.log(message),
|
||||
})
|
||||
);
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Auth
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1279,6 +1304,7 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
remoteWake.drop(session.id);
|
||||
await ctx.cleanupSession(session.id, killMux, 'user_delete');
|
||||
return {};
|
||||
});
|
||||
@@ -1555,6 +1581,28 @@ export function registerSessionRoutes(
|
||||
return {};
|
||||
}
|
||||
|
||||
// Wake-on-LAN (remote-wake.ts): a wake-enabled remote host that suspended leaves
|
||||
// the local ssh pane STALLED, and `send-keys` succeeds against it — the bytes
|
||||
// would vanish with no error anywhere. Give the registry the chance to probe the
|
||||
// host, wake it, reattach, and own delivery before we write into nothing.
|
||||
//
|
||||
// Costs nothing for non-wake hosts (the `wakeCommand` guard) or while the host is
|
||||
// known reachable inside the probe throttle window; the probe itself is a bare
|
||||
// TCP connect on wake-enabled hosts only, at most once per
|
||||
// REMOTE_WAKE_PROBE_MIN_INTERVAL_MS.
|
||||
if (!duplicate && session.remote?.wakeCommand) {
|
||||
if (wantsWait) {
|
||||
// Send-and-wait keeps the response open anyway, so blocking on the wake is
|
||||
// simpler and more correct than buffering (buffering would break the wait).
|
||||
await remoteWake.ensureAwake(session);
|
||||
} else if ((await remoteWake.handleInput(session, inputStr)) === 'buffered') {
|
||||
// The registry holds the bytes and flushes them in order once the pane is
|
||||
// reattached. The client's ACK is this 200 — a tagged retry is deduped
|
||||
// (`shouldApplyInput` above already consumed the seq), so nothing is lost.
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Only a waiting request pays for the tmux probe: the browser's plain input path
|
||||
// (thousands of calls per session) must stay exec-free.
|
||||
const workerDead = wantsWait && workerIsDead(ctx.mux, session);
|
||||
|
||||
@@ -737,6 +737,18 @@ export const RemoteHostSchema = z.object({
|
||||
.max(32)
|
||||
.optional(),
|
||||
commands: RemoteCommandOverridesSchema,
|
||||
// Wake-on-LAN: a single executable path (no arguments, no shell) run to power a
|
||||
// SLEEPING host back on, e.g. `/home/joe/bin/whuff`. Executed via spawn without
|
||||
// a shell, so there is no shell layer to escape; the regexes are belt-and-braces
|
||||
// (and the no-whitespace rule rejects an argument list before it can fail as a
|
||||
// confusing ENOENT at wake time). See docs/remote-sessions.md §Wake-on-LAN.
|
||||
wakeCommand: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(4096)
|
||||
.regex(/^\S+$/, 'Wake command must be a single executable path (no arguments)')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in wake command')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const RemoteCaseLinkSchema = z.object({
|
||||
|
||||
@@ -184,6 +184,13 @@ export const RemoteSessionDropped = 'remote:sessionDropped' as const;
|
||||
export const RemoteSessionReconnected = 'remote:sessionReconnected' as const;
|
||||
/** Auto-reconnect gave up after the bounded backoff cap — manual reconnect needed. */
|
||||
export const RemoteReconnectExhausted = 'remote:reconnectExhausted' as const;
|
||||
/**
|
||||
* User input arrived for a session whose host is unreachable, so a Wake-on-LAN
|
||||
* command was started (see `remote-wake.ts`). Input sent meanwhile is buffered.
|
||||
*/
|
||||
export const RemoteHostWaking = 'remote:hostWaking' as const;
|
||||
/** The host did not come back within the wake timeout — buffered input is still held. */
|
||||
export const RemoteHostWakeFailed = 'remote:hostWakeFailed' as const;
|
||||
|
||||
// ─── Respawn ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -535,6 +542,8 @@ export const SseEvent = {
|
||||
RemoteSessionDropped,
|
||||
RemoteSessionReconnected,
|
||||
RemoteReconnectExhausted,
|
||||
RemoteHostWaking,
|
||||
RemoteHostWakeFailed,
|
||||
|
||||
// Respawn
|
||||
RespawnStarted,
|
||||
|
||||
@@ -96,6 +96,14 @@ export class MockSession extends EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors `Session.reattachRemote()` — the COD-108 transport re-establish that
|
||||
* the wake-on-LAN flow calls once a sleeping host is back. Defaults to success;
|
||||
* set `reattachRemote.mockResolvedValue(false)` to model a pane that could not
|
||||
* be respawned.
|
||||
*/
|
||||
reattachRemote = vi.fn(async (): Promise<boolean> => true);
|
||||
|
||||
/** Exactly-once input dedup — mirrors Session.shouldApplyInput so route tests
|
||||
* exercising the reliable-delivery path behave like production. */
|
||||
private _appliedInputSeq = new Map<string, number>();
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
readRemoteHosts,
|
||||
remoteDisplayPath,
|
||||
remoteSshTarget,
|
||||
toSessionRemote,
|
||||
writeRemoteCases,
|
||||
writeRemoteHosts,
|
||||
} from '../src/remote-hosts.js';
|
||||
import { RemoteHostSchema } from '../src/web/schemas.js';
|
||||
|
||||
describe('remote-hosts domain', () => {
|
||||
let dir: string | null = null;
|
||||
@@ -69,4 +71,48 @@ describe('remote-hosts domain', () => {
|
||||
'aamer@box.local:/opt/work'
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the wake command from host config into the session', () => {
|
||||
// The input route reads `session.remote.wakeCommand` — it must survive the host
|
||||
// -> session mapping, or wake-on-LAN silently degrades to "no wake command".
|
||||
const remote = toSessionRemote(
|
||||
{
|
||||
id: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
username: 'j',
|
||||
wakeCommand: '/home/joe/bin/whuff',
|
||||
},
|
||||
{ name: 'c', type: 'remote', hostId: 'hufflepuff', remotePath: '/home/j/work' }
|
||||
);
|
||||
expect(remote.wakeCommand).toBe('/home/joe/bin/whuff');
|
||||
});
|
||||
|
||||
it('omits the wake command by default (feature off without a config entry)', () => {
|
||||
const remote = toSessionRemote(
|
||||
{ id: 'h', label: 'H', host: '10.0.0.1', username: 'j' },
|
||||
{ name: 'c', type: 'remote', hostId: 'h', remotePath: '/tmp' }
|
||||
);
|
||||
expect(remote.wakeCommand).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('RemoteHostSchema wakeCommand', () => {
|
||||
const host = { id: 'hufflepuff', label: 'Hufflepuff', host: '192.168.50.137', username: 'j' };
|
||||
|
||||
it('accepts an optional absolute executable path', () => {
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/home/joe/bin/whuff' }).success).toBe(true);
|
||||
expect(RemoteHostSchema.safeParse(host).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an argument list (spawn runs the path without a shell)', () => {
|
||||
// `spawn('/home/joe/bin/whuff --mac 00:11:22')` would fail as a confusing
|
||||
// ENOENT at wake time — refuse it at config time instead.
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/home/joe/bin/whuff --now' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects shell metacharacters as defence in depth', () => {
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/bin/sh$(id)' }).success).toBe(false);
|
||||
expect(RemoteHostSchema.safeParse({ ...host, wakeCommand: '/bin/`id`' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* @fileoverview Wake-on-LAN from user input (see `src/remote-wake.ts`).
|
||||
*
|
||||
* Covers the two things that are easy to get wrong and expensive when wrong:
|
||||
* 1. the decision/throttle table (probe at most once per window, never a probe
|
||||
* burst per keystroke),
|
||||
* 2. the guarantee that a wake is SINGLE-FLIGHT and that buffered input is
|
||||
* flushed IN ORDER once the pane is reattached — plus that no reconnect or
|
||||
* boot-recovery module can reach the wake flow at all (a wake there would
|
||||
* re-wake the host seconds after every suspend, so it could never sleep).
|
||||
*
|
||||
* Pure logic + a fake session/deps: no tmux, no ssh, no real host.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
RemoteWakeRegistry,
|
||||
appendBoundedPending,
|
||||
decideRemoteInputAction,
|
||||
REMOTE_WAKE_PENDING_MAX_BYTES,
|
||||
type RemoteWakeDeps,
|
||||
type WakeableRemote,
|
||||
type WakeableSession,
|
||||
} from '../src/remote-wake.js';
|
||||
|
||||
// ========== Pure decisions ==========
|
||||
|
||||
describe('decideRemoteInputAction', () => {
|
||||
const base = { hasWakeCommand: true, waking: false, probeAgeMs: 0, lastReachable: undefined as boolean | undefined };
|
||||
|
||||
it('delivers unchanged when the host has no wake command (feature off)', () => {
|
||||
expect(decideRemoteInputAction({ ...base, hasWakeCommand: false, probeAgeMs: Number.MAX_SAFE_INTEGER })).toBe(
|
||||
'deliver'
|
||||
);
|
||||
});
|
||||
|
||||
it('buffers while a wake is already in flight, whatever the probe state says', () => {
|
||||
expect(decideRemoteInputAction({ ...base, waking: true, probeAgeMs: Number.MAX_SAFE_INTEGER })).toBe('buffer');
|
||||
});
|
||||
|
||||
it('buffers without re-probing when the last probe said the host is down', () => {
|
||||
// Re-probing per keystroke would add seconds of latency to every character.
|
||||
expect(decideRemoteInputAction({ ...base, lastReachable: false, probeAgeMs: 1 })).toBe('buffer');
|
||||
});
|
||||
|
||||
it('delivers inside the throttle window when the host was reachable', () => {
|
||||
expect(decideRemoteInputAction({ ...base, lastReachable: true, probeAgeMs: 10 })).toBe('deliver');
|
||||
});
|
||||
|
||||
it('probes once the throttle window has elapsed', () => {
|
||||
expect(decideRemoteInputAction({ ...base, lastReachable: true, probeAgeMs: 30_001 })).toBe('probe');
|
||||
expect(decideRemoteInputAction({ ...base, lastReachable: true, probeAgeMs: 29_999 })).toBe('deliver');
|
||||
});
|
||||
|
||||
it('probes on the very first input of a session (probeAgeMs 0 is only "never probed")', () => {
|
||||
// probedAt is initialised to 0, so a fresh session's age is huge in real time.
|
||||
expect(decideRemoteInputAction({ ...base, probeAgeMs: Date.now() })).toBe('probe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendBoundedPending', () => {
|
||||
it('keeps everything under the cap, in order', () => {
|
||||
expect(appendBoundedPending(['a', 'b'], 'c')).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('drops the OLDEST chunk when the cap is exceeded, keeping the tail', () => {
|
||||
const big = 'x'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES);
|
||||
expect(appendBoundedPending([big], 'newest')).toEqual(['newest']);
|
||||
});
|
||||
|
||||
it('never drops the just-typed chunk even when it alone exceeds the cap', () => {
|
||||
const huge = 'y'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES + 100);
|
||||
expect(appendBoundedPending([], huge)).toEqual([huge]);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Registry ==========
|
||||
|
||||
const remote: WakeableRemote = {
|
||||
hostId: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
wakeCommand: '/home/joe/bin/whuff',
|
||||
};
|
||||
|
||||
interface Harness {
|
||||
registry: RemoteWakeRegistry;
|
||||
session: WakeableSession;
|
||||
probe: ReturnType<typeof vi.fn>;
|
||||
wake: ReturnType<typeof vi.fn>;
|
||||
waitUntilReady: ReturnType<typeof vi.fn>;
|
||||
reattachRemote: ReturnType<typeof vi.fn>;
|
||||
writeViaMux: ReturnType<typeof vi.fn>;
|
||||
noteReconnected: ReturnType<typeof vi.fn>;
|
||||
events: string[];
|
||||
}
|
||||
|
||||
function harness(opts: { remote?: WakeableRemote; writesFail?: boolean } = {}): Harness {
|
||||
const probe = vi.fn(async () => false);
|
||||
const wake = vi.fn(async () => true);
|
||||
const waitUntilReady = vi.fn(async () => true);
|
||||
const reattachRemote = vi.fn(async () => true);
|
||||
const writeViaMux = vi.fn(async () => !opts.writesFail);
|
||||
const noteReconnected = vi.fn();
|
||||
const events: string[] = [];
|
||||
|
||||
const deps: RemoteWakeDeps = {
|
||||
probe,
|
||||
wake,
|
||||
waitUntilReady,
|
||||
delay: async () => {},
|
||||
noteReconnected,
|
||||
broadcast: (event) => events.push(event),
|
||||
log: () => {},
|
||||
};
|
||||
|
||||
const session: WakeableSession = {
|
||||
id: 'sess-1',
|
||||
remote: opts.remote ?? remote,
|
||||
reattachRemote,
|
||||
writeViaMux,
|
||||
};
|
||||
|
||||
return {
|
||||
registry: new RemoteWakeRegistry(deps),
|
||||
session,
|
||||
probe,
|
||||
wake,
|
||||
waitUntilReady,
|
||||
reattachRemote,
|
||||
writeViaMux,
|
||||
noteReconnected,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RemoteWakeRegistry', () => {
|
||||
it('does nothing at all when the host has no wake command', async () => {
|
||||
const h = harness({ remote: { hostId: 'x', label: 'X', host: '10.0.0.9' } });
|
||||
await expect(h.registry.handleInput(h.session, 'a')).resolves.toBe('deliver');
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delivers normally when the host is reachable, without waking', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(true);
|
||||
await expect(h.registry.handleInput(h.session, 'a')).resolves.toBe('deliver');
|
||||
expect(h.probe).toHaveBeenCalledTimes(1);
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the probe inside the throttle window once the host was reachable', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(true);
|
||||
await h.registry.handleInput(h.session, 'a');
|
||||
await h.registry.handleInput(h.session, 'b');
|
||||
await h.registry.handleInput(h.session, 'c');
|
||||
expect(h.probe).toHaveBeenCalledTimes(1);
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wakes an unreachable host once, then flushes buffered input in order after reattach', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
// Hold the wake open so the second input lands while it is genuinely in flight
|
||||
// (with instantaneous mocks the whole wake chain can finish between two awaits).
|
||||
let releaseWake: (() => void) | undefined;
|
||||
h.waitUntilReady.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
releaseWake = () => resolve(true);
|
||||
})
|
||||
);
|
||||
|
||||
await expect(h.registry.handleInput(h.session, 'hal')).resolves.toBe('buffered');
|
||||
await expect(h.registry.handleInput(h.session, 'lo')).resolves.toBe('buffered');
|
||||
// Single-flight: the second input joins the in-flight wake, it does not start another.
|
||||
expect(h.registry.isWaking('sess-1')).toBe(true);
|
||||
expect(h.wake).toHaveBeenCalledTimes(1);
|
||||
|
||||
releaseWake?.();
|
||||
await h.registry.wake(h.session);
|
||||
|
||||
expect(h.wake).toHaveBeenCalledWith('/home/joe/bin/whuff');
|
||||
expect(h.reattachRemote).toHaveBeenCalledTimes(1);
|
||||
expect(h.noteReconnected).toHaveBeenCalledWith('sess-1', true);
|
||||
expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual(['hal', 'lo']);
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(0);
|
||||
expect(h.events).toEqual(['remote:hostWaking', 'remote:sessionReconnected']);
|
||||
});
|
||||
|
||||
it('keeps input buffered and reports failure when the host never comes back', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
h.waitUntilReady.mockResolvedValue(false);
|
||||
|
||||
await h.registry.handleInput(h.session, 'hello');
|
||||
await h.registry.wake(h.session);
|
||||
|
||||
expect(h.reattachRemote).not.toHaveBeenCalled();
|
||||
expect(h.writeViaMux).not.toHaveBeenCalled();
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(5);
|
||||
expect(h.events).toContain('remote:hostWakeFailed');
|
||||
});
|
||||
|
||||
it('retries the wake on the next input after a failed wake (probe state reset)', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
h.waitUntilReady.mockResolvedValueOnce(false);
|
||||
|
||||
await h.registry.handleInput(h.session, 'a');
|
||||
await h.registry.wake(h.session);
|
||||
expect(h.wake).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Next keystroke must probe again (not trust the stale "down" verdict) and retry.
|
||||
await h.registry.handleInput(h.session, 'b');
|
||||
await h.registry.wake(h.session);
|
||||
expect(h.probe).toHaveBeenCalledTimes(2);
|
||||
expect(h.wake).toHaveBeenCalledTimes(2);
|
||||
expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('does not claim reconnected when the pane cannot be reattached', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
h.reattachRemote.mockResolvedValue(false);
|
||||
|
||||
await h.registry.handleInput(h.session, 'a');
|
||||
await h.registry.wake(h.session);
|
||||
|
||||
expect(h.noteReconnected).not.toHaveBeenCalled();
|
||||
expect(h.writeViaMux).not.toHaveBeenCalled();
|
||||
expect(h.events).not.toContain('remote:sessionReconnected');
|
||||
});
|
||||
|
||||
it('retains input that could not be written and reports nothing lost', async () => {
|
||||
const h = harness({ writesFail: true });
|
||||
h.probe.mockResolvedValue(false);
|
||||
|
||||
await h.registry.handleInput(h.session, 'abc');
|
||||
await h.registry.wake(h.session);
|
||||
|
||||
expect(h.writeViaMux).toHaveBeenCalledTimes(1);
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(3);
|
||||
});
|
||||
|
||||
it('ensureAwake blocks only for the wait path and returns true without a wake command', async () => {
|
||||
const h = harness({ remote: { hostId: 'x', label: 'X', host: '10.0.0.9' } });
|
||||
await expect(h.registry.ensureAwake(h.session)).resolves.toBe(true);
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ensureAwake wakes an unreachable host without buffering anything', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
await expect(h.registry.ensureAwake(h.session)).resolves.toBe(true);
|
||||
expect(h.wake).toHaveBeenCalledTimes(1);
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(0);
|
||||
});
|
||||
|
||||
it('drops buffered input with the session', async () => {
|
||||
const h = harness();
|
||||
h.probe.mockResolvedValue(false);
|
||||
await h.registry.handleInput(h.session, 'abc');
|
||||
h.registry.drop('sess-1');
|
||||
expect(h.registry.pendingBytes('sess-1')).toBe(0);
|
||||
expect(h.registry.isWaking('sess-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== Wiring guard ==========
|
||||
|
||||
const SRC = fileURLToPath(new URL('../src', import.meta.url));
|
||||
|
||||
function walkTs(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
if (statSync(full).isDirectory()) {
|
||||
out.push(...walkTs(full));
|
||||
continue;
|
||||
}
|
||||
if (name.endsWith('.ts')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('wake wiring guard', () => {
|
||||
it('only the input route may reach the wake registry', () => {
|
||||
// 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')]);
|
||||
const importers = walkTs(SRC)
|
||||
.filter((full) => /from\s+['"][^'"]*remote-wake(\.js)?['"]/.test(readFileSync(full, 'utf-8')))
|
||||
.map((full) => relative(SRC, full));
|
||||
|
||||
expect(importers.sort()).toEqual([...allowed].sort());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @fileoverview Route tests for wake-on-LAN on `POST /api/sessions/:id/input`.
|
||||
*
|
||||
* The behavior that matters and cannot be tested at the registry level: a
|
||||
* wake-enabled remote session whose host is asleep must return 200 WITHOUT
|
||||
* writing into the stalled pane (the bytes would vanish), while every other
|
||||
* session keeps the historical fire-and-forget path untouched.
|
||||
*
|
||||
* The registry is injected through `registerSessionRoutes`'s test seam so no real
|
||||
* TCP connect, ssh, or WoL happens in CI.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { registerSessionRoutes, _resetPaneLivenessState } from '../../src/web/routes/session-routes.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { createMockRouteContext } from '../mocks/index.js';
|
||||
import { sessionWaits } from '../../src/web/session-wait-registry.js';
|
||||
import { RemoteWakeRegistry, type RemoteWakeDeps } from '../../src/remote-wake.js';
|
||||
import type { SessionRemote } from '../../src/types.js';
|
||||
|
||||
const SESSION_ID = 'remote-wake-session';
|
||||
const URL = `/api/sessions/${SESSION_ID}/input`;
|
||||
|
||||
afterEach(() => {
|
||||
sessionWaits.cancelAll(SESSION_ID);
|
||||
_resetPaneLivenessState();
|
||||
});
|
||||
|
||||
interface Harness {
|
||||
app: FastifyInstance;
|
||||
ctx: ReturnType<typeof createMockRouteContext>;
|
||||
registry: RemoteWakeRegistry;
|
||||
probe: ReturnType<typeof vi.fn>;
|
||||
wake: ReturnType<typeof vi.fn>;
|
||||
events: string[];
|
||||
/** Let a held wake finish (see `holdWake`). */
|
||||
releaseWake: () => void;
|
||||
}
|
||||
|
||||
const remoteSession: SessionRemote = {
|
||||
hostId: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
username: 'j',
|
||||
remotePath: '/home/j/codeman-pi-test',
|
||||
wakeCommand: '/home/joe/bin/whuff',
|
||||
};
|
||||
|
||||
async function harness(opts: { remote?: SessionRemote; hostUp?: boolean; holdWake?: boolean } = {}): Promise<Harness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
session.remote = opts.remote ?? remoteSession;
|
||||
|
||||
const probe = vi.fn(async () => opts.hostUp ?? false);
|
||||
const wake = vi.fn(async () => true);
|
||||
const events: string[] = [];
|
||||
// With instantaneous mocks the whole wake chain (wake -> wait -> reattach ->
|
||||
// flush) can finish inside one `await`, so a test that wants to observe the
|
||||
// in-flight state has to hold the readiness poll open.
|
||||
let release: (() => void) | null = null;
|
||||
const deps: RemoteWakeDeps = {
|
||||
probe,
|
||||
wake,
|
||||
waitUntilReady: () =>
|
||||
opts.holdWake
|
||||
? new Promise<boolean>((resolve) => {
|
||||
release = () => resolve(true);
|
||||
})
|
||||
: Promise.resolve(true),
|
||||
delay: async () => {},
|
||||
noteReconnected: () => {},
|
||||
broadcast: (event) => events.push(event),
|
||||
log: () => {},
|
||||
};
|
||||
const registry = new RemoteWakeRegistry(deps);
|
||||
|
||||
registerSessionRoutes(app, ctx as never, { remoteWake: registry });
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx, registry, probe, wake, events, releaseWake: () => release?.() };
|
||||
}
|
||||
|
||||
const send = (app: FastifyInstance, payload: Record<string, unknown>) =>
|
||||
app.inject({ method: 'POST', url: URL, payload });
|
||||
|
||||
describe('POST /api/sessions/:id/input — wake-on-LAN', () => {
|
||||
it('buffers input instead of writing into a sleeping host, then flushes after the wake', async () => {
|
||||
const h = await harness({ hostUp: false, holdWake: true });
|
||||
const session = h.ctx.sessions.get(SESSION_ID)!;
|
||||
|
||||
const res = await send(h.app, { input: 'hallo', useMux: true });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({});
|
||||
// Nothing reached the pane: writing now would be swallowed by the stalled ssh.
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
expect(h.wake).toHaveBeenCalledWith('/home/joe/bin/whuff');
|
||||
expect(h.registry.isWaking(SESSION_ID)).toBe(true);
|
||||
|
||||
h.releaseWake();
|
||||
await h.registry.wake(session);
|
||||
expect(session.writeBuffer).toEqual(['hallo']);
|
||||
expect(session.reattachRemote).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the historical fire-and-forget write when the host is reachable', async () => {
|
||||
const h = await harness({ hostUp: true });
|
||||
const session = h.ctx.sessions.get(SESSION_ID)!;
|
||||
|
||||
const res = await send(h.app, { input: 'hallo', useMux: true });
|
||||
|
||||
expect(res.json()).toEqual({});
|
||||
await vi.waitFor(() => expect(session.writeBuffer).toEqual(['hallo']));
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
expect(session.reattachRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never probes or wakes a session without a wake command', async () => {
|
||||
const { wakeCommand, ...withoutWake } = remoteSession;
|
||||
const h = await harness({ remote: withoutWake as SessionRemote });
|
||||
const session = h.ctx.sessions.get(SESSION_ID)!;
|
||||
|
||||
await send(h.app, { input: 'hallo', useMux: true });
|
||||
|
||||
await vi.waitFor(() => expect(session.writeBuffer).toEqual(['hallo']));
|
||||
expect(h.probe).not.toHaveBeenCalled();
|
||||
expect(h.wake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wakes before writing on the send-and-wait path (no buffering, the response waits anyway)', async () => {
|
||||
const h = await harness({ hostUp: false });
|
||||
const session = h.ctx.sessions.get(SESSION_ID)!;
|
||||
|
||||
await send(h.app, { input: 'hallo', useMux: true, wait: 'idle', waitTimeout: 60 });
|
||||
|
||||
expect(h.wake).toHaveBeenCalledTimes(1);
|
||||
// `ensureAwake` is awaited on this path, so the write happens inline and the
|
||||
// waiter is registered against a live pane.
|
||||
expect(session.writeBuffer).toEqual(['hallo']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user