diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.test.ts index b1c4c4f0133..2f2b3a2440e 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.test.ts @@ -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 => ({ 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) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.ts b/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.ts index 987e0006421..1e4f600e47f 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-guest-foreground.ts @@ -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//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//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() + 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() + 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 +): boolean { + const seen = new Set() + 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 }