Files
Codeman/test/remote-wake.test.ts
T
Randalix 7b947fa3f1 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.
2026-09-16 20:44:39 +02:00

704 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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,
buildMagicPacket,
createDefaultRemoteWakeDeps,
decideRemoteInputAction,
parseMacList,
resolveWakeTarget,
sendWakePackets,
waitUntilRemoteReady,
wakeConfigured,
REMOTE_WAKE_PENDING_MAX_BYTES,
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
type RemoteWakeDeps,
type WakeableRemote,
type WakeableSession,
} from '../src/remote-wake.js';
// ========== Pure decisions ==========
describe('decideRemoteInputAction', () => {
const base = { hasWakeTarget: 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, hasWakeTarget: 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('drops an oversized chunk outright instead of delivering a fragment of it', () => {
// One large paste is one input value and was never typed character by character, so its
// tail is not "what the user just typed" — writing it into the pane would run a partial
// command (with the paste's trailing carriage return, if it had one).
const huge = 'y'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES + 100);
expect(appendBoundedPending([], huge)).toEqual([]);
// The bytes already queued are left alone, not replaced by the fragment.
expect(appendBoundedPending(['typed'], huge)).toEqual(['typed']);
});
it('measures the cap in UTF-8 bytes, so a multi-byte paste is dropped too', () => {
const cap = 10;
const value = 'ä'.repeat(8); // 2 bytes each → 16 bytes > cap
expect(appendBoundedPending([], value, cap)).toEqual([]);
});
});
describe('MAC parsing + magic packet', () => {
it('parses one or more MACs with either separator', () => {
expect(parseMacList('04:d9:f5:80:c6:58')).toEqual([[4, 217, 245, 128, 198, 88]]);
expect(parseMacList('04-d9-f5-80-c6-58, 1c:61:b4:20:58:eb')).toEqual([
[4, 217, 245, 128, 198, 88],
[28, 97, 180, 32, 88, 235],
]);
});
it('is all-or-nothing so a typo cannot half-arm a host', () => {
expect(parseMacList('04:d9:f5:80:c6')).toBeNull();
expect(parseMacList('04:d9:f5:80:c6:58, nonsense')).toBeNull();
expect(parseMacList('')).toBeNull();
expect(
parseMacList('04:d9:f5:80:c6:58,1c:61:b4:20:58:eb,aa:bb:cc:dd:ee:ff,11:22:33:44:55:66,99:88:77:66:55:44')
).toBeNull();
});
it('builds the documented magic packet byte-for-byte', () => {
// 6 x 0xFF then the MAC repeated 16 times — a packet off by one byte simply never
// wakes anything, so the shape is pinned rather than described.
const mac = [4, 217, 245, 128, 198, 88];
const packet = buildMagicPacket(mac);
expect(packet.length).toBe(6 + 16 * 6);
expect([...packet.subarray(0, 6)]).toEqual([255, 255, 255, 255, 255, 255]);
for (let repeat = 0; repeat < 16; repeat++) {
expect([...packet.subarray(6 + repeat * 6, 12 + repeat * 6)]).toEqual(mac);
}
});
it('binds BEFORE enabling broadcast — the order that silently kills the packet on Linux', async () => {
// `setBroadcast()` on an unbound socket throws EBADF on Linux and the follow-up
// send dies with EACCES, so the magic packet never leaves the machine (verified
// against a real sleeping host). The order is asserted, not described.
const calls: string[] = [];
const sent: { packet: Buffer; port: number; address: string }[] = [];
const packets = await sendWakePackets(
[
[4, 217, 245, 128, 198, 88],
[28, 97, 180, 32, 88, 235],
],
9,
() => ({
bind: (cb: () => void) => {
calls.push('bind');
cb();
},
setBroadcast: () => calls.push('setBroadcast'),
send: (packet: Buffer, port: number, address: string, cb: (err?: Error | null) => void) => {
calls.push('send');
sent.push({ packet, port, address });
cb(null);
},
close: () => calls.push('close'),
once: () => undefined,
})
);
expect(packets).toBe(true);
expect(calls[0]).toBe('bind');
expect(calls[1]).toBe('setBroadcast');
// One 102-byte magic packet per MAC, to the broadcast address on port 9.
expect(sent).toHaveLength(2);
expect(sent.every((s) => s.packet.length === 102 && s.port === 9 && s.address === '255.255.255.255')).toBe(true);
});
it('reports failure when the platform refuses to broadcast', async () => {
const ok = await sendWakePackets([[4, 217, 245, 128, 198, 88]], 9, () => ({
bind: (cb: () => void) => cb(),
setBroadcast: () => {
throw new Error('EBADF');
},
send: () => undefined,
close: () => undefined,
once: () => undefined,
}));
expect(ok).toBe(false);
});
it('resolves the wake target with the command as the explicit override', () => {
const mac = '04:d9:f5:80:c6:58';
expect(resolveWakeTarget(undefined)).toBeNull();
expect(resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1' })).toBeNull();
expect(resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1', wakeMac: mac })).toEqual({
kind: 'mac',
macs: [[4, 217, 245, 128, 198, 88]],
});
expect(
resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1', wakeMac: mac, wakeCommand: '/bin/wake' })
).toEqual({ kind: 'command', command: '/bin/wake' });
// A malformed MAC (hand-written config) must not arm a broken wake.
expect(resolveWakeTarget({ hostId: 'h', label: 'H', host: '10.0.0.1', wakeMac: 'nope' })).toBeNull();
});
it('reports which wake path the UI should offer', () => {
expect(wakeConfigured(undefined)).toBe('none');
expect(wakeConfigured({ hostId: 'h', label: 'H', host: 'x' })).toBe('none');
expect(wakeConfigured({ hostId: 'h', label: 'H', host: 'x', wakeMac: '04:d9:f5:80:c6:58' })).toBe('mac');
expect(wakeConfigured({ hostId: 'h', label: 'H', host: 'x', wakeCommand: '/bin/wake' })).toBe('command');
});
});
// ========== 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; resolveRemote?: RemoteWakeDeps['resolveRemote'] } = {}
): 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: () => {},
...(opts.resolveRemote ? { resolveRemote: opts.resolveRemote } : {}),
};
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({ kind: 'command', command: '/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('wakes a MAC-configured host by magic packet, with no external command', async () => {
const h = harness({
remote: { hostId: 'h', label: 'H', host: '10.0.0.9', wakeMac: '04:d9:f5:80:c6:58' },
});
h.probe.mockResolvedValue(false);
await expect(h.registry.handleInput(h.session, 'hi')).resolves.toBe('buffered');
await h.registry.wake(h.session);
expect(h.wake).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual(['hi']);
});
it('resolves host config for a session that predates it, so a saved MAC works live', async () => {
// The persisted `remote` snapshot is taken at launch: without this the banner's
// config dialog would only take effect after restarting the session.
const resolveRemote = vi.fn(async () => ({
hostId: 'hufflepuff',
label: 'Hufflepuff',
host: '192.168.50.137',
wakeMac: '04:d9:f5:80:c6:58',
}));
const h = harness({
remote: { hostId: 'hufflepuff', label: 'Hufflepuff', host: '192.168.50.137' },
resolveRemote,
});
h.probe.mockResolvedValue(false);
expect(await h.registry.hasWakeTarget(h.session)).toBe(true);
await expect(h.registry.handleInput(h.session, 'a')).resolves.toBe('buffered');
await h.registry.wake(h.session);
expect(h.wake).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
expect(resolveRemote).toHaveBeenCalledTimes(1);
// Cached: the next keystroke must not re-read the host config.
await h.registry.hasWakeTarget(h.session);
expect(resolveRemote).toHaveBeenCalledTimes(1);
});
it('consults the resolver on the TTL even when the session has a target', async () => {
// The host config is authoritative in BOTH directions: a target removed in the config
// (or the dialog) must turn the feature off for a live session, which it cannot do if
// the session's own snapshot short-circuits the lookup.
const resolveRemote = vi.fn(async () => ({
hostId: 'hufflepuff',
label: 'Hufflepuff',
host: '192.168.50.137',
}));
const h = harness({
remote: {
hostId: 'hufflepuff',
label: 'Hufflepuff',
host: '192.168.50.137',
wakeMac: '04:d9:f5:80:c6:58',
},
resolveRemote,
});
expect(await h.registry.hasWakeTarget(h.session)).toBe(false);
expect(await h.registry.wakeConfigured(h.session)).toBe('none');
// ... and with the feature off there is nothing to buffer for.
expect(await h.registry.handleInput(h.session, 'x')).toBe('deliver');
// Cached for the TTL — not one host-config read per keystroke.
expect(resolveRemote).toHaveBeenCalledTimes(1);
await h.registry.hasWakeTarget(h.session);
expect(resolveRemote).toHaveBeenCalledTimes(1);
});
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);
});
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) ==========
describe('RemoteWakeRegistry — host-scoped wake for a request that waits on it', () => {
const hostRemote: WakeableRemote = {
hostId: 'hufflepuff',
label: 'Hufflepuff',
host: '192.168.50.137',
wakeMac: '04:d9:f5:80:c6:58',
};
it('does not even probe a host without a wake target (byte-identical to no feature)', async () => {
const h = harness({ remote: { hostId: 'x', label: 'X', host: '10.0.0.9' } });
await expect(h.registry.ensureHostAwake(h.session.remote!)).resolves.toBe('no-target');
expect(h.probe).not.toHaveBeenCalled();
expect(h.wake).not.toHaveBeenCalled();
});
it('reports ready without waking when the host already answers', async () => {
const h = harness({ remote: hostRemote });
h.probe.mockResolvedValue(true);
await expect(h.registry.ensureHostAwake(hostRemote)).resolves.toBe('ready');
expect(h.wake).not.toHaveBeenCalled();
});
it('wakes a sleeping host and waits with the caller’s budget, not the 90 s default', async () => {
const h = harness({ remote: hostRemote });
h.probe.mockResolvedValue(false);
await expect(
h.registry.ensureHostAwake(hostRemote, { timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS })
).resolves.toBe('ready');
expect(h.wake).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
// The budget has to reach the readiness poll: the reverse proxy cuts a request at
// 60 s, so a create-path wake must not inherit the 90 s session default.
expect(h.waitUntilReady).toHaveBeenCalledWith(hostRemote, {
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
// The shutdown signal rides along so `WebServer.stop()` can end the poll.
signal: expect.any(AbortSignal),
});
expect(h.events).toContain('remote:hostWaking');
});
it('reports failed when the host never comes back, and probes again on the next attempt', async () => {
const h = harness({ remote: hostRemote });
h.probe.mockResolvedValue(false);
h.waitUntilReady.mockResolvedValue(false);
await expect(h.registry.ensureHostAwake(hostRemote)).resolves.toBe('failed');
expect(h.events).toContain('remote:hostWakeFailed');
// The failure resets the probe verdict, so a second Run probes instead of
// trusting a stale "down" forever.
h.waitUntilReady.mockResolvedValue(true);
h.probe.mockClear();
await expect(h.registry.ensureHostAwake(hostRemote)).resolves.toBe('ready');
expect(h.probe).toHaveBeenCalled();
});
it('single-flights two concurrent create-path wakes for the same host', async () => {
const h = harness({ remote: hostRemote });
h.probe.mockResolvedValue(false);
let release: (value: boolean) => void = () => {};
h.waitUntilReady.mockImplementation(() => new Promise<boolean>((resolve) => (release = resolve)));
const first = h.registry.ensureHostAwake(hostRemote);
const second = h.registry.ensureHostAwake(hostRemote);
await vi.waitFor(() => expect(h.wake).toHaveBeenCalledTimes(1));
release(true);
await expect(Promise.all([first, second])).resolves.toEqual(['ready', 'ready']);
// One magic packet for a double click, not two.
expect(h.wake).toHaveBeenCalledTimes(1);
});
it('checkHostReachable is a question, never an action', async () => {
const h = harness({ remote: hostRemote });
h.probe.mockResolvedValue(false);
await expect(h.registry.checkHostReachable(hostRemote)).resolves.toBe(false);
expect(h.wake).not.toHaveBeenCalled();
});
it('reports failed instead of rejecting when the wake IO itself throws', async () => {
// A create route must answer with its own error, not a 500 from an unexpected
// rejection — the session flow catches for the same reason.
const h = harness({ remote: hostRemote });
h.probe.mockResolvedValue(false);
h.wake.mockRejectedValue(new Error('udp socket exploded'));
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 ==========
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 route module and the server may import remote-wake', () => {
// The auto-reconnect watcher (tmux-manager.ts), the server's dropped-session
// handler and any boot-recovery path must NOT be able to WAKE a host: waking there
// re-wakes the host seconds after each suspend. `web/server.ts` is allowed to hold
// the registry for its LIFETIME only (`drop()` on session cleanup, `stop()` on
// shutdown) — the test below pins that it never calls a waking method, which is the
// property this import list is an approximation of.
const allowed = new Set([join('web', 'routes', 'session-routes.ts'), join('web', 'server.ts')]);
const importers = walkTs(SRC)
.filter((full) => /from\s+['"][^'"]*remote-wake(\.js)?['"]/.test(readFileSync(full, 'utf-8')))
.map((full) => relative(SRC, full));
expect(importers.sort()).toEqual([...allowed].sort());
});
it('the server only ever calls drop/stop on the registry — never a waking method', () => {
// `server.ts` holds the registry because `cleanupSession` and `stop()` need it, and
// those run on timers and shutdown paths. Any wake-capable call from this file is the
// exact failure invariant #1 exists to prevent, so it is asserted here rather than
// left to the import check above (which the field's type alone would satisfy).
const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8');
for (const method of [
'wake',
'ensureAwake',
'ensureHostAwake',
'handleInput',
'checkReachable',
'checkHostReachable',
]) {
expect(server).not.toContain(`remoteWake.${method}(`);
expect(server).not.toContain(`remoteWake?.${method}(`);
}
expect(server).toContain('remoteWake?.drop(');
expect(server).toContain('remoteWake?.stop(');
});
it('wakes a host for a create/attach request ONLY from the HTTP route', () => {
// The create-path wake (`ensureHostAwake`) is a USER request, so it belongs to the
// HTTP route. `cron-service.ts` builds sessions through the shared service with
// nobody waiting on the answer, so a wake down there would power the host on for
// every schedule — the failure invariant #1 exists to prevent. Asserted across the
// source tree, so a future caller has to come through this test.
// `remote-wake.ts` names itself: that is the definition, not a caller, and the
// import guard above already pins the file to the route.
const allowed = new Set([join('web', 'routes', 'session-routes.ts'), 'remote-wake.ts']);
const callers = walkTs(SRC)
.filter((full) => /ensureHostAwake\s*\(/.test(readFileSync(full, 'utf-8')))
.map((full) => relative(SRC, full));
expect(callers.sort()).toEqual([...allowed].sort());
});
});