Windows terminal update-survival (single consolidated PR) (#7538)
This commit is contained in:
parent
46fe1b5799
commit
c256a5a417
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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 } }
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue