mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
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:
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ vi.mock('../../src/remote-hosts.js', async (orig) => {
|
||||
});
|
||||
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { RemoteWakeRegistry, REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS } from '../../src/remote-wake.js';
|
||||
import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js';
|
||||
|
||||
interface LocalHarness {
|
||||
@@ -62,6 +63,15 @@ interface LocalHarness {
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
// Wake-on-LAN seam: the production registry opens a real TCP connection to the host
|
||||
// and can run a real wake command, so every route registered here gets a fake one
|
||||
// (the same seam `test/routes/session-remote-wake.test.ts` uses). Default: the host
|
||||
// answers, so nothing ever wakes.
|
||||
const wakeProbe = vi.fn(async () => true);
|
||||
const wakeCommandRun = vi.fn(async () => true);
|
||||
const wakeWaitUntilReady = vi.fn(async () => true);
|
||||
let wakeRegistry: RemoteWakeRegistry;
|
||||
|
||||
/**
|
||||
* Build a Fastify instance that mirrors production's uniform-envelope behavior
|
||||
* (server.ts preSerialization hook) so the test wire format matches the contract:
|
||||
@@ -108,7 +118,17 @@ describe('session-routes', () => {
|
||||
let harness: LocalHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createEnvelopeHarness(registerSessionRoutes);
|
||||
wakeProbe.mockReset().mockResolvedValue(true);
|
||||
wakeCommandRun.mockReset().mockResolvedValue(true);
|
||||
wakeWaitUntilReady.mockReset().mockResolvedValue(true);
|
||||
wakeRegistry = new RemoteWakeRegistry({
|
||||
probe: wakeProbe,
|
||||
wake: wakeCommandRun,
|
||||
waitUntilReady: wakeWaitUntilReady,
|
||||
delay: async () => {},
|
||||
log: () => {},
|
||||
});
|
||||
harness = await createEnvelopeHarness((app, ctx) => registerSessionRoutes(app, ctx, { remoteWake: wakeRegistry }));
|
||||
// Reset remote store so tests start with empty hosts/cases and a passing tmux probe
|
||||
remoteStore.hosts = [];
|
||||
remoteStore.cases = [];
|
||||
@@ -1897,6 +1917,132 @@ describe('session-routes', () => {
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.OPERATION_FAILED });
|
||||
});
|
||||
|
||||
describe('remote create/attach wakes a sleeping host (Wake-on-LAN)', () => {
|
||||
const host = (extra: Record<string, unknown> = {}) => ({
|
||||
id: 'hufflepuff',
|
||||
label: 'Hufflepuff',
|
||||
host: '192.168.50.137',
|
||||
username: 'j',
|
||||
wakeMac: '04:d9:f5:80:c6:58',
|
||||
...extra,
|
||||
});
|
||||
const remoteCase = { name: 'hufflepuff-work', type: 'remote', hostId: 'hufflepuff', remotePath: '/home/j/work' };
|
||||
const quickStart = () =>
|
||||
harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'hufflepuff-work', mode: 'shell' },
|
||||
});
|
||||
|
||||
it('wakes the host before the tmux probe when the user runs a remote case', async () => {
|
||||
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [host()];
|
||||
remoteStore.cases = [remoteCase];
|
||||
wakeProbe.mockResolvedValue(false); // asleep
|
||||
|
||||
const res = await quickStart();
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
expect(wakeCommandRun).toHaveBeenCalledWith({ kind: 'mac', macs: [[4, 217, 245, 128, 198, 88]] });
|
||||
// The request budget, not the 90 s session default: the reverse proxy would
|
||||
// cut the request at 60 s while the session was still being built.
|
||||
expect(wakeWaitUntilReady).toHaveBeenCalledWith(expect.objectContaining({ hostId: 'hufflepuff' }), {
|
||||
timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS,
|
||||
});
|
||||
} finally {
|
||||
startShell.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not wake a host that answers, and never probes a host without a wake target', async () => {
|
||||
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [host()];
|
||||
remoteStore.cases = [remoteCase];
|
||||
// The fake probe answers `true` by default — a reachable host.
|
||||
expect((await quickStart()).statusCode).toBe(200);
|
||||
expect(wakeCommandRun).not.toHaveBeenCalled();
|
||||
|
||||
// No wake target at all: not even a probe, so hosts without WoL keep the
|
||||
// exact behavior (and latency) they had before this feature.
|
||||
wakeProbe.mockClear();
|
||||
remoteStore.hosts = [host({ wakeMac: undefined })];
|
||||
expect((await quickStart()).statusCode).toBe(200);
|
||||
expect(wakeProbe).not.toHaveBeenCalled();
|
||||
expect(wakeCommandRun).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
startShell.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses the run when the host never comes back, and starts no session', async () => {
|
||||
remoteStore.hosts = [host()];
|
||||
remoteStore.cases = [remoteCase];
|
||||
wakeProbe.mockResolvedValue(false);
|
||||
wakeWaitUntilReady.mockResolvedValue(false);
|
||||
const sessionsBefore = harness.ctx.sessions.size;
|
||||
|
||||
const res = await quickStart();
|
||||
|
||||
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
|
||||
expect(JSON.parse(res.body).error).toMatch(/did not come back after a wake-on-LAN request/);
|
||||
// No half-created session: the failure is the answer, not a dead tab.
|
||||
expect(harness.ctx.sessions.size).toBe(sessionsBefore);
|
||||
});
|
||||
|
||||
it('blames the sleeping host, not tmux, when the host has no wake target', async () => {
|
||||
remoteStore.hosts = [host({ wakeMac: undefined })];
|
||||
remoteStore.cases = [remoteCase];
|
||||
remoteStore.tmuxCheck = {
|
||||
ok: false,
|
||||
error: 'remote host 192.168.50.137 needs tmux installed for durable remote sessions',
|
||||
};
|
||||
wakeProbe.mockResolvedValue(false);
|
||||
|
||||
const res = await quickStart();
|
||||
|
||||
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
|
||||
expect(JSON.parse(res.body).error).toMatch(/has no wake-on-LAN target/);
|
||||
});
|
||||
|
||||
it('keeps the tmux error when the host is up but tmux is really missing', async () => {
|
||||
remoteStore.hosts = [host()];
|
||||
remoteStore.cases = [remoteCase];
|
||||
remoteStore.tmuxCheck = {
|
||||
ok: false,
|
||||
error: 'remote host 192.168.50.137 needs tmux installed for durable remote sessions',
|
||||
};
|
||||
// Probe answers `true`: the ssh failure is genuinely about tmux.
|
||||
|
||||
const res = await quickStart();
|
||||
|
||||
expect(JSON.parse(res.body).error).toMatch(/needs tmux installed/);
|
||||
});
|
||||
|
||||
it('wakes the host when attaching to a discovered remote session', async () => {
|
||||
const startInteractive = vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
|
||||
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [host()];
|
||||
wakeProbe.mockResolvedValue(false);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
payload: { attachRemoteSession: { hostId: 'hufflepuff', remoteSessionName: 'codeman-abc12345' } },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(wakeCommandRun).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
startInteractive.mockRestore();
|
||||
startShell.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('does not run local codex availability check for a remote codex case', async () => {
|
||||
// A remote codex case must NOT be blocked by the LOCAL codex availability gate
|
||||
// (the CLI runs on the remote host). Probe is stubbed ok in remoteStore.tmuxCheck.
|
||||
|
||||
Reference in New Issue
Block a user