diff --git a/config/scripts/wsl-hook-relay-reattach-benchmark.mjs b/config/scripts/wsl-hook-relay-reattach-benchmark.mjs new file mode 100644 index 00000000000..07ca9b3d633 --- /dev/null +++ b/config/scripts/wsl-hook-relay-reattach-benchmark.mjs @@ -0,0 +1,397 @@ +import { spawn } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import process from 'node:process' + +import { createJiti } from 'jiti' + +const DEFAULT_SAMPLES = 20 +const PANE_KEY = 'wsl-relay-bench:11111111-1111-4111-8111-111111111111' +const WSL_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' + +function parseArgs(argv) { + const options = { distro: null, samples: DEFAULT_SAMPLES } + for (let index = 0; index < argv.length; index++) { + const arg = argv[index] + if (arg === '--distro') { + options.distro = argv[++index] ?? null + } else if (arg === '--samples') { + options.samples = Number(argv[++index]) + } else { + throw new Error(`Unknown argument: ${arg}`) + } + } + if (!Number.isInteger(options.samples) || options.samples < 5 || options.samples > 100) { + throw new Error('--samples must be an integer between 5 and 100') + } + return options +} + +function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) + const stdout = [] + const stderr = [] + child.stdout.on('data', (chunk) => stdout.push(chunk)) + child.stderr.on('data', (chunk) => stderr.push(chunk)) + child.on('error', reject) + child.on('close', (status) => { + const result = { + status, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8') + } + if (status === 0 || options.allowFailure) { + resolve(result) + } else { + reject(new Error(`${command} exited ${status}: ${result.stderr.trim()}`)) + } + }) + child.stdin.end(options.input) + }) +} + +function wslArgs(distro, args) { + return ['-d', distro, '--exec', ...args] +} + +async function resolveDistro(requested) { + if (requested) { + return requested + } + const listed = await run('wsl.exe', ['--list', '--quiet'], { + env: { ...process.env, WSL_UTF8: '1' } + }) + const distro = listed.stdout + .replaceAll('\0', '') + .split(/\r?\n/) + .map((value) => value.trim()) + .find(Boolean) + if (!distro) { + throw new Error('No WSL distro is installed') + } + return distro +} + +async function readGuestFile(distro, path) { + const result = await run('wsl.exe', wslArgs(distro, ['/bin/cat', path]), { + allowFailure: true + }) + return result.status === 0 ? result.stdout : null +} + +async function waitFor(description, probe, timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const value = await probe() + if (value) { + return value + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`Timed out waiting for ${description}`) +} + +function parseEndpoint(contents) { + const port = Number(/ORCA_AGENT_HOOK_PORT=['"]?(\d+)/.exec(contents)?.[1]) + const token = /ORCA_AGENT_HOOK_TOKEN=['"]?([^'"\r\n]+)/.exec(contents)?.[1] + return Number.isInteger(port) && port > 0 && token ? { port, token } : null +} + +async function freeGuestPort(distro) { + const result = await run( + 'wsl.exe', + wslArgs(distro, [ + '/usr/bin/python3', + '-c', + 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()' + ]) + ) + const port = Number(result.stdout.trim()) + if (!Number.isInteger(port) || port <= 0) { + throw new Error(`Could not reserve a guest port: ${result.stdout}`) + } + return port +} + +async function startStallingGuestServer(distro) { + const source = [ + 'import socket,sys,threading', + 'stop=threading.Event()', + 'threading.Thread(target=lambda:(sys.stdin.buffer.read(),stop.set()),daemon=True).start()', + 'server=socket.socket()', + 'server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)', + 'server.bind(("127.0.0.1",0))', + 'server.listen()', + 'server.settimeout(0.1)', + 'print(f"READY {server.getsockname()[1]}",flush=True)', + 'held=[]', + 'while not stop.is_set():', + ' try:', + ' client,_=server.accept(); held.append(client); print("ACCEPT",flush=True)', + ' except TimeoutError: pass', + 'for client in held: client.close()', + 'server.close()' + ].join('\n') + const child = spawn('wsl.exe', wslArgs(distro, ['/usr/bin/python3', '-u', '-c', source]), { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true + }) + let output = '' + let accepted = 0 + child.stdout.on('data', (chunk) => { + output += chunk.toString('utf8') + accepted += chunk.toString('utf8').split('ACCEPT').length - 1 + }) + const port = await waitFor('stalling WSL listener', async () => { + const match = /READY (\d+)/.exec(output) + return match ? Number(match[1]) : null + }) + return { + port, + accepted: () => accepted, + stop: async () => { + child.stdin.end() + await new Promise((resolve) => child.once('close', resolve)) + } + } +} + +async function writeEndpoint(distro, path, endpoint) { + const contents = [ + `ORCA_AGENT_HOOK_PORT=${endpoint.port}`, + `ORCA_AGENT_HOOK_TOKEN=${endpoint.token}`, + 'ORCA_AGENT_HOOK_ENV=benchmark', + 'ORCA_AGENT_HOOK_VERSION=1', + '' + ].join('\n') + await run( + 'wsl.exe', + wslArgs(distro, [ + '/bin/sh', + '-c', + 'umask 077; mkdir -p "$(dirname "$1")"; cat > "$1"', + 'orca-wsl-benchmark', + path + ]), + { input: contents } + ) +} + +async function invokeHook(distro, scriptPath, endpointPath) { + const startedAt = performance.now() + const result = await run( + 'wsl.exe', + wslArgs(distro, [ + '/usr/bin/env', + `PATH=${WSL_PATH}`, + `ORCA_AGENT_HOOK_ENDPOINT=${endpointPath}`, + `ORCA_PANE_KEY=${PANE_KEY}`, + 'ORCA_TAB_ID=wsl-relay-bench', + 'ORCA_WORKTREE_ID=wsl-relay-bench', + '/bin/sh', + scriptPath + ]), + { input: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'benchmark' }) } + ) + return { status: result.status, elapsedMs: performance.now() - startedAt } +} + +function percentile(samples, percentileValue) { + const sorted = [...samples].sort((left, right) => left - right) + return sorted[Math.ceil((percentileValue / 100) * sorted.length) - 1] +} + +function summarize(samples) { + return { + samples: samples.length, + medianMs: Number(percentile(samples, 50).toFixed(1)), + p95Ms: Number(percentile(samples, 95).toFixed(1)), + minMs: Number(Math.min(...samples).toFixed(1)), + maxMs: Number(Math.max(...samples).toFixed(1)) + } +} + +async function main() { + if (process.platform !== 'win32') { + throw new Error('This benchmark requires a Windows host with WSL') + } + const options = parseArgs(process.argv.slice(2)) + const distro = await resolveDistro(options.distro) + const nodeVersion = await run( + 'wsl.exe', + wslArgs(distro, [ + '/bin/sh', + '-c', + 'for node_bin in "$(command -v node 2>/dev/null || true)" "$HOME/.local/bin/node"; do [ -x "$node_bin" ] || continue; "$node_bin" -p process.versions.node && exit 0; done; exit 1' + ]), + { allowFailure: true } + ) + if (nodeVersion.status !== 0 || Number(nodeVersion.stdout.trim().split('.')[0]) < 18) { + throw new Error(`WSL distro '${distro}' needs Node.js 18 or newer to run Orca's relay`) + } + + const bundleDir = join(process.cwd(), 'out', 'relay', 'wsl') + const bundlePath = join(bundleDir, 'wsl-agent-hook-relay.js') + const versionPath = join(bundleDir, '.version') + if (!existsSync(bundlePath) || !existsSync(versionPath)) { + await run(process.execPath, [join('config', 'scripts', 'build-relay.mjs')], { + cwd: process.cwd() + }) + } + + const jiti = createJiti(import.meta.url) + const [{ WslHookRelayManager }, { ensureWslHookRelayForReattach }, { codexHookService }] = + await Promise.all([ + jiti.import('../../src/main/agent-hooks/wsl-hook-relay-manager.ts'), + jiti.import('../../src/main/agent-hooks/wsl-hook-relay-reattach.ts'), + jiti.import('../../src/main/codex/hook-service.ts') + ]) + const { MANAGED_AGENT_HOOK_TARGETS } = await jiti.import( + '../../src/shared/managed-agent-hook-targets.ts' + ) + const guestHome = ( + await run('wsl.exe', wslArgs(distro, ['/bin/sh', '-c', 'printf %s "$HOME"'])) + ).stdout.trim() + const instanceKey = `bench-${process.pid}-${Date.now().toString(36)}` + const benchmarkRoot = `${guestHome}/.orca-wsl/benchmarks/${instanceKey}` + const scriptPath = `${benchmarkRoot}/.orca/agent-hooks/codex-hook.sh` + const endpointPath = `${guestHome}/.orca-wsl/agent-hooks/instance-${instanceKey}/endpoint.env` + const cleanupPaths = [benchmarkRoot, `${guestHome}/.orca-wsl/agent-hooks/instance-${instanceKey}`] + if (cleanupPaths.some((cleanupPath) => !cleanupPath.startsWith(`${guestHome}/.orca-wsl/`))) { + throw new Error('Refusing to use an unexpected guest cleanup path') + } + const disabledTuiAgents = MANAGED_AGENT_HOOK_TARGETS.filter( + (target) => target.tuiAgent !== 'codex' + ).map((target) => target.tuiAgent) + const bundleVersion = readFileSync(versionPath, 'utf8').trim() + const warnings = [] + let delivered = 0 + let manager = null + let staller = null + + const createManager = async (token) => { + const preferredPort = await freeGuestPort(distro) + return new WslHookRelayManager({ + platform: () => 'win32', + remoteHooksEnabled: () => true, + hookCoordsEnv: () => ({ + ORCA_AGENT_HOOK_PORT: String(preferredPort), + ORCA_AGENT_HOOK_TOKEN: token, + ORCA_AGENT_HOOK_ENV: 'benchmark', + ORCA_AGENT_HOOK_VERSION: '1' + }), + instanceKey: () => instanceKey, + resolveBundle: () => ({ jsPath: bundlePath, version: bundleVersion }), + listDistros: async () => [distro], + ingest: () => { + delivered++ + }, + installHooks: async (sftp) => [ + await codexHookService.installRemote(sftp, benchmarkRoot, { + codexHomeDir: `${benchmarkRoot}/codex-home`, + deferTrustUntilConfigToml: true + }) + ], + managedHookSettings: () => ({ + agentCmdOverrides: { codex: '/bin/true' }, + disabledTuiAgents + }), + pluginSources: () => ({}), + warn: (message) => warnings.push(message), + transientRetryDelayMs: 100 + }) + } + + try { + manager = await createManager('before-restart-token') + manager.ensureForDistro(distro) + await waitFor('initial relay and generated Codex hook', async () => { + const [endpoint, script] = await Promise.all([ + readGuestFile(distro, endpointPath), + readGuestFile(distro, scriptPath) + ]) + return endpoint?.includes('before-restart-token') && script?.includes('--max-time') + }) + manager.disposeAll() + manager = null + + staller = await startStallingGuestServer(distro) + await writeEndpoint(distro, endpointPath, { + port: staller.port, + token: 'stale-token' + }) + + await invokeHook(distro, scriptPath, endpointPath) + const staleSamples = [] + for (let index = 0; index < options.samples; index++) { + staleSamples.push((await invokeHook(distro, scriptPath, endpointPath)).elapsedMs) + } + + delivered = 0 + manager = await createManager('after-restart-token') + ensureWslHookRelayForReattach({ isReattach: true, wslDistro: distro }, null, (ownedDistro) => + manager.ensureForDistro(ownedDistro) + ) + const refreshedEndpoint = await waitFor('reattached relay endpoint rewrite', async () => { + const contents = await readGuestFile(distro, endpointPath) + const parsed = contents ? parseEndpoint(contents) : null + return parsed && parsed.token === 'after-restart-token' ? parsed : null + }) + + await invokeHook(distro, scriptPath, endpointPath) + await waitFor('refreshed hook warmup delivery', async () => (delivered >= 1 ? true : null)) + delivered = 0 + const refreshedSamples = [] + for (let index = 0; index < options.samples; index++) { + refreshedSamples.push((await invokeHook(distro, scriptPath, endpointPath)).elapsedMs) + } + await waitFor('all refreshed hooks to reach the host', async () => + delivered >= options.samples ? true : null + ) + + const stale = summarize(staleSamples) + const refreshed = summarize(refreshedSamples) + const result = { + distro, + endpointPath, + endpointPortAfterReattach: refreshedEndpoint.port, + stale, + refreshed, + medianSpeedup: Number((stale.medianMs / refreshed.medianMs).toFixed(1)), + stalledConnections: staller.accepted(), + delivered, + warnings + } + console.log(JSON.stringify(result, null, 2)) + + if (stale.medianMs < 1_000) { + throw new Error(`Stale endpoint did not exercise the timeout path: ${stale.medianMs}ms`) + } + if (refreshed.p95Ms >= 1_000) { + throw new Error(`Refreshed endpoint p95 regressed: ${refreshed.p95Ms}ms`) + } + if (delivered !== options.samples) { + throw new Error(`Expected ${options.samples} delivered hooks, received ${delivered}`) + } + } finally { + manager?.disposeAll() + await staller?.stop() + for (const cleanupPath of cleanupPaths) { + await run('wsl.exe', wslArgs(distro, ['/bin/rm', '-rf', '--', cleanupPath]), { + allowFailure: true + }) + } + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) diff --git a/package.json b/package.json index 76b037f94d9..df0f0ed2533 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "bench:macos-computer-helper-owner-loss": "node config/scripts/macos-computer-helper-owner-loss-benchmark.mjs", "bench:startup": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/startup-time-bench.mjs", "bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/daemon-coldstart-bench.mjs", + "bench:wsl-hook-relay-reattach": "pnpm run build:relay && node config/scripts/wsl-hook-relay-reattach-benchmark.mjs", "bench:hang-watchdog-memory": "pnpm run ensure:electron-runtime && node config/scripts/hang-watchdog-memory-benchmark.mjs", "bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/main-thread-jank-bench.mjs", "bench:worktree-deletion": "node tests/tools/benchmarks/worktree-deletion-dev-bench.mjs", diff --git a/src/main/agent-hooks/wsl-hook-relay-reattach.test.ts b/src/main/agent-hooks/wsl-hook-relay-reattach.test.ts new file mode 100644 index 00000000000..8e9e425a05c --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-reattach.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' + +import { ensureWslHookRelayForReattach } from './wsl-hook-relay-reattach' + +describe('ensureWslHookRelayForReattach', () => { + it('refreshes the surviving session distro after a local reattach', () => { + const ensure = vi.fn() + + ensureWslHookRelayForReattach({ isReattach: true, wslDistro: 'Ubuntu-24.04' }, null, ensure) + + expect(ensure).toHaveBeenCalledOnce() + expect(ensure).toHaveBeenCalledWith('Ubuntu-24.04') + }) + + it.each([ + ['fresh WSL spawn', { wslDistro: 'Ubuntu' }, null], + ['native reattach', { isReattach: true, wslDistro: null }, null], + ['legacy reattach without ownership context', { isReattach: true }, null], + ['blank distro', { isReattach: true, wslDistro: ' ' }, null], + ['SSH reattach', { isReattach: true, wslDistro: 'Ubuntu' }, 'ssh-1'] + ])('does not refresh a %s', (_label, result, connectionId) => { + const ensure = vi.fn() + + ensureWslHookRelayForReattach(result, connectionId, ensure) + + expect(ensure).not.toHaveBeenCalled() + }) + + it('preserves exact distro ownership across multiple local reattachments', () => { + const ensure = vi.fn() + + ensureWslHookRelayForReattach({ isReattach: true, wslDistro: 'Ubuntu' }, null, ensure) + ensureWslHookRelayForReattach({ isReattach: true, wslDistro: 'Debian' }, null, ensure) + + expect(ensure.mock.calls).toEqual([['Ubuntu'], ['Debian']]) + }) +}) diff --git a/src/main/agent-hooks/wsl-hook-relay-reattach.ts b/src/main/agent-hooks/wsl-hook-relay-reattach.ts new file mode 100644 index 00000000000..837fe82b983 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-reattach.ts @@ -0,0 +1,20 @@ +import type { PtySpawnResult } from '../providers/pty-spawn-result' +import { wslHookRelayManager } from './wsl-hook-relay-manager' + +type ReattachResult = Pick + +export function ensureWslHookRelayForReattach( + result: ReattachResult, + connectionId?: string | null, + ensureForDistro: (distro: string) => void = (distro) => + wslHookRelayManager.ensureForDistro(distro) +): void { + // Why: the current renderer preference can differ from the surviving PTY's proven distro ownership. + if (connectionId || result.isReattach !== true || typeof result.wslDistro !== 'string') { + return + } + const distro = result.wslDistro.trim() + if (distro) { + ensureForDistro(distro) + } +} diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 54ce28b6825..c80732744f2 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -58,6 +58,7 @@ import { } from '../../shared/command-token-scanner' import { agentHookServer } from '../agent-hooks/server' import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' +import { ensureWslHookRelayForReattach } from '../agent-hooks/wsl-hook-relay-reattach' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { piTitlebarExtensionService } from '../pi/titlebar-extension-service' import { @@ -4300,7 +4301,7 @@ export function registerPtyHandlers( if (!handle) { throw new Error('terminal_pane_owner_unknown') } - return { + const result = { id: preAdoptedStablePane.result.id, ...(preAdoptedStablePane.result.incarnationId ? { incarnationId: preAdoptedStablePane.result.incarnationId } @@ -4314,6 +4315,11 @@ export function registerPtyHandlers( leafId: preAdoptedStablePane.owner.leafId } } + ensureWslHookRelayForReattach( + { isReattach: true, wslDistro: preAdoptedStablePane.result.wslDistro }, + args.connectionId + ) + return result } if (!preAdoptedStablePane) { await assertFolderWorkspacePtyPathUsable(args.worktreeId) @@ -4882,6 +4888,7 @@ export function registerPtyHandlers( sequenceBeforeProviderSpawn ) } + ensureWslHookRelayForReattach(result, args.connectionId) runtime?.preparePtyExecutionContext?.( result.id, args.connectionId @@ -6225,6 +6232,7 @@ export function registerPtyHandlers( sequenceBeforeProviderSpawn ) } + ensureWslHookRelayForReattach(result, args.connectionId) runtime?.preparePtyExecutionContext?.( result.id, args.connectionId