diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 6ddabdc20b8..32528d972e5 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -1,3 +1,4 @@ +import { killWslChannelsInCloseGrace } from '../wsl/wsl-command-channel' import { app, type Event } from 'electron' import { closeAllWatchers } from '../ipc/filesystem/watchers/filesystem-watcher-shutdown' import { disposeWorktreeBaseDirectoryWatchers } from '../ipc/worktree-base-directory-watcher' @@ -177,6 +178,8 @@ function installWillQuitHandler(): void { : Promise.resolve() // Dispose relay timers and wsl.exe children explicitly rather than rely on pipe teardown. wslHookRelayManager.disposeAll() + // A closed WSL-host channel still in its EOF grace must not outlive the app. + killWslChannelsInCloseGrace() const statsFlush = state.stats?.flushAsync() ?? Promise.resolve() // Await per-session close children so browser daemons cannot retain ports/state after quit (#16367); retire headless owners first to avoid duplicate fanout. const browserShutdown = (async (): Promise => { diff --git a/src/main/wsl/wsl-command-channel.test.ts b/src/main/wsl/wsl-command-channel.test.ts new file mode 100644 index 00000000000..cc4dc186f19 --- /dev/null +++ b/src/main/wsl/wsl-command-channel.test.ts @@ -0,0 +1,77 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { PipedChildProcess } from '@orca/process-host/process-spec' +import { + killWslChannelsInCloseGrace, + WSL_CHANNEL_CLOSE_GRACE_MS, + wrapWslCommandProcess +} from './wsl-command-channel' + +const nullCode: number | null = null +const nullSignal: NodeJS.Signals | null = null + +function fakeChild() { + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: nullCode, + signalCode: nullSignal, + kill: vi.fn(() => true) + }) + const exit = (code: number): void => { + child.exitCode = code + child.emit('exit', code, null) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the channel reads only these members. + return { child: child as unknown as PipedChildProcess, fake: child, exit } +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +it('ends stdin first, so a guest that exits on EOF ends its WSL session cleanly', () => { + const { child, fake, exit } = fakeChild() + wrapWslCommandProcess(child).close() + expect(fake.stdin.writableEnded).toBe(true) + exit(0) + vi.advanceTimersByTime(WSL_CHANNEL_CLOSE_GRACE_MS) + expect(fake.kill).not.toHaveBeenCalled() +}) + +it('kills wsl.exe when the guest ignores EOF past the grace period', () => { + const { child, fake } = fakeChild() + wrapWslCommandProcess(child).close() + vi.advanceTimersByTime(WSL_CHANNEL_CLOSE_GRACE_MS - 1) + expect(fake.kill).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(fake.kill).toHaveBeenCalledOnce() +}) + +it('does nothing once wsl.exe already exited', () => { + const { child, fake, exit } = fakeChild() + const channel = wrapWslCommandProcess(child) + exit(0) + channel.close() + vi.advanceTimersByTime(WSL_CHANNEL_CLOSE_GRACE_MS) + expect(fake.kill).not.toHaveBeenCalled() +}) + +it('kills a channel still in its grace at app quit, and only once', () => { + const closing = fakeChild() + const open = fakeChild() + wrapWslCommandProcess(closing.child).close() + wrapWslCommandProcess(open.child) + killWslChannelsInCloseGrace() + expect(closing.fake.kill).toHaveBeenCalledOnce() + // An open channel is not ours to end at quit through this path. + expect(open.fake.kill).not.toHaveBeenCalled() + vi.advanceTimersByTime(WSL_CHANNEL_CLOSE_GRACE_MS) + expect(closing.fake.kill).toHaveBeenCalledOnce() +}) diff --git a/src/main/wsl/wsl-command-channel.ts b/src/main/wsl/wsl-command-channel.ts index d316dd80a01..7e6e0dc3fc2 100644 --- a/src/main/wsl/wsl-command-channel.ts +++ b/src/main/wsl/wsl-command-channel.ts @@ -2,6 +2,22 @@ import { Duplex } from 'node:stream' import type { PipedChildProcess } from '@orca/process-host/process-spec' import type { RemoteCommandChannel } from '../managed-server/install/remote-install-transport' +/** How long a closed channel's guest gets to exit on stdin EOF before wsl.exe is killed. */ +export const WSL_CHANNEL_CLOSE_GRACE_MS = 2_000 + +// wsl.exe children inside their close grace; quit kills them so none outlives Orca. +const closingChildren = new Set() + +/** App quit: no grace left, so a guest still running after close must not keep wsl.exe up. */ +export function killWslChannelsInCloseGrace(): void { + for (const child of closingChildren) { + if (child.exitCode === null && child.signalCode === null) { + child.kill() + } + } + closingChildren.clear() +} + /** Expose wsl.exe stdio as an SSH-style channel; exit reports status and close follows stdio teardown. */ export function wrapWslCommandProcess(child: PipedChildProcess): RemoteCommandChannel { // Only the child emits close, once after exit status, like ssh2. @@ -58,9 +74,27 @@ export function wrapWslCommandProcess(child: PipedChildProcess): RemoteCommandCh stdin: child.stdin, stderr: child.stderr, close: () => { - if (child.exitCode === null && child.signalCode === null) { - child.kill() + if (child.exitCode !== null || child.signalCode !== null) { + return } + // EOF first: a guest that exits on its own ends its WSL session; a killed wsl.exe can leave + // the session behind (a lingering wslhost.exe), which keeps an idle distro running. + child.stdin.end() + closingChildren.add(child) + const timer = setTimeout(() => { + // Quit may already have killed it. + if (!closingChildren.delete(child)) { + return + } + if (child.exitCode === null && child.signalCode === null) { + child.kill() + } + }, WSL_CHANNEL_CLOSE_GRACE_MS) + timer.unref?.() + child.once('exit', () => { + clearTimeout(timer) + closingChildren.delete(child) + }) } }) return channel diff --git a/src/main/wsl/wsl-orcad-host-transport.test.ts b/src/main/wsl/wsl-orcad-host-transport.test.ts index ec3162239c2..f23339f6dd9 100644 --- a/src/main/wsl/wsl-orcad-host-transport.test.ts +++ b/src/main/wsl/wsl-orcad-host-transport.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import type { PipedChildProcess, PipedProcessSpec } from '@orca/process-host/process-spec' import { execCommand } from '../ssh/remote-exec/remote-exec-command' import { isWslOrcadHostTarget } from '../ssh/orcad-host-transport' +import { WSL_CHANNEL_CLOSE_GRACE_MS } from './wsl-command-channel' import { createWslOrcadHostTransport } from './wsl-orcad-host-transport' import { openOrcadStdioBridgeChannel, resolveOrcadStdioBridge } from '../ssh/orcad-stdio-bridge' import { orcadBashStdioBridgeCommand } from '../managed-server/lifecycle/orcad-stdio-bridge-script' @@ -148,14 +149,23 @@ describe('createWslOrcadHostTransport', () => { }) it('kills the child when an exec is aborted', async () => { - const child = fakeChild() - const { transport } = transportWith([child]) - const controller = new AbortController() - const pending = execCommand(transport, 'sleep 300', { signal: controller.signal }) - await new Promise((resolve) => setImmediate(resolve)) - controller.abort() - await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) - expect(child.kill).toHaveBeenCalledTimes(1) + // Only timers: the fake child's spawn and close still arrive on real ticks. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const child = fakeChild() + const { transport } = transportWith([child]) + const controller = new AbortController() + const pending = execCommand(transport, 'sleep 300', { signal: controller.signal }) + const rejected = expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + await new Promise((resolve) => setImmediate(resolve)) + controller.abort() + // A guest ignoring stdin EOF is killed once the close grace runs out. + await vi.advanceTimersByTimeAsync(WSL_CHANNEL_CLOSE_GRACE_MS) + await rejected + expect(child.kill).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } }) it('rejects exec when wsl.exe cannot be started', async () => {