feat(remote): wake a sleeping host when a session is created or attached

Pressing Run on a remote case whose host was asleep failed with
`could not verify tmux on remote host 192.168.50.137: …` — an ssh error that
blames tmux for a machine that is merely suspended. The only wake paths were
typed input on an established session and the banner's Wake button, so OPENING a
session (the moment the user actually decides to use that host) had none.

`RemoteWakeRegistry.ensureHostAwake()` reuses the existing probe/wake/readiness
machinery for a host that has no session yet, and is wired into the two
user-initiated create paths: `POST /api/quick-start` for a remote case (before
the tmux prereq probe, which is what surfaced the misleading error) and
`POST /api/sessions` with `attachRemoteSession`. A host without a wake target is
not even probed, so its behavior and latency are byte-identical. The wake is
blocking — the caller gets the session or an error — but bounded by
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS (40 s) instead of the 90 s session default,
because the dashboard sits behind a reverse proxy whose default
`proxy_read_timeout` is 60 s: a longer wait would be cut off at the proxy while
the session was still being created. The budget has to cover the whole request
(40 s wake + 1.5 s probe + the tmux probe's own 15 s = 56.5 s worst case), which
is why it is 40 s and not 45. A timeout now says the host did not come back, and
an unreachable host without a wake target says so instead of pointing at tmux.

The wiring is deliberately in the HTTP ROUTE, never in the shared session
service: `cron-service.ts` builds sessions there with nobody waiting on the
answer, and a wake on that path would power the host on for every schedule —
the timer-driven re-wake invariant #1 exists to prevent. Both halves are asserted
(importers of `remote-wake`, and `ensureHostAwake` having exactly one caller
file), so a future caller has to come through the guard test. A rejection from
the wake IO is caught too: a broken target must fail the wake, not the route.

`remote:hostWaking`/`remote:hostWakeFailed` now carry `forNewSession` for the
session-less case, where "input is queued" would be untrue; the toast then reads
"the session starts when it is back".

Live wake numbers are unchanged (this reuses the measured ~12 s S3 path); the
route behavior is covered by new tests in session-routes.test.ts with an injected
registry, so no test opens a real socket or ssh.
This commit is contained in:
Randalix
2026-09-15 22:37:37 +02:00
parent 8dfc965d13
commit d0a5a583cd
7 changed files with 518 additions and 40 deletions
+109
View File
@@ -26,6 +26,7 @@ import {
sendWakePackets,
wakeConfigured,
REMOTE_WAKE_PENDING_MAX_BYTES,
REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
type RemoteWakeDeps,
type WakeableRemote,
type WakeableSession,
@@ -439,6 +440,98 @@ describe('RemoteWakeRegistry', () => {
});
});
// ========== 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,
});
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');
});
});
// ========== Wiring guard ==========
const SRC = fileURLToPath(new URL('../src', import.meta.url));
@@ -468,4 +561,20 @@ describe('wake wiring guard', () => {
expect(importers.sort()).toEqual([...allowed].sort());
});
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());
});
});