diff --git a/src/remote-wake.ts b/src/remote-wake.ts index fab22d15..89cc6e60 100644 --- a/src/remote-wake.ts +++ b/src/remote-wake.ts @@ -590,22 +590,27 @@ export class RemoteWakeRegistry { } /** - * The host config to act on: the session's own `remote` when it can wake, else a + * The host config to act on: the session's own `remote` when it is fresh enough, else a * freshly resolved one. * - * The persisted `remote` snapshot is taken at launch, so a wake target configured - * AFTER the session started (e.g. through the banner's config dialog, or by adding - * `wakeMac` to `remote-hosts.json`) is invisible to it. Recovery rehydration - * (server.ts) covers restarts; this covers the live session, and it is why saving - * the dialog takes effect without restarting anything. The resolver is asked at - * most once per TTL, and never for a session that already has a usable target. + * The persisted `remote` snapshot is taken at launch, so a wake target configured AFTER + * the session started (e.g. through the banner's config dialog, or by adding `wakeMac` + * to `remote-hosts.json`) is invisible to it. Recovery rehydration (server.ts) covers + * restarts; this covers the live session, and it is why saving the dialog takes effect + * without restarting anything. + * + * ⚠️ The host config wins in BOTH directions, so the resolver is consulted on the TTL + * regardless of whether the session already carries a target. Preferring the snapshot + * whenever it HAD one meant removing a MAC/command in the config (or the dialog) never + * took effect for a running session — the feature stayed on with a target nobody could + * see in the config any more, which is exactly the "host config is authoritative" + * promise failing in the one direction a user can observe. */ private async _effectiveRemote(session: WakeableSession): Promise { const state = this._state(session.id); - if (state.resolvedRemote && resolveWakeTarget(state.resolvedRemote)) return state.resolvedRemote; - if (resolveWakeTarget(session.remote)) return session.remote; - if (!session.remote || !this.deps.resolveRemote) return state.resolvedRemote ?? session.remote; - if (Date.now() - state.resolvedAt < REMOTE_WAKE_RESOLVE_TTL_MS) { + if (!session.remote) return state.resolvedRemote; + if (!this.deps.resolveRemote) return state.resolvedRemote ?? session.remote; + if (state.resolvedAt !== 0 && Date.now() - state.resolvedAt < REMOTE_WAKE_RESOLVE_TTL_MS) { return state.resolvedRemote ?? session.remote; } state.resolvedAt = Date.now(); diff --git a/test/remote-wake.test.ts b/test/remote-wake.test.ts index 57443320..1863fbf9 100644 --- a/test/remote-wake.test.ts +++ b/test/remote-wake.test.ts @@ -423,11 +423,33 @@ describe('RemoteWakeRegistry', () => { expect(resolveRemote).toHaveBeenCalledTimes(1); }); - it('does not consult the resolver when the session already has a wake target', async () => { - const resolveRemote = vi.fn(async () => undefined); - const h = harness({ resolveRemote }); - expect(await h.registry.hasWakeTarget(h.session)).toBe(true); - expect(resolveRemote).not.toHaveBeenCalled(); + 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 () => { diff --git a/test/routes/session-remote-wake.test.ts b/test/routes/session-remote-wake.test.ts index 1cdd2865..abcad42a 100644 --- a/test/routes/session-remote-wake.test.ts +++ b/test/routes/session-remote-wake.test.ts @@ -107,6 +107,26 @@ describe('POST /api/sessions/:id/input — wake-on-LAN', () => { expect(session.reattachRemote).toHaveBeenCalled(); }); + it('flushes several inputs typed during a wake IN ORDER (the browser posts one per keystroke)', async () => { + // The concurrency surface that only exists in production: xterm's onData posts each + // keystroke as its OWN request, so a wake collects N concurrent buffer writes and must + // replay them in order. Route-level, so it is covered on every run instead of only in a + // hand-driven browser session. + const h = await harness({ hostUp: false, holdWake: true }); + const session = h.ctx.sessions.get(SESSION_ID)!; + + for (const chunk of ['h', 'a', 'llo']) { + const res = await send(h.app, { input: chunk, useMux: true }); + expect(res.statusCode).toBe(200); + } + // Nothing written while the host is asleep/dead — that is the whole point. + expect(session.writeBuffer).toEqual([]); + + h.releaseWake(); + await h.registry.wake(session); + expect(session.writeBuffer).toEqual(['h', 'a', 'llo']); + }); + 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)!;