Windows terminal update-survival (single consolidated PR) (#7538)

This commit is contained in:
Jinwoo Hong 2026-07-07 16:21:07 -07:00 committed by GitHub
parent 46fe1b5799
commit c256a5a417
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 6305 additions and 68 deletions

View File

@ -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

145
.github/workflows/win-update-e2e.yml vendored Normal file
View File

@ -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

View File

@ -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

View File

@ -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',

View File

@ -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

View File

@ -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",

View File

@ -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:')
})
})

View File

@ -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 <path> --token <path>')
throw new Error('Usage: daemon-entry --socket <path> --token <path> [--log-file <path>]')
}
return { socketPath, tokenPath }
return logFilePath ? { socketPath, tokenPath, logFilePath } : { socketPath, tokenPath }
}
async function main(): Promise<void> {
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<void> {
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<void> => {
if (daemon) {
await daemon.shutdown()
daemon = null
const shutdown = async (reason: string): Promise<void> => {
// 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<void> {
if (process.send) {
process.send({ type: 'ready' })
}
daemonLog.log('ready')
warmWindowsConptyOnce()
}

View File

@ -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<string, unknown>[] {
return readFileSync(filePath, 'utf8')
.split('\n')
.filter((l) => l.length > 0)
.map((l) => JSON.parse(l) as Record<string, unknown>)
}
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<string, unknown> = {}
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()
})
})

View File

@ -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<string, unknown>): 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<string, unknown> = {}): 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
}
}
}

View File

@ -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)
})
})

View File

@ -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%\<LOCAL_HOST_ROOT_NAME>\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-<arch> 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<MaterializeMarker>
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/<version>, 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<N>.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<string> {
const pinned = new Set<string>()
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/<ver> 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<string>): 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.
}
}
}

View File

@ -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 })
)
})

View File

@ -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<void>((resolve, reject) => {
@ -398,6 +432,10 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
// 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) {

View File

@ -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<DaemonHandl
const server = new DaemonServer({
socketPath: opts.socketPath,
tokenPath: opts.tokenPath,
spawnSubprocess: opts.spawnSubprocess
spawnSubprocess: opts.spawnSubprocess,
...(opts.log ? { log: opts.log } : {})
})
await server.start()

View File

@ -12,6 +12,7 @@ import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
import type { SubprocessHandle } from './session'
import { checkPtySpawnHealth } from './pty-subprocess'
import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log'
import {
PROTOCOL_VERSION,
NOTIFY_PREFIX,
@ -24,6 +25,7 @@ export type DaemonServerOptions = {
socketPath: string
tokenPath: string
ptySpawnHealthCheck?: () => Promise<void>
log?: DaemonFileLog
spawnSubprocess: (opts: {
sessionId: string
cols: number
@ -48,6 +50,7 @@ export class DaemonServer {
private socketPath: string
private tokenPath: string
private ptySpawnHealthCheck: () => Promise<void>
private log: DaemonFileLog
private clients = new Map<string, ConnectedClient>()
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<void> {
@ -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()
}

View File

@ -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.

View File

@ -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<string> {
*/
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.

View File

@ -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
})
}

View File

@ -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')
}

View File

@ -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 <path-to-win-unpacked> \
--work-dir <scratch-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-<arch>`) relative to node-pty's own `__dirname`, and
node-pty's Windows addon loads `conpty.dll` from `<dir-of-conpty.node>/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-<nonce>\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 <pid>` 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.

View File

@ -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):
// <app-dir>/Orca.exe electron binary (run as node)
// <app-dir>/icudtl.dat ICU data (needed even as node)
// <app-dir>/snapshot_blob.bin V8 snapshot
// <app-dir>/v8_context_snapshot.bin V8 context snapshot
// <app-dir>/*.dll electron/GPU runtime DLLs
// <app-dir>/resources/app.asar.unpacked/out/main/daemon-entry.js (+ chunks/)
// <app-dir>/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 <native>/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')
}

View File

@ -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 <win-unpacked> --work-dir <temp out> [--tier <t>]
node tools/daemon-relocation-spike/spike.mjs --selftest
Required (launch mode):
--app-dir <path> Path to a packaged win-unpacked build (contains Orca.exe)
--work-dir <path> Scratch dir for the copied host + logs (created, then removed)
Options:
--tier <t> 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 } }
}

View File

@ -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)
})
}

View File

@ -0,0 +1,62 @@
// Materializes a tier's copy plan into <work-dir>/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 <native>/ 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 }
}

View File

@ -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
}
}

View File

@ -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)

View File

@ -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 <tokenPath> 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()
}
}

View File

@ -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
}

View File

@ -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 <marker>` input line.
const expectRe = new RegExp(`(?:^|\\r?\\n)${marker}(?:\\r|\\n)`)
// `echo <marker>` 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
})

View File

@ -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 }
}

View File

@ -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 <setup.exe> --to <setup.exe> --expect <profile>
# 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 <path>` / `--to <path>` | Local `orca-windows-setup.exe` for base (N) / update (N+1) |
| `--from-release <tag>` / `--to-release <tag>` | Download the setup asset from a GitHub release tag via `gh` |
| `--expect cold-restore \| survival` | Assertion profile (required) |
| `--install-dir <path>` | Isolated-install mode (see below) — install into `<path>` |
| `--asset-pattern <glob>` | gh asset glob (default `*windows-setup.exe`) |
| `--soak-seconds <n>` | 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 <path>`) lets the harness run on that box without disturbing the
real install.
**The /D mechanism.** electron-builder's NSIS honors the standard NSIS `/D=<path>`
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\<APP_GUID>` and
`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\<key>`,
`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 (`<setup.exe> /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 <dir>] [--scope <substr>]
# 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 `<userData>/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.

View File

@ -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 */
}
}
}
}

View File

@ -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')
}

View File

@ -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 <setup.exe> --to <setup.exe> --expect <profile> [options]
node tools/win-update-e2e/run.mjs --from-release <tag> --to-release <tag> --expect <profile>
Installer source (version N, then N+1) path or release tag on each side:
--from <path> Local orca-windows-setup.exe for the base version (N)
--to <path> Local orca-windows-setup.exe for the update (N+1)
--from-release <tag> Download N's setup asset via gh (e.g. v1.4.124-rc.9)
--to-release <tag> Download N+1's setup asset via gh
Required:
--expect <profile> 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 <path> Isolated-install mode: install the test build into
<path> 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 <glob> gh release asset glob (default: *windows-setup.exe)
--soak-seconds <n> 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 <path> or --from-release <tag>')
}
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 <path> or --to-release <tag>')
}
if (opts.to && opts.toRelease) {
errors.push('Pass only one of --to / --to-release')
}
if (!opts.expect) {
errors.push('Missing --expect <cold-restore|survival>')
} 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
}

View File

@ -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 <userData>/daemon/daemon-v<N>.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 <userData>/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
}
}

View File

@ -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 `<setup.exe> /S`; the app installs under
// %LOCALAPPDATA%\Programs\<dir> 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=<path>` 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 (<installDir>\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)
}

View File

@ -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)
}

View File

@ -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 `<userDataDir>/
// 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`)
}

View File

@ -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'
}

View File

@ -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
}
}

View File

@ -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 }
}

View File

@ -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))
}

View File

@ -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)
})
}

View File

@ -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<string>();
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
}

View File

@ -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')
}
}

View File

@ -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": "<iso>", "handle": <n>, "pid": <n>, "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
}