fix(wsl-mover): a WSL pane is idle only when its shell has nothing else

Review of #32343: reading only the terminal's foreground group let background jobs, nohup and
disowned work, setsid daemons and a script waiting at read pass as idle, and the move's Sleep would
end them. Scan the pane shell's subtree, its session and every process carrying its key; any
non-shell process, or a shell running a script, blocks.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-23 03:53:32 -07:00
co-authored by Claude
parent 37106eecf7
commit 18ca408e4d
2 changed files with 115 additions and 47 deletions
@@ -14,47 +14,73 @@ const KEY_A = 'tab-1:11111111-1111-4111-8111-111111111111'
const KEY_B = 'tab-2:22222222-1111-4111-8111-111111111111'
const scope = { workspaceIds: [WT], workspaceKeys: new Set([WT]) }
const proc = (
const RELAY = 10
// The WSL relay (`init`) starts the pane's login shell as a session leader.
const relay: WslGuestProcessRow = { pid: RELAY, ppid: 1, sid: 1, argc: 1, comm: 'init', paneKey: null }
const shellA: WslGuestProcessRow = {
pid: 100,
ppid: RELAY,
sid: 100,
argc: 1,
comm: 'bash',
paneKey: KEY_A
}
const child = (
pid: number,
pgrp: number,
tpgid: number,
comm: string,
paneKey: string | null
): WslGuestProcessRow => ({ pid, pgrp, tpgid, comm, paneKey })
extra: Partial<WslGuestProcessRow> = {}
): WslGuestProcessRow => ({ pid, ppid: 100, sid: 100, argc: 1, comm, paneKey: KEY_A, ...extra })
describe('classifyWslGuestPaneForeground', () => {
it('reads a pane whose foreground group is only its shell as idle', () => {
const rows = [proc(100, 100, 100, 'bash', KEY_A), proc(200, 200, 205, 'bash', KEY_B)]
it('reads a pane whose shell has nothing else, or only an idle nested shell, as idle', () => {
expect(classifyWslGuestPaneForeground([relay, shellA], KEY_A)).toBe('shell-idle')
expect(classifyWslGuestPaneForeground([relay, shellA, child(110, 'zsh')], KEY_A)).toBe(
'shell-idle'
)
})
// Every one of these survived the old foreground-group check and would end at the move's Sleep.
it.each([
['a foreground program', [child(150, 'vim')]],
['a background job', [child(150, 'npm')]],
['a nohup or disowned job reparented to init', [child(150, 'node', { ppid: 1 })]],
['a setsid daemon that kept the key', [child(150, 'redis-server', { ppid: 1, sid: 150 })]],
['a child that cleared its environment', [child(150, 'python3', { paneKey: null })]],
['a script waiting at `read`', [child(150, 'bash', { argc: 2 })]],
['`sh -c` running a command', [child(150, 'sh', { argc: 3 })]]
])('blocks %s', (_label, extra) => {
expect(classifyWslGuestPaneForeground([relay, shellA, ...extra], KEY_A)).toBe(
'process-running'
)
})
it("ignores another pane's work and the relay above the shell", () => {
const shellB = { ...shellA, pid: 200, sid: 200, paneKey: KEY_B }
const rows = [relay, shellA, shellB, child(250, 'vim', { ppid: 200, sid: 200, paneKey: KEY_B })]
expect(classifyWslGuestPaneForeground(rows, KEY_A)).toBe('shell-idle')
expect(classifyWslGuestPaneForeground(rows, KEY_B)).toBe('process-running')
})
it('reads a running program as busy, even one that cleared its environment', () => {
const rows = [
proc(100, 100, 150, 'bash', KEY_A),
proc(150, 150, 150, 'vim', null),
proc(200, 200, 200, 'zsh', KEY_B)
]
expect(classifyWslGuestPaneForeground(rows, KEY_A)).toBe('process-running')
expect(classifyWslGuestPaneForeground(rows, KEY_B)).toBe('shell-idle')
})
it('never reads a pane with no guest process, or an ambiguous terminal, as idle', () => {
expect(classifyWslGuestPaneForeground([], KEY_A)).toBe('unverifiable')
const split = [proc(100, 100, 100, 'bash', KEY_A), proc(300, 300, 300, 'bash', KEY_A)]
expect(classifyWslGuestPaneForeground(split, KEY_A)).toBe('unverifiable')
it('never reads a pane whose shell was not found as idle', () => {
expect(classifyWslGuestPaneForeground([relay], KEY_A)).toBe('unverifiable')
})
})
describe('parseWslGuestPaneProcesses', () => {
it('keeps comm with spaces and drops malformed lines', () => {
expect(
parseWslGuestPaneProcesses(`10\t10\t10\tmy prog\t${KEY_A}\nbad line\n11\t11\t11\tbash\t\n`)
).toEqual([proc(10, 10, 10, 'my prog', KEY_A), proc(11, 11, 11, 'bash', null)])
parseWslGuestPaneProcesses(
`10\t1\t10\t1\tmy prog\t${KEY_A}\nbad line\n11\t10\t10\t 2\tbash\t\n`
)
).toEqual([
{ pid: 10, ppid: 1, sid: 10, argc: 1, comm: 'my prog', paneKey: KEY_A },
{ pid: 11, ppid: 10, sid: 10, argc: 2, comm: 'bash', paneKey: null }
])
})
})
describe.runIf(process.platform === 'linux')('WSL_GUEST_PANE_PROCESS_SCRIPT on Linux', () => {
it('lists processes as pid, pgrp, tpgid, comm and pane key', () => {
it('lists processes as pid, ppid, sid, argc, comm and pane key', () => {
const out = execFileSync('sh', ['-c', WSL_GUEST_PANE_PROCESS_SCRIPT], { encoding: 'utf8' })
for (const row of parseWslGuestPaneProcesses(out)) {
expect(row.pid).toBeGreaterThan(0)
@@ -1,8 +1,11 @@
/**
* What a bridge WSL pane is running, read inside its distro. On Windows the pane's own process is
* `wsl.exe`, which every live pane has, so the host process table cannot tell an idle shell from a
* running program. Each guest process of a pane inherits `ORCA_PANE_KEY`; the pane is idle only
* when its terminal's foreground process group holds nothing but shells.
* running program. Each guest process of a pane inherits `ORCA_PANE_KEY`.
*
* Idle means the pane's shell has nothing else: no foreground program, no background job (`&`,
* `nohup`, `disown`), no daemon it started (`setsid` keeps the key), and no script it runs (a waiting
* `read` is still a script). The move's Sleep ends all of those, so any of them blocks.
*/
import { isShellProcess } from '../../../shared/shell-process-detection'
@@ -10,16 +13,19 @@ export type WslGuestPaneForeground = 'shell-idle' | 'process-running' | 'unverif
export type WslGuestProcessRow = {
pid: number
pgrp: number
/** The controlling terminal's foreground process group; -1 without a terminal. */
tpgid: number
ppid: number
/** Session id. */
sid: number
/** Arguments in /proc/<pid>/cmdline: an interactive shell has one, a script or `-c` more. */
argc: number
paneKey: string | null
comm: string
}
/**
* One line per process with a controlling terminal: pid, pgrp, tpgid, comm and the pane key.
* Tab-separated; fields are read after the last `)` because comm may contain spaces or parens.
* One line per process: pid, ppid, sid, argc, comm and the pane key, tab-separated. Fields come
* after the last `)` of /proc/<pid>/stat because comm may hold spaces or parens. POSIX sh and
* BusyBox tools only.
*/
export const WSL_GUEST_PANE_PROCESS_SCRIPT = [
'for d in /proc/[0-9]*; do',
@@ -27,25 +33,26 @@ export const WSL_GUEST_PANE_PROCESS_SCRIPT = [
' s=$(cat "$d/stat" 2>/dev/null) || continue',
' r=${s##*) }',
' set -- $r',
' [ "$5" = 0 ] && continue',
' c=$(cat "$d/comm" 2>/dev/null)',
` n=$(tr '\\0' '\\n' < "$d/cmdline" 2>/dev/null | wc -l)`,
` k=$(tr '\\0' '\\n' < "$d/environ" 2>/dev/null | sed -n 's/^ORCA_PANE_KEY=//p' | head -n 1)`,
` printf '%s\\t%s\\t%s\\t%s\\t%s\\n' "$p" "$3" "$6" "$c" "$k"`,
` printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' "$p" "$2" "$4" "$n" "$c" "$k"`,
'done'
].join('\n')
export function parseWslGuestPaneProcesses(stdout: string): WslGuestProcessRow[] {
const rows: WslGuestProcessRow[] = []
for (const line of stdout.split('\n')) {
const [pid, pgrp, tpgid, comm, paneKey] = line.split('\t')
const numbers = [pid, pgrp, tpgid].map((value) => Number(value))
const [pid, ppid, sid, argc, comm, paneKey] = line.split('\t')
const numbers = [pid, ppid, sid, argc].map((value) => Number(value?.trim()))
if (comm === undefined || numbers.some((value) => !Number.isInteger(value))) {
continue
}
rows.push({
pid: numbers[0],
pgrp: numbers[1],
tpgid: numbers[2],
ppid: numbers[1],
sid: numbers[2],
argc: numbers[3],
comm: comm.trim(),
paneKey: paneKey?.trim() || null
})
@@ -53,21 +60,56 @@ export function parseWslGuestPaneProcesses(stdout: string): WslGuestProcessRow[]
return rows
}
/** A pane with no guest process found is unverifiable, never idle. */
/** A pane whose shell cannot be found is unverifiable, never idle. */
export function classifyWslGuestPaneForeground(
rows: readonly WslGuestProcessRow[],
paneKey: string
): WslGuestPaneForeground {
const byPid = new Map(rows.map((row) => [row.pid, row]))
const tagged = rows.filter((row) => row.paneKey === paneKey)
const groups = new Set(tagged.map((row) => row.tpgid).filter((group) => group > 0))
if (groups.size !== 1) {
return 'unverifiable'
const taggedPids = new Set(tagged.map((row) => row.pid))
// The pane's shell: a tagged shell whose parent is not tagged (its parent is the WSL relay).
const shells = tagged.filter((row) => !taggedPids.has(row.ppid) && isShellProcess(row.comm))
if (shells.length !== 1) {
// None, or a second root (a tagged daemon that is itself a shell): never read as idle.
return shells.length === 0 ? 'unverifiable' : 'process-running'
}
const [foregroundGroup] = groups
// Untagged members count too: a program that cleared its environment is still the foreground.
const members = rows.filter((row) => row.pgrp === foregroundGroup)
if (members.length === 0) {
return 'unverifiable'
const [shell] = shells
const ancestors = new Set<number>()
for (let row = byPid.get(shell.ppid); row && !ancestors.has(row.pid); row = byPid.get(row.ppid)) {
ancestors.add(row.pid)
}
return members.every((row) => isShellProcess(row.comm)) ? 'shell-idle' : 'process-running'
const members = new Map<number, WslGuestProcessRow>()
for (const row of rows) {
if (row.pid === shell.pid || ancestors.has(row.pid)) {
continue
}
if (row.paneKey === paneKey || row.sid === shell.sid || descendsFrom(row, shell.pid, byPid)) {
members.set(row.pid, row)
}
}
for (const row of members.values()) {
if (!isShellProcess(row.comm) || row.argc > 1) {
return 'process-running'
}
}
return 'shell-idle'
}
function descendsFrom(
row: WslGuestProcessRow,
ancestorPid: number,
byPid: ReadonlyMap<number, WslGuestProcessRow>
): boolean {
const seen = new Set<number>()
for (let current = byPid.get(row.ppid); current; current = byPid.get(current.ppid)) {
if (current.pid === ancestorPid) {
return true
}
if (seen.has(current.pid)) {
return false
}
seen.add(current.pid)
}
return false
}