mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user