From c256a5a4172f82c10331533b6aaeeb4cc883ebc3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:21:07 -0700 Subject: [PATCH] Windows terminal update-survival (single consolidated PR) (#7538) --- .github/workflows/daemon-relocation-spike.yml | 97 +++ .github/workflows/win-update-e2e.yml | 145 ++++ .github/workflows/win-update-survival-e2e.yml | 116 ++++ config/electron-builder.config.cjs | 6 +- config/nsis/daemon-host-uninstall.nsh | 23 + package.json | 1 + src/main/daemon/daemon-entry.test.ts | 25 + src/main/daemon/daemon-entry.ts | 64 +- src/main/daemon/daemon-file-log.test.ts | 100 +++ src/main/daemon/daemon-file-log.ts | 137 ++++ .../daemon/daemon-host-relocation.test.ts | 270 ++++++++ src/main/daemon/daemon-host-relocation.ts | 398 +++++++++++ src/main/daemon/daemon-init.test.ts | 45 +- src/main/daemon/daemon-init.ts | 78 ++- src/main/daemon/daemon-main.ts | 5 +- src/main/daemon/daemon-server.ts | 25 + src/main/observability/bundle.test.ts | 47 ++ src/main/observability/bundle.ts | 30 +- src/main/observability/index.ts | 37 +- src/main/observability/logs-directory.ts | 41 ++ tools/daemon-relocation-spike/README.md | 160 +++++ .../daemon-relocation-spike/app-inventory.mjs | 146 ++++ tools/daemon-relocation-spike/cli.mjs | 88 +++ .../daemon-relocation-spike/daemon-launch.mjs | 99 +++ tools/daemon-relocation-spike/host-copy.mjs | 62 ++ .../loaded-module-probe.mjs | 85 +++ .../loaded-modules.ps1 | 44 ++ .../daemon-relocation-spike/ndjson-client.mjs | 262 ++++++++ tools/daemon-relocation-spike/selftest.mjs | 166 +++++ tools/daemon-relocation-spike/spike.mjs | 313 +++++++++ .../daemon-relocation-spike/tier-file-set.mjs | 135 ++++ tools/win-update-e2e/README.md | 215 ++++++ tools/win-update-e2e/app-driver.mjs | 357 ++++++++++ tools/win-update-e2e/assertions.mjs | 186 ++++++ tools/win-update-e2e/cli-args.mjs | 201 ++++++ tools/win-update-e2e/daemon-processes.mjs | 173 +++++ tools/win-update-e2e/installer-steps.mjs | 202 ++++++ tools/win-update-e2e/interactivity-probes.mjs | 99 +++ tools/win-update-e2e/onboarding-profile.mjs | 77 +++ tools/win-update-e2e/platform-guard.mjs | 38 ++ tools/win-update-e2e/powershell-runner.mjs | 87 +++ tools/win-update-e2e/preflight.mjs | 161 +++++ .../registry-shortcut-backup.mjs | 312 +++++++++ tools/win-update-e2e/run.mjs | 628 ++++++++++++++++++ tools/win-update-e2e/window-enum.ps1 | 122 ++++ tools/win-update-e2e/window-watch.mjs | 174 +++++ tools/win-update-e2e/window-watch.ps1 | 91 +++ 47 files changed, 6305 insertions(+), 68 deletions(-) create mode 100644 .github/workflows/daemon-relocation-spike.yml create mode 100644 .github/workflows/win-update-e2e.yml create mode 100644 .github/workflows/win-update-survival-e2e.yml create mode 100644 config/nsis/daemon-host-uninstall.nsh create mode 100644 src/main/daemon/daemon-file-log.test.ts create mode 100644 src/main/daemon/daemon-file-log.ts create mode 100644 src/main/daemon/daemon-host-relocation.test.ts create mode 100644 src/main/daemon/daemon-host-relocation.ts create mode 100644 src/main/observability/logs-directory.ts create mode 100644 tools/daemon-relocation-spike/README.md create mode 100644 tools/daemon-relocation-spike/app-inventory.mjs create mode 100644 tools/daemon-relocation-spike/cli.mjs create mode 100644 tools/daemon-relocation-spike/daemon-launch.mjs create mode 100644 tools/daemon-relocation-spike/host-copy.mjs create mode 100644 tools/daemon-relocation-spike/loaded-module-probe.mjs create mode 100644 tools/daemon-relocation-spike/loaded-modules.ps1 create mode 100644 tools/daemon-relocation-spike/ndjson-client.mjs create mode 100644 tools/daemon-relocation-spike/selftest.mjs create mode 100644 tools/daemon-relocation-spike/spike.mjs create mode 100644 tools/daemon-relocation-spike/tier-file-set.mjs create mode 100644 tools/win-update-e2e/README.md create mode 100644 tools/win-update-e2e/app-driver.mjs create mode 100644 tools/win-update-e2e/assertions.mjs create mode 100644 tools/win-update-e2e/cli-args.mjs create mode 100644 tools/win-update-e2e/daemon-processes.mjs create mode 100644 tools/win-update-e2e/installer-steps.mjs create mode 100644 tools/win-update-e2e/interactivity-probes.mjs create mode 100644 tools/win-update-e2e/onboarding-profile.mjs create mode 100644 tools/win-update-e2e/platform-guard.mjs create mode 100644 tools/win-update-e2e/powershell-runner.mjs create mode 100644 tools/win-update-e2e/preflight.mjs create mode 100644 tools/win-update-e2e/registry-shortcut-backup.mjs create mode 100644 tools/win-update-e2e/run.mjs create mode 100644 tools/win-update-e2e/window-enum.ps1 create mode 100644 tools/win-update-e2e/window-watch.mjs create mode 100644 tools/win-update-e2e/window-watch.ps1 diff --git a/.github/workflows/daemon-relocation-spike.yml b/.github/workflows/daemon-relocation-spike.yml new file mode 100644 index 000000000..319580dc2 --- /dev/null +++ b/.github/workflows/daemon-relocation-spike.yml @@ -0,0 +1,97 @@ +name: Daemon Relocation Spike + +# Why: Phase 1 of the Windows update-survival work relocates the terminal +# daemon's file closure out of the install dir so it survives an update. Before +# implementing it in the app, this spike finds the MINIMAL set of packaged files +# that a copied Orca.exe (run as node) needs to start the daemon and drive a +# ConPTY session from a relocated directory, holding no install-dir file locks. +# Runs from the feature branch via push (workflow_dispatch only works on the +# default branch); main is never touched. + +on: + push: + branches: + - Jinwoo-H/windows-update-survival + paths: + - 'tools/daemon-relocation-spike/**' + - '.github/workflows/daemon-relocation-spike.yml' + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: daemon-relocation-spike-${{ github.ref }} + cancel-in-progress: true + +jobs: + spike: + name: relocation file-set spike + runs-on: windows-2022 + timeout-minutes: 40 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # This job only reads the repo to build/run the spike; it never pushes. + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Why: cache the unpacked build keyed on the inputs that affect it, so + # spike-script-only edits skip the ~15 min electron-builder build. + - name: Cache unpacked build + id: cache-unpacked + uses: actions/cache@v4 + with: + path: dist/win-unpacked + key: win-unpacked-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }} + + - name: Build unpacked app + if: steps.cache-unpacked.outputs.cache-hit != 'true' + run: pnpm run build:unpack + + - name: Run relocation spike (all tiers) + shell: pwsh + run: | + New-Item -ItemType Directory -Force artifacts | Out-Null + $log = "artifacts/spike-output.log" + $tiers = @('full', 'no-gpu', 'minimal') + $any = $false + foreach ($tier in $tiers) { + "===== TIER: $tier =====" | Tee-Object -FilePath $log -Append + $work = Join-Path $env:RUNNER_TEMP "spike-$tier" + # --keep-work-dir so the per-tier daemon stdout/stderr logs survive + # for the artifact upload (the spike otherwise removes work-dir), + # which is what diagnoses a tier that fails to reach ready. + node tools/daemon-relocation-spike/spike.mjs ` + --app-dir dist/win-unpacked ` + --work-dir "$work" ` + --tier $tier ` + --keep-work-dir 2>&1 | Tee-Object -FilePath $log -Append + if ($LASTEXITCODE -eq 0) { $any = $true } + } + if (-not $any) { throw "No tier passed the relocation spike" } + + - name: Upload spike output + if: always() + uses: actions/upload-artifact@v7 + with: + name: daemon-relocation-spike-output + path: | + artifacts/spike-output.log + ${{ runner.temp }}/spike-*/**/*.log + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/win-update-e2e.yml b/.github/workflows/win-update-e2e.yml new file mode 100644 index 000000000..21c788256 --- /dev/null +++ b/.github/workflows/win-update-e2e.yml @@ -0,0 +1,145 @@ +name: Windows Update-Survival E2E + +# Why: proves what happens to terminal sessions across a real Windows app update +# by installing one released version, updating to another, and asserting on the +# packaged artifacts (daemon survival, terminal interactivity, zero console +# flashes). This MUST run on a disposable CI Windows: electron-builder's oneClick +# installer uninstalls the registry-registered copy of the app before every +# install, so running the harness on a machine with a real Orca install would +# delete it. A fresh runner has no Orca registered, so it is the only safe home +# for install-based testing. Manual dispatch only. + +# Why: workflow_dispatch only works once a workflow is on the default branch. +# To exercise this from the unmerged feature branch WITHOUT touching main, a +# push trigger scoped to that exact branch runs the workflow from the branch's +# own tree. The paths filter keeps it to harness/workflow edits so ordinary +# commits don't spend 30 min of Windows runner time. Push runs have no inputs, +# so every parameter falls back to a default below. +on: + push: + branches: + - Jinwoo-H/windows-update-survival + paths: + - 'tools/win-update-e2e/**' + - '.github/workflows/win-update-e2e.yml' + workflow_dispatch: + inputs: + from_tag: + description: Release tag to install first (e.g. v1.4.124-rc.8) + required: true + type: string + to_tag: + description: Release tag to update to (e.g. v1.4.124-rc.9) + required: true + type: string + expect: + description: Expected outcome profile + required: true + type: choice + default: cold-restore + options: + - cold-restore + - survival + asset_pattern: + description: Installer asset glob + required: false + type: string + default: '*windows-setup.exe' + soak_seconds: + description: Post-relaunch console-window watch duration + required: false + type: string + default: '60' + +permissions: + contents: read + +# Why: cancel a superseded run when iterating — a new push to the branch makes +# the in-flight Windows job obsolete, so free the runner immediately. +concurrency: + group: win-update-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + update-e2e: + name: update ${{ inputs.from_tag || 'v1.4.124-rc.8' }} -> ${{ inputs.to_tag || 'v1.4.124-rc.9' }} (${{ inputs.expect || 'cold-restore' }}) + runs-on: windows-2022 + timeout-minutes: 30 + env: + FROM_TAG: ${{ inputs.from_tag || 'v1.4.124-rc.8' }} + TO_TAG: ${{ inputs.to_tag || 'v1.4.124-rc.9' }} + EXPECT: ${{ inputs.expect || 'cold-restore' }} + ASSET_PATTERN: ${{ inputs.asset_pattern || '*windows-setup.exe' }} + SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # This job only reads the repo and downloads a release; it never pushes. + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + # Why: the harness only needs its runtime deps (the Playwright Electron + # driver); it drives already-built installer artifacts, so no app build. + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Why: fail fast with a readable message if a tag has no matching Windows + # installer, rather than deep inside the harness. + - name: Download installers + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + New-Item -ItemType Directory -Force artifacts/from | Out-Null + New-Item -ItemType Directory -Force artifacts/to | Out-Null + gh release download "$env:FROM_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/from + gh release download "$env:TO_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/to + $from = Get-ChildItem artifacts/from -Filter *.exe | Select-Object -First 1 + $to = Get-ChildItem artifacts/to -Filter *.exe | Select-Object -First 1 + if (-not $from) { throw "No installer matching '$env:ASSET_PATTERN' in $env:FROM_TAG" } + if (-not $to) { throw "No installer matching '$env:ASSET_PATTERN' in $env:TO_TAG" } + "FROM_EXE=$($from.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "TO_EXE=$($to.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + # Why: no --install-dir here. A CI runner has no real Orca registered, so + # the harness installs to the default per-user location and owns it — the + # isolated-install machinery exists only to (imperfectly) protect a real + # developer install, which does not apply on a throwaway runner. + - name: Run update-survival harness + id: harness + shell: pwsh + env: + # Why: on a driving failure the harness dumps a screenshot + DOM state + # here so the actual post-onboarding UI is visible in the artifacts. + ORCA_E2E_DIAG_DIR: artifacts/diag + run: | + $log = "artifacts/harness-output.log" + node tools/win-update-e2e/run.mjs ` + --from "$env:FROM_EXE" ` + --to "$env:TO_EXE" ` + --expect "$env:EXPECT" ` + --soak-seconds "$env:SOAK_SECONDS" 2>&1 | Tee-Object -FilePath $log + exit $LASTEXITCODE + + - name: Upload harness output + if: always() + uses: actions/upload-artifact@v7 + with: + name: win-update-e2e-output + # Why: log + diagnostics only, never the multi-hundred-MB installers. + path: | + artifacts/harness-output.log + artifacts/diag/** + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/win-update-survival-e2e.yml b/.github/workflows/win-update-survival-e2e.yml new file mode 100644 index 000000000..f5e7cb093 --- /dev/null +++ b/.github/workflows/win-update-survival-e2e.yml @@ -0,0 +1,116 @@ +name: Windows Update-Survival E2E (branch build) + +# Why: proves the Phase 1 daemon-host relocation actually makes terminal +# sessions SURVIVE a Windows update. Builds an (unsigned) installer FROM THIS +# BRANCH, then runs the win-update-e2e harness with --expect survival: install, +# open a terminal, silently update over it, relaunch, and assert the daemon PID +# is unchanged and the pre-update terminal is still interactive with no console +# flashing. Runs from the feature branch via push (workflow_dispatch only works +# on the default branch); main is never touched. A CI runner is the only safe +# place to install/update — see the relocation post-mortem. + +on: + push: + branches: + - Jinwoo-H/windows-update-survival + paths: + - 'src/main/daemon/**' + - 'src/main/pty/**' + - 'tools/win-update-e2e/**' + - 'config/electron-builder.config.cjs' + - '.github/workflows/win-update-survival-e2e.yml' + workflow_dispatch: + inputs: + expect: + description: Expected outcome profile + required: true + type: choice + default: survival + options: + - survival + - cold-restore + +permissions: + contents: read + +concurrency: + group: win-update-survival-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + survival: + name: survival (branch build over itself) + runs-on: windows-2022 + timeout-minutes: 50 + env: + EXPECT: ${{ inputs.expect || 'survival' }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + # This job only builds and runs the harness locally; it never pushes. + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Why: cache the built installer keyed on the inputs that affect it, so a + # harness-only edit skips the ~20 min electron-builder build. The daemon + # relocation code lives under src/, so changing it correctly rebuilds. + - name: Cache branch installer + id: cache-installer + uses: actions/cache@v4 + with: + path: dist/orca-windows-setup.exe + key: branch-installer-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }} + + - name: Build Windows installer (unsigned) + if: steps.cache-installer.outputs.cache-hit != 'true' + run: | + node config/scripts/ensure-native-runtime.mjs --runtime=electron + pnpm run build:desktop + pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never + + # Why: install the branch build, open a terminal, update the SAME build + # over it, and assert the relocated daemon survives. Same build on both + # sides isolates the survival mechanism (NSIS kill sweep + userData daemon + # + adoption) from cross-version staleness policy. No --install-dir: a CI + # runner has no real Orca to protect. + - name: Run survival harness + id: harness + shell: pwsh + env: + ORCA_E2E_DIAG_DIR: artifacts/diag + run: | + New-Item -ItemType Directory -Force artifacts | Out-Null + $exe = "dist/orca-windows-setup.exe" + if (-not (Test-Path $exe)) { throw "Installer not found at $exe" } + $log = "artifacts/survival-output.log" + node tools/win-update-e2e/run.mjs ` + --from "$exe" ` + --to "$exe" ` + --expect "$env:EXPECT" ` + --soak-seconds 60 2>&1 | Tee-Object -FilePath $log + exit $LASTEXITCODE + + - name: Upload survival output + if: always() + uses: actions/upload-artifact@v7 + with: + name: win-update-survival-output + path: | + artifacts/survival-output.log + artifacts/diag/** + retention-days: 7 + if-no-files-found: warn diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 9e482825a..51d2a7710 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -173,7 +173,11 @@ module.exports = { artifactName: 'orca-windows-setup.${ext}', shortcutName: '${productName}', uninstallDisplayName: '${productName}', - createDesktopShortcut: 'always' + createDesktopShortcut: 'always', + // Why: on a real uninstall, stop and remove the relocated terminal daemon + // (which lives outside the install dir under LOCALAPPDATA by design). Guarded + // by ${isUpdated} inside so it never runs during an update's uninstallOldVersion. + include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh') }, mac: { icon: 'resources/build/icon.icns', diff --git a/config/nsis/daemon-host-uninstall.nsh b/config/nsis/daemon-host-uninstall.nsh new file mode 100644 index 000000000..dc3a497ce --- /dev/null +++ b/config/nsis/daemon-host-uninstall.nsh @@ -0,0 +1,23 @@ +; Clean up the relocated terminal daemon on a REAL uninstall. +; +; Why: the daemon host is deliberately copied to a distinct image name +; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app +; UPDATES cannot kill it — that relocation is what keeps terminals alive across +; updates. The same design means a normal uninstall's process sweep and file +; removal both miss it, leaving an orphaned daemon plus its runtime copy behind. +; +; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as +; part of uninstallOldVersion on EVERY update, and killing the daemon there would +; defeat the whole feature. Only clean up on a genuine uninstall. +; +; The image name and the LOCALAPPDATA folder name must stay in sync with +; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in +; src/main/daemon/daemon-host-relocation.ts. +!macro customUnInstall + ${ifNot} ${isUpdated} + nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe' + ; Give the OS a moment to release the image lock before removing the tree. + Sleep 500 + RMDir /r "$LOCALAPPDATA\Orca\daemon-host" + ${endIf} +!macroend diff --git a/package.json b/package.json index 47fa7a695..5b853e94b 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs", "test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs", "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", + "win-update-e2e": "node tools/win-update-e2e/run.mjs", "test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs", "test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful", "test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts", diff --git a/src/main/daemon/daemon-entry.test.ts b/src/main/daemon/daemon-entry.test.ts index f2ac7a7ad..e24f1c8b3 100644 --- a/src/main/daemon/daemon-entry.test.ts +++ b/src/main/daemon/daemon-entry.test.ts @@ -29,4 +29,29 @@ describe('daemon-entry parseArgs', () => { it('throws with no args', () => { expect(() => parseArgs([])).toThrow('Usage:') }) + + it('omits logFilePath when --log-file is absent (adopted old daemons)', () => { + const result = parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token']) + expect(result).not.toHaveProperty('logFilePath') + }) + + it('parses --log-file when present', () => { + const result = parseArgs([ + '--socket', + '/tmp/t.sock', + '--token', + '/tmp/t.token', + '--log-file', + '/tmp/daemon.log' + ]) + expect(result).toEqual({ + socketPath: '/tmp/t.sock', + tokenPath: '/tmp/t.token', + logFilePath: '/tmp/daemon.log' + }) + }) + + it('still requires --socket and --token when --log-file is given', () => { + expect(() => parseArgs(['--log-file', '/tmp/daemon.log'])).toThrow('Usage:') + }) }) diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index aeffb7aa2..713cc8402 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -10,10 +10,20 @@ import { startDaemon, type DaemonHandle } from './daemon-main' import { createPtySubprocess } from './pty-subprocess' import { warmWindowsConptyOnce } from './windows-conpty-warmup' import { warmPwshAvailabilityCache } from '../pwsh' +import { createDaemonFileLog, createNoopDaemonFileLog } from './daemon-file-log' +import { PROTOCOL_VERSION } from './types' -export function parseArgs(argv: string[]): { socketPath: string; tokenPath: string } { +export type ParsedDaemonArgs = { + socketPath: string + tokenPath: string + /** Optional — absent for adopted old daemons and tests, which log nothing. */ + logFilePath?: string +} + +export function parseArgs(argv: string[]): ParsedDaemonArgs { let socketPath = '' let tokenPath = '' + let logFilePath = '' for (let i = 0; i < argv.length; i++) { if (argv[i] === '--socket' && argv[i + 1]) { @@ -22,18 +32,24 @@ export function parseArgs(argv: string[]): { socketPath: string; tokenPath: stri } else if (argv[i] === '--token' && argv[i + 1]) { tokenPath = argv[i + 1] i++ + } else if (argv[i] === '--log-file' && argv[i + 1]) { + logFilePath = argv[i + 1] + i++ } } if (!socketPath || !tokenPath) { - throw new Error('Usage: daemon-entry --socket --token ') + throw new Error('Usage: daemon-entry --socket --token [--log-file ]') } - return { socketPath, tokenPath } + return logFilePath ? { socketPath, tokenPath, logFilePath } : { socketPath, tokenPath } } async function main(): Promise { - const { socketPath, tokenPath } = parseArgs(process.argv.slice(2)) + const { socketPath, tokenPath, logFilePath } = parseArgs(process.argv.slice(2)) + // Fail-open: a broken log path must never block daemon startup. + const daemonLog = logFilePath ? createDaemonFileLog(logFilePath) : createNoopDaemonFileLog() + daemonLog.log('startup', { protocolVersion: PROTOCOL_VERSION, socketPath }) void warmPwshAvailabilityCache() // Why: node-pty can throw a C++ Napi::Error that escapes all JS try/catch @@ -55,29 +71,54 @@ async function main(): Promise { msg.includes('EBADF') || msg.includes('ENXIO')) if (isNativeError) { + daemonLog.log('uncaught-exception-suppressed', { name: err?.name, message: msg }) console.error('[daemon] Native PTY exception (suppressed):', err) return } + daemonLog.log('uncaught-exception-fatal', { name: err?.name, message: msg }) console.error('[daemon] Uncaught exception (fatal):', err) throw err }) let daemon: DaemonHandle | null = null + let shuttingDown = false + // Bound the wait so a wedged native shutdown can't leave the daemon running + // forever on SIGTERM/SIGINT (it would then survive a real quit, not just updates). + const SHUTDOWN_TIMEOUT_MS = 5000 - const shutdown = async (): Promise => { - if (daemon) { - await daemon.shutdown() - daemon = null + const shutdown = async (reason: string): Promise => { + // SIGTERM and SIGINT can both fire; guard against a double daemon.shutdown(). + if (shuttingDown) { + return + } + shuttingDown = true + daemonLog.log('shutdown', { reason }) + try { + if (daemon) { + await Promise.race([ + daemon.shutdown(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('shutdown timeout')), SHUTDOWN_TIMEOUT_MS) + ) + ]) + daemon = null + } + } catch (err) { + // Never let a rejected shutdown() escape as an unhandled rejection and skip exit. + daemonLog.log('shutdown-error', { message: (err as Error)?.message }) + } finally { + daemonLog.close() + process.exit(0) } - process.exit(0) } - process.on('SIGTERM', () => void shutdown()) - process.on('SIGINT', () => void shutdown()) + process.on('SIGTERM', () => void shutdown('SIGTERM')) + process.on('SIGINT', () => void shutdown('SIGINT')) daemon = await startDaemon({ socketPath, tokenPath, + log: daemonLog, spawnSubprocess: (opts) => createPtySubprocess(opts) }) @@ -85,6 +126,7 @@ async function main(): Promise { if (process.send) { process.send({ type: 'ready' }) } + daemonLog.log('ready') warmWindowsConptyOnce() } diff --git a/src/main/daemon/daemon-file-log.test.ts b/src/main/daemon/daemon-file-log.test.ts new file mode 100644 index 000000000..90677b9da --- /dev/null +++ b/src/main/daemon/daemon-file-log.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createDaemonFileLog, createNoopDaemonFileLog } from './daemon-file-log' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-file-log-')) +}) +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function readLines(filePath: string): Record[] { + return readFileSync(filePath, 'utf8') + .split('\n') + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as Record) +} + +describe('createDaemonFileLog', () => { + it('appends NDJSON lines with src/ts/pid/event and terse details', () => { + const filePath = join(dir, 'daemon.log') + const log = createDaemonFileLog(filePath) + log.log('startup', { protocolVersion: 18 }) + log.log('session-created', { sessionId: 'abc', pid: 42 }) + + const lines = readLines(filePath) + expect(lines).toHaveLength(2) + expect(lines[0]).toMatchObject({ src: 'daemon', event: 'startup', protocolVersion: 18 }) + expect(typeof lines[0].ts).toBe('string') + expect(lines[0].pid).toBe(process.pid) + expect(lines[1]).toMatchObject({ event: 'session-created', sessionId: 'abc', pid: 42 }) + }) + + it('rotates at the byte cap and keeps only the configured rotated files', () => { + const filePath = join(dir, 'daemon.log') + const log = createDaemonFileLog(filePath, { maxBytes: 150, maxRotatedFiles: 2 }) + for (let i = 0; i < 40; i++) { + log.log('tick', { i }) + } + + expect(existsSync(filePath)).toBe(true) + expect(existsSync(`${filePath}.1`)).toBe(true) + expect(existsSync(`${filePath}.2`)).toBe(true) + // Only 2 rotated files are retained — the oldest is dropped, not kept. + expect(existsSync(`${filePath}.3`)).toBe(false) + + // The active file holds the most recent line. + const active = readLines(filePath) + expect(active.at(-1)).toMatchObject({ event: 'tick', i: 39 }) + }) + + it('is fail-open when the log directory cannot be created', () => { + // Make the parent a file so mkdir of the logs subdir fails (ENOTDIR). + const blocker = join(dir, 'blocker') + writeFileSync(blocker, 'x') + const filePath = join(blocker, 'logs', 'daemon.log') + + const log = createDaemonFileLog(filePath) + expect(() => log.log('startup')).not.toThrow() + expect(() => log.close()).not.toThrow() + expect(existsSync(filePath)).toBe(false) + }) + + it('never throws from log() even for non-serializable details', () => { + const filePath = join(dir, 'daemon.log') + const log = createDaemonFileLog(filePath) + const circular: Record = {} + circular.self = circular + expect(() => log.log('weird', circular)).not.toThrow() + // The bad line is dropped; a later good line still lands. + log.log('ok') + const lines = readLines(filePath) + expect(lines.map((l) => l.event)).toEqual(['ok']) + }) + + it('close() writes a terminal marker and stops further writes', () => { + const filePath = join(dir, 'daemon.log') + const log = createDaemonFileLog(filePath) + log.log('startup') + log.close() + log.log('after-close') + + const events = readLines(filePath).map((l) => l.event) + expect(events).toEqual(['startup', 'daemon-log-closed']) + }) +}) + +describe('createNoopDaemonFileLog', () => { + it('accepts log/close calls without touching the filesystem', () => { + const log = createNoopDaemonFileLog() + expect(() => { + log.log('startup', { x: 1 }) + log.close() + }).not.toThrow() + }) +}) diff --git a/src/main/daemon/daemon-file-log.ts b/src/main/daemon/daemon-file-log.ts new file mode 100644 index 000000000..01090aedf --- /dev/null +++ b/src/main/daemon/daemon-file-log.ts @@ -0,0 +1,137 @@ +// Append-only NDJSON logger for the detached daemon process. The daemon runs +// out-of-process with stdio 'ignore', so console output goes nowhere; this +// writes lifecycle events to a rotated file under the app's logs directory so +// they land in diagnostic bundles (windows-terminal-update-survival-plan.md +// §Phase 0). Never log terminal input/output content or tokens. +// +// Two hard constraints: +// 1. FAIL-OPEN. Any error (EACCES, ENOSPC, bad path) disables logging and is +// swallowed — logging must never throw into daemon lifecycle logic or +// affect startup/shutdown. +// 2. Best-effort durability. Each line is a single synchronous appendFileSync +// so a process death mid-write can lose at most the last (partial) line; +// NDJSON readers skip a truncated trailing line. + +import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs' +import { dirname } from 'node:path' + +const DEFAULT_MAX_BYTES = 5 * 1024 * 1024 // 5 MB +const DEFAULT_MAX_ROTATED_FILES = 2 // daemon.log + daemon.log.1 + daemon.log.2 +const PRIVATE_FILE_MODE = 0o600 + +/** Total files in the rotated daemon-log family (active + rotated). The bundle + * collector passes this to `listRotatedFiles` so it reads every rotated file. */ +export const DAEMON_LOG_MAX_FILES = DEFAULT_MAX_ROTATED_FILES + 1 + +export type DaemonFileLog = { + /** Append one lifecycle event. Terse fields only — never user data. */ + log(event: string, details?: Record): void + /** Best-effort marker that no further writes are expected. */ + close(): void +} + +export type DaemonFileLogOptions = { + readonly maxBytes?: number + readonly maxRotatedFiles?: number +} + +/** No-op logger used when the daemon was launched without `--log-file` (adopted + * old daemons, tests). Keeps every call site unconditional. */ +export function createNoopDaemonFileLog(): DaemonFileLog { + return { + log() {}, + close() {} + } +} + +export function createDaemonFileLog( + filePath: string, + opts: DaemonFileLogOptions = {} +): DaemonFileLog { + const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES + const maxRotatedFiles = opts.maxRotatedFiles ?? DEFAULT_MAX_ROTATED_FILES + + let disabled = false + let currentBytes = 0 + + function disable(): void { + disabled = true + } + + try { + mkdirSync(dirname(filePath), { recursive: true }) + currentBytes = existsSync(filePath) ? statSync(filePath).size : 0 + } catch { + // Unwritable path — stay fail-open; the first log() no-ops via `disabled`. + disable() + } + + // Cascade rename base → .1 → .2, dropping the oldest, then reset the active + // file. Any failure disables logging rather than risking a partial-rotation + // loop that keeps throwing on every subsequent line. + function rotate(): void { + // With no rotated slots there is nothing to cascade; return without the + // `currentBytes = 0` reset below, which would otherwise falsely report the + // still-growing active file as empty and defeat the overflow check forever. + if (maxRotatedFiles < 1) { + return + } + try { + for (let i = maxRotatedFiles; i >= 1; i--) { + const src = i === 1 ? filePath : `${filePath}.${i - 1}` + const dst = `${filePath}.${i}` + if (!existsSync(src)) { + continue + } + if (existsSync(dst)) { + unlinkSync(dst) + } + renameSync(src, dst) + } + currentBytes = 0 + } catch { + disable() + } + } + + function log(event: string, details: Record = {}): void { + if (disabled) { + return + } + let line: string + try { + line = `${JSON.stringify({ + src: 'daemon', + ts: new Date().toISOString(), + pid: process.pid, + event, + ...details + })}\n` + } catch { + // Non-serializable detail (circular ref) — drop the line, never crash. + return + } + const lineBytes = Buffer.byteLength(line, 'utf8') + if (currentBytes > 0 && currentBytes + lineBytes > maxBytes) { + rotate() + if (disabled) { + return + } + } + try { + appendFileSync(filePath, line, { mode: PRIVATE_FILE_MODE }) + currentBytes += lineBytes + } catch { + disable() + } + } + + return { + log, + close(): void { + // Best-effort marker; append is synchronous so there is nothing to flush. + log('daemon-log-closed') + disabled = true + } + } +} diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts new file mode 100644 index 000000000..1d3ab98db --- /dev/null +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -0,0 +1,270 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import os from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Mutable Electron app stub, hoisted so the vi.mock factory closes over it. +const { electronApp } = vi.hoisted(() => ({ + electronApp: { + isPackaged: true, + userDataPath: '', + version: '9.9.9', + getPath: (): string => electronApp.userDataPath, + getVersion: (): string => electronApp.version + } +})) + +vi.mock('electron', () => ({ app: electronApp })) + +import { + buildDaemonHostManifest, + collectPinnedDaemonVersions, + getRelocatedDaemonHost, + materializeRelocatedDaemonHost, + pruneOldDaemonHosts +} from './daemon-host-relocation' + +let tempDir: string +let installDir: string +let userDataDir: string +let localAppDataDir: string +const originalPlatform = process.platform +const originalExecPath = process.execPath +const originalResourcesPath = process.resourcesPath +const originalLocalAppData = process.env.LOCALAPPDATA + +function setProcessProp(key: string, value: unknown): void { + Object.defineProperty(process, key, { value, configurable: true, writable: true }) +} + +// Build a win-unpacked fixture: exe + blobs + DLLs at root, daemon bundle and +// node-pty under resources, mirroring the packaged layout the copy expects. +function buildInstallFixture(root: string): void { + mkdirSync(root, { recursive: true }) + writeFileSync(join(root, 'Orca.exe'), 'exe-bytes') + for (const name of ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin']) { + writeFileSync(join(root, name), name) + } + writeFileSync(join(root, 'ffmpeg.dll'), 'dll') + writeFileSync(join(root, 'libEGL.dll'), 'dll') + const mainDir = join(root, 'resources', 'app.asar.unpacked', 'out', 'main') + mkdirSync(join(mainDir, 'chunks'), { recursive: true }) + writeFileSync(join(mainDir, 'daemon-entry.js'), 'entry') + writeFileSync(join(mainDir, 'chunks', 'a.js'), 'chunk') + writeFileSync(join(root, 'resources', 'app.asar.unpacked', 'out', 'package.json'), '{}') + const nativeDir = join(root, 'resources', 'node_modules', 'node-pty', 'build', 'Release') + mkdirSync(nativeDir, { recursive: true }) + writeFileSync(join(nativeDir, 'conpty.node'), 'native') + writeFileSync(join(nativeDir, 'conpty.pdb'), 'debug-symbols') + mkdirSync(join(nativeDir, 'conpty'), { recursive: true }) + writeFileSync(join(nativeDir, 'conpty', 'conpty.dll'), 'conpty-dll') + // Both win32 prebuilds exist in the packaged tree (build-time prune keeps the + // `win32-` prefix); the copy filter keeps the host arch's and drops the other. + const prebuildsRoot = join(root, 'resources', 'node_modules', 'node-pty', 'prebuilds') + for (const arch of ['win32-x64', 'win32-arm64']) { + mkdirSync(join(prebuildsRoot, arch), { recursive: true }) + writeFileSync(join(prebuildsRoot, arch, 'pty.node'), `${arch}-prebuild`) + } +} + +// The win32 prebuild dir the running host arch loads vs. the one that is pruned. +const HOST_PREBUILD = `win32-${process.arch}` +const OTHER_PREBUILD = HOST_PREBUILD === 'win32-arm64' ? 'win32-x64' : 'win32-arm64' + +beforeEach(() => { + tempDir = mkdtempSync(join(os.tmpdir(), 'daemon-host-relocation-')) + installDir = join(tempDir, 'app') + userDataDir = join(tempDir, 'userData') + mkdirSync(userDataDir, { recursive: true }) + localAppDataDir = join(tempDir, 'localAppData') + mkdirSync(localAppDataDir, { recursive: true }) + process.env.LOCALAPPDATA = localAppDataDir + buildInstallFixture(installDir) + electronApp.isPackaged = true + electronApp.userDataPath = userDataDir + electronApp.version = '9.9.9' + setProcessProp('platform', 'win32') + setProcessProp('execPath', join(installDir, 'Orca.exe')) + setProcessProp('resourcesPath', join(installDir, 'resources')) +}) + +afterEach(() => { + setProcessProp('platform', originalPlatform) + setProcessProp('execPath', originalExecPath) + setProcessProp('resourcesPath', originalResourcesPath) + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA + } else { + process.env.LOCALAPPDATA = originalLocalAppData + } + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch { + // Best-effort + } +}) + +describe('buildDaemonHostManifest', () => { + it('mirrors the win-unpacked layout: exe + data blobs + resources tree, no GPU DLLs', () => { + const appDir = 'C:\\app' + const ops = buildDaemonHostManifest({ + appDir, + execPath: 'C:\\app\\Orca.exe', + resourcesPath: 'C:\\app\\resources', + entrySourcePath: 'C:\\app\\resources\\app.asar.unpacked\\out\\main\\daemon-entry.js', + entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js' + }) + const byDest = new Map(ops.map((op) => [op.destRel, op])) + // The host exe is renamed to a distinct image name (NOT the source basename) + // so the NSIS updater's name-based `taskkill /IM Orca.exe` can't kill it. + expect(byDest.get('orca-terminal-daemon.exe')?.kind).toBe('file') + expect(byDest.has('Orca.exe')).toBe(false) + const exeOp = ops.find((op) => op.sourcePath === 'C:\\app\\Orca.exe') + expect(exeOp?.destRel).not.toBe('Orca.exe') + // V8/ICU data blobs are read by the Electron bootstrap and kept. + expect(byDest.has('icudtl.dat')).toBe(true) + // GPU/graphics DLLs are never loaded by the windowless host, so not copied. + expect(byDest.has('ffmpeg.dll')).toBe(false) + expect(byDest.has('libEGL.dll')).toBe(false) + // Daemon bundle + node-pty mirrored at their real resources-relative paths. + expect(byDest.get('resources/app.asar.unpacked/out/main/daemon-entry.js')?.kind).toBe('file') + expect(byDest.get('resources/app.asar.unpacked/out/main/chunks')?.kind).toBe('dir') + // node-pty is copied with a filter dropping .pdb + non-host-arch prebuilds. + const nodePtyOp = byDest.get('resources/node_modules/node-pty') + expect(nodePtyOp?.kind).toBe('dir') + expect(nodePtyOp?.filter?.('node-pty/build/Release/conpty.node')).toBe(true) + expect(nodePtyOp?.filter?.('node-pty/build/Release/conpty.pdb')).toBe(false) + expect(nodePtyOp?.filter?.(`node-pty/prebuilds/${HOST_PREBUILD}/pty.node`)).toBe(true) + expect(nodePtyOp?.filter?.(`node-pty/prebuilds/${OTHER_PREBUILD}/pty.node`)).toBe(false) + }) +}) + +describe('materializeRelocatedDaemonHost', () => { + it('copies the tree, writes the marker, and returns mirrored fork paths', () => { + const result = materializeRelocatedDaemonHost() + expect(result).not.toBeNull() + const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + expect(result?.execPath).toBe(join(dest, 'orca-terminal-daemon.exe')) + expect(result?.entryPath).toBe( + join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') + ) + expect(existsSync(result!.execPath)).toBe(true) + expect(existsSync(result!.entryPath)).toBe(true) + // node-pty native + conpty runtime copied at the require-resolvable path. + expect( + existsSync( + join(dest, 'resources', 'node_modules', 'node-pty', 'build', 'Release', 'conpty.node') + ) + ).toBe(true) + expect( + existsSync(join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'chunks', 'a.js')) + ).toBe(true) + // Trim: GPU DLLs, .pdb debug symbols, and non-host-arch prebuilds excluded; + // the host arch's prebuild is retained so node-pty resolves its native addon. + expect(existsSync(join(dest, 'ffmpeg.dll'))).toBe(false) + expect(existsSync(join(dest, 'libEGL.dll'))).toBe(false) + expect( + existsSync( + join(dest, 'resources', 'node_modules', 'node-pty', 'build', 'Release', 'conpty.pdb') + ) + ).toBe(false) + const prebuildsDest = join(dest, 'resources', 'node_modules', 'node-pty', 'prebuilds') + expect(existsSync(join(prebuildsDest, HOST_PREBUILD, 'pty.node'))).toBe(true) + expect(existsSync(join(prebuildsDest, OTHER_PREBUILD, 'pty.node'))).toBe(false) + // Marker records the version + entry rel path, written into the published dir. + const marker = JSON.parse(readFileSync(join(dest, '.materialized.json'), 'utf8')) + expect(marker.version).toBe('9.9.9') + expect(marker.entryRelPath).toBe('resources/app.asar.unpacked/out/main/daemon-entry.js') + }) + + it('is idempotent: a valid marker short-circuits without recopying', () => { + materializeRelocatedDaemonHost() + const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + // A recopy would rm the dest; a sentinel inside it must survive the 2nd call. + const sentinel = join(dest, 'sentinel.txt') + writeFileSync(sentinel, 'keep') + const result = materializeRelocatedDaemonHost() + expect(result?.execPath).toBe(join(dest, 'orca-terminal-daemon.exe')) + expect(existsSync(sentinel)).toBe(true) + }) + + it('fails open on a missing required input, leaving no dest or staging dir', () => { + rmSync(join(installDir, 'resources', 'node_modules', 'node-pty'), { + recursive: true, + force: true + }) + const result = materializeRelocatedDaemonHost() + expect(result).toBeNull() + const hostRoot = join(localAppDataDir, 'Orca', 'daemon-host') + // Neither the published dest nor any leftover staging dir remains. + const remaining = existsSync(hostRoot) ? readdirSync(hostRoot) : [] + expect(remaining).toEqual([]) + }) + + it('returns null off win32', () => { + setProcessProp('platform', 'darwin') + expect(materializeRelocatedDaemonHost()).toBeNull() + expect(existsSync(join(localAppDataDir, 'Orca', 'daemon-host'))).toBe(false) + }) +}) + +describe('getRelocatedDaemonHost', () => { + it('returns null when the marker version does not match the current version', () => { + const dest = join(localAppDataDir, 'Orca', 'daemon-host', '9.9.9') + mkdirSync(dirname(join(dest, 'x')), { recursive: true }) + writeFileSync(join(dest, 'Orca.exe'), 'exe') + mkdirSync(join(dest, 'resources', 'app.asar.unpacked', 'out', 'main'), { recursive: true }) + writeFileSync( + join(dest, 'resources', 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js'), + 'e' + ) + writeFileSync( + join(dest, '.materialized.json'), + JSON.stringify({ + version: '8.8.8', + completedAt: '', + entryRelPath: 'resources/app.asar.unpacked/out/main/daemon-entry.js' + }) + ) + expect(getRelocatedDaemonHost()).toBeNull() + }) +}) + +describe('pruneOldDaemonHosts', () => { + it('removes unpinned non-current version dirs, keeping current and pinned', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + for (const v of ['9.9.9', '1.0.0', '2.0.0']) { + mkdirSync(join(root, v), { recursive: true }) + } + pruneOldDaemonHosts(new Set(['2.0.0'])) + expect(existsSync(join(root, '9.9.9'))).toBe(true) + expect(existsSync(join(root, '2.0.0'))).toBe(true) + expect(existsSync(join(root, '1.0.0'))).toBe(false) + }) +}) + +describe('collectPinnedDaemonVersions', () => { + it('pins the app version of a live daemon pid file and skips dead ones', () => { + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(runtimeDir, { recursive: true }) + writeFileSync( + join(runtimeDir, 'daemon-v4.pid'), + JSON.stringify({ pid: process.pid, startedAtMs: null, appVersion: '7.0.0' }) + ) + writeFileSync( + join(runtimeDir, 'daemon-v3.pid'), + JSON.stringify({ pid: 2147483646, startedAtMs: null, appVersion: '6.0.0' }) + ) + const pinned = collectPinnedDaemonVersions(runtimeDir) + expect(pinned.has('7.0.0')).toBe(true) + expect(pinned.has('6.0.0')).toBe(false) + }) +}) diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts new file mode 100644 index 000000000..ac674fdd2 --- /dev/null +++ b/src/main/daemon/daemon-host-relocation.ts @@ -0,0 +1,398 @@ +import { randomBytes } from 'node:crypto' +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync +} from 'node:fs' +import { dirname, join, win32 as winPath } from 'node:path' +import { app } from 'electron' +import { parseDaemonPidFile, startTimeMatches } from './daemon-health' + +/** + * Relocates the terminal daemon's process image out of the app install + * directory into userData so it survives Windows auto-updates. + * + * Why: the daemon is forked as plain Node via ELECTRON_RUN_AS_NODE, so its + * image is the install-dir Orca.exe and its loaded modules (node-pty native, + * ConPTY runtime) map from the install dir. On update, electron-builder's NSIS + * installer deletes the old install and force-closes every process whose image + * lives under it — killing the daemon and every live terminal it owns. Copying + * the daemon's whole file closure to a version-keyed userData dir and forking + * from that copy takes its image + loaded modules out of the installer's reach. + * + * The copy keeps the ELECTRON binary run as node (not stock node.exe): a copy + * of Orca.exe (renamed to a distinct image name) is byte-identical, so + * run-as-node behavior — no console flashing, asar-correct — matches the in-dir + * fork exactly. The win-unpacked layout is mirrored verbatim so + * require('node-pty') and node-pty's native loader resolve the relocated tree + * identically to the packaged app. + * + * Fail-open everywhere: any failure returns null and the caller forks the + * install-dir host — the pre-relocation behavior, byte-identical off win32. + */ + +export type RelocatedDaemonHost = { + /** The relocated host exe to fork the daemon from (run as node). */ + execPath: string + /** The copied daemon-entry.js, mirrored under the relocated resources tree. */ + entryPath: string +} + +const HOST_SUBDIR = 'daemon-host' +const MARKER_NAME = '.materialized.json' + +// The relocated host is machine-specific runtime (~260MB). It must live under +// LOCAL appData, not the roaming userData dir, so a roaming profile or OneDrive +// Known-Folder-Move never syncs it (slow login/logout, sync bloat). This folder +// name is shared verbatim with the NSIS uninstall cleanup +// (config/nsis/daemon-host-uninstall.nsh), which removes +// %LOCALAPPDATA%\\daemon-host — keep the two in sync. +const LOCAL_HOST_ROOT_NAME = 'Orca' + +// The relocated host exe is a copy of Orca.exe renamed to a distinct image +// name. The NSIS updater's name-based kill (`taskkill /IM Orca.exe`) matches by +// image name, so a distinct name spares the daemon from that branch, while the +// userData path (outside $INSTDIR) spares it from the path-based branch. +const DAEMON_HOST_EXE_NAME = 'orca-terminal-daemon.exe' + +// V8 snapshots + ICU data the Electron bootstrap reads even under +// ELECTRON_RUN_AS_NODE; siblings of Orca.exe in win-unpacked. +const RUNTIME_DATA_FILES = ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin'] + +type CopyOp = { + sourcePath: string + /** Destination path relative to the host root, posix-separated. */ + destRel: string + kind: 'file' | 'dir' + /** When true, a missing source is skipped rather than failing the copy. */ + optional?: boolean + /** Per-source-path predicate for dir copies: return false to skip a path. */ + filter?: (sourcePath: string) => boolean +} + +type DaemonHostSources = { + appDir: string + execPath: string + resourcesPath: string + entrySourcePath: string + entryRelPath: string +} + +type MaterializeMarker = { + version: string + completedAt: string + entryRelPath: string +} + +// Uses win32 path semantics so Windows layout paths (drive letters, `\`) +// decompose correctly regardless of host OS — needed for cross-platform unit +// tests; production runs this on win32 only. +function toPosixRelative(fromDir: string, absPath: string): string { + return winPath.relative(fromDir, absPath).split(winPath.sep).join('/') +} + +function destPath(root: string, destRel: string): string { + return join(root, ...destRel.split('/')) +} + +// Mirror getDaemonEntryPath()'s resolution order (unpacked root first, then +// out/main) so the copied entry is the exact file the in-dir fork would run. +function resolveEntrySourcePath(resourcesPath: string): string { + const unpackedRoot = join(resourcesPath, 'app.asar.unpacked') + const direct = join(unpackedRoot, 'daemon-entry.js') + if (existsSync(direct)) { + return direct + } + return join(unpackedRoot, 'out', 'main', 'daemon-entry.js') +} + +// Discover the relocation inputs from the live packaged process, or null when +// relocation does not apply (non-win32, dev, or missing resourcesPath). +function collectDaemonHostSources(): DaemonHostSources | null { + if (process.platform !== 'win32' || !app.isPackaged) { + return null + } + const resourcesPath = process.resourcesPath + if (typeof resourcesPath !== 'string' || resourcesPath.length === 0) { + return null + } + const execPath = process.execPath + const appDir = winPath.dirname(execPath) + const entrySourcePath = resolveEntrySourcePath(resourcesPath) + return { + appDir, + execPath, + resourcesPath, + entrySourcePath, + entryRelPath: toPosixRelative(appDir, entrySourcePath) + } +} + +// node-pty ships debug symbols (.pdb) and a win32 prebuild dir per CPU arch; the +// run-as-node daemon loads neither the symbols nor any non-host-arch prebuild +// (verified against the live daemon's loaded module list), so they are filtered +// out of the copy — the bulk of node-pty's on-disk size. Keyed on the host arch +// rather than dropping arm64 outright so a future Windows-arm64 build keeps the +// `win32-arm64` prebuild it actually needs and prunes `win32-x64` instead. +const HOST_WIN_PREBUILD_DIR = `win32-${process.arch}`.toLowerCase() +function isRuntimeNodePtyPath(sourcePath: string): boolean { + const p = sourcePath.toLowerCase() + if (p.endsWith('.pdb')) { + return false + } + // Keep only the host arch's win32 prebuild; drop any other win32- dir. + const prebuild = p.match(/prebuilds[\\/](win32-[^\\/]+)/) + return !prebuild || prebuild[1] === HOST_WIN_PREBUILD_DIR +} + +/** + * The ordered copy plan. Every destRel mirrors the source's win-unpacked + * relative path so require() and node-pty's native loader resolve the mirror + * identically to the packaged app. Pure over its inputs so tests can assert the + * layout without a real build. + */ +export function buildDaemonHostManifest(sources: DaemonHostSources): CopyOp[] { + const { appDir, execPath, resourcesPath, entrySourcePath, entryRelPath } = sources + const ops: CopyOp[] = [] + + // Electron host binary + V8/ICU data blobs at the dest root. The exe is + // renamed to a distinct image name so the NSIS updater's name-based + // `taskkill /IM Orca.exe` can't match it; the blobs beside it are read by the + // Electron bootstrap by fixed name. Top-level DLLs are deliberately NOT copied + // — they are all GPU/graphics/media (swiftshader, vulkan, d3d, dxcompiler, + // ffmpeg) that a windowless run-as-node host never loads (verified empirically + // against the live daemon's module list), so copying them only wastes ~48MB. + ops.push({ sourcePath: execPath, destRel: DAEMON_HOST_EXE_NAME, kind: 'file' }) + for (const name of RUNTIME_DATA_FILES) { + ops.push({ sourcePath: join(appDir, name), destRel: name, kind: 'file', optional: true }) + } + + // Daemon bundle: entry + its sibling chunks/ + the unpacked out/package.json + // (CJS/ESM loader resolution), mirrored verbatim. + ops.push({ sourcePath: entrySourcePath, destRel: entryRelPath, kind: 'file' }) + const chunksDir = join(winPath.dirname(entrySourcePath), 'chunks') + ops.push({ + sourcePath: chunksDir, + destRel: toPosixRelative(appDir, chunksDir), + kind: 'dir', + optional: true + }) + const pkgJson = join(resourcesPath, 'app.asar.unpacked', 'out', 'package.json') + ops.push({ + sourcePath: pkgJson, + destRel: toPosixRelative(appDir, pkgJson), + kind: 'file', + optional: true + }) + + // node-pty package tree (native conpty.node + conpty/ runtime dir). It is a + // sibling of app.asar.unpacked; require('node-pty') resolves it by walking up + // from the mirrored daemon-entry dir to resources/node_modules. Filtered to + // drop .pdb debug symbols and other-arch prebuilds the host never loads. + const nodePtyDir = join(resourcesPath, 'node_modules', 'node-pty') + ops.push({ + sourcePath: nodePtyDir, + destRel: toPosixRelative(appDir, nodePtyDir), + kind: 'dir', + filter: isRuntimeNodePtyPath + }) + + return ops +} + +function executeManifest(ops: CopyOp[], stagingRoot: string): void { + for (const op of ops) { + if (!existsSync(op.sourcePath)) { + if (op.optional) { + continue + } + throw new Error(`daemon-host relocation: missing required input ${op.sourcePath}`) + } + const dest = destPath(stagingRoot, op.destRel) + mkdirSync(dirname(dest), { recursive: true }) + const { filter } = op + // Dereference symlinks so the copy holds no link back into the install dir. + cpSync(op.sourcePath, dest, { + recursive: op.kind === 'dir', + dereference: true, + force: true, + ...(filter ? { filter: (src: string) => filter(src) } : {}) + }) + } +} + +function readMarker(dir: string): MaterializeMarker | null { + try { + const parsed = JSON.parse( + readFileSync(join(dir, MARKER_NAME), 'utf8') + ) as Partial + if (typeof parsed.version === 'string' && typeof parsed.entryRelPath === 'string') { + return { + version: parsed.version, + completedAt: typeof parsed.completedAt === 'string' ? parsed.completedAt : '', + entryRelPath: parsed.entryRelPath + } + } + } catch { + // Missing/corrupt marker — treat as not materialized. + } + return null +} + +function hostRootDir(): string { + // Prefer LOCAL appData (see LOCAL_HOST_ROOT_NAME). Fall back to userData only + // if LOCALAPPDATA is somehow unset — a no-op off win32, where relocation never + // runs anyway; on win32 packaged the env var is always present. + const localAppData = process.env.LOCALAPPDATA + const base = + typeof localAppData === 'string' && localAppData.length > 0 + ? join(localAppData, LOCAL_HOST_ROOT_NAME) + : app.getPath('userData') + return join(base, HOST_SUBDIR) +} + +/** + * Cheap idempotency check: the relocated host for the current version, or null. + * Valid only when the marker matches this version AND the exe + entry exist, so + * a partial or stale copy never reports ready. + */ +export function getRelocatedDaemonHost(): RelocatedDaemonHost | null { + const sources = collectDaemonHostSources() + if (!sources) { + return null + } + const version = app.getVersion() + const dest = join(hostRootDir(), version) + const marker = readMarker(dest) + if (!marker || marker.version !== version) { + return null + } + const execPath = join(dest, DAEMON_HOST_EXE_NAME) + const entryPath = destPath(dest, marker.entryRelPath) + if (!existsSync(execPath) || !existsSync(entryPath)) { + return null + } + return { execPath, entryPath } +} + +/** + * Ensure the current version's daemon host is materialized under + * userData/daemon-host/, returning its fork paths or null (fail-open). + * Idempotent: a valid marker for this version short-circuits without recopying. + * The copy stages into a temp sibling and is published by atomic rename, so a + * crash mid-copy never leaves a half-populated dest. + */ +export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null { + const existing = getRelocatedDaemonHost() + if (existing) { + return existing + } + const sources = collectDaemonHostSources() + if (!sources) { + return null + } + const version = app.getVersion() + const root = hostRootDir() + const dest = join(root, version) + const staging = join(root, `${version}.staging-${randomBytes(6).toString('hex')}`) + try { + mkdirSync(root, { recursive: true }) + rmSync(staging, { recursive: true, force: true }) + executeManifest(buildDaemonHostManifest(sources), staging) + // Marker written LAST: an interrupted copy leaves a marker-less staging dir + // that the next launch discards, never a dest the cheap check trusts. + const marker: MaterializeMarker = { + version, + completedAt: new Date().toISOString(), + entryRelPath: sources.entryRelPath + } + writeFileSync(join(staging, MARKER_NAME), JSON.stringify(marker)) + // Replace any stale/partial dest, then publish the staging dir atomically. + rmSync(dest, { recursive: true, force: true }) + renameSync(staging, dest) + } catch { + try { + rmSync(staging, { recursive: true, force: true }) + } catch { + // Best-effort staging cleanup. + } + return null + } + return getRelocatedDaemonHost() +} + +function isDaemonPidAlive(pid: number, startedAtMs: number | null): boolean { + try { + process.kill(pid, 0) + } catch { + return false + } + return startTimeMatches(pid, startedAtMs) +} + +/** + * App versions still pinned by a live daemon, read from the daemon-v.pid + * files under `runtimeDir`. A surviving daemon runs from its version's host dir, + * so its dir must never be reclaimed while the process is alive. On win32 the + * start-time check cannot verify, so a matching pid pins conservatively. + */ +export function collectPinnedDaemonVersions(runtimeDir: string): Set { + const pinned = new Set() + let entries + try { + entries = readdirSync(runtimeDir, { withFileTypes: true }) + } catch { + return pinned + } + for (const entry of entries) { + if (!entry.isFile() || !/^daemon-v\d+\.pid$/.test(entry.name)) { + continue + } + let parsed + try { + parsed = parseDaemonPidFile(readFileSync(join(runtimeDir, entry.name), 'utf8')) + } catch { + continue + } + // appVersion null => a pre-relocation daemon forked from the install dir, + // which pins no host dir here. + if (parsed && parsed.appVersion !== null && isDaemonPidAlive(parsed.pid, parsed.startedAtMs)) { + pinned.add(parsed.appVersion) + } + } + return pinned +} + +/** + * Reclaim daemon-host/ dirs whose ver is neither the current version nor + * pinned by a live daemon. Best-effort — never throws; a still-locked or + * concurrently-staging dir is simply retried on a future launch. + */ +export function pruneOldDaemonHosts(pinnedVersions: ReadonlySet): void { + if (process.platform !== 'win32' || !app.isPackaged) { + return + } + const version = app.getVersion() + const root = hostRootDir() + let entries + try { + entries = readdirSync(root, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === version || pinnedVersions.has(entry.name)) { + continue + } + try { + rmSync(join(root, entry.name), { recursive: true, force: true }) + } catch { + // Still locked or already gone — retry on a future launch. + } + } +} diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index b6028a7a8..536d3cc9b 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -996,7 +996,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, - ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.arrayContaining([ + '--socket', + '/fake/socket', + '--token', + '/fake/token', + '--log-file', + join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') + ]), expect.objectContaining({ cwd: '/fake/userData', detached: true }) ) }) @@ -1122,7 +1129,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, - ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.arrayContaining([ + '--socket', + '/fake/socket', + '--token', + '/fake/token', + '--log-file', + join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') + ]), expect.objectContaining({ cwd: '/fake/userData', detached: true }) ) }) @@ -1239,7 +1253,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, - ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.arrayContaining([ + '--socket', + '/fake/socket', + '--token', + '/fake/token', + '--log-file', + join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') + ]), expect.objectContaining({ detached: true }) ) }) @@ -1499,7 +1520,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, - ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.arrayContaining([ + '--socket', + '/fake/socket', + '--token', + '/fake/token', + '--log-file', + join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') + ]), expect.objectContaining({ detached: true }) ) }) @@ -1584,7 +1612,14 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) expect(forkMock).toHaveBeenCalledWith( FAKE_DAEMON_ENTRY_PATH, - ['--socket', '/fake/socket', '--token', '/fake/token'], + expect.arrayContaining([ + '--socket', + '/fake/socket', + '--token', + '/fake/token', + '--log-file', + join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') + ]), expect.objectContaining({ detached: true }) ) }) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 3ba351c35..0517f2759 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -36,6 +36,11 @@ import { isDaemonStaleForCurrentBundle, killStaleDaemon } from './daemon-health' +import { + collectPinnedDaemonVersions, + materializeRelocatedDaemonHost, + pruneOldDaemonHosts +} from './daemon-host-relocation' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import { getLocalPtyProvider, @@ -44,6 +49,7 @@ import { rebindLocalProviderListeners } from '../ipc/pty' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics' +import { getDaemonLogFilePath } from '../observability/logs-directory' import { confirmSeededClaudeLivePtys, hasSeededUnconfirmedClaudePtys @@ -93,6 +99,18 @@ function getDaemonEntryPath(): string { return join(basePath, 'out', 'main', 'daemon-entry.js') } +// Why: the detached daemon writes lifecycle events to a rotated file so field +// failures are diagnosable from a bundle. Honor the same hard privacy switch +// the local trace sink honors (ORCA_DIAGNOSTICS_DISABLED); absence of the arg +// is fully supported, so gating it off is safe and adoption-neutral. +function daemonLogArgs(): string[] { + const disabled = (process.env.ORCA_DIAGNOSTICS_DISABLED ?? '').trim().toLowerCase() + if (disabled === '1' || disabled === 'true') { + return [] + } + return ['--log-file', getDaemonLogFilePath()] +} + // Why: before spawning a new daemon, check if an existing one is alive by // attempting a TCP connection to the socket. If it connects, the daemon // survived from a previous app session — reuse it instead of spawning. @@ -264,27 +282,43 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { await killStaleDaemon(runtimeDir, socketPath, tokenPath) const userDataPath = app.getPath('userData') - const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], { - // Why: detached daemons can outlive dev worktrees. Starting from - // userData keeps process.cwd() valid after a repo/worktree is deleted. - cwd: userDataPath, - // Why: detached + unref lets the daemon outlive the Electron process. - // stdio 'ignore' prevents the child from holding the parent's stdout - // open, which would prevent Electron from exiting cleanly. - detached: true, - stdio: ['ignore', 'ignore', 'ignore', 'ipc'], - // Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain - // Node.js process instead of an Electron renderer/main process. Without - // it, Electron's GPU/display initialization can interfere with native - // module operations like node-pty's posix_spawn of the spawn-helper. - env: { - ...process.env, - ELECTRON_RUN_AS_NODE: '1', - // Why: the detached daemon is plain Node and cannot call Electron's - // app.getPath(), but shell-ready rcfiles must live outside swept tmp. - ORCA_USER_DATA_PATH: userDataPath + // Why: on win32 packaged, fork from a copy of the Electron runtime staged + // in userData so the daemon's image + loaded modules escape the install dir + // the NSIS updater deletes and force-closes. Staged here (not at app start) + // so the one-time copy stays off the first-paint path and is skipped on + // launches that adopt a live daemon. Fail-open: null → in-dir host, below. + const relocatedHost = materializeRelocatedDaemonHost() + // Fork the relocated entry when available; otherwise the install-dir entry. + const forkEntryPath = relocatedHost ? relocatedHost.entryPath : entryPath + const child = fork( + forkEntryPath, + ['--socket', socketPath, '--token', tokenPath, ...daemonLogArgs()], + { + // Why: detached daemons can outlive dev worktrees. Starting from + // userData keeps process.cwd() valid after a repo/worktree is deleted. + cwd: userDataPath, + // Why: detached + unref lets the daemon outlive the Electron process. + // stdio 'ignore' prevents the child from holding the parent's stdout + // open, which would prevent Electron from exiting cleanly. + detached: true, + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + // Why: run the relocated Orca.exe copy instead of the install-dir one. + // It is byte-identical, so run-as-node behavior is unchanged; only the + // image path moves out of the updater's kill zone. + ...(relocatedHost ? { execPath: relocatedHost.execPath } : {}), + // Why: ELECTRON_RUN_AS_NODE makes the forked process run as a plain + // Node.js process instead of an Electron renderer/main process. Without + // it, Electron's GPU/display initialization can interfere with native + // module operations like node-pty's posix_spawn of the spawn-helper. + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + // Why: the detached daemon is plain Node and cannot call Electron's + // app.getPath(), but shell-ready rcfiles must live outside swept tmp. + ORCA_USER_DATA_PATH: userDataPath + } } - }) + ) // Wait for the daemon to signal readiness via IPC await new Promise((resolve, reject) => { @@ -398,6 +432,10 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise // throws, a stale spawner would prevent shutdownDaemon() from cleaning up // correctly on retry. const info = await newSpawner.ensureRunning() + // Reclaim superseded daemon-host copies on EVERY launch, not just on a fresh + // spawn: surviving daemons make spawns rare, so a spawn-only sweep would let + // old-version copies accumulate. Current + live-daemon-pinned versions stay. + pruneOldDaemonHosts(collectPinnedDaemonVersions(runtimeDir)) const launchMode = newSpawner.getHandle()?.mode logDaemonMilestone('daemon-current-ready') if (signal?.aborted) { diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index 63078067c..82f4dad7b 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -1,9 +1,11 @@ import { DaemonServer, type DaemonServerOptions } from './daemon-server' +import type { DaemonFileLog } from './daemon-file-log' export type DaemonStartOptions = { socketPath: string tokenPath: string spawnSubprocess: DaemonServerOptions['spawnSubprocess'] + log?: DaemonFileLog } export type DaemonHandle = { @@ -14,7 +16,8 @@ export async function startDaemon(opts: DaemonStartOptions): Promise Promise + log?: DaemonFileLog spawnSubprocess: (opts: { sessionId: string cols: number @@ -48,6 +50,7 @@ export class DaemonServer { private socketPath: string private tokenPath: string private ptySpawnHealthCheck: () => Promise + private log: DaemonFileLog private clients = new Map() private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId)) @@ -66,6 +69,7 @@ export class DaemonServer { this.token = randomUUID() this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess }) this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth + this.log = opts.log ?? createNoopDaemonFileLog() } async start(): Promise { @@ -139,23 +143,30 @@ export class DaemonServer { ): void { const hello = msg as HelloMessage if (hello.type !== 'hello') { + this.log.log('client-hello-rejected', { reason: 'expected-hello' }) socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Expected hello' })) socket.destroy() return } if (hello.version !== PROTOCOL_VERSION) { + this.log.log('client-hello-rejected', { + reason: 'protocol-mismatch', + clientVersion: hello.version + }) socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Protocol version mismatch' })) socket.destroy() return } if (hello.token !== this.token) { + this.log.log('client-hello-rejected', { reason: 'invalid-token', role: hello.role }) socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' })) socket.destroy() return } + this.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId }) socket.write(encodeNdjson({ type: 'hello', ok: true })) if (hello.role === 'control') { @@ -297,6 +308,7 @@ export class DaemonServer { onExit: (code) => { // Why: exit tears down renderer handlers; flush final output first // so the last few milliseconds of PTY data are not stranded. + this.log.log('session-exited', { sessionId: p.sessionId, code }) this.streamDataBatcher.flush(clientId) this.lastInputAtBySessionId.delete(p.sessionId) if (client?.streamSocket) { @@ -312,6 +324,10 @@ export class DaemonServer { } } }) + this.log.log(result.isNew ? 'session-created' : 'session-attached', { + sessionId: p.sessionId, + pid: result.pid + }) return { isNew: result.isNew, snapshot: result.snapshot, @@ -349,6 +365,10 @@ export class DaemonServer { case 'kill': this.lastInputAtBySessionId.delete(request.payload.sessionId) + this.log.log('session-killed', { + sessionId: request.payload.sessionId, + immediate: request.payload.immediate === true + }) this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate }) return {} @@ -359,6 +379,7 @@ export class DaemonServer { case 'detach': // Note: detach token handling is simplified here — full implementation // would track tokens per client + this.log.log('session-detached', { sessionId: request.payload.sessionId }) return {} case 'getCwd': @@ -402,6 +423,10 @@ export class DaemonServer { return { healthy: true } case 'shutdown': + this.log.log('shutdown', { + reason: 'rpc', + killSessions: request.payload.killSessions === true + }) if (request.payload.killSessions) { this.host.dispose() } diff --git a/src/main/observability/bundle.test.ts b/src/main/observability/bundle.test.ts index d53271182..347e10258 100644 --- a/src/main/observability/bundle.test.ts +++ b/src/main/observability/bundle.test.ts @@ -140,6 +140,53 @@ describe('bundle — collection', () => { expect(bundle.payload).not.toContain('"name":"old"') }) + it('merges the daemon lifecycle log, bounded by the same lookback', () => { + const daemonFile = join(dir, 'daemon.log') + writeFileSync(traceFile, makeNDJSON([makeSpan({ name: 'recent' })])) + writeFileSync( + daemonFile, + makeNDJSON([ + { src: 'daemon', ts: new Date().toISOString(), pid: 1, event: 'startup' }, + { + src: 'daemon', + // 1h ago — outside the 30m window, must be dropped. + ts: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + pid: 1, + event: 'session-exited' + } + ]) + ) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + daemonLogFilePath: daemonFile, + daemonLogMaxFiles: 3, + lookbackMinutes: 30, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + expect(bundle.payload).toContain('"event":"startup"') + expect(bundle.payload).toContain('"name":"recent"') + expect(bundle.payload).not.toContain('"event":"session-exited"') + }) + + it('collects no daemon log lines when no daemon log path is given', () => { + writeFileSync(traceFile, makeNDJSON([makeSpan({ name: 'recent' })])) + const bundle = collectBundle({ + traceFilePath: traceFile, + maxFiles: 10, + appVersion: '1', + platform: 'darwin', + arch: 'arm64', + osRelease: '24', + orcaChannel: 'dev' + }) + expect(bundle.payload).not.toContain('"src":"daemon"') + }) + it('runs the redactor on the merged payload (belt-and-suspenders)', () => { // Simulate a sink-write bug that leaked a secret through. The bundle // pass should still strip it. diff --git a/src/main/observability/bundle.ts b/src/main/observability/bundle.ts index 812e354d5..34011d9e4 100644 --- a/src/main/observability/bundle.ts +++ b/src/main/observability/bundle.ts @@ -39,6 +39,10 @@ const DEFAULT_LOOKBACK_MINUTES = 30 export type CollectBundleOptions = { readonly traceFilePath: string readonly maxFiles: number + /** Detached-daemon lifecycle log. Its rotated family is merged into the + * bundle so daemon-side failures are diagnosable from a field report. */ + readonly daemonLogFilePath?: string + readonly daemonLogMaxFiles?: number readonly lookbackMinutes?: number readonly appVersion: string readonly platform: string @@ -95,7 +99,8 @@ function* readLinesNewestFirst(text: string): Iterable { */ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { const lookbackMs = (opts.lookbackMinutes ?? DEFAULT_LOOKBACK_MINUTES) * 60 * 1000 - const cutoffNanos = BigInt(Date.now() - lookbackMs) * 1_000_000n + const cutoffMs = Date.now() - lookbackMs + const cutoffNanos = BigInt(cutoffMs) * 1_000_000n const bundleSubmissionId = generateBundleSubmissionId() const header: BundleHeader = { bundle_submission_id: bundleSubmissionId, @@ -123,7 +128,15 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { // older than the cutoff in an older file we can stop entirely. We don't // optimize that yet; the worst case (10 × 10 MB = 100 MB scan) takes // <1 s on a modern SSD and bundles are user-initiated, not hot-path. - const files = listRotatedFiles(opts.traceFilePath, opts.maxFiles) + // Trace spans first (the primary payload), then the daemon lifecycle log. + // Daemon records carry an ISO `ts` instead of `endTimeUnixNano`; both are + // filtered by the same lookback below. + const files = [ + ...listRotatedFiles(opts.traceFilePath, opts.maxFiles), + ...(opts.daemonLogFilePath + ? listRotatedFiles(opts.daemonLogFilePath, opts.daemonLogMaxFiles ?? opts.maxFiles) + : []) + ] outer: for (const file of files) { let text: string try { @@ -153,7 +166,11 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { continue } - const record = parsed as { startTimeUnixNano?: string; endTimeUnixNano?: string } + const record = parsed as { + startTimeUnixNano?: string + endTimeUnixNano?: string + ts?: string + } // Filter by end-time, not start-time. A long-lived span started 35 // minutes ago but ending inside the lookback is exactly what we want // in the bundle for diagnosing "session crashed at minute 32." @@ -166,6 +183,13 @@ export function collectBundle(opts: CollectBundleOptions): CollectedBundle { // Non-numeric end-time — keep it; better to over-include than to // drop a record we couldn't classify. } + } else if (typeof record.ts === 'string') { + // Daemon lifecycle lines timestamp with an ISO `ts`; bound them by the + // same lookback window. Unparseable timestamps are kept (over-include). + const tsMs = Date.parse(record.ts) + if (Number.isFinite(tsMs) && tsMs < cutoffMs) { + continue + } } // Run the redactor a SECOND TIME over the parsed shape, in server mode. diff --git a/src/main/observability/index.ts b/src/main/observability/index.ts index 9ab216d50..791653d27 100644 --- a/src/main/observability/index.ts +++ b/src/main/observability/index.ts @@ -28,15 +28,14 @@ // import isolation rule above. The cost of one duplicated array vs. // punching a hole in the architecture is trivially worth it. -import { app } from 'electron' -import { homedir, platform } from 'node:os' -import { join } from 'node:path' import { createLocalFileSink, DEFAULT_MAX_FILES, getRotatedFamilySize, type LocalFileSink } from './local-file-sink' +import { getDaemonLogFilePath, getTraceFilePath } from './logs-directory' +import { DAEMON_LOG_MAX_FILES } from '../daemon/daemon-file-log' import { collectBundle as _collectBundle, type CollectBundleOptions, @@ -128,30 +127,9 @@ export function resolveObservabilityConsent(): ObservabilityConsent { } } -/** Path for the trace NDJSON file. macOS conventional location is - * `~/Library/Application Support/Orca/logs/main.trace.ndjson`; we resolve - * the same intent on Windows / Linux via Electron's `userData` dir. The - * function falls back to homedir when Electron is not available (tests). - */ -export function getTraceFilePath(): string { - let userData: string - try { - userData = app.getPath('userData') - } catch { - // Tests — Electron's `app` may not be initialized. Use a sensible - // OS-conventional fallback so unit tests can construct the path - // without spinning up the full Electron runtime. - const home = homedir() - if (platform() === 'darwin') { - userData = join(home, 'Library', 'Application Support', 'Orca') - } else if (platform() === 'win32') { - userData = join(process.env.APPDATA ?? home, 'Orca') - } else { - userData = join(home, '.config', 'Orca') - } - } - return join(userData, 'logs', 'main.trace.ndjson') -} +// Re-exported so existing importers of the trace path keep working; the +// resolution now lives in one place alongside the daemon log path. +export { getTraceFilePath } from './logs-directory' // ── Module-level state ─────────────────────────────────────────────────── @@ -237,6 +215,11 @@ export function collectDiagnosticBundle( return _collectBundle({ traceFilePath: getTraceFilePath(), maxFiles: DEFAULT_MAX_FILES, + // Why: the detached daemon writes its lifecycle log to a separate file, so + // the bundle collector must be pointed at it explicitly — it does not glob + // the logs directory. + daemonLogFilePath: getDaemonLogFilePath(), + daemonLogMaxFiles: DAEMON_LOG_MAX_FILES, ...meta }) } diff --git a/src/main/observability/logs-directory.ts b/src/main/observability/logs-directory.ts new file mode 100644 index 000000000..94a1b7baa --- /dev/null +++ b/src/main/observability/logs-directory.ts @@ -0,0 +1,41 @@ +// Single source of truth for the app's logs directory and the files inside it. +// macOS convention is `~/Library/Application Support/Orca/logs/`; Windows and +// Linux resolve the same intent via Electron's `userData` dir. Falls back to a +// homedir-derived path when Electron's `app` is unavailable (unit tests). + +import { app } from 'electron' +import { homedir, platform } from 'node:os' +import { join } from 'node:path' + +function getUserDataDir(): string { + try { + return app.getPath('userData') + } catch { + // Tests — Electron's `app` may not be initialized. Use an OS-conventional + // fallback so callers can resolve the path without the Electron runtime. + const home = homedir() + if (platform() === 'darwin') { + return join(home, 'Library', 'Application Support', 'Orca') + } + if (platform() === 'win32') { + return join(process.env.APPDATA ?? home, 'Orca') + } + return join(home, '.config', 'Orca') + } +} + +export function getLogsDirectory(): string { + return join(getUserDataDir(), 'logs') +} + +/** NDJSON trace file written by the main-process error-tracking sink. */ +export function getTraceFilePath(): string { + return join(getLogsDirectory(), 'main.trace.ndjson') +} + +/** NDJSON lifecycle log written by the detached daemon process. Shared here so + * the daemon fork (which passes it as `--log-file`) and the bundle collector + * (which reads it) agree on one path. */ +export function getDaemonLogFilePath(): string { + return join(getLogsDirectory(), 'daemon.log') +} diff --git a/tools/daemon-relocation-spike/README.md b/tools/daemon-relocation-spike/README.md new file mode 100644 index 000000000..d7edd313a --- /dev/null +++ b/tools/daemon-relocation-spike/README.md @@ -0,0 +1,160 @@ +# daemon-relocation-spike + +A throwaway probe that answers one empirical question for Phase 1 of the +Windows update-survival work: + +> What is the **minimal set of files** that must be copied out of a packaged +> `win-unpacked` build so that a **copied `Orca.exe`** — run with +> `ELECTRON_RUN_AS_NODE=1` from a directory **outside** the install dir — can: +> (a) start the terminal daemon and signal ready over IPC, +> (b) spawn a real ConPTY `node-pty` session, write input, and read output back, +> (c) do all of that while holding **no open file handles into the app/install +> dir**, so an NSIS update could delete the original install. + +## Why this matters + +The daemon is `fork()`ed from the app's own Electron binary (`Orca.exe`) with +`ELECTRON_RUN_AS_NODE=1`, from the **install** directory. On a Windows update, +electron-builder's NSIS installer (a) runs `uninstallOldVersion` (deletes the +registered install's files) and (b) `CHECK_APP_RUNNING` force-closes every +process whose image path is under `$INSTDIR`. So the daemon dies and its held +file locks can break the update. + +The Phase 1 fix copies the daemon's whole file closure **out** of the install +dir (into `%LOCALAPPDATA%`/userData) and forks the daemon from the **copy**, so +its image + all loaded modules live outside `$INSTDIR`. This spike measures how +small that copy can be while still working. + +We keep the **Electron binary run as node** (not a stock `node.exe`): a prior +attempt (#7473, reverted) switched to stock node and caused Windows-console +flashing (stock node lacks Electron's `kHideConsoleWindows`) plus asar breakage. +This spike does **not** reintroduce stock node. + +## Usage + +``` +# Real run (Windows, needs a packaged win-unpacked build): +node tools/daemon-relocation-spike/spike.mjs \ + --app-dir \ + --work-dir \ + [--tier full|no-gpu|minimal] \ + [--keep-work-dir] + +# Offline logic validation (any OS, no build, no launch): +node tools/daemon-relocation-spike/spike.mjs --selftest +``` + +Exit code is `0` only when the run **PASSES**: daemon ready, PTY echo +round-trips the nonce, the daemon's main module is the copied `Orca.exe`, and +**no** loaded module resolves under `--app-dir`. + +## Tiers (defined as data in `tier-file-set.mjs`) + +Every tier includes the irreducible core: `Orca.exe`, `icudtl.dat`, both V8 +snapshot blobs (`snapshot_blob.bin`, `v8_context_snapshot.bin`), the daemon +bundle (`out/main/daemon-entry.js` + `chunks/` + `out/package.json`), and the +whole `node-pty` package (native `conpty.node` + the sibling `conpty/` runtime +dir holding `conpty.dll` + `OpenConsole.exe`). + +Tiers differ only in which top-level `*.dll` files they carry: + +| Tier | Top-level DLLs | +| --------- | ---------------------------------------------------------------- | +| `full` | **all** top-level `*.dll` | +| `no-gpu` | all **except** GPU/render DLLs (`libEGL`, `libGLESv2`, `vk_swiftshader`, `vulkan-1`, `d3dcompiler_47`); **keeps** `ffmpeg.dll` | +| `minimal` | **none** (exe + data blobs + daemon bundle + node-pty only) | + +Trimming further is a config change (edit `TIER_DEFINITIONS` / `GPU_DLLS`), not +a code change. + +## How node-pty's native + ConPTY runtime is handled + +The whole `node-pty` package tree is copied, and **the entire win-unpacked +layout is mirrored verbatim** (every copy destination is relative to the +win-unpacked root, not the asar-unpacked root). This matters because node-pty is +packaged at `resources/node_modules/node-pty` — a **sibling** of +`app.asar.unpacked`, not under it (see +`config/packaged-runtime-node-modules.cjs`). Mirroring the full layout preserves +two resolutions from the relocated path: + +1. **The daemon require-closure** resolves `require('node-pty')` by walking + parent dirs up from the mirrored + `resources/app.asar.unpacked/out/main/daemon-entry.js`, which passes through + `resources/` and finds `resources/node_modules/node-pty` — exactly as in the + packaged app. +2. **node-pty's own native loader** resolves `conpty.node` from `build/Release` + (or `prebuilds/win32-`) relative to node-pty's own `__dirname`, and + node-pty's Windows addon loads `conpty.dll` from `/conpty/` + and spawns `OpenConsole.exe` from beside it. Copying the tree verbatim keeps + all three side-by-side. + +### `ORCA_NODE_PTY_NATIVE_DIR` + +The reverted #7421 added a `node-pty` patch that reads +`ORCA_NODE_PTY_NATIVE_DIR` to override the native dir. **The current branch's +`config/patches/node-pty@1.1.0.patch` does NOT contain that override** — it was +reverted. The spike therefore relies on **layout preservation** (copying the +node-pty tree at its default relative path) rather than the env override. The +spike still *sets* `ORCA_NODE_PTY_NATIVE_DIR` to the relocated native dir so it +keeps working if pointed at a build that carries the patch, but on this branch +the var is inert. + +**Implication for the real Phase 1 implementation:** if the production copy does +NOT preserve node-pty at the path its loader resolves by default (e.g. if the +daemon-entry is relocated without the sibling `node_modules/node-pty`), the impl +will need to **re-add the `ORCA_NODE_PTY_NATIVE_DIR` patch** from #7421. If it +mirrors the layout as this spike does, the patch is not strictly required — +though re-adding it is the more robust choice. + +## The handshake / client + +`ndjson-client.mjs` is a small standalone NDJSON client (no electron/src +imports) that mirrors `src/main/daemon/daemon-server.ts`: + +1. Read the token the server writes to the token file after it begins listening. +2. Open a **control** socket, send `hello {role:'control'}`, await + `{type:'hello', ok:true}`. +3. Open a **stream** socket with the **same** `clientId`, send + `hello {role:'stream'}`. +4. `createOrAttach` on control, then `write` `echo SPIKE-OK-\r\n`, and + read `data` events on the stream socket until the nonce appears **alone at + line start** (executed output, distinct from the echoed input line). + +`PROTOCOL_VERSION` is read at runtime from `src/main/daemon/types.ts` so the +client never drifts from the daemon. + +## The handle probe + +`loaded-modules.ps1` (via `loaded-module-probe.mjs`) runs +`Get-Process -Id ` and enumerates `.Modules[].FileName`. Any module path +under `--app-dir` is a **lock risk** (the installer cannot replace a file a live +process maps), so a passing relocation must show **zero**. It also asserts the +process's **main module** is the copied `Orca.exe`, not the install-dir one. + +Loaded DLLs are the lock-critical set. Data files (`icudtl.dat`, asar) are not +memory-mapped as modules, so this probe does not enumerate them — the copy plan +handles those by construction (they are copied, so nothing opens the originals). + +## What remains unverified until CI runs it + +This session has **no build**, so the launch path is unproven. Verified here: +`node --check` on every `.mjs`, a green `--selftest`, and clean +`pnpm exec oxlint`. Open questions the real CI run must answer: + +- Whether `TIER_MINIMAL` (no top-level DLLs) boots `Orca.exe` as node at all, or + whether run-as-node still needs `ffmpeg.dll` / others — this is the core + empirical result. +- Whether the daemon bundle require-closure needs any **other** unpacked + `node_modules` beyond `node-pty` (surfaces as a ready-timeout if so). +- Whether any loaded module still resolves under `--app-dir` (the handle probe + will name it). + +## Recommendation for the likely-minimal tier + +`no-gpu` is the safe minimal target to ship: run-as-node Electron does not +initialize the GPU/render stack, so `libEGL` / `libGLESv2` / `vk_swiftshader` / +`vulkan-1` / `d3dcompiler_47` are very unlikely to load, while `ffmpeg.dll` and +the ICU/snapshot data are retained because the Electron bootstrap references +them regardless of run-as-node. Run `--tier minimal` on CI first: if it PASSES, +ship minimal; if `Orca.exe` fails to boot without the non-GPU DLLs, fall back to +`no-gpu`. `full` is the always-works upper bound for comparison. diff --git a/tools/daemon-relocation-spike/app-inventory.mjs b/tools/daemon-relocation-spike/app-inventory.mjs new file mode 100644 index 000000000..0460d1d4c --- /dev/null +++ b/tools/daemon-relocation-spike/app-inventory.mjs @@ -0,0 +1,146 @@ +// Locates the daemon-host inputs inside a packaged win-unpacked build. +// +// Layout the packager produces (see config/electron-builder.config.cjs and +// config/packaged-runtime-node-modules.cjs): +// /Orca.exe electron binary (run as node) +// /icudtl.dat ICU data (needed even as node) +// /snapshot_blob.bin V8 snapshot +// /v8_context_snapshot.bin V8 context snapshot +// /*.dll electron/GPU runtime DLLs +// /resources/app.asar.unpacked/out/main/daemon-entry.js (+ chunks/) +// /resources/app.asar.unpacked/node_modules/node-pty/** native + conpty + +import { existsSync, readdirSync, statSync } from 'node:fs' +import { basename, join } from 'node:path' + +export const RUNTIME_DATA_FILES = ['icudtl.dat', 'snapshot_blob.bin', 'v8_context_snapshot.bin'] +export const HOST_EXE = 'Orca.exe' + +// Windows arch dir names node-pty prebuilds ship under; build/Release is the +// packaged rebuild location and takes precedence. +const NODE_PTY_NATIVE_CANDIDATES = ['build/Release', 'prebuilds/win32-x64', 'prebuilds/win32-arm64'] + +function fileEntry(dir, name) { + const path = join(dir, name) + if (!existsSync(path)) { + return { name, path, exists: false, size: 0 } + } + return { name, path, exists: true, size: statSync(path).size } +} + +function listTopLevelDlls(appDir) { + const dlls = [] + for (const name of readdirSync(appDir)) { + if (name.toLowerCase().endsWith('.dll')) { + dlls.push(fileEntry(appDir, name)) + } + } + return dlls.sort((a, b) => a.name.localeCompare(b.name)) +} + +// Resolve the app.asar.unpacked root: everything the forked daemon-entry +// require-closure resolves (chunks, node-pty) lives under it, so the copied +// host must mirror it verbatim. +function resolveUnpackedRoot(appDir) { + const candidate = join(appDir, 'resources', 'app.asar.unpacked') + return existsSync(candidate) ? candidate : null +} + +// getDaemonEntryPath() in daemon-init.ts probes daemon-entry.js at the unpacked +// root first, then out/main — mirror that resolution order here. +function resolveDaemonEntry(unpackedRoot) { + if (!unpackedRoot) { + return { name: 'daemon-entry.js', path: '', exists: false, size: 0, relFromUnpacked: '' } + } + const direct = join(unpackedRoot, 'daemon-entry.js') + if (existsSync(direct)) { + return { ...fileEntry(unpackedRoot, 'daemon-entry.js'), relFromUnpacked: 'daemon-entry.js' } + } + const nested = join('out', 'main', 'daemon-entry.js') + const nestedPath = join(unpackedRoot, nested) + return { + name: 'daemon-entry.js', + path: nestedPath, + exists: existsSync(nestedPath), + size: existsSync(nestedPath) ? statSync(nestedPath).size : 0, + relFromUnpacked: nested.split('\\').join('/') + } +} + +function resolveNodePty(unpackedRoot, appDir) { + // Prefer the unpacked-root copy (what the daemon require-closure resolves); + // fall back to a resources-level copy some builds also stage. + const roots = [] + if (unpackedRoot) { + roots.push(join(unpackedRoot, 'node_modules', 'node-pty')) + } + roots.push(join(appDir, 'resources', 'node_modules', 'node-pty')) + + for (const dir of roots) { + if (!existsSync(dir)) { + continue + } + for (const rel of NODE_PTY_NATIVE_CANDIDATES) { + const nativeDir = join(dir, ...rel.split('/')) + if (existsSync(join(nativeDir, 'conpty.node'))) { + return { + exists: true, + packageDir: dir, + nativeDir, + nativeRel: rel, + conptyNode: fileEntry(nativeDir, 'conpty.node'), + // node-pty's Windows addon loads conpty.dll from /conpty/. + conptyDll: fileEntry(join(nativeDir, 'conpty'), 'conpty.dll'), + openConsole: fileEntry(join(nativeDir, 'conpty'), 'OpenConsole.exe') + } + } + } + // node-pty present but no ConPTY native found under known dirs. + return { exists: true, packageDir: dir, nativeDir: '', nativeRel: '', conptyNode: null } + } + return { exists: false, packageDir: '', nativeDir: '', nativeRel: '', conptyNode: null } +} + +/** + * Discover every daemon-host input in `appDir`. Never throws for missing files; + * each entry carries an `exists` flag so the caller can print a full report and + * decide whether the chosen tier is buildable. + */ +export function inventoryAppDir(appDir) { + const unpackedRoot = resolveUnpackedRoot(appDir) + return { + appDir, + unpackedRoot, + hostExe: fileEntry(appDir, HOST_EXE), + runtimeData: RUNTIME_DATA_FILES.map((name) => fileEntry(appDir, name)), + topLevelDlls: listTopLevelDlls(appDir), + daemonEntry: resolveDaemonEntry(unpackedRoot), + nodePty: resolveNodePty(unpackedRoot, appDir) + } +} + +/** Human-readable inventory dump for the run report. */ +export function formatInventory(inv) { + const lines = [] + const kib = (n) => `${(n / 1024).toFixed(1)} KiB` + const mark = (e) => (e.exists ? 'OK ' : 'MISS') + lines.push(`app-dir: ${inv.appDir}`) + lines.push(` ${mark(inv.hostExe)} ${inv.hostExe.name} (${kib(inv.hostExe.size)})`) + for (const e of inv.runtimeData) { + lines.push(` ${mark(e)} ${e.name} (${kib(e.size)})`) + } + lines.push(` top-level DLLs: ${inv.topLevelDlls.length}`) + for (const e of inv.topLevelDlls) { + lines.push(` - ${basename(e.name)} (${kib(e.size)})`) + } + lines.push( + ` ${mark(inv.daemonEntry)} daemon-entry: ${inv.daemonEntry.relFromUnpacked || '(none)'}` + ) + const np = inv.nodePty + lines.push(` node-pty: ${np.exists ? np.packageDir : '(none)'}`) + if (np.conptyNode) { + lines.push(` native: ${np.nativeRel} conpty.node (${kib(np.conptyNode.size)})`) + lines.push(` conpty.dll: ${mark(np.conptyDll)} OpenConsole.exe: ${mark(np.openConsole)}`) + } + return lines.join('\n') +} diff --git a/tools/daemon-relocation-spike/cli.mjs b/tools/daemon-relocation-spike/cli.mjs new file mode 100644 index 000000000..227580d5e --- /dev/null +++ b/tools/daemon-relocation-spike/cli.mjs @@ -0,0 +1,88 @@ +// Argument parsing + usage for the daemon-relocation spike. +// +// The spike answers an empirical question (see README): what is the MINIMAL +// set of files that must be copied out of a packaged win-unpacked build so a +// COPIED Orca.exe (ELECTRON_RUN_AS_NODE=1) can host the terminal daemon and a +// real ConPTY session while holding no open handles into the install dir. + +export const TIERS = ['full', 'no-gpu', 'minimal'] + +const USAGE = ` +daemon-relocation-spike — minimal relocated daemon-host file-set probe (Windows) + +Usage: + node tools/daemon-relocation-spike/spike.mjs --app-dir --work-dir [--tier ] + node tools/daemon-relocation-spike/spike.mjs --selftest + +Required (launch mode): + --app-dir Path to a packaged win-unpacked build (contains Orca.exe) + --work-dir Scratch dir for the copied host + logs (created, then removed) + +Options: + --tier File-set tier to copy: "full" (default), "no-gpu", "minimal" + full = Orca.exe + icudtl.dat + both snapshot blobs + + ALL top-level *.dll + daemon bundle + node-pty + no-gpu = full minus GPU/render DLLs (libEGL, libGLESv2, + vk_swiftshader, vulkan-1, d3dcompiler_47); + ffmpeg.dll kept + minimal = Orca.exe + icudtl.dat + both snapshots + + daemon bundle + node-pty only (no top-level DLLs) + --keep-work-dir Leave --work-dir on disk after the run (for inspection) + --selftest Validate arg parsing, tier defs, and the module-path filter + against synthetic inputs. No real build or launch. Exits + 0 on pass, non-zero on failure. + -h, --help Show this help + +Exit code is 0 only when the run PASSES: daemon ready, PTY echo round-trips the +nonce, and NO loaded module resolves under --app-dir. +` + +export function getUsage() { + return USAGE +} + +/** + * Parse argv (already sliced past `node script`). Returns a discriminated + * result: { help }, { selftest }, or { launch: {...} }. On a usage error + * returns { error } so the caller can print USAGE and exit non-zero. + */ +export function parseArgs(argv) { + if (argv.includes('-h') || argv.includes('--help')) { + return { help: true } + } + if (argv.includes('--selftest')) { + return { selftest: true } + } + + let appDir = '' + let workDir = '' + let tier = 'full' + let keepWorkDir = false + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--app-dir' && argv[i + 1]) { + appDir = argv[i + 1] + i++ + } else if (arg === '--work-dir' && argv[i + 1]) { + workDir = argv[i + 1] + i++ + } else if (arg === '--tier' && argv[i + 1]) { + tier = argv[i + 1] + i++ + } else if (arg === '--keep-work-dir') { + keepWorkDir = true + } else { + return { error: `Unknown or incomplete argument: ${arg}` } + } + } + + if (!appDir || !workDir) { + return { error: 'Both --app-dir and --work-dir are required' } + } + if (!TIERS.includes(tier)) { + return { error: `Invalid --tier "${tier}" (expected one of: ${TIERS.join(', ')})` } + } + + return { launch: { appDir, workDir, tier, keepWorkDir } } +} diff --git a/tools/daemon-relocation-spike/daemon-launch.mjs b/tools/daemon-relocation-spike/daemon-launch.mjs new file mode 100644 index 000000000..74784db9f --- /dev/null +++ b/tools/daemon-relocation-spike/daemon-launch.mjs @@ -0,0 +1,99 @@ +// Launches the copied Orca.exe as the daemon host (ELECTRON_RUN_AS_NODE=1) and +// waits for its {type:'ready'} IPC signal. Mirrors the real fork() options in +// src/main/daemon/daemon-init.ts (detached, ipc channel, ORCA_USER_DATA_PATH). + +import { spawn } from 'node:child_process' +import { createWriteStream } from 'node:fs' +import { join } from 'node:path' + +/** + * Spawn the daemon host. Resolves { child, pid } once the daemon signals ready, + * or rejects on early exit / timeout. stdout+stderr are teed to files under + * workDir. The caller owns shutdown (SIGTERM `child`). + */ +export function launchDaemonHost(options) { + const { + hostExePath, + daemonEntryPath, + socketPath, + tokenPath, + workDir, + nodePtyNativeDir, + logFilePath, + readyTimeoutMs = 30000 + } = options + + const stdoutLog = createWriteStream(join(workDir, 'daemon-stdout.log')) + const stderrLog = createWriteStream(join(workDir, 'daemon-stderr.log')) + + const env = { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + ORCA_USER_DATA_PATH: workDir + } + // The current branch's node-pty patch resolves natives relative to its own + // dir, so this env var is inert there; set it anyway so the spike still works + // if run against a build that carries the ORCA_NODE_PTY_NATIVE_DIR patch. + if (nodePtyNativeDir) { + env.ORCA_NODE_PTY_NATIVE_DIR = nodePtyNativeDir + } + + // Why: --log-file makes the daemon write its session lifecycle events + // (session-created / session-exited / uncaught-exception-suppressed) to a + // file, which is the only window into ConPTY spawn failures that don't reach + // stdout/stderr. daemon-entry parses it as an optional Phase 0 flag. + const daemonArgs = [daemonEntryPath, '--socket', socketPath, '--token', tokenPath] + if (logFilePath) { + daemonArgs.push('--log-file', logFilePath) + } + + const child = spawn(hostExePath, daemonArgs, { + cwd: workDir, + detached: true, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + env + }) + child.stdout.pipe(stdoutLog) + child.stderr.pipe(stderrLog) + + return new Promise((resolve, reject) => { + let settled = false + const timer = setTimeout(() => { + finish(new Error(`daemon did not signal ready within ${readyTimeoutMs}ms`)) + }, readyTimeoutMs) + + function finish(err) { + if (settled) { + return + } + settled = true + clearTimeout(timer) + child.off('message', onMessage) + child.off('error', onError) + child.off('exit', onExit) + if (err) { + // Expose the still-running detached child so the caller can stop it; + // otherwise a ready-timeout leaks the daemon with no handle to kill it. + err.child = child + reject(err) + } else { + resolve({ child, pid: child.pid }) + } + } + function onMessage(msg) { + if (msg && typeof msg === 'object' && msg.type === 'ready') { + finish(null) + } + } + function onError(err) { + finish(err) + } + function onExit(code, signal) { + finish(new Error(`daemon exited before ready (code=${code}, signal=${signal})`)) + } + + child.on('message', onMessage) + child.on('error', onError) + child.on('exit', onExit) + }) +} diff --git a/tools/daemon-relocation-spike/host-copy.mjs b/tools/daemon-relocation-spike/host-copy.mjs new file mode 100644 index 000000000..f95541a91 --- /dev/null +++ b/tools/daemon-relocation-spike/host-copy.mjs @@ -0,0 +1,62 @@ +// Materializes a tier's copy plan into /daemon-host/, preserving the +// relative layout the daemon require-closure and node-pty native resolution +// expect. Returns the paths the launcher needs. + +import { cpSync, existsSync, mkdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { HOST_EXE } from './app-inventory.mjs' +import { toPosixRelative } from './tier-file-set.mjs' + +export const HOST_SUBDIR = 'daemon-host' + +function destPath(hostRoot, destRel) { + return join(hostRoot, ...destRel.split('/')) +} + +/** + * Execute the copy plan. Returns { hostRoot, hostExePath, daemonEntryPath, + * nodePtyNativeDir, skipped } — nodePtyNativeDir is the / dir under the + * copied node-pty, offered to callers that want to set ORCA_NODE_PTY_NATIVE_DIR + * (see README: the current branch's node-pty patch does NOT read it, so it is + * belt-and-suspenders here). + */ +export function copyHost(inv, plan, workDir) { + const hostRoot = join(workDir, HOST_SUBDIR) + mkdirSync(hostRoot, { recursive: true }) + + const skipped = [] + for (const op of plan.ops) { + const dest = destPath(hostRoot, op.destRel) + if (!existsSync(op.sourcePath)) { + if (!op.optional) { + skipped.push(op.destRel) + } + continue + } + mkdirSync(dirname(dest), { recursive: true }) + // cpSync mirrors both files and directory trees; dereference symlinks so + // the copy holds no link back into the app dir. + cpSync(op.sourcePath, dest, { + recursive: op.kind === 'dir', + dereference: true, + force: true + }) + } + + const hostExePath = join(hostRoot, HOST_EXE) + // Both paths mirror the win-unpacked layout under hostRoot, so they resolve + // exactly as they do in the packaged app (relative to appDir). + const daemonEntryPath = inv.daemonEntry.exists + ? destPath(hostRoot, toPosixRelative(inv.appDir, inv.daemonEntry.path)) + : '' + + // The relocated node-pty native dir (build/Release or prebuilds/...), mirrored + // under the host root at node-pty's real win-unpacked-relative path. + let nodePtyNativeDir = '' + if (inv.nodePty.exists && inv.nodePty.nativeRel) { + const pkgRel = toPosixRelative(inv.appDir, inv.nodePty.packageDir) + nodePtyNativeDir = destPath(hostRoot, `${pkgRel}/${inv.nodePty.nativeRel}`) + } + + return { hostRoot, hostExePath, daemonEntryPath, nodePtyNativeDir, skipped } +} diff --git a/tools/daemon-relocation-spike/loaded-module-probe.mjs b/tools/daemon-relocation-spike/loaded-module-probe.mjs new file mode 100644 index 000000000..07170a508 --- /dev/null +++ b/tools/daemon-relocation-spike/loaded-module-probe.mjs @@ -0,0 +1,85 @@ +// Probes a running daemon's loaded modules (via loaded-modules.ps1) and flags +// any that resolve under the original app dir — those would be locked during an +// NSIS update and defeat the relocation. + +import { spawnSync } from 'node:child_process' +import { join, win32 } from 'node:path' + +const HERE = import.meta.dirname +const POWERSHELL = 'powershell.exe' +const PS_ARGS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass'] + +// Case-insensitive, separator-normalized containment test. The inputs are always +// Windows module paths, so use win32 semantics explicitly — otherwise --selftest +// on a non-Windows host would treat `\` as a literal and every check would fail. +function normalizeForCompare(p) { + return win32 + .normalize(p) + .replace(/[\\/]+$/, '') + .toLowerCase() +} + +/** + * Pure filter: which of `modulePaths` live under `appDir`. Exported so selftest + * can validate the containment logic against synthetic inputs without a launch. + * Matches on a path-segment boundary so `C:\App` does not match `C:\Application`. + */ +export function findAppDirResidentModules(modulePaths, appDir) { + const needle = normalizeForCompare(appDir) + const prefix = `${needle}${win32.sep}` + return modulePaths.filter((raw) => { + const candidate = normalizeForCompare(raw) + return candidate === needle || candidate.startsWith(prefix) + }) +} + +/** + * Run the PowerShell probe for `pid`. Returns + * { found, mainModule, modules } or throws if PowerShell itself fails. + */ +export function probeLoadedModules(pid) { + const script = join(HERE, 'loaded-modules.ps1') + const result = spawnSync(POWERSHELL, [...PS_ARGS, '-File', script, '-ProcessId', String(pid)], { + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024 + }) + if (result.error) { + throw new Error(`failed to spawn PowerShell probe: ${result.error.message}`) + } + // Check exit status before parsing: a non-zero exit that still wrote to stdout + // would otherwise surface as a bare JSON SyntaxError instead of the real error. + if (result.status !== 0) { + throw new Error(`loaded-modules.ps1 exited with status ${result.status}: ${result.stderr}`) + } + const trimmed = (result.stdout ?? '').trim() + if (!trimmed) { + throw new Error( + `loaded-modules.ps1 produced no output (exit ${result.status}): ${result.stderr}` + ) + } + return JSON.parse(trimmed) +} + +/** + * Full handle assessment for a running daemon: whether its main module is the + * copied host exe, and which loaded modules (if any) still live in the app dir. + * Returns a structured verdict; never throws for a clean/empty module list. + */ +export function assessDaemonHandles(pid, appDir, expectedHostExePath) { + const probe = probeLoadedModules(pid) + if (!probe.found) { + return { found: false, mainModuleOk: false, appDirModules: [], mainModule: null } + } + const modules = Array.isArray(probe.modules) ? probe.modules : [] + const appDirModules = findAppDirResidentModules(modules, appDir) + const mainModuleOk = + typeof probe.mainModule === 'string' && + normalizeForCompare(probe.mainModule) === normalizeForCompare(expectedHostExePath) + return { + found: true, + mainModule: probe.mainModule, + mainModuleOk, + moduleCount: modules.length, + appDirModules + } +} diff --git a/tools/daemon-relocation-spike/loaded-modules.ps1 b/tools/daemon-relocation-spike/loaded-modules.ps1 new file mode 100644 index 000000000..98568134e --- /dev/null +++ b/tools/daemon-relocation-spike/loaded-modules.ps1 @@ -0,0 +1,44 @@ +# Emits the main-module path and every loaded module (DLL) path for a process, +# as a single JSON document, for the daemon-relocation spike's handle probe. +# +# Loaded DLLs are the update-time lock risk: the NSIS installer's CHECK_APP_RUNNING +# sweep force-closes processes whose image is under $INSTDIR, and files a live +# process maps cannot be replaced. A relocated host must load ZERO modules from +# the app dir. Data files (icudtl.dat, asar) are not memory-mapped as modules, so +# this probe covers the lock-critical set, not the entire open-handle set. +# +# PS 5.1 guard: $proc.Modules can be a single object (no .Count); @(...) forces +# an array so the enumeration and count are correct for one-module processes. + +param( + [Parameter(Mandatory = $true)] + [int]$ProcessId +) + +$ErrorActionPreference = 'Stop' + +try { + $proc = Get-Process -Id $ProcessId -ErrorAction Stop +} catch { + Write-Output (@{ found = $false; mainModule = $null; modules = @() } | ConvertTo-Json -Compress) + exit 0 +} + +$modulePaths = @() +foreach ($m in @($proc.Modules)) { + if ($m -and $m.FileName) { + $modulePaths += $m.FileName + } +} + +$mainModule = $null +if ($proc.MainModule -and $proc.MainModule.FileName) { + $mainModule = $proc.MainModule.FileName +} + +$result = @{ + found = $true + mainModule = $mainModule + modules = $modulePaths +} +Write-Output ($result | ConvertTo-Json -Compress -Depth 4) diff --git a/tools/daemon-relocation-spike/ndjson-client.mjs b/tools/daemon-relocation-spike/ndjson-client.mjs new file mode 100644 index 000000000..8919823e5 --- /dev/null +++ b/tools/daemon-relocation-spike/ndjson-client.mjs @@ -0,0 +1,262 @@ +// Minimal purpose-built NDJSON client for the daemon wire protocol. +// +// Deliberately standalone (no electron / src imports) so the spike runs under +// plain node. Mirrors the handshake in src/main/daemon/daemon-server.ts: +// 1. read the token the server wrote to after it began listening +// 2. control socket: send hello {role:'control'}, await {type:'hello',ok:true} +// 3. stream socket: send hello {role:'stream'} with the SAME clientId +// 4. createOrAttach on control, then write() input, read 'data' events on stream + +import { connect } from 'node:net' +import { readFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' + +function encodeNdjson(msg) { + return `${JSON.stringify(msg)}\n` +} + +// Split incoming bytes on newlines and dispatch each complete JSON line. +function makeLineReader(onMessage) { + let buffer = '' + return (chunk) => { + buffer += chunk.toString('utf8') + let idx = buffer.indexOf('\n') + while (idx !== -1) { + const line = buffer.slice(0, idx) + buffer = buffer.slice(idx + 1) + if (line.length > 0) { + onMessage(JSON.parse(line)) + } + idx = buffer.indexOf('\n') + } + } +} + +function connectSocket(socketPath, timeoutMs) { + return new Promise((resolve, reject) => { + const socket = connect(socketPath) + const timer = setTimeout(() => { + socket.destroy() + reject(new Error(`connect timeout after ${timeoutMs}ms: ${socketPath}`)) + }, timeoutMs) + socket.once('connect', () => { + clearTimeout(timer) + resolve(socket) + }) + socket.once('error', (err) => { + clearTimeout(timer) + reject(err) + }) + }) +} + +// Send a hello and resolve once the server accepts (or reject on rejection). +function handshake(socket, hello) { + return new Promise((resolve, reject) => { + const read = makeLineReader((msg) => { + if (msg.type === 'hello') { + if (msg.ok) { + resolve(read) + } else { + reject(new Error(`hello rejected: ${msg.error}`)) + } + } + }) + socket.on('data', read) + socket.write(encodeNdjson(hello)) + }) +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +// Strip CSI / OSC / two-char VT escapes so the marker regex can match executed +// PTY output that ConPTY interleaves with cursor-move and SGR color codes. +// eslint-disable-next-line no-control-regex -- ANSI escapes are control chars by definition +const ANSI_ESCAPE = /\[[0-9;?]*[ -/]*[@-~]|\][^]*?|[@-Z\\-_]/g + +function stripAnsi(text) { + return text.replace(ANSI_ESCAPE, '') +} + +// Race a promise against a timeout so a dead daemon can't wedge the failure +// path (an unanswered RPC would otherwise hang until the CI job limit). +function withTimeout(promise, ms, label) { + let timer + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms) + }) + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) +} + +/** + * Connect a control+stream client, create a session, submit `command` + CR, and + * resolve once `expectRe` matches the accumulated PTY output (or reject on + * timeout). Returns { output, diagnostics } on success. On timeout/error the + * rejected Error carries `.diagnostics` and `.output` so the caller can tell + * "no session created" vs "session created but no output" vs "output arrived + * but the regex did not match". Always tears down its sockets. + */ +export async function runPtyEcho(options) { + const { + socketPath, + tokenPath, + protocolVersion, + command, + expectRe, + shellOverride, + connectTimeoutMs = 5000, + ioTimeoutMs = 20000, + // Why: PowerShell/PSReadLine needs a beat to initialize before it echoes + // typed input; writing the command the instant createOrAttach returns can + // race the shell's own startup so the keystrokes land before the prompt. + writeDelayMs = 400 + } = options + + const token = readFileSync(tokenPath, 'utf8').trim() + const clientId = randomUUID() + const sessionId = `spike-${randomUUID()}` + + // Everything we learn on the failure path, so the CI log can pinpoint where + // the ConPTY round-trip broke. + const diagnostics = { + createResponse: null, + ourDataFrames: 0, + otherDataFrames: 0, + exitEvents: [], + rawSample: '', + sessionsAtTimeout: null + } + + const control = await connectSocket(socketPath, connectTimeoutMs) + const stream = await connectSocket(socketPath, connectTimeoutMs) + const teardown = () => { + control.destroy() + stream.destroy() + } + + try { + const controlRead = await handshake(control, { + type: 'hello', + version: protocolVersion, + token, + clientId, + role: 'control' + }) + await handshake(stream, { + type: 'hello', + version: protocolVersion, + token, + clientId, + role: 'stream' + }) + + // Route control RPC responses by id. + const pending = new Map() + control.removeAllListeners('data') + control.on( + 'data', + makeLineReader((msg) => { + if (msg.id && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id) + pending.delete(msg.id) + if (msg.ok) { + resolve(msg.payload) + } else { + reject(new Error(msg.error)) + } + } + }) + ) + // The initial controlRead consumed only the hello; discard it now. + void controlRead + + const rpc = (type, payload) => { + const id = randomUUID() + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }) + control.write(encodeNdjson({ id, type, payload })) + }) + } + + return await new Promise((resolve, reject) => { + let output = '' + let settled = false + + const rejectWith = (err) => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + err.diagnostics = diagnostics + err.output = output + reject(err) + } + + const timer = setTimeout(() => { + // On timeout, ask the daemon what sessions it thinks exist — this + // distinguishes "session never created / already exited" from "session + // alive but silent". + withTimeout(rpc('listSessions'), 2000, 'listSessions') + .then((payload) => { + diagnostics.sessionsAtTimeout = payload?.sessions ?? payload + }) + .catch((err) => { + diagnostics.sessionsAtTimeout = `listSessions error: ${err.message}` + }) + .finally(() => { + rejectWith(new Error(`pty echo timeout after ${ioTimeoutMs}ms`)) + }) + }, ioTimeoutMs) + + stream.removeAllListeners('data') + stream.on( + 'data', + makeLineReader((msg) => { + if (msg.type === 'event' && msg.event === 'data') { + const data = msg.payload?.data ?? '' + if (diagnostics.rawSample.length < 500) { + diagnostics.rawSample = (diagnostics.rawSample + data).slice(0, 500) + } + if (msg.sessionId === sessionId) { + diagnostics.ourDataFrames++ + output += data + // Test against the ANSI-stripped stream: ConPTY interleaves the + // executed marker with cursor-move / SGR codes. + if (expectRe.test(stripAnsi(output)) && !settled) { + settled = true + clearTimeout(timer) + resolve({ output, diagnostics }) + } + } else { + diagnostics.otherDataFrames++ + } + } else if (msg.type === 'event' && msg.event === 'exit') { + diagnostics.exitEvents.push({ sessionId: msg.sessionId, code: msg.payload?.code }) + } + }) + ) + + const createPayload = { sessionId, cols: 120, rows: 30 } + if (shellOverride) { + createPayload.shellOverride = shellOverride + } + rpc('createOrAttach', createPayload) + .then((payload) => { + diagnostics.createResponse = payload + return delay(writeDelayMs) + }) + // Submit with a lone CR: PSReadLine treats CRLF as a soft newline + // (multiline continuation) and leaves the command typed but unexecuted; + // a bare CR is Enter. + .then(() => rpc('write', { sessionId, data: `${command}\r` })) + .catch((err) => { + rejectWith(err instanceof Error ? err : new Error(String(err))) + }) + }) + } finally { + teardown() + } +} diff --git a/tools/daemon-relocation-spike/selftest.mjs b/tools/daemon-relocation-spike/selftest.mjs new file mode 100644 index 000000000..520126716 --- /dev/null +++ b/tools/daemon-relocation-spike/selftest.mjs @@ -0,0 +1,166 @@ +// Offline validation of the spike's pure logic: argument parsing, tier +// definitions, the tier -> copy-plan resolver, and the app-dir module filter. +// No real build, copy, or launch. `runSelftest()` returns true on all-pass. + +import { parseArgs, TIERS } from './cli.mjs' +import { TIER_DEFINITIONS, selectTierDlls, resolveTierFileSet, GPU_DLLS } from './tier-file-set.mjs' +import { findAppDirResidentModules } from './loaded-module-probe.mjs' + +function makeSyntheticInventory() { + const unpackedRoot = 'C:\\App\\resources\\app.asar.unpacked' + const f = (path) => ({ path, exists: true, size: 1 }) + return { + appDir: 'C:\\App', + unpackedRoot, + hostExe: { name: 'Orca.exe', ...f('C:\\App\\Orca.exe') }, + runtimeData: [ + { name: 'icudtl.dat', ...f('C:\\App\\icudtl.dat') }, + { name: 'snapshot_blob.bin', ...f('C:\\App\\snapshot_blob.bin') }, + { name: 'v8_context_snapshot.bin', ...f('C:\\App\\v8_context_snapshot.bin') } + ], + topLevelDlls: [ + { name: 'ffmpeg.dll', ...f('C:\\App\\ffmpeg.dll') }, + { name: 'libEGL.dll', ...f('C:\\App\\libEGL.dll') }, + { name: 'libGLESv2.dll', ...f('C:\\App\\libGLESv2.dll') }, + { name: 'vk_swiftshader.dll', ...f('C:\\App\\vk_swiftshader.dll') }, + { name: 'vulkan-1.dll', ...f('C:\\App\\vulkan-1.dll') }, + { name: 'd3dcompiler_47.dll', ...f('C:\\App\\d3dcompiler_47.dll') } + ], + daemonEntry: { + name: 'daemon-entry.js', + path: `${unpackedRoot}\\out\\main\\daemon-entry.js`, + exists: true, + size: 1, + relFromUnpacked: 'out/main/daemon-entry.js' + }, + // node-pty is packaged at resources/node_modules/node-pty — a SIBLING of + // app.asar.unpacked, NOT under it (see packaged-runtime-node-modules.cjs). + // The synthetic inventory reflects that so the copy-plan test catches any + // regression that mislocates it. + nodePty: { + exists: true, + packageDir: 'C:\\App\\resources\\node_modules\\node-pty', + nativeDir: 'C:\\App\\resources\\node_modules\\node-pty\\build\\Release', + nativeRel: 'build/Release', + conptyNode: { name: 'conpty.node', path: 'x', exists: true, size: 1 } + } + } +} + +function run() { + const failures = [] + const check = (name, cond) => { + if (!cond) { + failures.push(name) + } + } + + // ── Argument parsing ──────────────────────────────────────────────── + check('help flag', parseArgs(['--help']).help === true) + check('selftest flag', parseArgs(['--selftest']).selftest === true) + check('missing app-dir errors', Boolean(parseArgs(['--work-dir', 'w']).error)) + check('missing work-dir errors', Boolean(parseArgs(['--app-dir', 'a']).error)) + check( + 'bad tier errors', + Boolean(parseArgs(['--app-dir', 'a', '--work-dir', 'w', '--tier', 'x']).error) + ) + check('unknown arg errors', Boolean(parseArgs(['--nope']).error)) + const ok = parseArgs(['--app-dir', 'a', '--work-dir', 'w', '--tier', 'no-gpu', '--keep-work-dir']) + check('valid parse launch', Boolean(ok.launch)) + check('valid parse tier', ok.launch?.tier === 'no-gpu') + check('valid parse keep', ok.launch?.keepWorkDir === true) + check( + 'default tier full', + parseArgs(['--app-dir', 'a', '--work-dir', 'w']).launch?.tier === 'full' + ) + + // ── Tier definitions ──────────────────────────────────────────────── + check( + 'all tiers defined', + TIERS.every((t) => Boolean(TIER_DEFINITIONS[t])) + ) + const dlls = makeSyntheticInventory().topLevelDlls + check('full keeps all dlls', selectTierDlls(dlls, 'full').length === 6) + check('minimal keeps no dlls', selectTierDlls(dlls, 'minimal').length === 0) + const noGpu = selectTierDlls(dlls, 'no-gpu').map((d) => d.name) + check('no-gpu keeps ffmpeg', noGpu.includes('ffmpeg.dll')) + check('no-gpu drops libEGL', !noGpu.includes('libEGL.dll')) + check( + 'no-gpu drops all gpu dlls', + noGpu.every((n) => !GPU_DLLS.has(n.toLowerCase())) + ) + check('no-gpu count is 1', noGpu.length === 1) + + // ── Copy-plan resolution ──────────────────────────────────────────── + const inv = makeSyntheticInventory() + for (const tier of TIERS) { + const plan = resolveTierFileSet(inv, tier) + check(`${tier}: no warnings`, plan.warnings.length === 0) + const dests = plan.ops.map((o) => o.destRel) + check(`${tier}: has exe`, dests.includes('Orca.exe')) + check(`${tier}: has icu`, dests.includes('icudtl.dat')) + // destRel mirrors the full win-unpacked layout so the require-closure and + // node-pty native resolution work verbatim from the copy. + check( + `${tier}: has daemon entry`, + dests.includes('resources/app.asar.unpacked/out/main/daemon-entry.js') + ) + check(`${tier}: has node-pty dir`, dests.includes('resources/node_modules/node-pty')) + check( + `${tier}: node-pty op is a dir`, + plan.ops.find((o) => o.destRel === 'resources/node_modules/node-pty')?.kind === 'dir' + ) + } + const fullDests = resolveTierFileSet(inv, 'full').ops.map((o) => o.destRel) + check('full plan includes gpu dll', fullDests.includes('vulkan-1.dll')) + const minimalDests = resolveTierFileSet(inv, 'minimal').ops.map((o) => o.destRel) + check('minimal plan excludes ffmpeg', !minimalDests.includes('ffmpeg.dll')) + check('minimal plan excludes gpu dll', !minimalDests.includes('vulkan-1.dll')) + + // Missing required input surfaces a warning rather than throwing. + const brokenInv = { ...inv, nodePty: { exists: false, conptyNode: null } } + check('missing node-pty warns', resolveTierFileSet(brokenInv, 'full').warnings.length > 0) + + // ── Module-path filter ────────────────────────────────────────────── + const appDir = 'C:\\Users\\me\\AppData\\Local\\Programs\\orca' + const modules = [ + 'C:\\Users\\me\\AppData\\Local\\Programs\\orca\\Orca.exe', + 'C:\\Windows\\System32\\kernel32.dll', + 'C:\\Users\\me\\AppData\\Local\\orca-daemon-host\\Orca.exe' + ] + const resident = findAppDirResidentModules(modules, appDir) + check('detects app-dir module', resident.length === 1) + check('detects the right module', resident[0].toLowerCase().includes('programs\\orca\\orca.exe')) + // Sibling-prefix must NOT match (C:\...\orca vs C:\...\orca-daemon-host). + check( + 'sibling prefix not matched', + findAppDirResidentModules(['C:\\a\\orca-daemon-host\\x.dll'], 'C:\\a\\orca').length === 0 + ) + // Forward/back-slash + case normalization. + check( + 'slash + case normalized', + findAppDirResidentModules(['c:/a/ORCA/x.dll'], 'C:\\A\\orca').length === 1 + ) + check( + 'relocated host has zero app-dir modules', + findAppDirResidentModules( + ['C:\\work\\daemon-host\\Orca.exe', 'C:\\Windows\\System32\\ntdll.dll'], + appDir + ).length === 0 + ) + + return failures +} + +export function runSelftest() { + const failures = run() + if (failures.length === 0) { + console.log('selftest: PASS (all checks green)') + return true + } + console.error(`selftest: FAIL (${failures.length} check(s))`) + for (const name of failures) { + console.error(` - ${name}`) + } + return false +} diff --git a/tools/daemon-relocation-spike/spike.mjs b/tools/daemon-relocation-spike/spike.mjs new file mode 100644 index 000000000..5ce1c9eac --- /dev/null +++ b/tools/daemon-relocation-spike/spike.mjs @@ -0,0 +1,313 @@ +// daemon-relocation-spike — empirically finds the minimal file set that lets a +// COPIED Orca.exe (ELECTRON_RUN_AS_NODE=1), launched from outside the install +// dir, host the terminal daemon + a real ConPTY session while holding no open +// handles into the app dir. See README.md. +// +// Requires a packaged win-unpacked build (runs on Windows CI). Use --selftest to +// validate the pure logic anywhere without a build. + +import { randomUUID } from 'node:crypto' +import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { join, relative } from 'node:path' +import { parseArgs, getUsage } from './cli.mjs' +import { inventoryAppDir, formatInventory } from './app-inventory.mjs' +import { resolveTierFileSet } from './tier-file-set.mjs' +import { copyHost } from './host-copy.mjs' +import { launchDaemonHost } from './daemon-launch.mjs' +import { runPtyEcho } from './ndjson-client.mjs' +import { assessDaemonHandles } from './loaded-module-probe.mjs' +import { runSelftest } from './selftest.mjs' + +const HERE = import.meta.dirname +const FALLBACK_PROTOCOL_VERSION = 18 + +// Read PROTOCOL_VERSION from the daemon source so the spike client never drifts +// out of sync with the running daemon's handshake. +function resolveProtocolVersion() { + try { + const typesPath = join(HERE, '..', '..', 'src', 'main', 'daemon', 'types.ts') + const match = readFileSync(typesPath, 'utf8').match(/PROTOCOL_VERSION\s*=\s*(\d+)/) + if (match) { + return Number(match[1]) + } + } catch { + // Fall through to the pinned default. + } + return FALLBACK_PROTOCOL_VERSION +} + +function makeSocketPath() { + if (process.platform === 'win32') { + return `\\\\?\\pipe\\orca-daemon-spike-${randomUUID().slice(0, 12)}` + } + return join(process.env.TMPDIR ?? '/tmp', `orca-daemon-spike-${randomUUID().slice(0, 12)}.sock`) +} + +/** Print a recursive listing of the copied daemon-host tree so the mirrored + * layout (daemon-entry + node-pty at their require-resolvable paths) is + * verifiable from the CI log. Depth-limited to keep output readable. */ +function printHostTree(hostRoot, maxDepth = 6) { + console.log(`\n--- copied daemon-host tree: ${hostRoot} ---`) + if (!existsSync(hostRoot)) { + console.log(' (missing)') + return + } + const walk = (dir, depth) => { + let entries = [] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const e of entries) { + const full = join(dir, e.name) + const rel = relative(hostRoot, full).split('\\').join('/') + if (e.isDirectory()) { + console.log(` ${rel}/`) + if (depth < maxDepth) { + walk(full, depth + 1) + } + } else { + console.log(` ${rel}`) + } + } + } + walk(hostRoot, 0) +} + +/** Dump the tail of the daemon's captured stdout/stderr — the actual crash + * reason when it exits before ready. */ +function printDaemonLogs(workDir, tailLines = 60) { + for (const name of ['daemon.log', 'daemon-stdout.log', 'daemon-stderr.log']) { + const p = join(workDir, name) + console.log(`\n--- ${name} ---`) + try { + const text = readFileSync(p, 'utf8').trimEnd() + const lines = text.split('\n') + console.log(lines.slice(-tailLines).join('\n') || '(empty)') + } catch { + console.log('(unavailable)') + } + } +} + +/** Print the raw-frame diagnostics the client collects so a failed ConPTY + * round-trip is classifiable from the CI log alone. */ +function printEchoDiagnostics(d) { + if (!d) { + console.log(' (no diagnostics captured)') + return + } + console.log(` createOrAttach response: ${JSON.stringify(d.createResponse)}`) + console.log(` data frames (our session): ${d.ourDataFrames}`) + console.log(` data frames (other sessions): ${d.otherDataFrames}`) + console.log(` exit events: ${JSON.stringify(d.exitEvents)}`) + if (d.sessionsAtTimeout !== null) { + console.log(` listSessions at timeout: ${JSON.stringify(d.sessionsAtTimeout)}`) + } + const sample = d.rawSample || '' + console.log(` raw stream sample (${sample.length} chars): ${JSON.stringify(sample)}`) +} + +async function shutdownDaemon(child) { + if (!child || child.exitCode !== null) { + return + } + await new Promise((resolve) => { + const timer = setTimeout(resolve, 5000) + child.once('exit', () => { + clearTimeout(timer) + resolve() + }) + try { + child.kill('SIGTERM') + } catch { + clearTimeout(timer) + resolve() + } + }) +} + +async function runLaunch(opts) { + const { appDir, workDir, tier, keepWorkDir } = opts + const report = { + tier, + ready: false, + ptyEchoOk: false, + mainModuleOk: false, + appDirModules: [], + warnings: [], + error: null + } + + console.log(`\n=== daemon-relocation-spike (tier=${tier}) ===\n`) + + const inv = inventoryAppDir(appDir) + console.log(formatInventory(inv)) + console.log('') + + const plan = resolveTierFileSet(inv, tier) + report.warnings = plan.warnings + console.log(`tier: ${plan.label} (${plan.ops.length} copy ops)`) + if (plan.warnings.length > 0) { + for (const w of plan.warnings) { + console.error(` WARNING: ${w}`) + } + report.error = 'incomplete file set for chosen tier' + return report + } + + const { hostRoot, hostExePath, daemonEntryPath, nodePtyNativeDir, skipped } = copyHost( + inv, + plan, + workDir + ) + if (skipped.length > 0) { + console.error(` copy skipped (missing sources): ${skipped.join(', ')}`) + report.error = `required sources missing: ${skipped.join(', ')}` + return report + } + console.log(`copied host: ${hostExePath}`) + console.log(`daemon entry: ${daemonEntryPath}`) + console.log(`node-pty native dir: ${nodePtyNativeDir || '(none)'}`) + printHostTree(hostRoot) + console.log('') + + const socketPath = makeSocketPath() + const tokenPath = join(workDir, 'daemon.token') + const logFilePath = join(workDir, 'daemon.log') + const protocolVersion = resolveProtocolVersion() + + let child = null + try { + const launched = await launchDaemonHost({ + hostExePath, + daemonEntryPath, + socketPath, + tokenPath, + workDir, + nodePtyNativeDir, + logFilePath + }) + child = launched.child + report.ready = true + console.log(`daemon ready (pid=${launched.pid})`) + + // Probe loaded modules BEFORE shutdown — the daemon and its node-pty native + // are mapped at this point. + const handles = assessDaemonHandles(launched.pid, appDir, hostExePath) + report.mainModuleOk = handles.mainModuleOk + report.appDirModules = handles.appDirModules + console.log( + `handle probe: mainModuleOk=${handles.mainModuleOk} ` + + `modules=${handles.moduleCount ?? 0} appDirResident=${handles.appDirModules.length}` + ) + for (const m of handles.appDirModules) { + console.error(` APP-DIR MODULE (would lock during update): ${m}`) + } + + const nonce = randomUUID().slice(0, 8) + const marker = `SPIKE-OK-${nonce}` + // Match the marker only when it appears alone at line start (executed + // output), not inside the echoed `echo ` input line. + const expectRe = new RegExp(`(?:^|\\r?\\n)${marker}(?:\\r|\\n)`) + // `echo ` is shell-agnostic (cmd / powershell / pwsh / bash); force + // powershell.exe so the CI runner's ambient COMSPEC can't pick a shell that + // behaves differently under ConPTY. + const echo = await runPtyEcho({ + socketPath, + tokenPath, + protocolVersion, + command: `echo ${marker}`, + expectRe, + shellOverride: process.platform === 'win32' ? 'powershell.exe' : undefined + }) + report.ptyEchoOk = true + console.log(`pty echo: nonce round-tripped (${echo.output.length} bytes of output)`) + console.log('pty echo diagnostics:') + printEchoDiagnostics(echo.diagnostics) + } catch (err) { + // Recover the handle from a pre-ready rejection so `finally` can stop a + // daemon that started but never signaled ready. + child = child ?? err?.child ?? null + report.error = err instanceof Error ? err.message : String(err) + console.error(`\nFAILURE: ${report.error}`) + if (err && err.diagnostics) { + console.error('pty echo diagnostics:') + printEchoDiagnostics(err.diagnostics) + } + printDaemonLogs(workDir) + } finally { + await shutdownDaemon(child) + if (!keepWorkDir) { + try { + rmSync(workDir, { recursive: true, force: true }) + } catch { + // Best-effort cleanup; a locked file just means the relocation is + // incomplete, which the handle probe already reports. + } + } + } + + return report +} + +function printFinalReport(report) { + const pass = + report.ready && + report.ptyEchoOk && + report.appDirModules.length === 0 && + report.mainModuleOk && + !report.error + + console.log('\n=== FINAL REPORT ===') + console.log(` tier: ${report.tier}`) + console.log(` daemon ready: ${report.ready}`) + console.log(` pty echo ok: ${report.ptyEchoOk}`) + console.log(` main module = copy: ${report.mainModuleOk}`) + console.log(` app-dir modules: ${report.appDirModules.length}`) + for (const m of report.appDirModules) { + console.log(` - ${m}`) + } + if (report.error) { + console.log(` error: ${report.error}`) + } + console.log(` VERDICT: ${pass ? 'PASS' : 'FAIL'}\n`) + return pass +} + +async function main() { + const parsed = parseArgs(process.argv.slice(2)) + + if (parsed.help) { + console.log(getUsage()) + return 0 + } + if (parsed.error) { + console.error(`error: ${parsed.error}`) + console.error(getUsage()) + return 2 + } + if (parsed.selftest) { + return runSelftest() ? 0 : 1 + } + + if (process.platform !== 'win32') { + console.error( + 'launch mode requires Windows (ConPTY + loaded-module probe). Use --selftest elsewhere.' + ) + return 2 + } + + const report = await runLaunch(parsed.launch) + return printFinalReport(report) ? 0 : 1 +} + +main() + .then((code) => { + process.exitCode = code + }) + .catch((err) => { + console.error('unexpected error:', err) + process.exitCode = 1 + }) diff --git a/tools/daemon-relocation-spike/tier-file-set.mjs b/tools/daemon-relocation-spike/tier-file-set.mjs new file mode 100644 index 000000000..312d9a6c8 --- /dev/null +++ b/tools/daemon-relocation-spike/tier-file-set.mjs @@ -0,0 +1,135 @@ +// Tier definitions as DATA, so trimming the copied file set is a config change +// rather than a code change. A tier resolves (given a discovered inventory) to a +// flat list of copy operations { sourcePath, destRel, kind }. + +import { dirname, join, relative, sep } from 'node:path' + +// GPU/render DLLs that a run-as-node Electron host plausibly never loads. The +// spike measures whether dropping them still yields a working ConPTY host. +// ffmpeg.dll is deliberately NOT here — it is kept in the no-gpu tier. +export const GPU_DLLS = new Set([ + 'libegl.dll', + 'libglesv2.dll', + 'vk_swiftshader.dll', + 'vulkan-1.dll', + 'd3dcompiler_47.dll' +]) + +// Each tier declares which top-level DLLs to include. The exe, runtime data +// blobs, daemon bundle, and node-pty are in every tier — they are the +// irreducible core (Orca.exe needs icu + snapshots even as node; the daemon +// needs its bundle; node-pty needs its native + conpty runtime). +export const TIER_DEFINITIONS = { + full: { label: 'TIER_FULL_RUNTIME', dlls: 'all' }, + 'no-gpu': { label: 'TIER_NO_GPU_DLLS', dlls: 'non-gpu' }, + minimal: { label: 'TIER_MINIMAL', dlls: 'none' } +} + +function isGpuDll(name) { + return GPU_DLLS.has(name.toLowerCase()) +} + +/** + * Which top-level DLL entries a tier keeps. Pure over the inventory's DLL list + * so selftest can exercise it without a real build. + */ +export function selectTierDlls(topLevelDlls, tier) { + const def = TIER_DEFINITIONS[tier] + if (!def || def.dlls === 'none') { + return [] + } + if (def.dlls === 'all') { + return topLevelDlls + } + return topLevelDlls.filter((e) => !isGpuDll(e.name)) +} + +/** + * A source file/dir's path relative to the win-unpacked root, normalized to '/'. + * + * Why relative to the APP DIR (not the app.asar.unpacked root): node-pty is + * packaged at resources/node_modules/node-pty (see + * config/packaged-runtime-node-modules.cjs), a SIBLING of app.asar.unpacked. + * The daemon resolves `require('node-pty')` by walking parent dirs up from + * resources/app.asar.unpacked/out/main/daemon-entry.js, which passes through + * resources/ and finds resources/node_modules/node-pty. Mirroring the full + * win-unpacked layout verbatim is the only copy that preserves that walk. + */ +export function toPosixRelative(appDir, absPath) { + return relative(appDir, absPath).split(sep).join('/') +} + +/** + * Build the ordered copy plan for a tier. Returns { ops, warnings } where each + * op is { sourcePath, destRel, kind: 'file' | 'dir' }. Every destRel mirrors the + * source's win-unpacked-relative path. Missing required inputs are surfaced as + * warnings rather than thrown, so the report stays complete. + */ +export function resolveTierFileSet(inv, tier) { + const ops = [] + const warnings = [] + const appDir = inv.appDir + + const addFile = (entry, requiredLabel, optional = false) => { + if (!entry || !entry.exists) { + if (requiredLabel) { + warnings.push(`missing required input: ${requiredLabel}`) + } + return + } + ops.push({ + sourcePath: entry.path, + destRel: toPosixRelative(appDir, entry.path), + kind: 'file', + optional + }) + } + + // Core: exe + runtime data blobs live next to Orca.exe in win-unpacked. + addFile(inv.hostExe, 'Orca.exe') + for (const entry of inv.runtimeData) { + addFile(entry, entry.name) + } + + // Top-level DLLs per tier. + for (const dll of selectTierDlls(inv.topLevelDlls, tier)) { + addFile(dll) + } + + // Daemon bundle: the entry, its sibling chunks/, and the unpacked + // out/package.json (CJS/ESM loader resolution). Mirror the layout verbatim. + if (inv.daemonEntry.exists && inv.unpackedRoot) { + addFile(inv.daemonEntry, 'daemon-entry.js') + const entryDir = dirname(inv.daemonEntry.path) + const chunksDir = join(entryDir, 'chunks') + ops.push({ + sourcePath: chunksDir, + destRel: toPosixRelative(appDir, chunksDir), + kind: 'dir', + optional: true + }) + const pkgJson = join(inv.unpackedRoot, 'out', 'package.json') + ops.push({ + sourcePath: pkgJson, + destRel: toPosixRelative(appDir, pkgJson), + kind: 'file', + optional: true + }) + } else { + warnings.push('missing required input: daemon-entry.js (+ chunks)') + } + + // node-pty package tree (native binding + conpty runtime), mirrored at its + // real win-unpacked path (resources/node_modules/node-pty). + if (inv.nodePty.exists && inv.nodePty.conptyNode) { + ops.push({ + sourcePath: inv.nodePty.packageDir, + destRel: toPosixRelative(appDir, inv.nodePty.packageDir), + kind: 'dir' + }) + } else { + warnings.push('missing required input: node-pty (with conpty.node)') + } + + return { ops, warnings, label: TIER_DEFINITIONS[tier].label } +} diff --git a/tools/win-update-e2e/README.md b/tools/win-update-e2e/README.md new file mode 100644 index 000000000..973958a8b --- /dev/null +++ b/tools/win-update-e2e/README.md @@ -0,0 +1,215 @@ +# win-update-e2e — packaged NSIS update proof harness + +**Windows only.** Given two Orca Windows installers (version N and N+1), this +harness performs a real silent update and proves, with machine-checkable +assertions, what happens to the terminal **daemon** and its **sessions** across +the update — and whether any console/terminal window flashes. + +It is the Phase 0 "proof harness" deliverable from +[`docs/windows-terminal-update-survival-plan.md`](../../docs/windows-terminal-update-survival-plan.md). +It exists specifically because the July 2026 attempt shipped four broken RCs +without ever installing the packaged artifact (see +[`docs/windows-terminal-update-survival-postmortem.md`](../../docs/windows-terminal-update-survival-postmortem.md), +"Why verification missed every one of these"). Its design refuses to repeat +those verification failures: + +- **Window visibility is measured by window enumeration + owner/canary + attribution — never by conhost command-line heuristics.** The post-mortem + proved conhost flags invert with parent console state and `MainWindowHandle` + is `0` for Windows-Terminal-hosted consoles. See `window-enum.ps1`. +- **Interactivity is proven by execution, not by result-shape.** Typed commands + write sentinel **files**; the harness checks the files. A command that "runs + and returns correct output" but flashes a window is still caught, because the + window watch runs independently. +- **The daemon is identified by command-line marker, never by exe name.** With + `ELECTRON_RUN_AS_NODE` the daemon image is `Orca.exe`; a relocated Phase 1 + host may be a differently-named copied binary. See `daemon-processes.mjs`. +- **Each run uses an isolated userData dir** so its daemon's socket/token path + is unique and never collides with the many other daemons a dev box or CI + runner can host. + +## Usage + +``` +pnpm win-update-e2e --from --to --expect +# or download release assets via gh (one call each): +pnpm win-update-e2e --from-release v1.4.124-rc.9 --to-release v1.4.125-rc.1 --expect cold-restore +``` + +Or directly: `node tools/win-update-e2e/run.mjs --from ... --to ... --expect ...` + +### Flags + +| Flag | Meaning | +| --------------------------------------------- | ----------------------------------------------------------- | +| `--from ` / `--to ` | Local `orca-windows-setup.exe` for base (N) / update (N+1) | +| `--from-release ` / `--to-release ` | Download the setup asset from a GitHub release tag via `gh` | +| `--expect cold-restore \| survival` | Assertion profile (required) | +| `--install-dir ` | Isolated-install mode (see below) — install into `` | +| `--asset-pattern ` | gh asset glob (default `*windows-setup.exe`) | +| `--soak-seconds ` | Post-relaunch window-watch soak (default `180`) | +| `--keep-install` | Skip teardown/uninstall for debugging (ignored in isolated) | + +### Profiles + +- **`cold-restore`** — **today's** behavior and the baseline that must keep + passing against current `main`. The installer's path sweep kills the in-dir + daemon, so: old daemon PID is **dead**, a **fresh** daemon exists, scrollback + is cold-restored (best-effort), a new terminal is interactive, and **zero** + unexpected console/terminal windows appear. +- **`survival`** — the Phase 1 target. Daemon PID **unchanged** across the + update, marker process **still alive**, the pre-update session still + interactive (typed input echoes, Ctrl+C interrupts), and **zero** unexpected + windows. + +## Safety + +This harness installs, overwrites, and can uninstall a real app. Two guards +protect a developer's machine; a clean CI/VM is unaffected by either: + +- **Pre-existing app process → hard refusal.** If an Orca _app_ process (not a + daemon) is already running, the run aborts and prints the offending PIDs. The + harness never kills a process it did not start. +- **Pre-existing install → refusal unless `--allow-existing-install`.** If an + Orca install already exists under `%LOCALAPPDATA%\Programs`, the run refuses, + because installing N then N+1 would silently overwrite that build and leave + the `--to` version behind. Pass `--allow-existing-install` to proceed anyway. + +Uninstall behavior at teardown follows ownership: + +- **No pre-existing install** (harness fully owns it): teardown silently + uninstalls, unless `--keep-install`. +- **`--allow-existing-install` was used** (an install existed first): teardown + does **not** uninstall — removing a build the harness did not place would be + wrong. It prints a prominent note that the machine now has the `--to` version + and the prior build was not restored. + +## Isolated install mode (developer machines) + +On a clean CI/VM the harness installs into the default per-user location +(`%LOCALAPPDATA%\Programs\Orca`). A developer's box already has a real Orca there, +and the safety guards above would (correctly) refuse to run. **Isolated mode** +(`--install-dir `) lets the harness run on that box without disturbing the +real install. + +**The /D mechanism.** electron-builder's NSIS honors the standard NSIS `/D=` +override for the install *directory* (`node_modules/app-builder-lib/templates/nsis/multiUser.nsh`). +`/D` is special: it must be the **last** argument and **cannot be quoted**, so the +path must be absolute and **spaces-free** (validated by `validateInstallDir`). The +installer's kill-sweep only matches processes under its own `$INSTDIR`, so a +separate directory never touches the real install's app or daemon processes. + +**Why registry/shortcut backup-restore exists.** `/D` relocates *files only*. +Regardless of `/D`, the installer writes `InstallLocation` + the uninstall entry to +the **same per-user HKCU keys** as the real install +(`HKCU\Software\` and +`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\`, +`node_modules/app-builder-lib/templates/nsis/include/installer.nsh`) and rewrites the +Start Menu / Desktop shortcuts. Left hijacked, the user's **next real update would +install into the test directory**. So isolated mode, before installing: + +1. **Snapshots** the shared state (`registry-shortcut-backup.mjs`): `reg export`s + each existing key to `.reg` files, copies the Orca `*.lnk` shortcuts, and records + a manifest (which keys/shortcuts existed, the pre-run `InstallLocation`). +2. Runs the full install → update → assert proof against the isolated directory. +3. **Always restores** at teardown (a `try/finally` wraps everything after the + snapshot): `reg import`s keys that pre-existed, `reg delete`s keys the test + created, copies shortcuts back / deletes test-created ones, then **re-reads + `InstallLocation` and verifies** it matches the snapshot. On mismatch it prints a + loud block with the exact manual `reg import` command to recover. Isolated + teardown **always** uninstalls the test install (the harness owns the directory) + and removes the directory if empty — `--keep-install` is ignored. + +**Residual risk.** The backup/restore covers `InstallLocation`, the uninstall entry, +and the Orca shortcuts — the state that steers a future update and the user-visible +launchers. It does **not** attempt to snapshot auto-update state files under the real +install's `userData` (the harness uses an isolated `userData` throughout, so it never +writes there), and it cannot restore state if the machine loses power mid-teardown +(re-run with a valid `--install-dir` to let restore complete, or run the printed +`reg import` by hand). The `.reg` backups live under the run's temp dir until a +successful teardown removes it. + +**Example.** + +``` +pnpm win-update-e2e \ + --from-release v1.4.124-rc.9 --to-release v1.4.125-rc.1 \ + --expect cold-restore --install-dir C:\OrcaE2E +``` + +Read-only, touches nothing — print what isolated mode would snapshot on this machine: + +``` +node tools/win-update-e2e/registry-shortcut-backup.mjs +``` + +## What it does + +1. **Preflight** — assert win32; warn if elevated; **refuse** to run if a + pre-existing Orca _app_ process (not a daemon) is running that the harness + did not start (it is printed and the run aborts — the harness never kills a + user's processes); snapshot the baseline set of visible top-level windows. +2. **Install N** silently (` /S`) and locate `Orca.exe`. +3. **Launch** the installed app (Playwright `_electron`, isolated userData), + create ≥2 terminals, start a **marker** in one: a `powershell` loop that sets + a unique window-title **canary**, records its PID, and heartbeats a file. +4. **Record** the daemon PID (scoped pid-file + live-process scan), marker PID, + and session tab ids. +5. **Close** the app normally; verify the detached daemon is still alive. +6. **Start the window watch** — a background PowerShell loop polling visible + top-level windows every 500ms, diffing against baseline, recording every new + window (and title change) to a JSONL log through the update and soak. +7. **Install N+1** silently (the update). +8. **Relaunch** the app. +9. **Assert** per profile, then print a PASS/FAIL/INFO evidence table. +10. **Teardown** (unless `--keep-install`) — close app, kill only harness-created + processes, silent-uninstall. + +Exit code is `0` when every non-informational assertion passes, else `1` +(`2` for a CLI usage error). + +## Standalone instrument self-tests (no installers needed) + +Each probe module runs on its own so the harness's own instruments are testable: + +``` +# Opens a real transient console window and asserts the watch catches it: +node tools/win-update-e2e/window-watch.mjs --selftest + +# Read-only: list daemon processes + PID files on this machine: +node tools/win-update-e2e/daemon-processes.mjs [--user-data ] [--scope ] + +# Emit the current visible-window snapshot as JSON: +powershell -File tools/win-update-e2e/window-enum.ps1 +``` + +## Files + +| File | Responsibility | +| -------------------------- | ----------------------------------------------------------------------------- | +| `run.mjs` | Orchestrator + CLI entry | +| `cli-args.mjs` | Argument parsing / validation | +| `preflight.mjs` | win32/elevation checks, pre-existing-app refusal, baseline snapshot | +| `installer-steps.mjs` | Silent install/update/uninstall, exe discovery, gh download | +| `registry-shortcut-backup.mjs` | Isolated mode: snapshot/restore the shared HKCU keys + Orca shortcuts | +| `app-driver.mjs` | Playwright Electron launch + terminal driving (production-safe DOM selectors) | +| `interactivity-probes.mjs` | Sentinel-file echo / heartbeat / Ctrl+C probes | +| `daemon-processes.mjs` | Daemon PID discovery (command-line marker + pid file), scoped | +| `window-enum.ps1` | Shared visible-top-level-window enumerator (P/Invoke `EnumWindows`) | +| `window-watch.ps1` | Background baseline-diff watch loop → JSONL | +| `window-watch.mjs` | Node wrapper: start/stop watch, `--selftest`, baseline capture | +| `assertions.mjs` | Window-event classification + profile PASS/FAIL table | +| `platform-guard.mjs` | `assertWin32`, elevation detection | +| `powershell-runner.mjs` | Windows PowerShell 5.1 spawn helpers | + +## Known limitations + +- **Scrollback fidelity is best-effort.** A production build renders the + terminal with WebGL, so xterm text is not reliably in the DOM and the e2e + `SerializeAddon` is not exposed. When text cannot be read the check reports + `INFO` (unknown), never a false `FAIL`. +- **Daemon file log** does not exist yet in packaged builds (the fork's stdio is + suppressed). The "daemon log free of ERROR lines" assertion is `INFO` until + Phase 0 daemon logging lands, then it reads `/logs/daemon.log`. +- The harness assumes the packaged main honors `ORCA_E2E_USER_DATA_DIR` to + relocate userData; verify this against a real packaged build. diff --git a/tools/win-update-e2e/app-driver.mjs b/tools/win-update-e2e/app-driver.mjs new file mode 100644 index 000000000..8f16a4464 --- /dev/null +++ b/tools/win-update-e2e/app-driver.mjs @@ -0,0 +1,357 @@ +// Drive the installed, packaged Orca app with Playwright's Electron driver. +// +// This targets a PRODUCTION build, so it must NOT depend on the e2e-only store +// exposure (window.__store / window.__paneManagers) — those exist only under a +// `--mode e2e` / VITE_EXPOSE_STORE build. Everything here uses ARIA/DOM +// selectors that ship in production (matching tests/e2e/helpers/terminal.ts and +// terminal-attention.spec.ts) and proves interactivity through filesystem +// sentinels rather than by reading the WebGL-rendered xterm buffer: +// - typed commands write a marker FILE; the harness checks the file. This +// proves keystrokes reached the shell AND executed — stronger, and robust, +// than scraping canvas-rendered terminal text. +// +// The long-running marker also sets a unique window-title canary and writes a +// heartbeat file every ~500ms: the canary lets the window watch attribute any +// real console flash to our child, and the heartbeat proves the session is +// live and streaming. + +import { _electron as electron } from '@stablyai/playwright-test' +import { execFileSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { seedFreshProfile } from './onboarding-profile.mjs' + +const NEW_TAB_BUTTON = { role: 'button', name: 'New tab' } +const NEW_TERMINAL_ITEM = /New Terminal/i +const NEW_WORKSPACE_BUTTON = { role: 'button', name: 'New workspace' } +const SORTABLE_TAB = '[data-testid="sortable-tab"]' +// Why: the layout mounts hidden duplicate panes; only the visible one is the +// live terminal, so target `:visible` to avoid focusing/measuring a hidden copy. +const TERMINAL_SURFACE_VISIBLE = '[data-terminal-tab-id]:visible' +const XTERM_CONTAINER_VISIBLE = '.xterm:visible' +const XTERM_INPUT = '.xterm-helper-textarea' + +/** + * Launch the installed Orca.exe. Pointing userDataDir at a harness-owned temp + * dir isolates this run's daemon (its socket/token path becomes unique), so + * daemon lookups never collide with other Orca installs/daemons on the box. + * Pass `seedProfile` (a buildFreshProfile object) to write orca-data.json + * BEFORE this launch — do so only on the FIRST launch, never before the + * post-update relaunch, or the persisted session under test is destroyed. + */ +export async function launchInstalledApp({ + exePath, + userDataDir, + seedProfile = null, + extraEnv = {} +}) { + const { ELECTRON_RUN_AS_NODE: _drop, ...cleanEnv } = process.env + mkdirSync(userDataDir, { recursive: true }) + if (seedProfile) { + seedFreshProfile(userDataDir, seedProfile) + } + const app = await electron.launch({ + executablePath: exePath, + args: [], + env: { + ...cleanEnv, + // Packaged main honors ORCA_E2E_USER_DATA_DIR to relocate userData + // (logs/daemon/terminal-history) under a controlled dir. + ORCA_E2E_USER_DATA_DIR: userDataDir, + ...extraEnv + } + }) + const page = await app.firstWindow({ timeout: 120_000 }) + await page.waitForLoadState('domcontentloaded') + return { app, page } +} + +/** + * Best-effort diagnostics dump when driving fails: a screenshot, the visible + * body text, and whether the e2e store is exposed (it is not in production + * builds). Written under `dir` so CI can upload it and reveal the actual + * post-launch DOM state. Never throws. + */ +export async function captureFailureDiagnostics(page, dir, label) { + const out = {} + try { + mkdirSync(dir, { recursive: true }) + } catch { + return out + } + try { + await page.screenshot({ + path: path.join(dir, `${label}.png`), + fullPage: false, + timeout: 10_000 + }) + out.screenshot = `${label}.png` + } catch { + /* renderer may be unresponsive */ + } + try { + const info = await page.evaluate(() => ({ + hasStore: typeof window.__store, + title: document.title, + url: location.href, + bodyText: (document.body?.innerText ?? '').slice(0, 4000), + testIds: Array.from(document.querySelectorAll('[data-testid]')) + .map((el) => el.getAttribute('data-testid')) + .filter((v, i, a) => v && a.indexOf(v) === i) + .slice(0, 80), + buttons: Array.from(document.querySelectorAll('button,[role="button"]')) + .map((el) => (el.getAttribute('aria-label') || el.textContent || '').trim()) + .filter((v, i, a) => v && a.indexOf(v) === i) + .slice(0, 60) + })) + writeFileSync(path.join(dir, `${label}.json`), JSON.stringify(info, null, 2)) + out.info = info + } catch { + /* renderer may be unresponsive */ + } + return out +} + +/** Wait until the visible terminal surface and its xterm container are mounted. */ +export async function waitForTerminalReady(page, timeoutMs = 60_000) { + await page + .locator(TERMINAL_SURFACE_VISIBLE) + .first() + .waitFor({ state: 'visible', timeout: timeoutMs }) + await page + .locator(XTERM_CONTAINER_VISIBLE) + .first() + .waitFor({ state: 'visible', timeout: timeoutMs }) +} + +/** + * Get the app to an interactive terminal. + * - `allowCreate` true (first launch): if no terminal is visible, create a + * workspace from the seeded repo (or a new tab if a workspace already + * exists) — the drivable composer, not the native folder dialog. + * - `allowCreate` false (post-update relaunch): the session should be + * RESTORED, so only wait for the restored terminal — never create a second + * workspace (which would mask a broken restore). + */ +export async function ensureTerminal(page, { allowCreate = true, timeoutMs = 60_000 } = {}) { + const visibleTerminal = page.locator(TERMINAL_SURFACE_VISIBLE).first() + if (await visibleTerminal.isVisible().catch(() => false)) { + await waitForTerminalReady(page, timeoutMs) + return + } + if (!allowCreate) { + // Wait for the restored terminal to appear; a timeout here is a real + // (asserted) failure of session restore, not a driving gap. + await waitForTerminalReady(page, timeoutMs) + return + } + const newTab = page.getByRole(NEW_TAB_BUTTON.role, { name: NEW_TAB_BUTTON.name }).first() + if (await newTab.isVisible().catch(() => false)) { + await createTerminalTab(page) + return + } + await createWorkspaceFromSeededRepo(page, timeoutMs) + await waitForTerminalReady(page, timeoutMs) +} + +/** + * Drive the "New workspace" composer to create a worktree from the single + * seeded project. The composer is in-app DOM (unlike the native Add-Project + * dialog): open it, choose the "Blank Terminal" mode so the worktree opens a + * plain terminal (not an agent), then submit "Create worktree". + */ +async function createWorkspaceFromSeededRepo(page, timeoutMs) { + await page + .getByRole(NEW_WORKSPACE_BUTTON.role, { name: NEW_WORKSPACE_BUTTON.name }) + .first() + .click({ timeout: timeoutMs }) + // Choose the plain-terminal mode (best-effort — if it is already the default + // or the label differs, the create below still produces a worktree). + await page + .getByRole('button', { name: 'Blank Terminal' }) + .first() + .click({ timeout: 15_000 }) + .catch(() => {}) + // Submit. The create button's accessible name carries the shortcut hint + // ("Create worktreeCtrl"), so match by prefix; fall back to the documented + // Ctrl+Enter shortcut if the button is not directly clickable. + const created = await page + .getByRole('button', { name: /^Create worktree/ }) + .last() + .click({ timeout: 15_000 }) + .then(() => true) + .catch(() => false) + if (!created) { + await page.keyboard.press('Control+Enter') + } +} + +const OVERLAY_DISMISS_LABELS = ['Got it', 'Dismiss setup scripts', 'Dismiss tip', 'Dismiss update'] + +/** + * Best-effort dismissal of the modals/banners that appear after creating a + * worktree (a full-screen "Got it" feature-tip modal, the setup-script prompt, + * update banner) and intercept all input over the terminal. Loops because tips + * can appear in sequence. Never throws. + */ +export async function dismissOverlays(page, rounds = 3) { + for (let i = 0; i < rounds; i++) { + let acted = false + for (const name of OVERLAY_DISMISS_LABELS) { + const btn = page.getByRole('button', { name }).first() + if (await btn.isVisible().catch(() => false)) { + await btn.click({ timeout: 3_000 }).catch(() => {}) + acted = true + } + } + await page.keyboard.press('Escape').catch(() => {}) + if (!acted) { + return + } + await page.waitForTimeout(400) + } +} + +/** Create a new terminal tab via the New tab menu. Returns the count after. */ +export async function createTerminalTab(page) { + await dismissOverlays(page, 1) + const before = await page.locator(SORTABLE_TAB).count() + await page + .getByRole(NEW_TAB_BUTTON.role, { name: NEW_TAB_BUTTON.name }) + .first() + .click({ force: true }) + await page.getByRole('menuitem', { name: NEW_TERMINAL_ITEM }).first().click({ force: true }) + await page.waitForFunction( + ({ selector, prev }) => document.querySelectorAll(selector).length > prev, + { selector: SORTABLE_TAB, prev: before }, + { timeout: 10_000 } + ) + await waitForTerminalReady(page) + return page.locator(SORTABLE_TAB).count() +} + +/** Cheap session identifiers: the rendered tab ids. */ +export async function listTabIds(page) { + return page + .locator(SORTABLE_TAB) + .evaluateAll((tabs) => + tabs.map((t) => t.getAttribute('data-tab-id')).filter((id) => Boolean(id)) + ) +} + +/** + * Focus the live terminal so keystrokes reach the shell. Clicking the visible + * xterm surface is what actually gives xterm keyboard focus — focusing the + * off-screen helper textarea alone does not, which is why typed input was being + * dropped. Click the pane, then focus the helper textarea as a belt-and-braces. + */ +export async function focusActiveTerminal(page) { + // A feature-tip modal can appear late and swallow keystrokes; clear any before + // focusing so typed commands actually reach the shell. + for (const name of OVERLAY_DISMISS_LABELS) { + const btn = page.getByRole('button', { name }).first() + if (await btn.isVisible().catch(() => false)) { + await btn.click({ timeout: 2_000 }).catch(() => {}) + } + } + const surface = page.locator(TERMINAL_SURFACE_VISIBLE).first() + await surface.click({ position: { x: 24, y: 24 }, timeout: 15_000 }).catch(() => {}) + // Scope the helper textarea to the visible surface so focus can't land on a + // hidden duplicate pane's textarea (which would silently swallow keystrokes). + const input = surface.locator(XTERM_INPUT).last() + await input.focus().catch(() => {}) + return input +} + +/** Type a line and submit it (Enter → \r submits in the shell). */ +export async function typeLine(page, text) { + await focusActiveTerminal(page) + await page.keyboard.type(text) + await page.keyboard.press('Enter') +} + +/** Send Ctrl+C to the active terminal. */ +export async function sendCtrlC(page) { + await focusActiveTerminal(page) + await page.keyboard.press('Control+C') +} + +/** + * Run a PowerShell command inside the active terminal by invoking a nested + * powershell.exe. The command is wrapped in double quotes for the OUTER + * interactive shell (also pwsh), which would otherwise expand `$var`, `$(...)` + * and consume backticks before the nested shell sees them — so escape backticks, + * quotes, and `$`. Without the `$` escape, `while($true)` reaches the nested + * shell as `while(True)` and never runs (the bug that silently broke every + * loop/heartbeat probe while simple `$`-free commands worked). + */ +export async function runShellCommand(page, psCommand) { + const escaped = psCommand.replace(/`/g, '``').replace(/"/g, '`"').replace(/\$/g, '`$') + await typeLine(page, `powershell.exe -NoProfile -NonInteractive -Command "${escaped}"`) +} + +/** + * Start the long-running marker in the active terminal: sets the canary window + * title, records its own PID, and heartbeats a file every 500ms. Returns the + * command string (the caller reads the pid file to learn the marker PID). + */ +export async function startMarker(page, { canary, pidFile, heartbeatFile }) { + const script = [ + `$host.UI.RawUI.WindowTitle='${canary}'`, + `Set-Content -LiteralPath '${pidFile}' -Value $PID`, + `while($true){ [System.IO.File]::WriteAllText('${heartbeatFile}',(Get-Date).ToString('o')); Start-Sleep -Milliseconds 500 }` + ].join('; ') + await runShellCommand(page, script) + return script +} + +/** + * Best-effort read of the active terminal's visible text, for the cold-restore + * scrollback fidelity check. Prefers the SerializeAddon when the build happens + * to expose paneManagers; falls back to DOM rows (populated only under the DOM + * renderer, so this may be empty under WebGL — hence best-effort). + */ +export async function readTerminalTextBestEffort(page) { + return page.evaluate(() => { + const managers = window.__paneManagers + if (managers && typeof managers.forEach === 'function') { + let out = '' + managers.forEach((m) => { + const pane = m.getActivePane?.() ?? m.getPanes?.()[0] + const text = pane?.serializeAddon?.serialize?.() + if (text) { + out += text + } + }) + if (out) { + return out + } + } + return Array.from(document.querySelectorAll('.xterm-rows')) + .map((el) => el.textContent ?? '') + .join('\n') + }) +} + +/** + * Close the app gracefully; force-kill its process tree on timeout. Mirrors + * tests/e2e/helpers/electron-process-shutdown.ts so the daemon (detached) is + * left alive exactly as a normal quit would. + */ +export async function closeApp(app, timeoutMs = 10_000) { + const proc = app.process() + try { + await Promise.race([ + app.close(), + new Promise((_, reject) => setTimeout(() => reject(new Error('close timeout')), timeoutMs)) + ]) + } catch { + if (proc?.pid) { + try { + execFileSync('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { stdio: 'ignore' }) + } catch { + /* already gone */ + } + } + } +} diff --git a/tools/win-update-e2e/assertions.mjs b/tools/win-update-e2e/assertions.mjs new file mode 100644 index 000000000..20db79e48 --- /dev/null +++ b/tools/win-update-e2e/assertions.mjs @@ -0,0 +1,186 @@ +// Profile assertions and the final PASS/FAIL evidence table. +// +// Two profiles: +// cold-restore — TODAY's behavior. The installer's path sweep kills the +// in-dir daemon, so the old daemon PID must be DEAD, a fresh daemon must +// exist, scrollback is cold-restored (best-effort), a new terminal is +// interactive, and NO unexpected console/terminal windows appear. +// survival — Phase 1 target. The daemon PID is UNCHANGED across the update, +// the marker process is still alive, the pre-update session is still +// interactive (echo + Ctrl+C), and NO unexpected windows appear. + +const CONSOLE_HOST_PROCESSES = new Set([ + 'powershell', + 'pwsh', + 'cmd', + 'conhost', + 'windowsterminal', + 'openconsole' +]) + +// The app's own windows are expected on relaunch and never count as a flash. +const APP_OWNER_PROCESSES = new Set(['orca', 'electron']) + +/** + * Split window-watch events into unexpected flashes vs benign. Unexpected = + * (a) any window whose title contains the run's canary — a real flash of our + * marker child, which must never get its own window — or (b) any new + * console/terminal-host window that is not the app itself. Attribution is by + * title + owner process only (never conhost command-line heuristics). + */ +export function classifyWindowEvents(events, { canary }) { + const unexpected = [] + for (const event of events) { + const title = typeof event.title === 'string' ? event.title : '' + const owner = (event.processName ?? '').toLowerCase() + const canaryHit = canary && title.includes(canary) + const consoleHit = CONSOLE_HOST_PROCESSES.has(owner) && !APP_OWNER_PROCESSES.has(owner) + if (canaryHit || consoleHit) { + unexpected.push({ ...event, reason: canaryHit ? 'canary-title' : 'console-host' }) + } + } + return { unexpected } +} + +function assertion(name, pass, expected, actual, detail = '') { + return { name, pass, expected, actual, detail } +} + +/** Build the ordered assertion list for the run's profile. */ +export function buildAssertions(ctx) { + const { unexpected } = classifyWindowEvents(ctx.watchEvents ?? [], { canary: ctx.canary }) + const windowAssertion = assertion( + 'zero unexpected console/terminal windows', + unexpected.length === 0, + '0 windows', + `${unexpected.length} windows`, + unexpected.map((u) => `${u.processName}:"${u.title}" (${u.reason})`).join('; ') + ) + + const common = [windowAssertion, daemonLogAssertion(ctx)] + + return ctx.profile === 'survival' + ? [...survivalAssertions(ctx), ...common] + : [...coldRestoreAssertions(ctx), ...common] +} + +function survivalAssertions(ctx) { + const samePid = + ctx.preDaemonPid != null && ctx.preDaemonPid === ctx.postDaemonPid && ctx.postDaemonAlive + return [ + assertion( + 'daemon PID unchanged across update', + samePid, + `pid ${ctx.preDaemonPid} still alive`, + `post pid ${ctx.postDaemonPid} (alive: ${ctx.postDaemonAlive})` + ), + assertion( + 'marker process still alive', + Boolean(ctx.markerAliveAfter), + `marker pid ${ctx.markerPid} alive`, + String(ctx.markerAliveAfter) + ), + assertion( + 'pre-update session streams (heartbeat advanced)', + Boolean(ctx.heartbeatAdvancedAfterUpdate), + 'heartbeat mtime advanced post-update', + String(ctx.heartbeatAdvancedAfterUpdate) + ), + assertion( + 'typed input echoes in pre-update session', + Boolean(ctx.echoObserved), + 'echo sentinel file written', + String(ctx.echoObserved) + ), + assertion( + 'Ctrl+C interrupts marker loop', + Boolean(ctx.ctrlCInterrupted), + 'heartbeat stopped + post-interrupt sentinel written', + String(ctx.ctrlCInterrupted) + ) + ] +} + +function coldRestoreAssertions(ctx) { + const freshDaemon = + ctx.postDaemonPid != null && ctx.postDaemonPid !== ctx.preDaemonPid && ctx.postDaemonAlive + return [ + assertion( + 'old daemon PID is dead after update', + ctx.preDaemonAliveAfter === false, + `pid ${ctx.preDaemonPid} dead`, + `alive: ${ctx.preDaemonAliveAfter}` + ), + assertion( + 'fresh daemon exists after relaunch', + freshDaemon, + 'new daemon pid, alive', + `post pid ${ctx.postDaemonPid} (alive: ${ctx.postDaemonAlive})` + ), + // Best-effort: WebGL renderer may hide buffer text from DOM scraping, so a + // null result is reported as informational (pass=null), not a failure. + assertion( + 'previous terminals show restored scrollback (best-effort)', + ctx.scrollbackRestored === null ? null : ctx.scrollbackRestored, + 'prior output text present after restore', + ctx.scrollbackRestored === null ? 'unknown (renderer opaque)' : String(ctx.scrollbackRestored) + ), + assertion( + 'new terminal is interactive (typed input echoes)', + Boolean(ctx.echoObserved), + 'echo sentinel file written', + String(ctx.echoObserved) + ), + assertion( + 'Ctrl+C kills a sleep loop in new terminal', + Boolean(ctx.ctrlCInterrupted), + 'loop interrupted + post-interrupt sentinel written', + String(ctx.ctrlCInterrupted) + ) + ] +} + +function daemonLogAssertion(ctx) { + // The daemon has no file log today (stdio is suppressed in packaged forks). + // Treat "no log present" as informational; when a log IS present (Phase 0 + // observability), assert it is free of ERROR lines. + if (!ctx.daemonLog) { + return assertion( + 'daemon log free of fatal records', + null, + 'no fatal/invalid-token records', + 'no daemon log present (informational)' + ) + } + // Only genuinely-bad records count (fatal uncaught exceptions, invalid-token + // hello rejections). Benign suppressed native-PTY exceptions are reported as + // context but never affect pass/fail. + const errorLines = ctx.daemonLog.errorLines ?? [] + const suppressed = ctx.daemonLog.suppressedCount ?? 0 + const suppressedNote = suppressed > 0 ? ` (${suppressed} benign suppressed, ignored)` : '' + return assertion( + 'daemon log free of fatal records', + errorLines.length === 0, + 'no fatal/invalid-token records', + `${errorLines.length} fatal record(s)${suppressedNote}`, + errorLines.slice(0, 3).join(' | ') + ) +} + +/** True only if every non-informational assertion passed. */ +export function allPassed(assertions) { + return assertions.every((a) => a.pass === true || a.pass === null) +} + +/** Render the assertion list as an aligned PASS/FAIL/INFO table. */ +export function renderTable(assertions) { + const symbol = (pass) => (pass === true ? 'PASS' : pass === false ? 'FAIL' : 'INFO') + const nameWidth = Math.max(...assertions.map((a) => a.name.length), 10) + const lines = assertions.map((a) => { + const detail = a.detail ? ` — ${a.detail}` : '' + return ` [${symbol(a.pass)}] ${a.name.padEnd(nameWidth)} expected: ${a.expected}; actual: ${a.actual}${detail}` + }) + const failed = assertions.filter((a) => a.pass === false).length + const header = `\n===== win-update-e2e assertions (${failed === 0 ? 'ALL PASS' : `${failed} FAILED`}) =====` + return [header, ...lines, ''].join('\n') +} diff --git a/tools/win-update-e2e/cli-args.mjs b/tools/win-update-e2e/cli-args.mjs new file mode 100644 index 000000000..ed5041231 --- /dev/null +++ b/tools/win-update-e2e/cli-args.mjs @@ -0,0 +1,201 @@ +// Argument parsing for `node run.mjs`. +// +// Two installer sources are accepted per side: a local path (--from/--to) or a +// GitHub release tag (--from-release/--to-release) that the harness downloads +// via `gh release download`. Exactly one profile (--expect) is required. + +import { existsSync, readdirSync } from 'node:fs' +import path from 'node:path' + +const VALID_PROFILES = new Set(['cold-restore', 'survival']) + +const USAGE = ` +win-update-e2e — packaged NSIS update proof harness (Windows only) + +Usage: + node tools/win-update-e2e/run.mjs --from --to --expect [options] + node tools/win-update-e2e/run.mjs --from-release --to-release --expect + +Installer source (version N, then N+1) — path or release tag on each side: + --from Local orca-windows-setup.exe for the base version (N) + --to Local orca-windows-setup.exe for the update (N+1) + --from-release Download N's setup asset via gh (e.g. v1.4.124-rc.9) + --to-release Download N+1's setup asset via gh + +Required: + --expect Assertion profile: "cold-restore" or "survival" + cold-restore = today's behavior (daemon killed by the + installer sweep, app cold-restores scrollback, no + flashing). survival = Phase 1 target (daemon PID + unchanged, sessions still interactive). + +Options: + --install-dir Isolated-install mode: install the test build into + instead of the default per-user location, + leaving a developer's REAL Orca install untouched. + The path must be absolute and contain NO SPACES (the + NSIS /D override cannot be quoted), must not be the + default install location, and must not point at a + non-empty directory that is not a prior harness + install. Isolated mode snapshots and restores the + shared per-user registry keys + shortcuts at teardown + so the real install's "next update" target is + preserved. See README "Isolated install mode". + --allow-existing-install Proceed even if an Orca install already exists. The + run overwrites it with the --from/--to versions and + leaves the --to version installed (your prior build + is NOT restored). Without this flag the harness + refuses to run when an install exists, to protect a + developer's real Orca. Clean machines (CI/VM) never + need it. Ignored in --install-dir mode, which never + touches the real install. + --keep-install Skip teardown/uninstall (leaves the app installed) + --asset-pattern gh release asset glob (default: *windows-setup.exe) + --soak-seconds Post-relaunch window watch duration (default: 180) + -h, --help Show this help +` + +export function parseArgs(argv) { + if (argv.includes('-h') || argv.includes('--help')) { + return { help: true, usage: USAGE } + } + + const opts = { + from: takeValue(argv, '--from'), + to: takeValue(argv, '--to'), + fromRelease: takeValue(argv, '--from-release'), + toRelease: takeValue(argv, '--to-release'), + expect: takeValue(argv, '--expect'), + assetPattern: takeValue(argv, '--asset-pattern') ?? '*windows-setup.exe', + soakSeconds: Number(takeValue(argv, '--soak-seconds') ?? '180'), + installDir: takeValue(argv, '--install-dir'), + keepInstall: argv.includes('--keep-install'), + allowExistingInstall: argv.includes('--allow-existing-install'), + usage: USAGE + } + + // Distinguish "--install-dir omitted" from "--install-dir with no value": the + // latter must fail rather than silently fall back to a non-isolated install. + const errors = validate(opts, argv.includes('--install-dir')) + return { ...opts, errors } +} + +/** Default per-user oneClick install location: %LOCALAPPDATA%\Programs\Orca. */ +function defaultInstallDir() { + const localAppData = + process.env.LOCALAPPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Local') + return path.join(localAppData, 'Programs', 'Orca') +} + +/** True if `child` is equal to, inside, or an ancestor of `parent` (case-insensitive). */ +function pathsOverlap(a, b) { + const na = path + .resolve(a) + .replace(/[\\/]+$/, '') + .toLowerCase() + const nb = path + .resolve(b) + .replace(/[\\/]+$/, '') + .toLowerCase() + if (na === nb) { + return true + } + return na.startsWith(`${nb}\\`) || nb.startsWith(`${na}\\`) +} + +/** A prior harness install directory carries both the app exe and its uninstaller. */ +function looksLikeHarnessInstall(dir) { + return existsSync(path.join(dir, 'Orca.exe')) && existsSync(path.join(dir, 'Uninstall Orca.exe')) +} + +/** + * Validate --install-dir for isolated-install mode. The NSIS /D override must be + * the last, unquoted argument, so the path cannot contain spaces. It must also + * not overlap the default install location (that would defeat isolation) and + * must not clobber an unrelated non-empty directory. + */ +export function validateInstallDir(installDir) { + const errors = [] + if (!path.isAbsolute(installDir)) { + errors.push(`--install-dir must be an absolute path (got "${installDir}")`) + return errors + } + if (/\s/.test(installDir)) { + errors.push( + `--install-dir must not contain spaces (got "${installDir}"). The NSIS installer's ` + + `/D path override must be the last, UNQUOTED argument, so a path with spaces cannot ` + + `be passed. Choose a spaces-free location (e.g. C:\\OrcaE2E).` + ) + } + if (pathsOverlap(installDir, defaultInstallDir())) { + errors.push( + `--install-dir "${installDir}" overlaps the default install location ` + + `"${defaultInstallDir()}". Isolated mode must target a separate directory so the ` + + `real install is never touched.` + ) + } + if (existsSync(installDir)) { + let entries = [] + try { + entries = readdirSync(installDir) + } catch (err) { + // Fail closed: an unreadable existing directory must not be treated as + // empty/safe to overwrite. + errors.push( + `--install-dir "${installDir}" could not be read (${err.message}). ` + + `Refusing to treat an unreadable directory as safe to overwrite.` + ) + return errors + } + if (entries.length > 0 && !looksLikeHarnessInstall(installDir)) { + errors.push( + `--install-dir "${installDir}" is a non-empty directory that does not look like a ` + + `prior harness install (no Orca.exe + "Uninstall Orca.exe"). Refusing to overwrite ` + + `unrelated files. Point at an empty or non-existent directory.` + ) + } + } + return errors +} + +function validate(opts, installDirFlagPresent) { + const errors = [] + if (!opts.from && !opts.fromRelease) { + errors.push('Missing base installer: pass --from or --from-release ') + } + if (opts.from && opts.fromRelease) { + errors.push('Pass only one of --from / --from-release') + } + if (!opts.to && !opts.toRelease) { + errors.push('Missing update installer: pass --to or --to-release ') + } + if (opts.to && opts.toRelease) { + errors.push('Pass only one of --to / --to-release') + } + if (!opts.expect) { + errors.push('Missing --expect ') + } else if (!VALID_PROFILES.has(opts.expect)) { + errors.push(`Invalid --expect "${opts.expect}" (expected cold-restore or survival)`) + } + if (!Number.isFinite(opts.soakSeconds) || opts.soakSeconds < 0) { + errors.push('--soak-seconds must be a non-negative number') + } + if (installDirFlagPresent && opts.installDir === undefined) { + errors.push('--install-dir requires a path value') + } else if (opts.installDir !== undefined) { + errors.push(...validateInstallDir(opts.installDir)) + } + return errors +} + +function takeValue(argv, flag) { + const idx = argv.indexOf(flag) + if (idx < 0) { + return undefined + } + const value = argv[idx + 1] + if (value === undefined || value.startsWith('--')) { + return undefined + } + return value +} diff --git a/tools/win-update-e2e/daemon-processes.mjs b/tools/win-update-e2e/daemon-processes.mjs new file mode 100644 index 000000000..cfd9c5d41 --- /dev/null +++ b/tools/win-update-e2e/daemon-processes.mjs @@ -0,0 +1,173 @@ +// Identify and inspect the Orca terminal daemon on Windows. +// +// The daemon is forked with ELECTRON_RUN_AS_NODE=1, so on Windows its process +// image is Orca.exe (the Electron binary running as plain Node) — it CANNOT be +// matched by executable name. The only reliable discriminators are the +// command-line markers the fork always passes: the daemon entry script +// (daemon-entry.js) and its --socket / --token arguments. See +// src/main/daemon/daemon-init.ts (createOutOfProcessLauncher). +// +// The daemon also writes a PID file at /daemon/daemon-v.pid whose +// JSON carries { pid, startedAtMs, entryPath, appVersion } — the appVersion +// field lets the harness prove whether a post-update daemon was re-forked by +// the new build (cold-restore) or is the same process (survival). + +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { assertWin32 } from './platform-guard.mjs' +import { runCommandSync } from './powershell-runner.mjs' + +const DAEMON_ENTRY_MARKER = 'daemon-entry.js' + +/** Default packaged userData root on Windows: %APPDATA%\Orca. */ +export function defaultUserDataDir() { + const appData = + process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming') + return path.join(appData, 'Orca') +} + +/** + * Find live daemon processes by scanning Win32_Process command lines for the + * daemon-entry.js marker. Returns [{ pid, ppid, name, commandLine }]. + * + * A developer box (and a busy CI runner) can host many unrelated daemons — one + * per worktree/profile, plus lingering hosts from reverted builds. Pass + * `scope` (a substring of the harness's own userData/token/socket path) to + * match ONLY the daemon this harness's app instance owns. Omit it for a + * machine-wide listing. + */ +export function findDaemonProcesses(scope = '') { + assertWin32('daemon-processes') + // Match by command-line marker only, never by exe name: with + // ELECTRON_RUN_AS_NODE the daemon's image is Orca.exe today but a relocated + // Phase 1 host may run from a differently-named copied binary. @() around the + // filtered result guards the PS 5.1 single-item unwrap — one match must still + // serialize as an array or the JS side sees an object and .length explodes. + // This exact class caused a production incident. + const command = [ + `$procs = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |`, + ` Where-Object { $_.CommandLine -and $_.CommandLine -match 'daemon-entry\\.js' })`, + `$out = @($procs | ForEach-Object {`, + ` [pscustomobject]@{ pid = $_.ProcessId; ppid = $_.ParentProcessId; name = $_.Name; commandLine = $_.CommandLine } })`, + `ConvertTo-Json -InputObject @{ processes = $out } -Depth 4 -Compress` + ].join('\n') + + const parsed = runJsonCommand(command) + const scopeNeedle = scope.toLowerCase() + return normalizeArray(parsed.processes).filter( + (p) => + typeof p.commandLine === 'string' && + p.commandLine.includes(DAEMON_ENTRY_MARKER) && + (scopeNeedle === '' || p.commandLine.toLowerCase().includes(scopeNeedle)) + ) +} + +/** + * Read all daemon PID files under /daemon (daemon-v*.pid). Globbing + * the protocol-versioned name keeps this correct across PROTOCOL_VERSION bumps. + * Returns [{ file, pid, startedAtMs, entryPath, appVersion }]. + */ +export function readDaemonPidFiles(userDataDir = defaultUserDataDir()) { + const daemonDir = path.join(userDataDir, 'daemon') + if (!existsSync(daemonDir)) { + return [] + } + const records = [] + for (const entry of readdirSync(daemonDir)) { + if (!entry.startsWith('daemon-v') || !entry.endsWith('.pid')) { + continue + } + const filePath = path.join(daemonDir, entry) + // Read once: a PID file can vanish between readdir and here, and re-reading + // it in the catch path would crash discovery on a single stale file. + let raw = '' + try { + raw = readFileSync(filePath, 'utf8').trim() + const parsed = JSON.parse(raw) + records.push({ file: filePath, ...parsed }) + } catch { + // Legacy/partial pid files may hold a bare integer. + const pid = Number(raw) + if (Number.isInteger(pid)) { + records.push({ file: filePath, pid }) + } + } + } + return records +} + +/** True if a PID currently maps to a live process. */ +export function isPidAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) { + return false + } + const { stdout } = runCommandSync( + `if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { 'alive' } else { 'dead' }` + ) + return stdout.trim() === 'alive' +} + +function runJsonCommand(command) { + const { stdout, stderr, code, error } = runCommandSync(command) + if (error) { + throw new Error(`PowerShell spawn failed: ${error.message}`) + } + const trimmed = stdout.trim() + if (!trimmed) { + // No matches: ConvertTo-Json of an empty array can emit nothing. + return { processes: [] } + } + try { + return JSON.parse(trimmed) + } catch (parseError) { + throw new Error( + `daemon-processes query returned non-JSON (exit ${code}): ${parseError.message}\n` + + `stdout:\n${trimmed}\nstderr:\n${stderr}` + ) + } +} + +function normalizeArray(raw) { + if (!raw) { + return [] + } + return Array.isArray(raw) ? raw : [raw] +} + +function parseUserDataArg(argv) { + const idx = argv.indexOf('--user-data') + return idx >= 0 && argv[idx + 1] ? argv[idx + 1] : defaultUserDataDir() +} + +function runStandalone(argv) { + assertWin32('daemon-processes standalone') + const userDataDir = parseUserDataArg(argv) + console.log(`[daemon-processes] userData: ${userDataDir}`) + + const pidFiles = readDaemonPidFiles(userDataDir) + console.log(`[daemon-processes] PID files (${pidFiles.length}):`) + console.log(JSON.stringify(pidFiles, null, 2)) + + const scopeIdx = argv.indexOf('--scope') + const scope = scopeIdx >= 0 && argv[scopeIdx + 1] ? argv[scopeIdx + 1] : '' + const processes = findDaemonProcesses(scope) + console.log( + `[daemon-processes] live daemon processes${scope ? ` scoped to "${scope}"` : ''} (${processes.length}):` + ) + console.log(JSON.stringify(processes, null, 2)) + + for (const rec of pidFiles) { + if (typeof rec.pid === 'number') { + console.log(`[daemon-processes] pid ${rec.pid} alive: ${isPidAlive(rec.pid)}`) + } + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { + try { + runStandalone(process.argv.slice(2)) + } catch (err) { + console.error(err.message) + process.exitCode = 1 + } +} diff --git a/tools/win-update-e2e/installer-steps.mjs b/tools/win-update-e2e/installer-steps.mjs new file mode 100644 index 000000000..18ba4ed09 --- /dev/null +++ b/tools/win-update-e2e/installer-steps.mjs @@ -0,0 +1,202 @@ +// Silent NSIS install / update / uninstall and installed-app discovery. +// +// Orca ships a per-user oneClick NSIS installer (electron-builder defaults: +// oneClick=true, perMachine=false) named orca-windows-setup.exe. One-click +// silent mode is ` /S`; the app installs under +// %LOCALAPPDATA%\Programs\ and the exe is Orca.exe. The install dir casing +// is not guaranteed (observed lowercase "orca" on a dev box), so the exe is +// located by search, never by a hard-coded path. + +import { existsSync, mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { assertWin32 } from './platform-guard.mjs' +import { runCommandSync } from './powershell-runner.mjs' + +const PRODUCT_NAME = 'Orca' +const EXE_NAME = 'Orca.exe' + +/** Programs root that per-user oneClick NSIS installs into. */ +function programsRoot() { + const localAppData = + process.env.LOCALAPPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Local') + return path.join(localAppData, 'Programs') +} + +/** + * Resolve a base/update installer to a local .exe path, downloading from a + * GitHub release tag when requested. Keeps gh usage to a single `release + * download` call (AGENTS.md rate-limit guidance). + */ +export function resolveInstaller({ localPath, releaseTag, assetPattern }) { + if (localPath) { + if (!existsSync(localPath)) { + throw new Error(`Installer not found: ${localPath}`) + } + return path.resolve(localPath) + } + if (!releaseTag) { + throw new Error('resolveInstaller: neither localPath nor releaseTag provided') + } + const outDir = mkdtempSync(path.join(tmpdir(), 'orca-e2e-installer-')) + const result = spawnSync( + 'gh', + ['release', 'download', releaseTag, '--pattern', assetPattern, '--dir', outDir], + { encoding: 'utf8' } + ) + if (result.status !== 0) { + throw new Error( + `gh release download ${releaseTag} failed (exit ${result.status}): ${result.stderr || result.stdout}` + ) + } + const found = findSetupExe(outDir) + if (!found) { + throw new Error(`No installer matching "${assetPattern}" in downloaded release ${releaseTag}`) + } + return found +} + +function findSetupExe(dir) { + const { stdout } = runCommandSync( + `Get-ChildItem -Path '${dir}' -Filter '*.exe' -Recurse -ErrorAction SilentlyContinue | ` + + `Select-Object -First 1 -ExpandProperty FullName` + ) + const line = stdout.trim().split('\n')[0]?.trim() + return line && existsSync(line) ? line : null +} + +/** + * Run an NSIS installer in one-click silent mode and wait for the installed exe + * to appear. Returns { exePath, version }. The installer process returns before + * copying finishes, so completion is confirmed by polling for the exe. + * + * When `installDir` is set (isolated-install mode), `/D=` overrides the + * install location. `/D` is special in NSIS: it must be the LAST argument and + * cannot be quoted, so the path must be spaces-free (validated upstream) and is + * passed as a single unquoted argv entry. + */ +export function silentInstall(setupExe, { timeoutMs = 180_000, installDir = null } = {}) { + assertWin32('silentInstall') + if (!existsSync(setupExe)) { + throw new Error(`Installer not found: ${setupExe}`) + } + // /S is the NSIS silent switch; the electron-builder oneClick installer needs + // no other flags for a per-user install. /D, when present, MUST be last. + const args = ['/S'] + if (installDir) { + args.push(`/D=${installDir}`) + } + const proc = spawnSync(setupExe, args, { encoding: 'utf8' }) + if (proc.error) { + throw new Error(`Failed to launch installer ${setupExe}: ${proc.error.message}`) + } + + // On update runs the old Orca.exe already exists, so wait for the exe whose + // version matches this installer — not just any exe the installer hasn't yet + // overwritten — to avoid reading the pre-update binary mid-copy. + const targetVersion = getExeVersion(setupExe) + const exePath = waitForInstalledExe(timeoutMs, installDir, targetVersion) + if (!exePath) { + const where = installDir ?? programsRoot() + throw new Error(`Installed ${EXE_NAME} did not appear under ${where} within ${timeoutMs}ms`) + } + return { exePath, version: getExeVersion(exePath) } +} + +function waitForInstalledExe(timeoutMs, installDir = null, expectedVersion = null) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const exe = locateInstalledExe(installDir) + if (exe && (!expectedVersion || getExeVersion(exe) === expectedVersion)) { + return exe + } + sleepSync(1000) + } + return null +} + +/** + * Locate the installed Orca.exe. In isolated mode (`installDir` set), the exe is + * at a known fixed path (\Orca.exe). Otherwise it is discovered + * under %LOCALAPPDATA%\Programs (case-tolerant — casing is not guaranteed). + */ +export function locateInstalledExe(installDir = null) { + if (installDir) { + const exe = path.join(installDir, EXE_NAME) + return existsSync(exe) ? exe : null + } + const root = programsRoot() + if (!existsSync(root)) { + return null + } + const { stdout } = runCommandSync( + `Get-ChildItem -Path '${root}' -Directory -ErrorAction SilentlyContinue | ` + + `ForEach-Object { Join-Path $_.FullName '${EXE_NAME}' } | ` + + `Where-Object { Test-Path $_ } | Select-Object -First 1` + ) + const line = stdout.trim().split('\n')[0]?.trim() + return line && existsSync(line) ? line : null +} + +/** Read the ProductVersion string from an exe's version resource. */ +export function getExeVersion(exePath) { + const { stdout } = runCommandSync(`(Get-Item '${exePath}').VersionInfo.ProductVersion`) + return stdout.trim() || null +} + +/** + * Silently uninstall the test install at an EXPLICIT directory via its + * NSIS-generated uninstaller. Best-effort: returns false if no uninstaller is + * found rather than throwing, so teardown never masks the real assertion result. + * + * SAFETY: `installDir` is REQUIRED and must be the exact directory the harness + * installed into this run — there is deliberately no scan-and-discover fallback, + * because a `null` default once made this function locate and uninstall the + * developer's REAL Orca. It additionally refuses to run against the default + * per-user install location unless `allowDefaultLocation` is explicitly set (only + * the owns-the-install non-isolated teardown may do so). + */ +export function silentUninstall(installDir, { allowDefaultLocation = false } = {}) { + assertWin32('silentUninstall') + if (typeof installDir !== 'string' || installDir.trim() === '') { + throw new Error('silentUninstall requires an explicit install directory (no scan fallback)') + } + const resolved = path.resolve(installDir) + if (!allowDefaultLocation && pathsEqual(resolved, path.join(programsRoot(), PRODUCT_NAME))) { + throw new Error( + `Refusing to uninstall the default install location "${resolved}" — this is where a ` + + `developer's REAL Orca lives. Isolated mode must target a separate --install-dir.` + ) + } + const exe = path.join(resolved, EXE_NAME) + if (!existsSync(exe)) { + return false + } + const exeDir = resolved + const uninstaller = path.join(exeDir, `Uninstall ${PRODUCT_NAME}.exe`) + if (!existsSync(uninstaller)) { + return false + } + // NSIS uninstallers must be run from a copy (they relocate themselves); _?= + // forces synchronous, in-place uninstall so we can assert completion. + spawnSync(uninstaller, ['/S', `_?=${exeDir}`], { encoding: 'utf8' }) + sleepSync(2000) + return !existsSync(exe) +} + +/** Case-insensitive path equality after trailing-separator normalization. */ +function pathsEqual(a, b) { + const norm = (p) => + path + .resolve(p) + .replace(/[\\/]+$/, '') + .toLowerCase() + return norm(a) === norm(b) +} + +function sleepSync(ms) { + // Blocking sleep via Atomics keeps install polling simple and synchronous. + const sab = new Int32Array(new SharedArrayBuffer(4)) + Atomics.wait(sab, 0, 0, ms) +} diff --git a/tools/win-update-e2e/interactivity-probes.mjs b/tools/win-update-e2e/interactivity-probes.mjs new file mode 100644 index 000000000..fd58a3546 --- /dev/null +++ b/tools/win-update-e2e/interactivity-probes.mjs @@ -0,0 +1,99 @@ +// Filesystem-sentinel interactivity probes for a packaged terminal session. +// +// These prove a session is interactive without reading the (WebGL) xterm +// buffer: a typed command writes a marker FILE, and the harness checks the +// file. That verifies keystrokes reached the shell AND the shell executed them. + +import { existsSync, statSync, readFileSync, rmSync } from 'node:fs' +import path from 'node:path' +import { sendCtrlC, runShellCommand } from './app-driver.mjs' + +const POLL_MS = 500 + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function waitForFile(filePath, timeoutMs) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (existsSync(filePath)) { + return true + } + await delay(POLL_MS) + } + return false +} + +/** + * Type a command that writes a unique token to a sentinel file, then confirm + * the file appears with that token. Proves typed input reaches and runs in the + * active terminal. Returns true on success. + */ +export async function probeEcho(page, runDir, label = 'echo') { + const token = `${label.toUpperCase()}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const file = path.join(runDir, `${label}-${token}.txt`) + await runShellCommand(page, `Set-Content -LiteralPath '${file}' -Value '${token}'`) + const appeared = await waitForFile(file, 15_000) + if (!appeared) { + return false + } + return readFileSync(file, 'utf8').includes(token) +} + +/** Latest mtime (ms) of a file, or 0 if absent. */ +export function fileMtimeMs(filePath) { + try { + return statSync(filePath).mtimeMs + } catch { + return 0 + } +} + +/** + * Confirm a heartbeat file keeps advancing (session is live and streaming). + * Samples twice Poll apart; returns true if the second sample is newer. + */ +export async function probeHeartbeatAdvancing(heartbeatFile) { + const first = fileMtimeMs(heartbeatFile) + await delay(1500) + const second = fileMtimeMs(heartbeatFile) + return second > first && second > 0 +} + +/** + * Ctrl+C on a foreground marker loop: after interrupt the heartbeat must STOP + * advancing and the shell must return to a prompt (a follow-up sentinel command + * runs). Both conditions are required — output stopping alone could be a hang. + */ +export async function probeCtrlCInterruptsMarker(page, runDir, heartbeatFile) { + await sendCtrlC(page) + await delay(1500) + const afterCtrlC = fileMtimeMs(heartbeatFile) + await delay(1500) + const stillLater = fileMtimeMs(heartbeatFile) + const heartbeatStopped = stillLater === afterCtrlC + const promptReturned = await probeEcho(page, runDir, 'post-interrupt') + return heartbeatStopped && promptReturned +} + +/** + * cold-restore Ctrl+C: start a fresh foreground sleep loop in the active (new) + * terminal, interrupt it, then confirm the prompt returns via a sentinel. + */ +export async function probeCtrlCOnFreshLoop(page, runDir) { + const heartbeatFile = path.join(runDir, `fresh-loop-${Date.now()}.txt`) + if (existsSync(heartbeatFile)) { + rmSync(heartbeatFile) + } + await runShellCommand( + page, + `while($true){ [System.IO.File]::WriteAllText('${heartbeatFile}',(Get-Date).ToString('o')); Start-Sleep -Milliseconds 500 }` + ) + // Let the loop spin up and prove it is actually running before interrupting. + const running = await probeHeartbeatAdvancing(heartbeatFile) + if (!running) { + return false + } + return probeCtrlCInterruptsMarker(page, runDir, heartbeatFile) +} diff --git a/tools/win-update-e2e/onboarding-profile.mjs b/tools/win-update-e2e/onboarding-profile.mjs new file mode 100644 index 000000000..dd1d4771d --- /dev/null +++ b/tools/win-update-e2e/onboarding-profile.mjs @@ -0,0 +1,77 @@ +// Persistence seed for a fresh packaged Orca profile: dismisses onboarding and +// registers a throwaway git repo as a project so the harness can open a +// workspace + terminal without the native "Add Project" folder dialog (which +// Playwright cannot drive). +// +// A first-run profile renders a fullscreen onboarding overlay (`fixed inset-0 +// z-[100]`) that intercepts every pointer event; the renderer shows it only +// while `onboarding.closedAt === null` (src/renderer/src/components/onboarding/ +// should-show-onboarding.ts), so writing this object to `/ +// orca-data.json` BEFORE launch dismisses it. The app derives the Projects list +// from the persisted `repos` array (src/main/persistence.ts), so a single repo +// entry pointing at a real git checkout makes the project selectable. +// +// IMPORTANT: seed only BEFORE the first launch. The app rewrites orca-data.json +// on quit; overwriting it before the post-update relaunch would destroy the +// persisted session that the cold-restore/survival assertions depend on. +// +// Onboarding flow-version / final-step mirror src/shared/constants +// (ONBOARDING_FLOW_VERSION=4, ONBOARDING_FINAL_STEP=5); refresh if the app bumps +// the flow version, or a stale version re-arms onboarding. + +import { writeFileSync, mkdirSync } from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' + +const ONBOARDING_FLOW_VERSION = 4 +const ONBOARDING_FINAL_STEP = 5 + +/** + * Create a throwaway git repo under `dir` and return a persisted `Repo` entry + * for it. A real checkout (init + one commit) is required — Orca treats a + * project as a git repository. + */ +export function createSeededRepo(dir) { + mkdirSync(dir, { recursive: true }) + const git = (...args) => execFileSync('git', args, { cwd: dir, stdio: 'ignore' }) + git('init', '-b', 'main') + git('config', 'user.email', 'win-update-e2e@orca.test') + git('config', 'user.name', 'win-update-e2e') + writeFileSync(path.join(dir, 'README.md'), '# win-update-e2e fixture repo\n') + git('add', '-A') + git('commit', '-m', 'seed') + return { + id: '00000000-0000-4000-8000-00000000e2e0', + path: dir, + displayName: 'e2e-fixture', + badgeColor: '#888888', + addedAt: 1 + } +} + +/** The persisted profile object: onboarding dismissed + telemetry opted in + + * an optional seeded repo. */ +export function buildFreshProfile({ repo = null } = {}) { + return { + settings: { + telemetry: { + optedIn: true, + installId: '00000000-0000-4000-8000-000000000000', + existedBeforeTelemetryRelease: false + } + }, + onboarding: { + flowVersion: ONBOARDING_FLOW_VERSION, + closedAt: 1, + outcome: 'completed', + lastCompletedStep: ONBOARDING_FINAL_STEP + }, + repos: repo ? [repo] : [] + } +} + +/** Write a fresh profile into a userData dir before the FIRST launch only. */ +export function seedFreshProfile(userDataDir, profile) { + mkdirSync(userDataDir, { recursive: true }) + writeFileSync(path.join(userDataDir, 'orca-data.json'), `${JSON.stringify(profile, null, 2)}\n`) +} diff --git a/tools/win-update-e2e/platform-guard.mjs b/tools/win-update-e2e/platform-guard.mjs new file mode 100644 index 000000000..faf9fc451 --- /dev/null +++ b/tools/win-update-e2e/platform-guard.mjs @@ -0,0 +1,38 @@ +// Windows-only guards for the packaged-update E2E harness. +// +// This tool drives real NSIS installers, named-pipe daemons, and Win32 window +// enumeration, none of which exist off Windows. Every entry point calls +// assertWin32() so a macOS/Linux invocation fails loudly with a useful message +// instead of throwing an opaque "powershell: command not found" later. + +import { runCommandSync } from './powershell-runner.mjs' + +/** Throw a clear error unless we are on win32. */ +export function assertWin32(context = 'win-update-e2e') { + if (process.platform !== 'win32') { + throw new Error( + `${context} is Windows-only (it drives NSIS installers, named-pipe ` + + `daemons, and Win32 window enumeration). Detected platform ` + + `"${process.platform}". Run it on a Windows machine or CI runner.` + ) + } +} + +/** + * True when this process is running elevated. Non-elevated is the expected, + * supported mode (per-user oneClick NSIS needs no elevation); this is only used + * to print an informational warning during preflight. + */ +export function isElevated() { + if (process.platform !== 'win32') { + return false + } + // Use the WindowsPrincipal role check via PowerShell rather than `whoami`, + // which under a Git Bash / MSYS PATH can resolve to a Unix whoami that + // rejects the /groups flag. + const { stdout } = runCommandSync( + `[bool]([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()` + + `).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)` + ) + return stdout.trim().toLowerCase() === 'true' +} diff --git a/tools/win-update-e2e/powershell-runner.mjs b/tools/win-update-e2e/powershell-runner.mjs new file mode 100644 index 000000000..68561b261 --- /dev/null +++ b/tools/win-update-e2e/powershell-runner.mjs @@ -0,0 +1,87 @@ +// Thin wrapper around Windows PowerShell 5.1 for the harness's Win32 probes. +// +// Everything that inspects windows or processes goes through Windows PowerShell +// (powershell.exe) rather than pwsh, because 5.1 is guaranteed present on every +// Windows box and the .ps1 probes are written for its quirks (notably the +// single-item .Count pitfall — see window-enum.ps1 / daemon-processes.mjs). + +import { spawn, spawnSync } from 'node:child_process' + +const POWERSHELL = 'powershell.exe' +const BASE_ARGS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass'] +// Cap sync probes so a wedged PowerShell call can't block the harness until the +// whole CI job times out. Callers can override via opts.timeout. +const DEFAULT_SYNC_TIMEOUT_MS = 60_000 + +/** + * Run a .ps1 file synchronously and return { code, stdout, stderr }. + * scriptArgs is an array of string arguments passed after -File. + */ +export function runScriptFileSync(scriptPath, scriptArgs = [], opts = {}) { + const result = spawnSync(POWERSHELL, [...BASE_ARGS, '-File', scriptPath, ...scriptArgs], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + timeout: DEFAULT_SYNC_TIMEOUT_MS, + ...opts + }) + return { + code: result.status ?? (result.error ? -1 : 0), + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + error: result.error ?? null + } +} + +/** + * Run a .ps1 file whose stdout is a single JSON document and return the parsed + * value. Throws with the raw stderr/stdout attached when parsing fails, so a + * malformed probe surfaces its actual PowerShell error instead of a bare + * SyntaxError. + */ +export function runScriptFileJson(scriptPath, scriptArgs = [], opts = {}) { + const { code, stdout, stderr, error } = runScriptFileSync(scriptPath, scriptArgs, opts) + if (error) { + throw new Error(`Failed to spawn PowerShell for ${scriptPath}: ${error.message}`) + } + const trimmed = stdout.trim() + if (!trimmed) { + throw new Error( + `PowerShell script ${scriptPath} produced no stdout (exit ${code}). stderr:\n${stderr}` + ) + } + try { + return JSON.parse(trimmed) + } catch (parseError) { + throw new Error( + `PowerShell script ${scriptPath} did not emit valid JSON (exit ${code}): ` + + `${parseError.message}\n--- stdout ---\n${trimmed}\n--- stderr ---\n${stderr}` + ) + } +} + +/** + * Spawn a .ps1 file as a long-running background child. Returns the ChildProcess + * so the caller can track/stop it. Used for the window watch loop. + */ +export function spawnScriptFile(scriptPath, scriptArgs = [], opts = {}) { + return spawn(POWERSHELL, [...BASE_ARGS, '-File', scriptPath, ...scriptArgs], { + stdio: ['ignore', 'pipe', 'pipe'], + ...opts + }) +} + +/** Run an inline command string synchronously and return { code, stdout, stderr }. */ +export function runCommandSync(command, opts = {}) { + const result = spawnSync(POWERSHELL, [...BASE_ARGS, '-Command', command], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + timeout: DEFAULT_SYNC_TIMEOUT_MS, + ...opts + }) + return { + code: result.status ?? (result.error ? -1 : 0), + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + error: result.error ?? null + } +} diff --git a/tools/win-update-e2e/preflight.mjs b/tools/win-update-e2e/preflight.mjs new file mode 100644 index 000000000..b5218ed01 --- /dev/null +++ b/tools/win-update-e2e/preflight.mjs @@ -0,0 +1,161 @@ +// Preflight safety checks and the baseline window snapshot. +// +// The harness installs, updates, and uninstalls a real app and kills processes +// it created. To avoid ever touching a user's live Orca, it REFUSES to run when +// a pre-existing Orca app process (not a daemon) is already running that it did +// not start. Existing installs and detached daemons are warned about, not +// treated as fatal (the update path is what exercises them). + +import path from 'node:path' +import { assertWin32, isElevated } from './platform-guard.mjs' +import { runCommandSync } from './powershell-runner.mjs' +import { captureBaseline } from './window-watch.mjs' +import { locateInstalledExe } from './installer-steps.mjs' +import { findDaemonProcesses } from './daemon-processes.mjs' + +/** + * Find running Orca APP processes (main window process), excluding daemons. + * The daemon runs as Orca.exe too but always carries the daemon-entry.js marker + * on its command line, so excluding that marker isolates the actual app. The + * ExecutablePath lets isolated mode decide whether a running app is under the + * test dir (fatal) or is the developer's real Orca elsewhere (informational). + */ +export function findAppProcesses() { + const command = [ + `$procs = @(Get-CimInstance Win32_Process -Filter "Name = 'Orca.exe'" -ErrorAction SilentlyContinue |`, + ` Where-Object { -not ($_.CommandLine -match 'daemon-entry\\.js') })`, + `$out = @($procs | ForEach-Object {`, + ` [pscustomobject]@{ pid = $_.ProcessId; path = $_.ExecutablePath; commandLine = $_.CommandLine } })`, + `ConvertTo-Json -InputObject @{ processes = $out } -Depth 4 -Compress` + ].join('\n') + // Fail closed: this guard protects the user's real processes, so a failed + // query must abort the run rather than look like "no Orca is running". + const { stdout, stderr, code, error } = runCommandSync(command) + if (error) { + throw new Error(`Failed to query Orca app processes: ${error.message}`) + } + if (code !== 0) { + throw new Error(`Failed to query Orca app processes (exit ${code}): ${stderr || stdout}`) + } + const trimmed = stdout.trim() + if (!trimmed) { + return [] + } + try { + const parsed = JSON.parse(trimmed) + const arr = parsed.processes + return Array.isArray(arr) ? arr : arr ? [arr] : [] + } catch (parseError) { + throw new Error( + `Orca app process query returned invalid JSON: ${parseError.message}\n` + + `stdout:\n${trimmed}\nstderr:\n${stderr}` + ) + } +} + +/** True if `childPath` is equal to or inside `parentDir` (case-insensitive). */ +function isPathUnder(childPath, parentDir) { + const child = path + .resolve(childPath) + .replace(/[\\/]+$/, '') + .toLowerCase() + const parent = path + .resolve(parentDir) + .replace(/[\\/]+$/, '') + .toLowerCase() + return child === parent || child.startsWith(`${parent}\\`) +} + +/** + * Run preflight. Returns { baseline, warnings, existingInstall }. Throws if a + * pre-existing Orca app is running (never kill a user's process) or if an Orca + * install already exists and allowExistingInstall was not passed (the run would + * overwrite a developer's real build). baselinePath receives the snapshot of + * currently-visible top-level windows. + * + * In isolated mode (`installDir` set) both refusals become target-scoped: only a + * running app whose exe is UNDER installDir is fatal, and only an install already + * in installDir triggers the existing-install refusal. A real Orca running or + * installed elsewhere is untouched by isolated mode and is merely noted. + */ +export function preflight({ baselinePath, allowExistingInstall = false, installDir = null }) { + assertWin32('preflight') + const warnings = [] + const isolated = Boolean(installDir) + + if (isElevated()) { + warnings.push( + 'Running elevated. A per-user oneClick install does not need elevation; ' + + 'an elevated run can install to an unexpected profile.' + ) + } + + const appProcesses = findAppProcesses() + if (isolated) { + const inTarget = appProcesses.filter((p) => p.path && isPathUnder(p.path, installDir)) + if (inTarget.length > 0) { + const listing = inTarget.map((p) => ` pid ${p.pid}: ${p.path}`).join('\n') + throw new Error( + `Refusing to run: ${inTarget.length} Orca app process(es) are running from the ` + + `isolated target dir ${installDir} that this harness did not start. Close them ` + + `first (this harness never kills pre-existing user processes):\n${listing}` + ) + } + const elsewhere = appProcesses.filter((p) => !(p.path && isPathUnder(p.path, installDir))) + if (elsewhere.length > 0) { + warnings.push( + `${elsewhere.length} Orca app process(es) are running from outside the isolated ` + + `target dir (pids: ${elsewhere.map((p) => p.pid).join(', ')}). Isolated mode never ` + + `touches them; proceeding.` + ) + } + } else if (appProcesses.length > 0) { + const listing = appProcesses.map((p) => ` pid ${p.pid}: ${p.commandLine}`).join('\n') + throw new Error( + `Refusing to run: ${appProcesses.length} Orca app process(es) are already ` + + `running that this harness did not start. Close them first (this harness ` + + `never kills pre-existing user processes):\n${listing}` + ) + } + + // Scope the existing-install check to the target dir in isolated mode; an + // install at the default location is left untouched and does not count. + const existingInstall = isolated ? locateInstalledExe(installDir) : locateInstalledExe() + if (existingInstall && !allowExistingInstall) { + throw new Error( + isolated + ? `Refusing to run: an install already exists in the isolated target dir ` + + `${existingInstall}. Pass --allow-existing-install to overwrite it (isolated mode ` + + `never touches the real install elsewhere), or point --install-dir at an empty dir.` + : `Refusing to run: an Orca install already exists at ${existingInstall}. ` + + `This run would silently OVERWRITE it with the --from/--to versions and ` + + `leave the --to version installed — destroying a real Orca install on a ` + + `developer machine. Pass --allow-existing-install to proceed anyway ` + + `(your prior build will NOT be restored), or uninstall Orca first. Clean ` + + `machines (CI/VM) never hit this.` + ) + } + if (existingInstall && !isolated) { + warnings.push( + `--allow-existing-install set: the existing install at ${existingInstall} will be ` + + `overwritten and the --to version left installed; teardown will NOT uninstall it.` + ) + } else if (existingInstall) { + warnings.push( + `A prior harness install exists in the target dir ${existingInstall}; it will be ` + + `overwritten and cleaned up at teardown (isolated mode owns the test dir).` + ) + } + + const existingDaemons = findDaemonProcesses() + if (existingDaemons.length > 0) { + warnings.push( + `${existingDaemons.length} pre-existing daemon process(es) found on this machine ` + + `(pids: ${existingDaemons.map((d) => d.pid).join(', ')}). The run uses an isolated ` + + `userData dir, so its daemon is tracked by scope and will not collide.` + ) + } + + const baseline = captureBaseline(baselinePath) + return { baseline, warnings, existingInstall } +} diff --git a/tools/win-update-e2e/registry-shortcut-backup.mjs b/tools/win-update-e2e/registry-shortcut-backup.mjs new file mode 100644 index 000000000..59f66e914 --- /dev/null +++ b/tools/win-update-e2e/registry-shortcut-backup.mjs @@ -0,0 +1,312 @@ +// Snapshot and restore the per-user install state that an NSIS install hijacks. +// +// electron-builder's NSIS honors the /D install-dir override for FILE layout, so +// isolated mode installs into a separate directory. But regardless of /D, the +// installer writes InstallLocation + the uninstall entry to the SAME per-user +// HKCU keys as the real install, and rewrites the Start Menu / Desktop shortcuts +// (node_modules/app-builder-lib/templates/nsis/include/installer.nsh). Left +// hijacked, a developer's next REAL update would target the test directory. +// +// This module snapshots those shared keys + shortcuts before an isolated run and +// restores them at teardown, always. All registry access is via reg.exe (atomic +// export/import/delete); discovery is read-only PowerShell. + +import { existsSync, mkdirSync, copyFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { runCommandSync } from './powershell-runner.mjs' + +const UNINSTALL_ROOT = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall' +const APP_ROOT = 'HKCU\\Software' + +/** + * Read-only discovery of the machine's current Orca install registry state. + * The uninstall entry is found by DisplayName (electron-builder writes the app + * GUID as the key name, not a fixed string); the app key that carries + * InstallLocation is found under HKCU\Software by its ShortcutName/InstallLocation. + * Returns concrete reg.exe key paths + the current InstallLocation value (all + * null when no install exists). + */ +export function discoverInstallRegistryState() { + const ps = [ + `$un = 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall'`, + `$uninstallName = $null; $displayName = $null; $displayVersion = $null`, + `foreach ($k in @(Get-ChildItem $un -ErrorAction SilentlyContinue)) {`, + ` $dn = $k.GetValue('DisplayName'); $il = $k.GetValue('InstallLocation')`, + ` if ($dn -eq 'Orca' -or ($il -and $il -match '\\\\Programs\\\\orca')) {`, + ` $uninstallName = $k.PSChildName; $displayName = $dn; $displayVersion = $k.GetValue('DisplayVersion')`, + ` }`, + `}`, + `$appName = $null; $installLocation = $null`, + `foreach ($k in @(Get-ChildItem 'HKCU:\\Software' -ErrorAction SilentlyContinue)) {`, + ` $il = $k.GetValue('InstallLocation'); $sn = $k.GetValue('ShortcutName')`, + ` if ($il -and ($sn -eq 'Orca' -or $il -match 'orca')) {`, + ` $appName = $k.PSChildName; $installLocation = $il`, + ` }`, + `}`, + `ConvertTo-Json -Compress -InputObject @{ uninstallName = $uninstallName; appName = $appName; installLocation = $installLocation; displayName = $displayName; displayVersion = $displayVersion }` + ].join('\n') + const { stdout } = runCommandSync(ps) + let parsed = {} + try { + parsed = JSON.parse(stdout.trim() || '{}') + } catch { + parsed = {} + } + return { + uninstallKey: parsed.uninstallName ? `${UNINSTALL_ROOT}\\${parsed.uninstallName}` : null, + appKey: parsed.appName ? `${APP_ROOT}\\${parsed.appName}` : null, + installLocation: parsed.installLocation || null, + displayName: parsed.displayName || null, + displayVersion: parsed.displayVersion || null + } +} + +/** The two directories NSIS writes Orca shortcuts into: Start Menu + Desktop. */ +export function shortcutDirs() { + const appData = + process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming') + const startMenu = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs') + return [startMenu, resolveDesktopDir()] +} + +function resolveDesktopDir() { + // Desktop can be redirected (OneDrive), so resolve via the shell folder API + // rather than assuming %USERPROFILE%\Desktop. + const { stdout } = runCommandSync(`[Environment]::GetFolderPath('Desktop')`) + const line = stdout.trim() + if (line && existsSync(line)) { + return line + } + return path.join(process.env.USERPROFILE ?? '', 'Desktop') +} + +/** Read-only: list Orca *.lnk shortcuts under the given dirs (recursive). */ +export function discoverOrcaShortcuts(dirs = shortcutDirs()) { + const found = [] + for (const dir of dirs) { + collectOrcaLnks(dir, found) + } + return found +} + +function collectOrcaLnks(dir, out) { + if (!existsSync(dir)) { + return + } + let entries = [] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + collectOrcaLnks(full, out) + } else if (entry.isFile() && /\.lnk$/i.test(entry.name) && /orca/i.test(entry.name)) { + out.push(full) + } + } +} + +/** + * Snapshot the shared install registry keys + Orca shortcuts before an isolated + * install writes over them. `reg export`s each existing key and copies each + * existing shortcut into runDir, recording a manifest of what pre-existed (and + * the pre-run InstallLocation) so restore knows what to put back vs. delete. + */ +export function backupInstallState(runDir) { + const backupDir = path.join(runDir, 'install-state-backup') + mkdirSync(backupDir, { recursive: true }) + const state = discoverInstallRegistryState() + const dirs = shortcutDirs() + const manifest = { + backupDir, + shortcutDirs: dirs, + installLocation: state.installLocation, + uninstall: null, + app: null, + shortcuts: [] + } + if (state.uninstallKey) { + const regFile = path.join(backupDir, 'uninstall.reg') + if (regExport(state.uninstallKey, regFile)) { + manifest.uninstall = { path: state.uninstallKey, regFile } + } + } + if (state.appKey) { + const regFile = path.join(backupDir, 'app.reg') + if (regExport(state.appKey, regFile)) { + manifest.app = { path: state.appKey, regFile } + } + } + let i = 0 + for (const lnk of discoverOrcaShortcuts(dirs)) { + const backup = path.join(backupDir, `shortcut-${i}-${path.basename(lnk)}`) + try { + copyFileSync(lnk, backup) + manifest.shortcuts.push({ path: lnk, backup }) + } catch { + manifest.shortcuts.push({ path: lnk, backup: null }) + } + i += 1 + } + writeFileSync(path.join(backupDir, 'manifest.json'), JSON.stringify(manifest, null, 2)) + return manifest +} + +/** + * Restore the shared install state captured by backupInstallState. Keys that + * pre-existed are `reg import`ed back to their original values; keys that did NOT + * pre-exist but exist now (created by the test install) are `reg delete`d. + * Pre-existing shortcuts are copied back; test-created Orca shortcuts are removed. + * Finally re-reads InstallLocation and, on mismatch, prints a LOUD block with the + * exact manual `reg import` command to recover. + */ +export function restoreInstallState(manifest) { + const result = { + imported: [], + deleted: [], + failures: [], + shortcutsRestored: [], + shortcutsDeleted: [], + verified: false + } + const current = discoverInstallRegistryState() + + if (manifest.uninstall) { + if (regImport(manifest.uninstall.regFile)) { + result.imported.push(manifest.uninstall.path) + } else { + result.failures.push(`Failed to import ${manifest.uninstall.regFile}`) + } + } else if (current.uninstallKey) { + if (regDelete(current.uninstallKey)) { + result.deleted.push(current.uninstallKey) + } else { + result.failures.push(`Failed to delete ${current.uninstallKey}`) + } + } + + if (manifest.app) { + if (regImport(manifest.app.regFile)) { + result.imported.push(manifest.app.path) + } else { + result.failures.push(`Failed to import ${manifest.app.regFile}`) + } + } else if (current.appKey) { + if (regDelete(current.appKey)) { + result.deleted.push(current.appKey) + } else { + result.failures.push(`Failed to delete ${current.appKey}`) + } + } + + restoreShortcuts(manifest, result) + + const after = discoverInstallRegistryState() + // A silently-failed import/delete can leave stale keys while InstallLocation + // still matches, so restore isn't "verified" unless every op also succeeded. + result.verified = + result.failures.length === 0 && + normLoc(manifest.installLocation) === normLoc(after.installLocation) + if (!result.verified) { + printMismatchWarning(manifest, manifest.installLocation, after.installLocation) + } + return result +} + +function restoreShortcuts(manifest, result) { + const preExisting = new Set(manifest.shortcuts.map((s) => s.path.toLowerCase())) + for (const s of manifest.shortcuts) { + if (s.backup && existsSync(s.backup)) { + try { + copyFileSync(s.backup, s.path) + result.shortcutsRestored.push(s.path) + } catch { + /* best effort — a missing dir means the shortcut target is gone anyway */ + } + } + } + for (const lnk of discoverOrcaShortcuts(manifest.shortcutDirs)) { + if (!preExisting.has(lnk.toLowerCase())) { + try { + rmSync(lnk, { force: true }) + result.shortcutsDeleted.push(lnk) + } catch { + /* best effort */ + } + } + } +} + +function printMismatchWarning(manifest, expected, actual) { + const bar = '!'.repeat(72) + const importCmds = [] + if (manifest.app) { + importCmds.push(`reg import "${manifest.app.regFile}"`) + } + if (manifest.uninstall) { + importCmds.push(`reg import "${manifest.uninstall.regFile}"`) + } + const recovery = + importCmds.length > 0 ? importCmds : ['(no backup was captured — no manual import available)'] + console.error(`\n${bar}`) + console.error('!! WIN-UPDATE-E2E: REGISTRY RESTORE VERIFICATION FAILED') + console.error(`!! Expected InstallLocation: ${expected ?? '(none / no pre-existing install)'}`) + console.error(`!! Actual InstallLocation: ${actual ?? '(none)'}`) + console.error('!! Your REAL Orca install pointer may be hijacked to the test directory.') + console.error('!! The next real Orca update could install into the test location.') + console.error('!! Recover manually by running these command(s) in an elevated-free shell:') + for (const cmd of recovery) { + console.error(`!! ${cmd}`) + } + console.error(`${bar}\n`) +} + +function normLoc(value) { + if (!value) { + return '' + } + return value.replace(/[\\/]+$/, '').toLowerCase() +} + +function regExport(keyPath, file) { + try { + execFileSync('reg', ['export', keyPath, file, '/y'], { stdio: 'ignore' }) + return existsSync(file) + } catch { + return false + } +} + +function regImport(file) { + if (!file || !existsSync(file)) { + return false + } + try { + execFileSync('reg', ['import', file], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +function regDelete(keyPath) { + try { + execFileSync('reg', ['delete', keyPath, '/f'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +// Standalone read-only mode: print what an isolated run would snapshot. Touches +// nothing. `node registry-shortcut-backup.mjs` +if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { + console.log('[registry-shortcut-backup] discovered install registry state:') + console.log(JSON.stringify(discoverInstallRegistryState(), null, 2)) + console.log('[registry-shortcut-backup] Orca shortcuts that would be backed up:') + console.log(JSON.stringify(discoverOrcaShortcuts(), null, 2)) +} diff --git a/tools/win-update-e2e/run.mjs b/tools/win-update-e2e/run.mjs new file mode 100644 index 000000000..7c8d8cde6 --- /dev/null +++ b/tools/win-update-e2e/run.mjs @@ -0,0 +1,628 @@ +// win-update-e2e — packaged NSIS update proof harness. +// +// Given two Orca Windows installers (version N and N+1), proves what happens to +// the terminal daemon and its sessions across a real silent update, with +// machine-checkable assertions. Windows-only. See README.md for usage and the +// design context in docs/windows-terminal-update-survival-plan.md (Phase 0). + +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { assertWin32 } from './platform-guard.mjs' +import { parseArgs } from './cli-args.mjs' +import { preflight } from './preflight.mjs' +import { resolveInstaller, silentInstall, silentUninstall } from './installer-steps.mjs' +import { backupInstallState, restoreInstallState } from './registry-shortcut-backup.mjs' +import { + launchInstalledApp, + ensureTerminal, + dismissOverlays, + createTerminalTab, + listTabIds, + startMarker, + readTerminalTextBestEffort, + closeApp, + captureFailureDiagnostics +} from './app-driver.mjs' +import { readDaemonPidFiles, findDaemonProcesses, isPidAlive } from './daemon-processes.mjs' +import { startWatch } from './window-watch.mjs' +import { + probeEcho, + probeHeartbeatAdvancing, + probeCtrlCInterruptsMarker, + probeCtrlCOnFreshLoop, + fileMtimeMs +} from './interactivity-probes.mjs' +import { buildAssertions, renderTable, allPassed } from './assertions.mjs' +import { createSeededRepo, buildFreshProfile } from './onboarding-profile.mjs' + +function log(step, msg) { + console.log(`[win-update-e2e] ${step}: ${msg}`) +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)) + if (opts.help) { + console.log(opts.usage) + return 0 + } + if (opts.errors?.length) { + console.error(`Argument errors:\n - ${opts.errors.join('\n - ')}\n${opts.usage}`) + return 2 + } + assertWin32('win-update-e2e') + + const installDir = opts.installDir ?? null + const isolated = Boolean(installDir) + + const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + const canary = `ORCA-E2E-CANARY-${runId}` + const runDir = mkdtempSync(path.join(tmpdir(), `orca-win-update-e2e-${runId}-`)) + const userDataDir = path.join(runDir, 'userData') + const baselinePath = path.join(runDir, 'baseline.json') + const watchOut = path.join(runDir, 'window-watch.jsonl') + const markerPidFile = path.join(runDir, 'marker.pid') + const heartbeatFile = path.join(runDir, 'heartbeat.txt') + const created = { markerPid: null, daemonPids: new Set() } + + log( + 'setup', + `runId=${runId} runDir=${runDir} profile=${opts.expect}${isolated ? ` installDir=${installDir}` : ''}` + ) + if (isolated && opts.keepInstall) { + log( + 'setup', + '--keep-install is ignored in isolated mode: the test install and its per-user ' + + 'registry hijack are always cleaned up so the real install stays safe.' + ) + } + + const { warnings, existingInstall } = preflight({ + baselinePath, + allowExistingInstall: opts.allowExistingInstall, + installDir + }) + const hadPreexistingInstall = Boolean(existingInstall) + for (const w of warnings) { + log('preflight-warning', w) + } + + // Isolated mode: snapshot the shared per-user registry keys + shortcuts BEFORE + // any install writes over them. Everything after this must run through the + // try/finally so the snapshot is always restored, even on failure. + let manifest = null + if (isolated) { + manifest = backupInstallState(runDir) + log('isolated', `backed up install registry/shortcut state -> ${manifest.backupDir}`) + } + + const runArgs = { + opts, + installDir, + canary, + runDir, + userDataDir, + baselinePath, + watchOut, + markerPidFile, + heartbeatFile, + created + } + + // Both paths ALWAYS tear down (close the app, kill the marker/watch, uninstall) + // and, in isolated mode, restore registry/shortcuts — whatever happens in the + // proof body. A driving failure captures diagnostics first, then surfaces as a + // FATAL. Teardown in a finally is what keeps a driving hang from pinning the + // Node process alive until the CI job timeout. + const ctx = { session: null } + const diagDir = process.env.ORCA_E2E_DIAG_DIR || path.join(runDir, 'diag') + let passed = false + try { + passed = await runProof(ctx, runArgs) + } catch (err) { + console.error(`[win-update-e2e] FATAL: ${err.stack || err.message}`) + if (ctx.session?.page) { + const diag = await captureFailureDiagnostics(ctx.session.page, diagDir, 'driving-failure') + log('diag', `captured -> ${diagDir} (store=${diag.info?.hasStore ?? 'n/a'})`) + if (diag.info?.bodyText) { + log('diag', `visible text: ${diag.info.bodyText.replace(/\s+/g, ' ').slice(0, 300)}`) + } + } + passed = false + } finally { + await (isolated + ? isolatedTeardown({ + app: ctx.session?.app, + created, + userDataDir, + installDir, + manifest, + runDir + }) + : teardown({ + app: ctx.session?.app, + created, + userDataDir, + keepInstall: opts.keepInstall, + hadPreexistingInstall, + installedExePath: ctx.installedExePath ?? null, + runDir + })) + } + return passed ? 0 : 1 +} + +/** + * Run the install → drive → update → relaunch → assert proof. `ctx.session` is + * assigned as soon as each app launches so a caller's finally can tear down a + * partially-created session on failure. Returns whether every assertion passed. + */ +async function runProof(ctx, args) { + const { + opts, + installDir, + canary, + runDir, + userDataDir, + baselinePath, + watchOut, + markerPidFile, + heartbeatFile, + created + } = args + + const fromInstaller = resolveInstaller({ + localPath: opts.from, + releaseTag: opts.fromRelease, + assetPattern: opts.assetPattern + }) + log('install-base', `installing ${fromInstaller}`) + const base = silentInstall(fromInstaller, { installDir }) + // Track the install now (not only after the update at L238) so a failure + // anywhere before the update still tears down the base install this harness + // created, instead of orphaning it and blocking the next run's preflight. + ctx.installedExePath = base.exePath + log('install-base', `installed ${base.exePath} (version ${base.version})`) + + // Seed a fresh profile (onboarding dismissed + one throwaway git repo as a + // project) ONLY before the first launch. The relaunch must use the app's own + // persisted state so the cold-restore/survival assertions are meaningful. + const seededRepo = createSeededRepo(path.join(runDir, 'fixture-repo')) + const seedProfile = buildFreshProfile({ repo: seededRepo }) + + // --- Base version: launch, create sessions, start marker, record daemon --- + let session = await launchInstalledApp({ exePath: base.exePath, userDataDir, seedProfile }) + ctx.session = session + // A fresh profile has no terminal yet, so create a workspace + terminal, then + // clear the post-creation overlays that would intercept typing. + await ensureTerminal(session.page, { allowCreate: true }) + await dismissOverlays(session.page) + const tabIds = await listTabIds(session.page) + log('sessions', `terminal ready; tab ids: ${tabIds.join(', ')}`) + + // Baseline typed-input on the IDLE shell, before the perpetual marker loop + // takes over the foreground (a command typed against a running loop can't + // execute, so this must precede startMarker to be meaningful). + const echoBeforeUpdate = await probeEcho(session.page, runDir, 'echo-pre') + log('sessions', `pre-update echo interactive: ${echoBeforeUpdate}`) + + await startMarker(session.page, { canary, pidFile: markerPidFile, heartbeatFile }) + const markerLive = await probeHeartbeatAdvancing(heartbeatFile) + created.markerPid = readIntFile(markerPidFile) + log('marker', `pid=${created.markerPid} heartbeatAdvancing=${markerLive}`) + + const preDaemon = resolveScopedDaemon(userDataDir) + preDaemon.pids.forEach((p) => created.daemonPids.add(p)) + log('daemon', `pre-update daemon pid=${preDaemon.pid} appVersion=${preDaemon.appVersion}`) + log('daemon', `pre-update daemon exe: ${preDaemon.exePath ?? '(unknown)'}`) + + const preScrollback = await readTerminalTextBestEffort(session.page) + + // Close app normally; the detached daemon must remain alive. + await closeApp(session.app) + const daemonAliveAfterClose = preDaemon.pid != null && isPidAlive(preDaemon.pid) + log('daemon', `alive after app close: ${daemonAliveAfterClose}`) + + // --- Start the console-window watch through the whole update + soak --- + const watchDuration = 120 + opts.soakSeconds + const watch = startWatch({ baselinePath, outPath: watchOut, durationSec: watchDuration }) + log('watch', `started (duration ${watchDuration}s) -> ${watchOut}`) + + // --- Update: install N+1 --- + const toInstaller = resolveInstaller({ + localPath: opts.to, + releaseTag: opts.toRelease, + assetPattern: opts.assetPattern + }) + log('update', `installing ${toInstaller}`) + const updated = silentInstall(toInstaller, { installDir }) + // Record the exact dir the harness installed into so non-isolated teardown + // uninstalls THAT and never scan-discovers the developer's real install. + ctx.installedExePath = updated.exePath + log('update', `installed ${updated.exePath} (version ${updated.version})`) + + // --- Relaunch and gather post-update evidence --- + session = await launchInstalledApp({ exePath: updated.exePath, userDataDir }) + ctx.session = session + // No create on relaunch: the session must be RESTORED, not freshly made. + await ensureTerminal(session.page, { allowCreate: false }) + await dismissOverlays(session.page) + + const evidence = await gatherEvidence({ + profile: opts.expect, + page: session.page, + runDir, + heartbeatFile, + preDaemon, + userDataDir, + preScrollback, + echoBeforeUpdate + }) + evidence.postDaemon?.pids?.forEach((p) => created.daemonPids.add(p)) + + // --- Soak, then stop the watch and evaluate --- + log('soak', `waiting ${opts.soakSeconds}s for delayed flashes`) + await delay(opts.soakSeconds * 1000) + const { events } = await watch.stop() + log('watch', `recorded ${events.length} new-window events`) + + const assertionCtx = { + profile: opts.expect, + canary, + watchEvents: events, + markerPid: created.markerPid, + daemonLog: readDaemonLog(userDataDir), + ...evidence + } + const assertions = buildAssertions(assertionCtx) + const passed = allPassed(assertions) + console.log(renderTable(assertions)) + log('result', passed ? 'PASS' : 'FAIL') + return passed +} + +async function gatherEvidence(args) { + const { profile, page, runDir, heartbeatFile, preDaemon, userDataDir, preScrollback } = args + const postDaemon = resolveScopedDaemon(userDataDir) + + if (profile === 'survival') { + // The decisive survival signals: did the SPECIFIC pre-update daemon process + // live through the update, and is the new app running that same relocated + // exe or a fresh in-dir fork? Log both regardless of the assertion outcome. + const preDaemonAliveAfter = preDaemon.pid != null && isPidAlive(preDaemon.pid) + log( + 'daemon', + `post-update daemon pid=${postDaemon.pid} exe: ${postDaemon.exePath ?? '(unknown)'}` + ) + log( + 'daemon', + `pre-update daemon pid=${preDaemon.pid} still alive after update: ${preDaemonAliveAfter}` + ) + dumpDaemonLog(userDataDir) + const heartbeatBefore = fileMtimeMs(heartbeatFile) + const heartbeatAdvancedAfterUpdate = await heartbeatAdvancedSince( + heartbeatFile, + heartbeatBefore + ) + // Sample marker survival BEFORE interrupting it: the pre-update shell must be + // measured while its heartbeat loop still runs, not after + // probeCtrlCInterruptsMarker deliberately breaks that loop. + const markerAliveAfter = isMarkerAlive(runDir) + log( + 'marker', + `pid=${readIntFile(path.join(runDir, 'marker.pid'))} aliveAfterUpdate=${markerAliveAfter}` + ) + // Interrupt the foreground loop first so the shell returns to a prompt; only + // then can a freshly-typed command execute. Typing while the infinite + // heartbeat loop owns the shell never runs, regardless of input health — so + // the echo probe is meaningful only after the interrupt. + const ctrlCInterrupted = await probeCtrlCInterruptsMarker(page, runDir, heartbeatFile) + const echoObserved = await probeEcho(page, runDir, 'echo-post') + return { + preDaemonPid: preDaemon.pid, + preDaemonAliveAfter, + postDaemonPid: postDaemon.pid, + postDaemonAlive: postDaemon.pid != null && isPidAlive(postDaemon.pid), + postDaemon, + markerAliveAfter, + heartbeatAdvancedAfterUpdate, + echoObserved, + ctrlCInterrupted + } + } + + // cold-restore + const postScrollback = await readTerminalTextBestEffort(page) + const scrollbackRestored = scrollbackFidelity(preScrollback, postScrollback) + await createTerminalTab(page) + const echoObserved = await probeEcho(page, runDir, 'echo-fresh') + const ctrlCInterrupted = await probeCtrlCOnFreshLoop(page, runDir) + return { + preDaemonPid: preDaemon.pid, + preDaemonAliveAfter: preDaemon.pid != null && isPidAlive(preDaemon.pid), + postDaemonPid: postDaemon.pid, + postDaemonAlive: postDaemon.pid != null && isPidAlive(postDaemon.pid), + postDaemon, + scrollbackRestored, + echoObserved, + ctrlCInterrupted + } +} + +/** + * Resolve THIS run's daemon, scoped to its isolated userData dir so unrelated + * daemons on the machine are ignored. Prefers the pid file (authoritative, + * carries appVersion); cross-checks the live process scan. + */ +function resolveScopedDaemon(userDataDir) { + const pidFiles = readDaemonPidFiles(userDataDir) + const scan = findDaemonProcesses(userDataDir) + const pids = new Set() + for (const rec of pidFiles) { + if (typeof rec.pid === 'number') { + pids.add(rec.pid) + } + } + for (const proc of scan) { + if (typeof proc.pid === 'number') { + pids.add(proc.pid) + } + } + const primary = pidFiles.find((r) => typeof r.pid === 'number') + const pid = primary?.pid ?? scan[0]?.pid ?? null + const scanEntry = scan.find((p) => p.pid === pid) ?? scan[0] + return { + pid, + appVersion: primary?.appVersion ?? null, + startedAtMs: primary?.startedAtMs ?? null, + // Why: the daemon's exe path (first token of its command line) tells us + // whether it was forked from the relocated userData/daemon-host copy or the + // install-dir Orca.exe — the key survival signal. + exePath: daemonExePath(scanEntry?.commandLine), + pids: [...pids] + } +} + +/** Print the daemon's lifecycle log (Phase 0) so its startup/session events are + * visible in the CI log before teardown removes the userData dir. */ +function dumpDaemonLog(userDataDir) { + const logPath = path.join(userDataDir, 'logs', 'daemon.log') + try { + const lines = readFileSync(logPath, 'utf8').trim().split('\n') + log('daemon-log', `${logPath} (${lines.length} lines):`) + for (const line of lines.slice(-40)) { + console.log(` ${line}`) + } + } catch { + log('daemon-log', `${logPath} (unavailable)`) + } +} + +/** Extract the host exe path (first token) from a daemon command line. */ +function daemonExePath(commandLine) { + if (typeof commandLine !== 'string') { + return null + } + const trimmed = commandLine.trim() + if (trimmed.startsWith('"')) { + const end = trimmed.indexOf('"', 1) + return end > 0 ? trimmed.slice(1, end) : null + } + const space = trimmed.indexOf(' ') + return space > 0 ? trimmed.slice(0, space) : trimmed +} + +function isMarkerAlive(runDir) { + const pid = readIntFile(path.join(runDir, 'marker.pid')) + return pid != null && isPidAlive(pid) +} + +async function heartbeatAdvancedSince(heartbeatFile, sinceMs) { + await delay(1500) + return fileMtimeMs(heartbeatFile) > sinceMs +} + +function scrollbackFidelity(before, after) { + // WebGL renderer can leave both empty; report unknown (null) rather than a + // false failure. When text is available, check a stable prefix survived. + if (!before || !before.trim() || !after || !after.trim()) { + return null + } + const marker = before + .trim() + .split('\n') + .find((l) => l.trim().length > 3) + if (!marker) { + return null + } + return after.includes(marker.trim()) +} + +export function readDaemonLog(userDataDir) { + // The daemon log (when present) is JSONL: { src, ts, pid, event, ...details }. + // Only genuinely-bad records fail a run — matched by EVENT NAME, not by any + // string containing "error". The daemon logs benign 'uncaught-exception- + // suppressed' events with name:"Error" for native PTY errors it intentionally + // swallows (src/main/daemon/daemon-entry.ts); those must never fail, and + // 'client-hello-rejected' with reason expected-hello/protocol-mismatch is + // normal version-skew during an update. Non-JSON lines fall back to a raw + // FATAL match so an unstructured crash dump still counts. + const logPath = path.join(userDataDir, 'logs', 'daemon.log') + if (!existsSync(logPath)) { + return null + } + const errorLines = [] + let suppressedCount = 0 + for (const raw of readFileSync(logPath, 'utf8').split('\n')) { + const line = raw.trim() + if (!line) { + continue + } + let rec + try { + rec = JSON.parse(line) + } catch { + if (/\bFATAL\b/.test(line)) { + errorLines.push(line) + } + continue + } + if (rec.event === 'uncaught-exception-suppressed') { + suppressedCount += 1 + } else if ( + rec.event === 'uncaught-exception-fatal' || + (rec.event === 'client-hello-rejected' && rec.reason === 'invalid-token') + ) { + errorLines.push(line) + } + } + return { path: logPath, errorLines, suppressedCount } +} + +/** + * Isolated-mode teardown. The harness OWNS the isolated dir by construction, so + * it always uninstalls the test install, then ALWAYS restores the shared per-user + * registry keys + shortcuts the installer hijacked (the uninstaller clears the + * test install's copies; restore re-imports the real install's originals or + * deletes freshly-created keys). Finally removes the emptied install dir + runDir. + */ +async function isolatedTeardown({ app, created, userDataDir, installDir, manifest, runDir }) { + try { + await closeApp(app) + } catch { + /* already closed / never launched */ + } + killPid(created.markerPid) + for (const pid of resolveScopedDaemon(userDataDir).pids) { + killPid(pid) + } + for (const pid of created.daemonPids) { + killPid(pid) + } + + const uninstalled = silentUninstall(installDir) + log('isolated-teardown', `uninstalled test install: ${uninstalled}`) + + try { + const restore = restoreInstallState(manifest) + log( + 'isolated-teardown', + `registry restore verified=${restore.verified} imported=[${restore.imported.join(', ')}] ` + + `deleted=[${restore.deleted.join(', ')}] shortcutsRestored=${restore.shortcutsRestored.length} ` + + `shortcutsDeleted=${restore.shortcutsDeleted.length}` + ) + } catch (err) { + // Restore must never be silently skipped — surface it loudly and point at the + // manifest so the shared keys can be recovered by hand. + console.error( + `\n*** WIN-UPDATE-E2E: registry/shortcut restore THREW: ${err.stack || err.message}\n` + + ` Recover manually from the backups under ${manifest?.backupDir}. ***\n` + ) + } + + removeDirIfEmpty(installDir) + rmSync(runDir, { recursive: true, force: true }) +} + +/** Remove a directory only if it is empty (best-effort; leaves non-empty dirs). */ +function removeDirIfEmpty(dir) { + try { + if (existsSync(dir) && readdirSync(dir).length === 0) { + rmSync(dir, { recursive: true, force: true }) + } + } catch { + /* leave it in place */ + } +} + +async function teardown({ + app, + created, + userDataDir, + keepInstall, + hadPreexistingInstall, + installedExePath, + runDir +}) { + try { + await closeApp(app) + } catch { + /* already closed */ + } + // Kill ONLY processes this harness created: the marker and this run's daemon + // (scoped to the isolated userData). Never touch pre-existing user processes. + killPid(created.markerPid) + for (const pid of resolveScopedDaemon(userDataDir).pids) { + killPid(pid) + } + for (const pid of created.daemonPids) { + killPid(pid) + } + + if (keepInstall) { + log('teardown', `--keep-install set; leaving install + ${runDir}`) + return + } + // Only uninstall when the harness fully OWNS the install (no pre-existing + // build). When it overwrote a developer's existing install, leave it in place + // — uninstalling would remove a build we did not put there. + if (hadPreexistingInstall) { + console.log( + '\n*** NOTE: an Orca install existed before this run. It was OVERWRITTEN and the\n' + + ' --to version is now installed. Your prior build was NOT restored — reinstall\n' + + ' your intended build if needed. Skipping uninstall. ***\n' + ) + } else if (installedExePath) { + // Uninstall ONLY the exact directory the harness installed into this run, + // with the explicit default-location opt-in (non-isolated mode legitimately + // installs to the default path and owns it here). Never scan-and-discover. + const uninstalled = silentUninstall(path.dirname(installedExePath), { + allowDefaultLocation: true + }) + log('teardown', `uninstalled: ${uninstalled}`) + } else { + log('teardown', 'no install path recorded; skipping uninstall (nothing owned to remove)') + } + rmSync(runDir, { recursive: true, force: true }) +} + +function killPid(pid) { + if (!Number.isInteger(pid) || pid <= 0) { + return + } + try { + execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' }) + } catch { + /* already dead */ + } +} + +function readIntFile(filePath) { + try { + const n = Number(readFileSync(filePath, 'utf8').trim()) + return Number.isInteger(n) ? n : null + } catch { + return null + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +// Only run the orchestrator when invoked directly, so the module (and +// readDaemonLog) can be imported by tests without kicking off a real install. +if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { + main() + .then((code) => { + // Why: force-exit. A launched Electron app or the window-watch child can + // keep libuv handles open; without this the process lingers to the CI job + // timeout instead of exiting when the run is logically done. + process.exit(code) + }) + .catch((err) => { + console.error('[win-update-e2e] FATAL:', err.stack || err.message) + process.exit(1) + }) +} diff --git a/tools/win-update-e2e/window-enum.ps1 b/tools/win-update-e2e/window-enum.ps1 new file mode 100644 index 000000000..c628ae115 --- /dev/null +++ b/tools/win-update-e2e/window-enum.ps1 @@ -0,0 +1,122 @@ +<# + window-enum.ps1 — enumerate visible top-level windows with owner attribution. + + Dot-source this file, then call Get-VisibleTopLevelWindows. It returns objects + { handle, pid, processName, title } for every visible, non-cloaked top-level + window that has a title. + + WHY window enumeration (and never conhost command-line heuristics): the July + 2026 post-mortem proved that (a) conhost flag interpretation inverts depending + on the parent's console state, and (b) MainWindowHandle is 0 for + Windows-Terminal-hosted consoles, so handle-based "is it visible" checks read + a visibly-flashing window as hidden. The only sound signal is a real visible + top-level window, attributed to an owner process and (for our own children) a + canary title. This function is the single instrument every probe shares. +#> + +# Compile the P/Invoke enumerator once. Guard on the type already existing so +# repeated dot-sourcing inside the watch loop does not re-run Add-Type (which +# throws on a duplicate type). +if (-not ('OrcaWinEnum.Native' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Text; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace OrcaWinEnum { + public static class Native { + private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll")] + private static extern int GetWindowTextLength(IntPtr hWnd); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + [DllImport("dwmapi.dll")] + private static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out int pvAttribute, int cbAttribute); + + // DWMWA_CLOAKED: a window can be IsWindowVisible()==true yet cloaked by the + // shell (e.g. background UWP hosts). Cloaked windows are not really on + // screen, so we skip them to keep the baseline diff free of ghost churn. + private const int DWMWA_CLOAKED = 14; + + public static string[] Enumerate() { + var rows = new List(); + EnumWindows(delegate(IntPtr hWnd, IntPtr lParam) { + if (!IsWindowVisible(hWnd)) { return true; } + int len = GetWindowTextLength(hWnd); + if (len <= 0) { return true; } + int cloaked = 0; + try { DwmGetWindowAttribute(hWnd, DWMWA_CLOAKED, out cloaked, sizeof(int)); } catch { } + if (cloaked != 0) { return true; } + var sb = new StringBuilder(len + 1); + GetWindowText(hWnd, sb, sb.Capacity); + uint pid; + GetWindowThreadProcessId(hWnd, out pid); + // Tab-separated: handle, pid, title. Titles never contain a tab, and + // the handle/pid are numeric, so this parses unambiguously downstream. + rows.Add(((long)hWnd).ToString() + "\t" + pid.ToString() + "\t" + sb.ToString()); + return true; + }, IntPtr.Zero); + return rows.ToArray(); + } + } +} +'@ +} + +function Get-VisibleTopLevelWindows { + # @() forces an array even when Enumerate() returns a single row — the PS 5.1 + # single-item unwrap pitfall that caused a production incident when a count + # of "1" silently became a scalar. + $rows = @([OrcaWinEnum.Native]::Enumerate()) + + # Cache pid -> process name so we resolve each owning process at most once. + $nameByPid = @{} + $result = New-Object System.Collections.Generic.List[object] + + foreach ($row in $rows) { + $parts = $row -split "`t", 3 + if ($parts.Count -lt 3) { continue } + $handle = [long]$parts[0] + $procId = [int]$parts[1] + $title = $parts[2] + + if (-not $nameByPid.ContainsKey($procId)) { + $procName = $null + try { + $procName = (Get-Process -Id $procId -ErrorAction Stop).ProcessName + } catch { + $procName = $null + } + $nameByPid[$procId] = $procName + } + + $result.Add([pscustomobject]@{ + handle = $handle + pid = $procId + processName = $nameByPid[$procId] + title = $title + }) + } + + # Return the raw array; callers wrap with @() to normalize the PS 5.1 + # single-item unwrap. (Do not use the comma operator here — combined with a + # caller's @() it produces a nested array.) + return $result.ToArray() +} + +# When run directly (not dot-sourced) emit the snapshot as JSON so this file +# doubles as the baseline-snapshot tool. Wrapped in an object with a `windows` +# array; the JS side normalizes single-element results because PS 5.1 +# ConvertTo-Json unwraps a one-element array to a bare object. +if ($MyInvocation.InvocationName -ne '.') { + [pscustomobject]@{ windows = @(Get-VisibleTopLevelWindows) } | + ConvertTo-Json -Depth 4 -Compress +} diff --git a/tools/win-update-e2e/window-watch.mjs b/tools/win-update-e2e/window-watch.mjs new file mode 100644 index 000000000..1f35ef8b2 --- /dev/null +++ b/tools/win-update-e2e/window-watch.mjs @@ -0,0 +1,174 @@ +// Node wrapper around window-watch.ps1: start/stop the background window watch +// and parse its JSONL event log. Also runnable standalone as `--selftest`, +// which opens a real transient PowerShell window and asserts the watch caught +// it — so the harness's own instrument is testable without any installers. + +import { existsSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { assertWin32 } from './platform-guard.mjs' +import { runScriptFileJson, spawnScriptFile, runCommandSync } from './powershell-runner.mjs' + +const HERE = import.meta.dirname +const WATCH_SCRIPT = path.join(HERE, 'window-watch.ps1') +const ENUM_SCRIPT = path.join(HERE, 'window-enum.ps1') + +/** + * Take a one-shot baseline snapshot of visible top-level windows and write it + * to baselinePath. Returns the window array. Normalizes the PS 5.1 single-item + * unwrap (windows may arrive as one object or as a nested array). + */ +export function captureBaseline(baselinePath) { + assertWin32('window-watch baseline') + const parsed = runScriptFileJson(ENUM_SCRIPT) + const windows = normalizeWindows(parsed.windows) + writeFileSync(baselinePath, JSON.stringify({ windows }, null, 2)) + return windows +} + +function normalizeWindows(raw) { + if (!raw) { + return [] + } + // PS 5.1 ConvertTo-Json can hand back a single object, a flat array, or (from + // a stray comma operator) a one-element array wrapping the real array. + if (Array.isArray(raw)) { + if (raw.length === 1 && Array.isArray(raw[0])) { + return raw[0] + } + return raw + } + return [raw] +} + +/** + * Start the background watch. Returns a handle with stop() -> events array. + * The watch seeds from baselinePath and records new windows to outPath until + * stop() (which drops a stop-file) or durationSec elapses. + */ +export function startWatch({ baselinePath, outPath, stopFile, durationSec = 600, pollMs = 500 }) { + assertWin32('window-watch') + const resolvedStopFile = stopFile ?? `${outPath}.stop` + if (existsSync(resolvedStopFile)) { + rmSync(resolvedStopFile) + } + + const child = spawnScriptFile(WATCH_SCRIPT, [ + '-BaselinePath', + baselinePath, + '-OutPath', + outPath, + '-StopFile', + resolvedStopFile, + '-DurationSec', + String(durationSec), + '-PollMs', + String(pollMs), + '-EnumScript', + ENUM_SCRIPT + ]) + + let stderr = '' + child.stderr.on('data', (d) => { + stderr += d.toString() + }) + + const exited = new Promise((resolve) => child.once('exit', resolve)) + + return { + process: child, + stopFile: resolvedStopFile, + outPath, + async stop() { + // Signal the loop to end, then wait for it to flush and exit. Fall back + // to a hard kill if the loop is wedged so a stuck watch never hangs teardown. + writeFileSync(resolvedStopFile, 'stop') + const timer = setTimeout(() => child.kill(), 5000) + await exited + clearTimeout(timer) + return { events: readEvents(outPath), stderr } + } + } +} + +/** Parse a window-watch JSONL log into an array of event objects. */ +export function readEvents(outPath) { + if (!existsSync(outPath)) { + return [] + } + return readFileSync(outPath, 'utf8') + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line)] + } catch { + return [] + } + }) +} + +async function selftest() { + assertWin32('window-watch --selftest') + const dir = mkdtempSync(path.join(tmpdir(), 'orca-winwatch-selftest-')) + const baselinePath = path.join(dir, 'baseline.json') + const outPath = path.join(dir, 'events.jsonl') + const canary = `ORCA-E2E-SELFTEST-${Date.now()}` + + console.log(`[selftest] baseline snapshot -> ${baselinePath}`) + const baseline = captureBaseline(baselinePath) + console.log(`[selftest] baseline captured ${baseline.length} visible windows`) + + const watch = startWatch({ baselinePath, outPath, durationSec: 20, pollMs: 300 }) + // Give the watch a moment to compile Add-Type and take its first poll before + // the transient window appears, so the appearance is genuinely "new". + await delay(1500) + + console.log(`[selftest] opening transient window titled ${canary}`) + // Must be a real console window: Start-Process allocates one, whereas a + // detached Node spawn gets DETACHED_PROCESS (no console at all) and would + // never appear in enumeration. This mirrors how a daemon child that lacks + // CREATE_NO_WINDOW allocates a fresh visible console — exactly the flash the + // real harness must catch. + const transientScript = path.join(dir, 'transient.ps1') + writeFileSync(transientScript, `$host.UI.RawUI.WindowTitle='${canary}'; Start-Sleep -Seconds 5`) + runCommandSync( + `Start-Process -FilePath 'powershell.exe' -ArgumentList '-NoProfile','-File','${transientScript}'` + ) + + // Poll cycles at 300ms; 3.5s covers several polls while the window is alive. + await delay(3500) + const { events, stderr } = await watch.stop() + rmSync(dir, { recursive: true, force: true }) + + const caught = events.filter((e) => typeof e.title === 'string' && e.title.includes(canary)) + console.log(`[selftest] watch recorded ${events.length} new windows total`) + if (stderr.trim()) { + console.log(`[selftest] watch stderr:\n${stderr.trim()}`) + } + if (caught.length === 0) { + console.error( + `[selftest] FAIL: watch did not capture a window titled "${canary}". ` + + `New windows seen: ${JSON.stringify(events.map((e) => e.title))}` + ) + process.exitCode = 1 + return + } + console.log(`[selftest] PASS: caught canary window: ${JSON.stringify(caught[0])}`) +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { + if (process.argv.includes('--selftest')) { + selftest().catch((err) => { + console.error(err) + process.exitCode = 1 + }) + } else { + console.log('Usage: node window-watch.mjs --selftest') + } +} diff --git a/tools/win-update-e2e/window-watch.ps1 b/tools/win-update-e2e/window-watch.ps1 new file mode 100644 index 000000000..7b1b73a5c --- /dev/null +++ b/tools/win-update-e2e/window-watch.ps1 @@ -0,0 +1,91 @@ +<# + window-watch.ps1 — poll visible top-level windows and record every NEW one. + + Runs a tight loop (default 500ms) that diffs the current visible top-level + windows against a baseline snapshot, appending any window handle it has not + seen before to a JSONL file as one event per line: + { "ts": "", "handle": , "pid": , "processName": "...", "title": "..." } + + It stops when -DurationSec elapses or -StopFile appears (whichever comes + first), so the orchestrator can end the watch deterministically after the + post-relaunch soak window. + + Attribution is by owner process + title only (see window-enum.ps1 for why + conhost heuristics are invalid). Classification of which new windows count as + "unexpected" (canary title, terminal/console owner) is done by the JS + assertions layer against this raw event log — this probe records everything. +#> +param( + [Parameter(Mandatory = $true)][string]$BaselinePath, + [Parameter(Mandatory = $true)][string]$OutPath, + [string]$StopFile = '', + [int]$DurationSec = 600, + [int]$PollMs = 500, + [string]$EnumScript = '' +) + +$ErrorActionPreference = 'Stop' + +if (-not $EnumScript) { + $EnumScript = Join-Path $PSScriptRoot 'window-enum.ps1' +} +. $EnumScript + +# Seed the baseline handle set so pre-existing windows never register. Their +# later title churn (clocks, tab names) is irrelevant noise, so baseline +# handles are excluded outright. Baseline JSON is { windows: [ { handle, ... } ] }; +# tolerate the PS 5.1 single-element unwrap by wrapping with @(). +$baselineHandles = New-Object System.Collections.Generic.HashSet[long] +if (Test-Path $BaselinePath) { + $baseline = Get-Content -Raw $BaselinePath | ConvertFrom-Json + foreach ($w in @($baseline.windows)) { + if ($null -ne $w -and $null -ne $w.handle) { + [void]$baselineHandles.Add([long]$w.handle) + } + } +} + +# Track the last-seen title of each NEW window. A window is emitted on first +# sighting ("appear") and again whenever its title changes ("retitle"): a real +# flashing console often opens with a generic title (e.g. WindowsTerminal's +# "Terminal") and only later shows our child's canary title, so title evolution +# must be captured or canary attribution is missed. +$titleByHandle = @{} + +# Truncate/create the output file up front so the reader can always open it. +[System.IO.File]::WriteAllText($OutPath, '') + +$deadline = (Get-Date).AddSeconds($DurationSec) + +while ((Get-Date) -lt $deadline) { + if ($StopFile -and (Test-Path $StopFile)) { break } + + $windows = @(Get-VisibleTopLevelWindows) + $now = (Get-Date).ToString('o') + foreach ($w in $windows) { + $handle = [long]$w.handle + if ($baselineHandles.Contains($handle)) { continue } + + $title = [string]$w.title + $prior = $null + $isNew = -not $titleByHandle.ContainsKey($handle) + if (-not $isNew) { $prior = $titleByHandle[$handle] } + if ($isNew -or $prior -ne $title) { + $titleByHandle[$handle] = $title + $event = [pscustomobject]@{ + ts = $now + kind = if ($isNew) { 'appear' } else { 'retitle' } + handle = $handle + pid = $w.pid + processName = $w.processName + title = $title + } + $line = ($event | ConvertTo-Json -Compress -Depth 3) + # AppendAllText with an explicit newline keeps each event on its own line + # even if the process is killed mid-write (no buffered partial records). + [System.IO.File]::AppendAllText($OutPath, $line + "`n") + } + } + + Start-Sleep -Milliseconds $PollMs +}