diff --git a/src/remote-wake.ts b/src/remote-wake.ts index b848f4c9..f9fbc143 100644 --- a/src/remote-wake.ts +++ b/src/remote-wake.ts @@ -690,14 +690,22 @@ export class RemoteWakeRegistry { private async _flush(state: WakeState, session: WakeableSession): Promise { while (state.pending.length > 0) { const chunk = state.pending[0]; + // Take the chunk OUT before awaiting the write. Input arriving during the await is + // enqueued by `handleInput` (a wake is still in flight, so it takes the buffer + // path), and `appendBoundedPending` may then drop the OLDEST chunk to stay under + // the cap — which would be this one, already on its way to the pane. Shifting + // afterwards removed the NEXT chunk instead, so the drop-oldest bookkeeping lost a + // chunk that was never written while the log line blamed the one that was. + state.pending = state.pending.slice(1); const ok = await session.writeViaMux(chunk).catch(() => false); if (!ok) { + // Retain it, IN ORDER: a failed write must not reorder the queue behind it. + state.pending = [chunk, ...state.pending]; this.deps.log?.( `[RemoteWake] flush failed for session ${session.id} — ${state.pending.length} chunk(s) retained` ); return; } - state.pending.shift(); } } } diff --git a/src/web/public/host-wake-ui.js b/src/web/public/host-wake-ui.js index e18fa082..2dd0d682 100644 --- a/src/web/public/host-wake-ui.js +++ b/src/web/public/host-wake-ui.js @@ -110,8 +110,10 @@ Object.assign(CodemanApp.prototype, { state.label = session.remote.label || 'Remote host'; // Text from the session payload first (instant, no round trip), corrected by the // poll — a session whose wake config was added after launch only knows it after - // the server resolves host config. - state.wakeConfigured = session.remote.wakeMac || session.remote.wakeCommand ? 'mac' : 'none'; + // the server resolves host config. The kind matters: the payload can say WHICH + // path is configured, so a command-only host is not mislabelled 'mac' until the + // first poll lands. + state.wakeConfigured = session.remote.wakeMac ? 'mac' : session.remote.wakeCommand ? 'command' : 'none'; this._renderHostWakeBanner(); } this._pollHostReachability(); diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index aca01e23..fc488791 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2474,6 +2474,10 @@ Object.assign(CodemanApp.prototype, { 'remoteHostSocksProxy', 'remoteHostJumpHost', 'remoteHostExtraSshOptions', + // Wake-on-LAN: they belong to the HOST being configured, so leaving them filled in + // would carry one host's MAC/command onto the next host this form saves. + 'remoteHostWakeMac', + 'remoteHostWakeCommand', ]; remoteFields.forEach(id => { const el = document.getElementById(id); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 38482247..531ffcdd 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -866,8 +866,9 @@ export function registerSessionRoutes( log: (message) => console.log(message), // The session's `remote` block is a launch-time snapshot, so a wake target // configured later (banner's config dialog, or a hand-edited remote-hosts.json) - // is resolved here — throttled by the registry, and only for sessions that - // have no usable target of their own. + // is resolved here — throttled by the registry, and the host config is + // authoritative in BOTH directions (removing the field turns the feature off + // for a live session too). resolveRemote: async (session) => { const remote = session.remote; if (!remote) return undefined; diff --git a/test/remote-wake.test.ts b/test/remote-wake.test.ts index 2d492270..a9814aa3 100644 --- a/test/remote-wake.test.ts +++ b/test/remote-wake.test.ts @@ -372,6 +372,38 @@ describe('RemoteWakeRegistry', () => { expect(h.registry.pendingBytes('sess-1')).toBe(3); }); + it('flushes the chunk it is writing out of the buffer first, so a concurrent enqueue cannot drop a different one', async () => { + // Input arriving DURING the flush is enqueued (`waking` is still set), and the cap + // then drops the OLDEST chunk — the one already on its way to the pane. Shifting the + // buffer after the write removed the NEXT chunk instead, so the drop-oldest + // bookkeeping lost a chunk that was never written. + const h = harness(); + h.probe.mockResolvedValue(false); + let release: (() => void) | undefined; + h.waitUntilReady.mockImplementation( + () => + new Promise((resolve) => { + release = () => resolve(true); + }) + ); + + const big = 'a'.repeat(REMOTE_WAKE_PENDING_MAX_BYTES - 10); + await h.registry.handleInput(h.session, big); + await h.registry.handleInput(h.session, 'bbbbbbbbbb'); // fills the cap exactly + // The third chunk arrives while the FIRST write is in flight, which is what pushes + // the buffer over the cap mid-flush. + h.writeViaMux.mockImplementationOnce(async () => { + await h.registry.handleInput(h.session, 'c'); + return true; + }); + + release?.(); + await h.registry.wake(h.session); + + expect(h.writeViaMux.mock.calls.map((c) => c[0])).toEqual([big, 'bbbbbbbbbb', 'c']); + expect(h.registry.pendingBytes('sess-1')).toBe(0); + }); + it('ensureAwake blocks only for the wait path and returns true without a wake command', async () => { const h = harness({ remote: { hostId: 'x', label: 'X', host: '10.0.0.9' } }); await expect(h.registry.ensureAwake(h.session)).resolves.toBe(true);