fix(remote): merge-time fixes for Wake-on-LAN

The MAC-count limit lived in two places that disagreed. RemoteHostSchema.wakeMac's
128-character cap admits seven comma-separated MACs while parseMacList takes at most
four, all-or-nothing, so a five-MAC value validated, was written to remote-hosts.json,
and then resolved to NO wake target: POST /api/sessions/:id/wake answered
"No wake-on-LAN target configured for this host" and the banner offered "Configure WoL"
for a host the user had just configured. MAX_WAKE_MACS now lives in
src/config/remote-wake-limits.ts and both sides refine against it. Its own module
because src/remote-wake.ts is import-fenced to session-routes.ts and server.ts (the
wiring guard that stops a watcher waking a host), and because schemas.ts must not drag
dgram/net/child_process into every request-validating module.

The documented 40 s request budget also omitted the wake's own cost. A `command` target
is bounded by REMOTE_WAKE_COMMAND_TIMEOUT_MS and runs BEFORE the readiness poll, so a
slow one pushed a wakeCommand host's worst case to ~68 s, past the 60 s
proxy_read_timeout the budget exists to stay under. _wakeAndWait now subtracts the
wake's measured elapsed time from the readiness budget, floored at one poll interval so
a wake that ate the whole budget still gets one probe. A magic packet is effectively
instant and is unaffected, which is why live testing never saw it.

Also: the two new endpoints are documented in docs/api-reference.md with the import
fence stated as the rule it is, CLAUDE.md's frontend module count moves to 34, and the
release changesets carry the Thanks section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-19 12:18:40 +02:00
parent 4c705094f7
commit 035bfbc2fe
8 changed files with 176 additions and 9 deletions
+83 -6
View File
@@ -31,11 +31,14 @@ import {
waitUntilRemoteReady,
wakeConfigured,
REMOTE_WAKE_PENDING_MAX_BYTES,
REMOTE_WAKE_READY_INTERVAL_MS,
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
type RemoteWakeDeps,
type WakeableRemote,
type WakeableSession,
} from '../src/remote-wake.js';
import { RemoteHostSchema } from '../src/web/schemas.js';
import { MAX_WAKE_MACS } from '../src/config/remote-wake-limits.js';
// ========== Pure decisions ==========
@@ -564,6 +567,36 @@ describe('RemoteWakeRegistry', () => {
// ========== Host-scoped wake (session create/attach) ==========
describe('the MAC-count limit lives in one place', () => {
// The schema's 128-character cap admits seven MACs while parseMacList takes at most
// MAX_WAKE_MACS, all-or-nothing. They used to disagree, so a five-MAC wakeMac
// validated, persisted to remote-hosts.json, and then resolved to NO wake target:
// the host read as unconfigured and the banner offered "Configure WoL" for a host
// the user had just set up.
const mac = (n: number) => `04:d9:f5:80:c6:${n.toString(16).padStart(2, '0')}`;
it('parses exactly MAX_WAKE_MACS', () => {
const value = Array.from({ length: MAX_WAKE_MACS }, (_, i) => mac(i)).join(',');
expect(parseMacList(value)).toHaveLength(MAX_WAKE_MACS);
expect(
RemoteHostSchema.safeParse({ id: 'h', label: 'H', host: '10.0.0.5', username: 'joe', wakeMac: value }).success
).toBe(true);
});
it('rejects one more in BOTH the schema and the parser, so neither can admit what the other drops', () => {
const value = Array.from({ length: MAX_WAKE_MACS + 1 }, (_, i) => mac(i)).join(',');
expect(parseMacList(value)).toBeNull();
const parsed = RemoteHostSchema.safeParse({
id: 'h',
label: 'H',
host: '10.0.0.5',
username: 'joe',
wakeMac: value,
});
expect(parsed.success).toBe(false);
});
});
describe('isProbeable', () => {
const base: WakeableRemote = { hostId: 'h', label: 'H', host: '10.0.0.9', wakeMac: '04:d9:f5:80:c6:58' };
@@ -750,15 +783,59 @@ describe('RemoteWakeRegistry — host-scoped wake for a request that waits on it
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),
});
// 60 s, so a create-path wake must not inherit the 90 s session default. A magic
// packet is effectively instant, so the poll gets essentially the whole budget;
// it is not asserted to the millisecond because the wake's own elapsed time is
// subtracted (see the wakeCommand case below).
const [, readyOpts] = h.waitUntilReady.mock.calls[0] as [unknown, { timeoutMs: number; signal: AbortSignal }];
expect(readyOpts.timeoutMs).toBeLessThanOrEqual(REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS);
expect(readyOpts.timeoutMs).toBeGreaterThan(REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS - 1_000);
// The shutdown signal rides along so `WebServer.stop()` can end the poll.
expect(readyOpts.signal).toEqual(expect.any(AbortSignal));
expect(h.events).toContain('remote:hostWaking');
});
it('spends a slow wake command out of the request budget rather than on top of it', async () => {
// REMOTE_WAKE_COMMAND_TIMEOUT_MS is 10 s and runs BEFORE the readiness poll, so the
// original arithmetic (40 s poll + 1.5 s probe + 15 s tmux prereq) understated a
// wakeCommand host's worst case by the whole wake: ~68 s against the 60 s
// proxy_read_timeout this budget exists to stay under.
const h = harness({ remote: { ...hostRemote, wakeMac: undefined, wakeCommand: '/usr/bin/whuff' } });
h.probe.mockResolvedValue(false);
h.wake.mockImplementation(async () => {
await new Promise((r) => setTimeout(r, 60));
return true;
});
await expect(
h.registry.ensureHostAwake(
{ ...hostRemote, wakeMac: undefined, wakeCommand: '/usr/bin/whuff' },
{ timeoutMs: 5_000 }
)
).resolves.toBe('ready');
const [, readyOpts] = h.waitUntilReady.mock.calls[0] as [unknown, { timeoutMs: number }];
expect(readyOpts.timeoutMs).toBeLessThan(5_000);
expect(readyOpts.timeoutMs).toBeGreaterThanOrEqual(5_000 - 2_000);
});
it('still gives a wake that ate the whole budget one readiness probe', async () => {
const h = harness({ remote: { ...hostRemote, wakeMac: undefined, wakeCommand: '/usr/bin/whuff' } });
h.probe.mockResolvedValue(false);
h.wake.mockImplementation(async () => {
await new Promise((r) => setTimeout(r, 40));
return true;
});
await h.registry.ensureHostAwake(
{ ...hostRemote, wakeMac: undefined, wakeCommand: '/usr/bin/whuff' },
{ timeoutMs: 10 }
);
const [, readyOpts] = h.waitUntilReady.mock.calls[0] as [unknown, { timeoutMs: number }];
expect(readyOpts.timeoutMs).toBe(REMOTE_WAKE_READY_INTERVAL_MS);
});
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);