diff --git a/.github/workflows/windows-wsl-e2e.yml b/.github/workflows/windows-wsl-e2e.yml index 9f9b36eb21d..80463909616 100644 --- a/.github/workflows/windows-wsl-e2e.yml +++ b/.github/workflows/windows-wsl-e2e.yml @@ -26,7 +26,8 @@ concurrency: jobs: wsl-terminal: runs-on: windows-2022 - timeout-minutes: 30 + # The E9 guard adds three ~2-5 minute runs to the launch, paste and image checks. + timeout-minutes: 50 env: NODE_OPTIONS: --max-old-space-size=4096 steps: @@ -75,6 +76,26 @@ jobs: --output=test-results/terminal-images --repeat-each=3 --workers=1 + - name: Guard that background work never starts the stopped WSL distro + env: + SKIP_BUILD: '1' + ORCA_BACKGROUND_LAUNCH: '1' + ORCA_E2E_FORWARD_APP_LOGS: '1' + # The spec terminates this distro; it is the one this job imported. + ORCA_E2E_WSL_TERMINATE_DISTRO: Ubuntu + PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/wsl-no-background-boot-results.json + run: >- + pnpm exec playwright test + tests/e2e/wsl-bridge-no-background-boot.spec.ts + --config tests/playwright.config.ts + --project=electron-headless + --output=test-results/wsl-no-background-boot + --repeat-each=3 + --workers=1 + --reporter=list,json + - name: Require all three E9 guard executions + if: always() + run: node config/scripts/verify-wsl-no-background-boot-participation.mjs test-results/wsl-no-background-boot-results.json - name: Upload terminal image proof uses: actions/upload-artifact@v7 if: always() diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index d1977d35202..388d8b9ddba 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -416,7 +416,7 @@ export const PR_E2E_SOURCE_ROUTES = [ ], matches: (file) => isProductSource(file) && - /^(?:config\/scripts\/(?:verify-wsl-e2e-participation|verify-playwright-participation)\.mjs$|src\/main\/(?:wsl[/-]|pty\/.*wsl|providers\/wsl)|src\/shared\/(?:wsl-|windows-terminal-shell|shell-argument-quoting)|src\/renderer\/src\/.*(?:terminal-paste|pty-paste)|tests\/e2e\/(?:golden-tab-bar-agent-launch\.spec|terminal-windows-shell-paste-ownership\.spec|terminal-inline-images-windows\.spec|helpers\/(?:wsl-golden-stub-agent|golden-stub-agent))|\.github\/(?:actions\/setup-wsl-test-runtime\/|workflows\/windows-wsl-e2e\.yml))/.test( + /^(?:config\/scripts\/(?:verify-wsl-e2e-participation|verify-wsl-no-background-boot-participation|verify-playwright-participation)\.mjs$|src\/main\/(?:wsl[/-]|pty\/.*wsl|providers\/wsl)|src\/shared\/(?:wsl-|windows-terminal-shell|shell-argument-quoting)|src\/renderer\/src\/.*(?:terminal-paste|pty-paste)|tests\/e2e\/(?:golden-tab-bar-agent-launch\.spec|terminal-windows-shell-paste-ownership\.spec|terminal-inline-images-windows\.spec|wsl-bridge-no-background-boot\.spec|helpers\/(?:wsl-golden-stub-agent|golden-stub-agent))|\.github\/(?:actions\/setup-wsl-test-runtime\/|workflows\/windows-wsl-e2e\.yml))/.test( file ) }, diff --git a/config/scripts/verify-wsl-no-background-boot-participation.mjs b/config/scripts/verify-wsl-no-background-boot-participation.mjs new file mode 100644 index 00000000000..87a61e8a791 --- /dev/null +++ b/config/scripts/verify-wsl-no-background-boot-participation.mjs @@ -0,0 +1,21 @@ +import { verifyPlaywrightParticipation } from './verify-playwright-participation.mjs' +import { readFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' + +// A skipped E9 guard would pass silently; the lane must prove it actually ran. +export const WSL_NO_BACKGROUND_BOOT_TITLES = [ + 'starts nothing in the stopped distro until the user opens a terminal there', + 'a focused WSL workspace left idle through the stop never starts it again' +] + +export function verifyWslNoBackgroundBootParticipation(report) { + verifyPlaywrightParticipation(report, { + titles: WSL_NO_BACKGROUND_BOOT_TITLES, + label: 'WSL E9 guard' + }) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + verifyWslNoBackgroundBootParticipation(JSON.parse(readFileSync(process.argv[2], 'utf8'))) + console.log('Both WSL E9 guards passed three times without skips or retries.') +} diff --git a/config/scripts/verify-wsl-no-background-boot-participation.test.mjs b/config/scripts/verify-wsl-no-background-boot-participation.test.mjs new file mode 100644 index 00000000000..300eb32b276 --- /dev/null +++ b/config/scripts/verify-wsl-no-background-boot-participation.test.mjs @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { + verifyWslNoBackgroundBootParticipation, + WSL_NO_BACKGROUND_BOOT_TITLES +} from './verify-wsl-no-background-boot-participation.mjs' + +function report(runs = 3) { + return { + stats: { + expected: runs * WSL_NO_BACKGROUND_BOOT_TITLES.length, + skipped: 0, + unexpected: 0, + flaky: 0 + }, + suites: [ + { + specs: WSL_NO_BACKGROUND_BOOT_TITLES.map((title) => ({ + title, + tests: Array.from({ length: runs }, () => ({ + expectedStatus: 'passed', + results: [{ status: 'passed' }] + })) + })) + } + ] + } +} + +describe('WSL E9 guard participation', () => { + it('accepts both guards executed three times', () => { + expect(() => verifyWslNoBackgroundBootParticipation(report())).not.toThrow() + }) + it('rejects a run where the guard skipped', () => { + const value = report() + value.stats.skipped = 3 + value.stats.expected = 0 + expect(() => verifyWslNoBackgroundBootParticipation(value)).toThrow('participation failed') + }) +}) diff --git a/config/scripts/wsl-e2e-lane-contract.test.mjs b/config/scripts/wsl-e2e-lane-contract.test.mjs index df9984661eb..b559b1c3a05 100644 --- a/config/scripts/wsl-e2e-lane-contract.test.mjs +++ b/config/scripts/wsl-e2e-lane-contract.test.mjs @@ -19,6 +19,8 @@ describe('real WSL terminal lane', () => { 'tests/e2e/golden-tab-bar-agent-launch.spec.ts', 'tests/e2e/terminal-windows-shell-paste-ownership.spec.ts', 'tests/e2e/terminal-inline-images-windows.spec.ts', + 'tests/e2e/wsl-bridge-no-background-boot.spec.ts', + 'config/scripts/verify-wsl-no-background-boot-participation.mjs', '.github/actions/setup-wsl-test-runtime/setup.ps1', '.github/workflows/windows-wsl-e2e.yml' ])('routes %s to both WSL sentinels', (path) => { @@ -68,5 +70,14 @@ describe('real WSL terminal lane', () => { expect(receipt.run).toBe( 'node config/scripts/verify-wsl-e2e-participation.mjs test-results/wsl-results.json' ) + // E9: the guard terminates only the distro this job imported, and must prove it ran. + const guard = steps.find( + (step) => step.name === 'Guard that background work never starts the stopped WSL distro' + ) + expect(guard.env.ORCA_E2E_WSL_TERMINATE_DISTRO).toBe('Ubuntu') + expect(guard.run).toContain('tests/e2e/wsl-bridge-no-background-boot.spec.ts') + expect(guard.run).toContain('--repeat-each=3') + const guardReceipt = steps.find((step) => step.name === 'Require all three E9 guard executions') + expect(guardReceipt.if).toBe('always()') }) }) diff --git a/src/main/index.ts b/src/main/index.ts index 94eb10e13ef..3af46c372ea 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -42,6 +42,10 @@ import { ignoreInheritedWslHostsGateState } from '../shared/wsl-host-internal-ga import { registerOrcaProtocolClient } from './startup/orca-protocol-registration' import { clearWindowsStdHandleInheritance } from './startup/windows-std-handle-inheritance' import { installWindowsIsolatedSpawner } from './startup/windows-isolated-spawner' +import { dropE2eSpawnLogOutsideE2e } from './startup/e2e-spawn-log-env' + +// Before anything spawns, so no daemon, PTY or agent inherits it outside e2e. +dropE2eSpawnLogOutsideE2e() function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { return openMainWindowController(options) diff --git a/src/main/startup/e2e-spawn-log-env.test.ts b/src/main/startup/e2e-spawn-log-env.test.ts new file mode 100644 index 00000000000..7b015d7cec2 --- /dev/null +++ b/src/main/startup/e2e-spawn-log-env.test.ts @@ -0,0 +1,44 @@ +import { mkdtempSync, existsSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { runProcess } from '@orca/process-host' +import { E2E_SPAWN_LOG_ENV } from '@orca/process-host/spawn-observer' +import { dropE2eSpawnLogOutsideE2e } from './e2e-spawn-log-env' + +const saved = { log: process.env[E2E_SPAWN_LOG_ENV], userData: process.env.ORCA_E2E_USER_DATA_DIR } + +afterEach(() => { + for (const [key, value] of [ + [E2E_SPAWN_LOG_ENV, saved.log], + ['ORCA_E2E_USER_DATA_DIR', saved.userData] + ] as const) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +}) + +it('drops the spawn log outside e2e mode, so a spawn writes nothing', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'orca-spawn-log-drop-')) + const log = path.join(dir, 'spawns.jsonl') + try { + delete process.env.ORCA_E2E_USER_DATA_DIR + process.env[E2E_SPAWN_LOG_ENV] = log + dropE2eSpawnLogOutsideE2e() + expect(process.env[E2E_SPAWN_LOG_ENV]).toBeUndefined() + await runProcess({ program: process.execPath, args: ['-e', 'process.exit(0)'] }) + expect(existsSync(log)).toBe(false) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +it('keeps it in e2e mode', () => { + process.env.ORCA_E2E_USER_DATA_DIR = '/tmp/orca-e2e-user-data' + process.env[E2E_SPAWN_LOG_ENV] = '/tmp/spawns.jsonl' + dropE2eSpawnLogOutsideE2e() + expect(process.env[E2E_SPAWN_LOG_ENV]).toBe('/tmp/spawns.jsonl') +}) diff --git a/src/main/startup/e2e-spawn-log-env.ts b/src/main/startup/e2e-spawn-log-env.ts new file mode 100644 index 00000000000..ff1f170edea --- /dev/null +++ b/src/main/startup/e2e-spawn-log-env.ts @@ -0,0 +1,12 @@ +import { E2E_SPAWN_LOG_ENV } from '@orca/process-host/spawn-observer' + +/** + * The e2e spawn log records full argv, which can include tokens; tests only. Outside e2e mode + * (no ORCA_E2E_USER_DATA_DIR) it is dropped before anything spawns, so no daemon, PTY or agent + * inherits it and a stray variable never writes a user's command lines to disk. + */ +export function dropE2eSpawnLogOutsideE2e(env: NodeJS.ProcessEnv = process.env): void { + if (!env.ORCA_E2E_USER_DATA_DIR) { + delete env[E2E_SPAWN_LOG_ENV] + } +} diff --git a/src/main/startup/wsl-boot-diagnostics-shipping-boundary.test.ts b/src/main/startup/wsl-boot-diagnostics-shipping-boundary.test.ts new file mode 100644 index 00000000000..c42acb403be --- /dev/null +++ b/src/main/startup/wsl-boot-diagnostics-shipping-boundary.test.ts @@ -0,0 +1,36 @@ +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +const SRC = path.resolve(__dirname, '../..') +// Markers of the e2e harness that patches main's fs and child_process; shipped code never carries them. +const HARNESS_MARKERS = [ + 'wsl-boot-diagnostics-log', + 'orcaE9SpawnLog', + 'recordMainProcessWslDiagnostics' +] + +function shippedSources(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + return entry.name === 'node_modules' ? [] : shippedSources(full) + } + return /\.(ts|tsx|mts|cts|js|mjs)$/.test(entry.name) && !/\.(test|spec)\./.test(entry.name) + ? [full] + : [] + }) +} + +describe('E9 WSL boot diagnostics stay in the e2e harness', () => { + it('no shipped source imports or installs the fs and spawn hooks', () => { + const sources = shippedSources(SRC) + // Non-vacuous: the scan really walked the app's sources. + expect(sources.some((file) => file.endsWith(path.join('main', 'index.ts')))).toBe(true) + const offenders = sources.filter((file) => { + const text = readFileSync(file, 'utf8') + return HARNESS_MARKERS.some((marker) => text.includes(marker)) + }) + expect(offenders).toEqual([]) + }) +}) diff --git a/src/packages/process-host/src/spawn-observer.test.ts b/src/packages/process-host/src/spawn-observer.test.ts index 1d6eab6dfee..358d6e7dc7a 100644 --- a/src/packages/process-host/src/spawn-observer.test.ts +++ b/src/packages/process-host/src/spawn-observer.test.ts @@ -1,11 +1,21 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' -import { setSpawnObserver } from './spawn-observer' +import { + E2E_SPAWN_LOG_ENV, + E2E_USER_DATA_DIR_ENV, + hasSpawnObserver, + notifySpawnObserver, + setSpawnObserver +} from './spawn-observer' import { runProcess, runProcessSync, spawnProcess } from './run-process' afterEach(() => { setSpawnObserver(null) + vi.restoreAllMocks() + delete process.env[E2E_SPAWN_LOG_ENV] + delete process.env[E2E_USER_DATA_DIR_ENV] }) describe('spawn observer', () => { @@ -65,4 +75,49 @@ describe('spawn observer', () => { await runProcess({ program: process.execPath, args: ['-e', 'process.exit(0)'] }) expect(calls).toBe(0) }) + + it('logs every spawn, sync or async, to the e2e spawn log when one is named', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'orca-spawn-log-')) + const log = path.join(dir, 'spawns.jsonl') + process.env[E2E_SPAWN_LOG_ENV] = log + process.env[E2E_USER_DATA_DIR_ENV] = dir + try { + await runProcess({ program: process.execPath, args: ['-e', 'process.exit(0)'] }) + runProcessSync({ program: process.execPath, args: ['-e', 'process.exit(0)'] }) + const lines = readFileSync(log, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + const spawned = { + file: process.execPath, + args: ['-e', 'process.exit(0)'], + at: expect.any(Number), + stack: expect.arrayContaining([expect.stringContaining('spawn-observer.test')]) + } + expect(lines).toEqual([spawned, spawned]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('outside e2e mode writes nothing and captures no stack, even with the log variable set', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'orca-spawn-log-')) + const log = path.join(dir, 'spawns.jsonl') + process.env[E2E_SPAWN_LOG_ENV] = log + const errors = vi.spyOn(globalThis, 'Error') + try { + notifySpawnObserver(process.execPath, ['-e', 'process.exit(0)'], 0) + expect(errors).not.toHaveBeenCalled() + expect(existsSync(log)).toBe(false) + expect(hasSpawnObserver()).toBe(false) + + // Control: the same call in e2e mode does capture the stack and write the line. + process.env[E2E_USER_DATA_DIR_ENV] = dir + notifySpawnObserver(process.execPath, ['-e', 'process.exit(0)'], 0) + expect(errors).toHaveBeenCalledTimes(1) + expect(existsSync(log)).toBe(true) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) }) diff --git a/src/packages/process-host/src/spawn-observer.ts b/src/packages/process-host/src/spawn-observer.ts index 05974385254..fbd1210e9ad 100644 --- a/src/packages/process-host/src/spawn-observer.ts +++ b/src/packages/process-host/src/spawn-observer.ts @@ -1,8 +1,24 @@ +import { appendFileSync } from 'node:fs' + // Main installs diagnostics here; daemon, relay, and CLI cannot depend on main code. export type SpawnObserver = (command: string, args: readonly string[], blockMs: number) => void let observer: SpawnObserver | null = null +/** + * E2E only: every process-host spawn, in any thread or child that inherits the environment, is + * appended here as a JSON line, so a guard can see spawns main's own observer never would. + * Logs full argv, which can include tokens; tests only (main drops it outside e2e mode). + */ +export const E2E_SPAWN_LOG_ENV = 'ORCA_E2E_SPAWN_LOG' + +/** Set only in e2e mode; a stray log variable alone never writes argv or captures stacks. */ +export const E2E_USER_DATA_DIR_ENV = 'ORCA_E2E_USER_DATA_DIR' + +function e2eSpawnLog(): string | undefined { + return (process.env[E2E_USER_DATA_DIR_ENV] && process.env[E2E_SPAWN_LOG_ENV]) || undefined +} + export function setSpawnObserver(next: SpawnObserver | null): void { observer = next } @@ -13,8 +29,27 @@ export function notifySpawnObserver( blockMs: number ): void { observer?.(command, args, blockMs) + const log = e2eSpawnLog() + if (log) { + try { + // The caller's stack lets a guard name what launched a spawn it flags. + const limit = Error.stackTraceLimit + Error.stackTraceLimit = 60 + const raw = new Error('spawn-log caller').stack ?? '' + Error.stackTraceLimit = limit + const stack = raw + .split('\n') + .slice(2) + .map((line) => line.trim()) + .filter((line) => !/\((node:|)|^at (new Promise|process\.)/.test(line)) + .slice(0, 30) + appendFileSync(log, `${JSON.stringify({ file: command, args, at: Date.now(), stack })}\n`) + } catch { + // A diagnostics write must never fail the spawn it describes. + } + } } export function hasSpawnObserver(): boolean { - return observer !== null + return observer !== null || e2eSpawnLog() !== undefined } diff --git a/tests/e2e/helpers/wsl-boot-diagnostics-log.ts b/tests/e2e/helpers/wsl-boot-diagnostics-log.ts new file mode 100644 index 00000000000..1b39e727078 --- /dev/null +++ b/tests/e2e/helpers/wsl-boot-diagnostics-log.ts @@ -0,0 +1,122 @@ +/** + * Diagnostics for the E9 WSL guard: a JSON-lines log of every spawn, plus main-process filesystem + * touches of a `\\wsl.localhost` path, each with its time and caller stack, so a boot the + * running-list watcher sees can be matched to the act that caused it. Test harness only: installed + * into an e2e-mode app through Playwright, never imported by shipped code. + */ +import { existsSync, readFileSync } from 'node:fs' +import type { ElectronApplication } from '@stablyai/playwright-test' + +/** `file` is the launched program, or `fs` for a filesystem touch whose `args` are [method, path]. */ +export type DiagnosticRecord = { file: string; args: string[]; at?: number; stack?: string[] } + +export const UNC_TOUCH = 'fs' + +/** + * Logs main's direct child_process spawns (spawn, exec and execFile all pass through + * ChildProcess#spawn) and its fs touches of WSL UNC paths, beside the process-host log. node-pty + * terminals spawn elsewhere and need not be logged. + */ +export async function recordMainProcessWslDiagnostics( + app: ElectronApplication, + logFile: string +): Promise { + await app.evaluate((_electron, logFile) => { + // Never patch fs or capture stacks in an app that is not in e2e mode. + if (!process.env.ORCA_E2E_USER_DATA_DIR || !process.env.ORCA_E2E_SPAWN_LOG) { + throw new Error('WSL boot diagnostics need an e2e-mode app with the spawn log named') + } + const fs = process.mainModule?.require('node:fs') + const proto = process.mainModule?.require('node:child_process')?.ChildProcess?.prototype + if (!fs || !proto) { + throw new Error('Cannot hook child_process in the main process') + } + if (proto.spawn.orcaE9SpawnLog) { + return + } + const append = (file: string, args: unknown[]): void => { + // Deep enough to reach the IPC handler past async hops; node internals add nothing. + const limit = Error.stackTraceLimit + Error.stackTraceLimit = 60 + const raw = new Error('e9 diagnostics caller').stack ?? '' + Error.stackTraceLimit = limit + const stack = raw + .split('\n') + .slice(3) + .map((line) => line.trim()) + .filter((line) => !/\((node:|)|^at (new Promise|process\.)/.test(line)) + .slice(0, 30) + fs.appendFileSync( + logFile, + `${JSON.stringify({ file, args: args.map(String), at: Date.now(), stack })}\n` + ) + } + const original = proto.spawn + const logged = function (this: unknown, options: { file?: string; args?: string[] }) { + append(String(options?.file ?? ''), options?.args ?? []) + return original.call(this, options) + } + logged.orcaE9SpawnLog = true + proto.spawn = logged + + const isWslUnc = (value: unknown): boolean => + /^([\\/]{2})(wsl\.localhost|wsl\$)[\\/]/i.test( + typeof value === 'string' ? value : value instanceof URL ? value.pathname : '' + ) + const wrap = (target: Record, prefix: string, names: string[]): void => { + for (const name of names) { + const fn = target[name] + if (typeof fn !== 'function') { + continue + } + const wrapped = function (this: unknown, ...args: unknown[]) { + if (isWslUnc(args[0])) { + append('fs', [`${prefix}${name}`, args[0]]) + } + return fn.apply(this, args) + } + // Keep attached members such as `realpathSync.native`. + target[name] = Object.assign(wrapped, fn) + } + } + const methods = [ + 'stat', + 'lstat', + 'access', + 'readFile', + 'readdir', + 'realpath', + 'open', + 'opendir' + ] + wrap(fs, 'fs.', [...methods, ...methods.map((name) => `${name}Sync`), 'existsSync', 'watch']) + wrap(fs.promises, 'fs.promises.', [...methods, 'writeFile', 'mkdir']) + }, logFile) +} + +export function readDiagnosticLog(logFile: string): DiagnosticRecord[] { + if (!existsSync(logFile)) { + return [] + } + return readFileSync(logFile, 'utf8') + .split('\n') + .filter(Boolean) + .flatMap((line): DiagnosticRecord[] => { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + // A line torn by a concurrent append; the next read sees it whole. + return [] + } + if (!parsed || typeof parsed !== 'object') { + return [] + } + const file = 'file' in parsed ? String(parsed.file) : '' + const args = 'args' in parsed && Array.isArray(parsed.args) ? parsed.args.map(String) : [] + const at = 'at' in parsed && typeof parsed.at === 'number' ? parsed.at : undefined + const stack = + 'stack' in parsed && Array.isArray(parsed.stack) ? parsed.stack.map(String) : undefined + return [{ file, args, ...(at === undefined ? {} : { at }), ...(stack ? { stack } : {}) }] + }) +} diff --git a/tests/e2e/helpers/wsl-boot-guard.ts b/tests/e2e/helpers/wsl-boot-guard.ts new file mode 100644 index 00000000000..a52dea65703 --- /dev/null +++ b/tests/e2e/helpers/wsl-boot-guard.ts @@ -0,0 +1,238 @@ +/** + * Shared by the E9 real-WSL guards: wsl.exe reads that can never start a distro, a running-list + * watcher with a timestamped timeline, and the spawn-log check that names what started it. + */ +import { execFile } from 'node:child_process' +import type { Page } from '@stablyai/playwright-test' +import { expect } from './orca-app' +import { buildWslExecArgs } from '../../../src/shared/wsl-login-shell-command' +import { readDiagnosticLog, UNC_TOUCH, type DiagnosticRecord } from './wsl-boot-diagnostics-log' + +const WSL_EXE = /(^|[\\/])wsl(\.exe)?$/i + +/** Async with a timeout: a hung wsl.exe must fail the test, never block the worker past its timeout. */ +export function wsl(args: readonly string[], timeoutMs = 60_000): Promise { + return new Promise((resolve, reject) => { + execFile( + 'wsl.exe', + [...args], + // UTF-8 instead of wsl.exe's default UTF-16 for its own listings. + { + encoding: 'utf8', + env: { ...process.env, WSL_UTF8: '1' }, + timeout: timeoutMs, + windowsHide: true + }, + (error, stdout, stderr) => + error + ? reject(new Error(`wsl ${args.join(' ')} failed: ${String(error)} ${stderr}`)) + : resolve(stdout) + ) + }) +} + +/** Timestamped steps in the test output, so a lane failure shows when each read and act happened. */ +const startedAt = Date.now() +export function timeline(event: string, at = Date.now()): void { + console.log(`[e9-guard +${Math.round(at - startedAt)}ms] ${event}`) +} + +/** + * `--list --running` never starts a distro. Exit 1 is "no running distributions"; any other + * failure (timeout, spawn error, other exit) rejects, so it can never read as "stopped". + */ +export function isDistroRunning(distro: string): Promise { + return new Promise((resolve, reject) => { + execFile( + 'wsl.exe', + ['--list', '--running', '--quiet'], + { + encoding: 'utf8', + env: { ...process.env, WSL_UTF8: '1' }, + timeout: 5_000, + windowsHide: true + }, + (error, stdout) => { + if (error) { + if (error.code === 1 && !error.killed) { + resolve(false) + } else { + reject(error) + } + return + } + resolve( + stdout + .split(/\r?\n/) + .map((line) => line.replaceAll('\0', '').trim().toLowerCase()) + .includes(distro.toLowerCase()) + ) + } + ) + }) +} + +/** How long a just-terminated instance may still be listed: two reads, each up to 5 s on a loaded runner. */ +const TERMINATE_GRACE_MS = 10_000 + +/** + * Polls the running list once a second, never overlapping, from right after `--terminate`. + * `stopped` resolves on the first not-running read, which must come within the grace; only + * running reads before it are forgiven, so a real boot is never waited out. Any running read + * after it is a boot. Three failed reads in a row fail the guard. + */ +export function watchDistroRunning( + distro: string, + spawnLog: string +): { + stopped: Promise + stop: () => Promise<{ seenRunning: boolean; reads: number }> +} { + let finished = false + let confirmedStopped = false + let seenRunning = false + let reads = 0 + let failures = 0 + let failure: unknown = null + let timer: ReturnType | undefined + let current: Promise = Promise.resolve() + let resolveStopped: () => void = () => {} + let rejectStopped: (error: Error) => void = () => {} + const stopped = new Promise((resolve, reject) => { + resolveStopped = resolve + rejectStopped = reject + }) + const grace = setTimeout( + () => + rejectStopped( + new Error(`${distro} was still listed ${TERMINATE_GRACE_MS} ms after --terminate`) + ), + TERMINATE_GRACE_MS + ) + // Spawns are interleaved into the timeline so a boot can be matched to the launch before it. + let spawnsSeen = readDiagnosticLog(spawnLog).length + const logNewWslSpawns = (): void => { + const spawns = readDiagnosticLog(spawnLog) + for (const spawn of spawns.slice(spawnsSeen)) { + if (WSL_EXE.test(spawn.file) || spawn.file === UNC_TOUCH) { + // `at` is when it happened; the line prints at this poll, up to a second later. + timeline( + `${spawn.file === UNC_TOUCH ? 'unc' : 'spawn'} ${spawn.file} ${spawn.args.join(' ')}`, + spawn.at + ) + if (startsDistro(spawn, distro) || spawn.file === UNC_TOUCH) { + for (const frame of spawn.stack ?? []) { + timeline(` ${frame}`, spawn.at) + } + } + } + } + spawnsSeen = spawns.length + } + const tick = (): void => { + logNewWslSpawns() + current = isDistroRunning(distro) + .then((running) => { + reads += 1 + failures = 0 + timeline(`running list: ${distro} ${running ? 'RUNNING' : 'stopped'}`) + if (confirmedStopped) { + seenRunning ||= running + } else if (!running) { + confirmedStopped = true + clearTimeout(grace) + resolveStopped() + } + }) + .catch((error: unknown) => { + failures += 1 + timeline(`running-list read failed: ${String(error)}`) + if (failures >= 3) { + failure = error + rejectStopped(new Error(`wsl --list --running kept failing: ${String(error)}`)) + } + }) + .finally(() => { + if (!finished && failure === null) { + timer = setTimeout(tick, 1_000) + } + }) + } + tick() + return { + stopped, + stop: async () => { + finished = true + clearTimeout(timer) + clearTimeout(grace) + await current + logNewWslSpawns() + if (failure !== null) { + throw new Error(`wsl --list --running kept failing: ${String(failure)}`) + } + return { seenRunning, reads } + } + } +} + +export function runInDistro(distro: string, script: string): Promise { + return wsl(['-u', 'root', ...buildWslExecArgs(distro, ['sh', '-c', script])]) +} + +/** wsl.exe verbs that never start a distro; any other launch counts as starting the default one. */ +const NON_BOOTING_WSL_VERBS = new Set([ + '-l', + '--list', + '--status', + '--version', + '-v', + '-t', + '--terminate', + '--shutdown', + '--help' +]) + +/** Fails closed: a wsl.exe launch starts `distro` unless it names another one or only lists. */ +export function startsDistro(spawn: DiagnosticRecord, distro: string): boolean { + if (!WSL_EXE.test(spawn.file)) { + return false + } + // main's ChildProcess#spawn args carry argv0; process-host's do not. + const raw = WSL_EXE.test(spawn.args[0] ?? '') ? spawn.args.slice(1) : spawn.args + const args = raw.map((arg) => arg.toLowerCase()) + const named = args.findIndex((arg) => arg === '-d' || arg === '--distribution') + if (named !== -1) { + return args[named + 1] === distro.toLowerCase() + } + return !NON_BOOTING_WSL_VERBS.has(args[0] ?? '') +} + +/** Waits until no wsl.exe launch has been logged for 3 s, so a user act's tail is over. */ +export async function waitForWslLaunchesToSettle(spawnLog: string): Promise { + let last = { count: -1, since: 0 } + await expect + .poll( + () => { + const count = readDiagnosticLog(spawnLog).filter((spawn) => WSL_EXE.test(spawn.file)).length + const now = performance.now() + if (count !== last.count) { + last = { count, since: now } + } + return now - last.since >= 3_000 + }, + { timeout: 60_000, intervals: [500] } + ) + .toBe(true) +} + +export async function cycleWindowFocus(page: Page): Promise { + await page.evaluate(() => { + window.dispatchEvent(new Event('blur')) + document.dispatchEvent(new Event('visibilitychange')) + window.dispatchEvent(new Event('focus')) + }) +} + +export async function activate(page: Page, worktreeId: string | null): Promise { + await page.evaluate((id) => window.__store?.getState().setActiveWorktree(id), worktreeId) +} diff --git a/tests/e2e/wsl-bridge-no-background-boot.spec.ts b/tests/e2e/wsl-bridge-no-background-boot.spec.ts new file mode 100644 index 00000000000..c34588751d3 --- /dev/null +++ b/tests/e2e/wsl-bridge-no-background-boot.spec.ts @@ -0,0 +1,242 @@ +/** + * E9 guard on a real WSL distro: background work never starts a stopped distro. A project inside + * the lane's distro is opened, the distro is terminated, and a window of ordinary background work + * elsewhere must start nothing in it; the user then opening a terminal there starts it. Runs only + * where ORCA_E2E_WSL_TERMINATE_DISTRO names a distro this run owns, since it terminates it. + * + * The running-list watcher is the authoritative assertion: `wsl --list --running`, polled through + * the window, sees every boot, including a plain UNC read or git.exe with a UNC cwd. The spawn log + * is diagnostics that name the caller: process-host spawns from any thread or inheriting child, + * plus main's direct child_process spawns. It cannot see direct spawnSync calls outside + * process-host (wsl-availability's missing-kernel `--exec /bin/true` is a documented exception) + * or spawns in children that drop the environment. + */ +import { rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePanePtyId } from './helpers/terminal' +import { visitSettingsPanes } from './helpers/settings-pane-visits' +import { quotePosixShell } from '../../src/shared/shell-argument-quoting' +import { + readDiagnosticLog, + recordMainProcessWslDiagnostics +} from './helpers/wsl-boot-diagnostics-log' +import { + activate, + cycleWindowFocus, + isDistroRunning, + runInDistro, + startsDistro, + timeline, + waitForWslLaunchesToSettle, + watchDistroRunning, + wsl +} from './helpers/wsl-boot-guard' + +const DISTRO = process.env.ORCA_E2E_WSL_TERMINATE_DISTRO?.trim() ?? '' + +/** Settings panes whose mount reads host, account, CLI or integration state. */ +const SETTINGS_PANES = [ + 'general', + 'integrations', + 'accounts', + 'agents', + 'git', + 'tasks', + 'session-history', + 'automations' +] as const + +const SPAWN_LOG = path.join(os.tmpdir(), `orca-e2e-wsl-spawns-${process.pid}-${Date.now()}.jsonl`) +// Every process-host spawn in the app (main, worker threads, inheriting children) lands here. +test.use({ launchEnv: { ORCA_E2E_SPAWN_LOG: SPAWN_LOG } }) + +type WslWorkspace = { linuxRoot: string; worktreeId: string; repoId: string } + +/** A git project inside the lane's distro, opened and focused while it runs, its terminal settled. */ +async function openWslWorkspace( + electronApp: ElectronApplication, + page: Page +): Promise { + const linuxRoot = `/tmp/orca-e9-${Date.now()}` + const repoLinuxPath = `${linuxRoot}/repo` + await runInDistro( + DISTRO, + `mkdir -p ${quotePosixShell(`${repoLinuxPath}/src`)} && cd ${quotePosixShell(repoLinuxPath)} && ` + + 'git init -q -b main . && printf "# e9\\n" > README.md && printf "e9\\n" > src/main.txt && ' + + 'git add . && git -c user.name=e2e -c user.email=e2e@example.com commit -qm init' + ) + const uncPath = `\\\\wsl.localhost\\${DISTRO}${repoLinuxPath.replaceAll('/', '\\')}` + // Its sidebar card carries a linked issue and PR, whose refreshes must not start it either. + const opened = await page.evaluate(async (path) => { + const store = window.__store! + const added = await window.api.repos.add({ path, kind: 'git' }) + if ('error' in added) { + throw new Error(added.error) + } + await store.getState().fetchWorktrees(added.repo.id) + const worktree = store + .getState() + .worktreesByRepo[added.repo.id]?.find((row) => row.isMainWorktree) + if (!worktree) { + throw new Error('WSL checkout missing') + } + await store.getState().updateWorktreeMeta(worktree.id, { linkedIssue: 1, linkedPR: 2 }) + return { worktreeId: worktree.id, repoId: added.repo.id } + }, uncPath) + // Focusing it opens a WSL terminal. Let that launch (its PTY, hook relay and agent probes) + // finish before the terminate, or it boots the distro after it. + await recordMainProcessWslDiagnostics(electronApp, SPAWN_LOG) + await activate(page, opened.worktreeId) + await ensureTerminalVisible(page, 60_000) + await waitForActivePanePtyId(page, 60_000) + await waitForWslLaunchesToSettle(SPAWN_LOG) + timeline('WSL workspace terminal launched and settled') + return { linuxRoot, ...opened } +} + +/** + * Terminates the distro, runs `window` once a read has seen it stopped, and fails if anything + * started it meanwhile. Then the user opens a terminal there, which must start it. + */ +async function expectNothingStartsTheStoppedDistro( + page: Page, + testInfo: TestInfo, + workspace: WslWorkspace, + window: () => Promise +): Promise { + // Counted from before the terminate, so work started while it stops counts too. + const spawnsBefore = readDiagnosticLog(SPAWN_LOG).length + timeline('terminate') + await wsl(['--terminate', DISTRO], 30_000) + timeline('terminated') + // WSL1 instances can live only seconds, so watch the running list from the terminate on. + const watcher = watchDistroRunning(DISTRO, SPAWN_LOG) + let watched: { seenRunning: boolean; reads: number } | undefined + try { + // The window starts only once a read has seen it stopped, within the grace. + await watcher.stopped + timeline('confirmed stopped') + await window() + await page.screenshot({ path: testInfo.outputPath('wsl-bridge-background-window.png') }) + // ast-grep-ignore: test-no-real-time-wait -- the claim is that nothing boots over a window of real background work + await page.waitForTimeout(15_000) + } finally { + watched = await watcher.stop() + } + // Authoritative: the distro never ran during the window. + expect(watched.reads).toBeGreaterThan(10) + expect(watched.seenRunning).toBe(false) + expect(await isDistroRunning(DISTRO)).toBe(false) + // Diagnostics that name the caller; non-empty so the check cannot pass vacuously. + const background = readDiagnosticLog(SPAWN_LOG).slice(spawnsBefore) + expect(background.length).toBeGreaterThan(0) + expect(background.filter((spawn) => startsDistro(spawn, DISTRO))).toEqual([]) + + timeline(`window done: ${background.length} spawns`) + // The user opens the WSL workspace and a terminal in it: that act starts the distro. + await activate(page, workspace.worktreeId) + await page.getByRole('button', { name: 'New tab', exact: true }).first().click() + await page + .getByRole('menuitem', { name: /New Terminal/ }) + .first() + .click() + await expect(page.locator('.xterm:visible').first()).toBeVisible({ timeout: 60_000 }) + await expect.poll(() => isDistroRunning(DISTRO), { timeout: 60_000 }).toBe(true) + await page.screenshot({ path: testInfo.outputPath('wsl-bridge-after-terminal.png') }) +} + +async function removeWslWorkspace(workspace: WslWorkspace | undefined): Promise { + if (workspace) { + await runInDistro(DISTRO, `rm -rf ${quotePosixShell(workspace.linuxRoot)}`).catch( + (error: unknown) => timeline(`cleanup failed: ${String(error)}`) + ) + } + rmSync(SPAWN_LOG, { force: true }) +} + +test.describe('E9: background work on a stopped real WSL distro', () => { + test.skip( + process.platform !== 'win32' || !DISTRO, + 'Needs a Windows run that owns a WSL distro it may terminate (ORCA_E2E_WSL_TERMINATE_DISTRO)' + ) + + test('starts nothing in the stopped distro until the user opens a terminal there', async ({ + electronApp, + orcaPage: page + }, testInfo) => { + // Distro setup, a terminate, a 20 s background window and a boot. + test.setTimeout(300_000) + await waitForSessionReady(page) + const localWorktreeId = await waitForActiveWorktree(page) + let workspace: WslWorkspace | undefined + try { + workspace = await openWslWorkspace(electronApp, page) + const { repoId } = workspace + await activate(page, localWorktreeId) + await expectNothingStartsTheStoppedDistro(page, testInfo, workspace, async () => { + for (let round = 0; round < 2; round += 1) { + timeline(`round ${round}: focus and workspace switches`) + await cycleWindowFocus(page) + await activate(page, null) + await cycleWindowFocus(page) + await activate(page, localWorktreeId) + timeline(`round ${round}: settings`) + await visitSettingsPanes(page, SETTINGS_PANES, repoId) + timeline(`round ${round}: tasks and account switches`) + await page.evaluate(() => window.__store?.getState().setActiveView('tasks')) + await page.evaluate(async () => { + // Account switches re-sync every account home, including WSL ones. + await window.api.codexAccounts.select({ accountId: null }).catch(() => {}) + await window.api.claudeAccounts.select({ accountId: null }).catch(() => {}) + }) + await page.evaluate(() => window.__store?.getState().setActiveView('terminal')) + } + }) + } finally { + await removeWslWorkspace(workspace) + } + }) + + test('a focused WSL workspace left idle through the stop never starts it again', async ({ + electronApp, + orcaPage: page + }, testInfo) => { + // Distro setup, an Explorer and Source Control load, a terminate, a 22.5 s idle and a boot. + test.setTimeout(300_000) + await waitForSessionReady(page) + let workspace: WslWorkspace | undefined + try { + workspace = await openWslWorkspace(electronApp, page) + // The user's view: the Explorer with a folder expanded, then Source Control. + await page.evaluate(() => { + const state = window.__store?.getState() + state?.setRightSidebarOpen(true) + state?.setRightSidebarTab('explorer') + }) + const row = (name: string) => + page + .locator('[data-file-explorer-row]') + .filter({ has: page.getByText(name, { exact: true }) }) + await row('src').click() + await expect(row('main.txt')).toBeVisible({ timeout: 30_000 }) + await page.evaluate(() => window.__store?.getState().setRightSidebarTab('source-control')) + await waitForWslLaunchesToSettle(SPAWN_LOG) + timeline('focused: explorer expanded, source control shown') + // Nothing more from the user: the stop's own fallout (bridge exit, watcher stop, host loss and + // the re-renders they cause) is all that runs, half with Explorer and half with Source Control. + await expectNothingStartsTheStoppedDistro(page, testInfo, workspace, async () => { + await page.evaluate(() => window.__store?.getState().setRightSidebarTab('explorer')) + // ast-grep-ignore: test-no-real-time-wait -- the Explorer must sit visible while the stop lands + await page.waitForTimeout(7_500) + timeline('idle: source control shown') + await page.evaluate(() => window.__store?.getState().setRightSidebarTab('source-control')) + }) + } finally { + await removeWslWorkspace(workspace) + } + }) +})