fix(wsl): reap the bash stdio relay's cats before it exits (#34260)

* fix(wsl): reap the bash stdio relay's cats before it exits

The relay killed the surviving cat and exited at once. WSL's per-session init never reaps
orphans, so that cat stayed a zombie, its session never ended, and the distro never stopped
even with nothing of Orca's left in it (seen on every real-WSL idle-release run). Wait for both
cats after the kill.

* test(wsl): prove the bash relay waits for the cat it kills
This commit is contained in:
Neil
2026-10-26 22:54:27 -07:00
committed by GitHub
parent 483573f0b9
commit fa4d2e99db
2 changed files with 43 additions and 2 deletions
@@ -90,7 +90,8 @@ export function orcadBashStdioBridgeCommand(port: number): string {
'cat <&3 & r=$!;',
'cat <&4 >&3 & w=$!;',
'exec 4<&-;',
'wait -n; kill $r $w 2>/dev/null; exit 0; fi;',
// Reap the cat just killed: WSL's session init never does, and its zombie keeps the distro up.
'wait -n; kill $r $w 2>/dev/null; wait; exit 0; fi;',
'eval "$1"'
].join(' ')
// Carry the Node bridge once, as data both shells can run.
+41 -1
View File
@@ -1,4 +1,5 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { createServer, type Server, type Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -64,6 +65,10 @@ function spawnBridge(port: number, env: Partial<NodeJS.ProcessEnv> = {}): Bridge
})
}
function realCat(): string {
return spawnSync('sh', ['-c', 'command -v cat'], { encoding: 'utf8' }).stdout.trim()
}
function exitOf(child: Bridge): Promise<number | null> {
return new Promise((resolve) => child.once('exit', (code) => resolve(code)))
}
@@ -258,6 +263,41 @@ describe.runIf(RELAY)('the bash stdio bridge', () => {
}
})
it('waits for the cat it kills before exiting, so no zombie is left for the session init', async () => {
// WSL's per-session init never reaps orphans; a zombie there kept an idle distro running (R40).
// This cat takes a moment to die and leaves a mark, so the relay must wait to see it.
const dir = mkdtempSync(join(scratch, 'reap-'))
const bin = join(dir, 'bin')
mkdirSync(bin)
writeFileSync(
join(bin, 'cat'),
'#!/bin/sh\n' +
`trap 'kill $c 2>/dev/null; sleep 0.3; : > "${join(dir, 'killed-cat')}"; exit 0' TERM\n` +
`${realCat()} "$@" & c=$!\nwait $c\n` +
// The relay starts the socket reader first; the stdin reader stays blocked as on a real
// host, where the desktop keeps its pipe open.
`mkdir "${join(dir, 'socket-reader')}" 2>/dev/null || { sleep 30 & c=$!; wait $c; }\n`,
{ mode: 0o755 }
)
let accepted: Socket | null = null
const server = createServer((socket) => {
accepted = socket
})
const port = await listen(server)
try {
const child = spawnBridge(port, { PATH: `${bin}:${process.env.PATH}` })
const exited = exitOf(child)
await decode(child.stdout).ready
await expect.poll(() => accepted).not.toBeNull()
// orcad hangs up: the reading cat ends, the relay kills the one still reading stdin.
accepted!.destroy()
await expect(exited).resolves.toBe(0)
expect(existsSync(join(dir, 'killed-cat'))).toBe(true)
} finally {
server.close()
}
})
it('closes orcad’s socket as soon as the desktop ends stdin', async () => {
const server = createServer()
const ended = new Promise<void>((resolve) => {