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:
Randalix
2026-09-15 10:25:52 +02:00
parent 88e3faa456
commit a81f430e41
13 changed files with 1056 additions and 1 deletions
+8
View File
@@ -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>();
+46
View File
@@ -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);
});
});
});
+305
View File
@@ -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());
});
});
+145
View File
@@ -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']);
});
});