fix(remote): let the host config turn wake-on-LAN OFF for a live session too

Found by driving the real UI: with a MAC configured in remote-hosts.json, removing it
(here: to reach the "Configure WoL" dialog) changed nothing for a running session —
_effectiveRemote short-circuited on the session's own snapshot whenever that snapshot
HAD a target, so the resolver was only ever consulted in the one direction where the
feature was missing. The documented "host config is authoritative" promise therefore
failed in the direction a user can actually observe, and a wake target could live on
invisibly after being deleted from the config.

The resolver is now consulted on the TTL regardless, and wins for the wake fields in
both directions. Also adds a route test for the browser's real input shape: one POST
per keystroke, all buffered during a wake, replayed IN ORDER.
This commit is contained in:
Randalix
2026-09-15 23:21:50 +02:00
parent d0a5a583cd
commit 4a30f510e6
3 changed files with 63 additions and 16 deletions
+16 -11
View File
@@ -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<WakeableRemote | undefined> {
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();
+27 -5
View File
@@ -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 () => {
+20
View File
@@ -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)!;