mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(wsl): close a WSL host channel with stdin EOF before killing wsl.exe (#34307)
* fix(wsl): close a WSL command channel with stdin EOF before killing wsl.exe Killing wsl.exe while its guest command still runs can leave the WSL session (an /init with a wslhost.exe helper) behind, which keeps an idle distro running. A closed channel now ends stdin first and kills wsl.exe only if the guest has not exited within 2 s. * fix(wsl): kill WSL-host channels still in their close grace at app quit Review of #34307: a guest command that ignores stdin EOF would otherwise keep its wsl.exe up past Orca's exit for the rest of the grace. Quit teardown now kills every channel still in its grace. The abort test runs on fake timers instead of waiting the grace in real time.
This commit is contained in:
@@ -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<void> => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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<PipedChildProcess>()
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user