mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 16:02:41 +00:00
* docs(windows): document the EDR signal surface Six Microsoft Defender for Endpoint incidents fired against Orca 1.4.192 in eight days on one enterprise Windows 11 / Intune tenant. All six were behavioural process-tree scoring, not signature hits; two escalated to multi-stage incidents mapped to ATT&CK Execution and Collection. Add a reference doc mapping each attack-technique-shaped behaviour to the code that produces it and to why it exists: the renamed daemon image (T1036), the per-process PEB read, encoded policy-bypassed PowerShell (T1049), caret-escaped cmd.exe lines, and computer-use screen capture plus runtime-compiled MSIL (T1113). Records that signing is not the gate -- reputation is signer plus hash-keyed prevalence -- and carries the two evidence gaps the report noted. Adds an engineer checklist, deployment guidance for admins (AV path exclusions do not suppress EDR behavioural alerts; an MDE alert suppression rule does), and an explicit pre-deployment warning about computer use. * docs(windows): correct the PowerShell flag inventory and admin paths Review corrections to the EDR posture doc. The "encoded, policy-bypassing PowerShell" list conflated three different shapes and was incomplete. Split it into the three tiers an EDR actually scores differently -- bypass plus encoding, encoding alone, and bypass alone -- and add the sites it missed, including windows-mobile-firewall.ts, which encodes a script and launches it elevated through Start-Process -Verb RunAs. system-fonts.ts (-Command) and desktop-script-provider-bridge.ts (-File) were listed as encoded and are not. Notes that a raw grep under-reports, because the hook sites reach -EncodedCommand through wrapWindowsPowerShellEncodedCommand. Attribute the in-payload Set-ExecutionPolicy move to #16576 rather than to #16003's measurement, which keyed on -WindowStyle Hidden + -EncodedCommand, and record that the launcher's own tradeoff is unverified on a real box. Admin guidance was missing two ways a suppression rule pinned to one full path misses real activity: the .staging-<hex> sibling that exists mid-update, which is when the update-cluster incidents fire, and the userData fallback when LOCALAPPDATA is unset. Also: state the measurement conditions on the process-table timings, note that Hermes has surface even though we have no telemetry for it, note that the uninstaller names are electron-builder-generated and in no repo file, drop a volatile line count, and mark the per-operation computer-use shape as being addressed by an unmerged change. Drops the duplicated AGENTS.md section, keeping the indexed bullet. * docs(windows): reconcile the EDR posture doc with the shipped remediation Three claims in this doc became false once the rest of the Windows EDR set landed, and two told engineers the opposite of what the release does. The process-table section still described one shared snapshot taken with `Memory | CommandLine | CreationTime`, argued that splitting the cache per field set "would restore exactly the fan-out it exists to prevent", and concluded the shape was unfixable because "the information is only in the PEB". The split shipped (identity opens no handle at all), `Memory` is retired, and the command line now comes from the kernel through `ProcessCommandLineInformation` -- `ReadProcessMemory` is absent from the compiled addon and a ratchet asserts it against the import table. An engineer reading the old text would have concluded both fixes were dead ends. The PowerShell site inventories were stale in three of four lists: the port scan went native, every `-ExecutionPolicy Bypass` + `-EncodedCommand` pair was dropped as a measured no-op, and of the unencoded-bypass list only `wsl-cli-scripts.ts` survives. Regenerated against the merged tree, including the sites that reach the flag through `wrapWindowsPowerShellEncodedCommand` and never spell it, which a raw `rg` misses. Incident-evidence sections are left alone: they record what the tenant observed on 1.4.192, not what the code does now. * fix(windows): copy the daemon host exe verbatim instead of renaming it Microsoft Defender for Endpoint flagged `orca-terminal-daemon.exe` as MITRE T1036 (Masquerading): Orca copied its own `Orca.exe` into %LOCALAPPDATA% under a different name, specifically so the NSIS updater's `taskkill /IM Orca.exe` could not match, then ran it detached. Because that process is what every other flagged action was attributed to, the name mismatch acted as a reputation multiplier on unrelated findings. The rename was never what made the daemon survive. In app-builder-lib 26.15.3 the installer's FIND_PROCESS/KILL_PROCESS select processes whose image path is under $INSTDIR; `taskkill /IM` is only the fallback for hosts where PowerShell is missing or blocked. Survival is a property of the path, and %LOCALAPPDATA%\Orca\daemon-host is outside $INSTDIR whatever the file is called. Derive the host exe name from process.execPath so the copy is byte-for-byte, name included — it keeps its Authenticode signature and carries no renamed-image signal. On the no-PowerShell fallback the daemon is now killed with the app and terminals cold-restore, which is the documented pre-relocation outcome the update harness already asserts, not a regression. The uninstall macro no longer needs a distinct name to find the daemon; it kills the app's own image name (plus the legacy name, for hosts left by older builds). Adds docs/reference/windows-daemon-host-relocation.md with the survival contract, the rejected alternatives and their measured costs, and the invariants to keep. * fix(windows): apply daemon-host relocation review corrections Scope the uninstall taskkill to the current user with `/FI "USERNAME eq %USERNAME%"` via cmd.exe, matching upstream's per-user KILL_PROCESS — without it an elevated machine-wide uninstall reaches another logged-on user's session, so the "no collateral" claim in the comment was overstated. Comment the rmSync-before-publish: Windows refuses to delete a running image, so a live daemon already hosted in this version's dir (same-version reinstall, or a dev channel reusing a version) throws and materialization fails open. Doc corrections: - The fallback selector is the full per-user `taskkill /F /IM "<app>.exe" /FI "PID ne $pid" /FI "USERNAME eq %USERNAME%"`, not a bare `taskkill /IM`. - The probe reads `Get-ExecutionPolicy -Scope Process`, not the effective policy, and GPO writes MachinePolicy/UserPolicy — so GPO-managed hosts take the primary path-scoped branch. Narrow the fallback triggers accordingly. - Drop the Authenticode sentence: the old name was equally byte-identical and equally signed, so a filename has no bearing on signature validity. - Name the new update-abort path: the daemon now matches FIND_PROCESS, so on the fallback branch an unkillable host reaches the retry loop's MessageBox /SD IDCANCEL and Quits, aborting a silent update. - Correct the customCheckAppRunning rejection. It is ~6 lines, not a rewrite; it is wrong because forcing the PowerShell branch where PowerShell is absent makes FIND/KILL silently no-op and leaves the real app running with files in use. - Bound the win honestly: OriginalFilename is empty on the shipped binary, so the strongest T1036 indicator never fired, and the residual copy-and-run-detached shape still maps to T1036.005. Reconcile docs/reference/windows-edr-posture.md, which documents the rename as a live finding and would otherwise contradict this change. Content-only edit: markdown under docs/reference/ is not oxfmt-formatted as a matter of practice and nothing in CI gates it, so the file is left consistent with its neighbours. * fix(windows): expand USERNAME in NSIS instead of spawning cmd.exe The uninstall macro routed both taskkills through `"$SYSDIR\cmd.exe" /C` purely so `%USERNAME%` would expand — two extra interpreter spawns on the uninstall path, in a change whose whole point is not adding scored behaviour, and the exact `cmd.exe /c` shape the new AGENTS.md EDR bullet warns about. NSIS reads the variable itself with ReadEnvStr, so the spawns buy nothing. Verified on Windows 11 that the generated command line does what the filter is there for: a copy of cmd.exe running as orca-nonexistent-probe.exe (pid 34244) was terminated by `taskkill /F /IM "orca-nonexistent-probe.exe" /FI "USERNAME eq <user>"` — SUCCESS, exit 0, process gone. Guarded on an empty USERNAME because the degenerate case is silent: taskkill rejects an empty filter value outright ("The search filter cannot be recognized") and kills nothing, which would leave exactly the orphaned daemon this macro exists to reap. `*` is rejected as a filter value too, so there is no branchless spelling. With no USERNAME to scope by it kills unfiltered, as the macro did before the filter was added. Stack stays balanced: three pushes, two nsExec pops, three restores. Also strike the last stale row in windows-edr-posture.md's remediation table. "Copying our own image under a different name" read as outstanding work; it is done by this change, so the row now points at the relocation doc. Same class of staleness as the section reconciled in the previous commit, and git would not have flagged it either. * fix(windows): port the daemon-host uninstall sweep into the live NSIS include The uninstall macro this branch rewrote lived in config/nsis/daemon-host-uninstall.nsh, which main no longer includes: #17906 consolidated every Windows installer hook into config/nsis/orca-installer-hooks.nsh because electron-builder accepts exactly one `nsis.include`. Merged as-is, the rewritten macro would have been dead code while the shipped uninstaller kept running main's stale sweep — `taskkill /F /IM orca-terminal-daemon.exe`, which matches nothing now that the relocated host is a verbatim Orca.exe copy. The RMDir that follows then cannot delete the running image, so a live orphaned daemon and its ~224 MB tree would survive every uninstall. Ported into the live include: the ${APP_EXECUTABLE_FILENAME} kill, the USERNAME filter that keeps an elevated machine-wide uninstall out of another logged-on user's session, and the register save/restore around both. The legacy orca-terminal-daemon.exe kill stays so hosts left by older builds are still reaped. The ratchet that was meant to catch exactly this pinned only the legacy image name, which main's stale macro already satisfied, so it passed both ways. It now asserts the app-exe kill and the USERNAME filter, against comment-stripped script — the prose above the macro names both image names, so a toContain over the raw file proves nothing. --------- Co-authored-by: Orca Worker <orca-worker@localhost>
433 lines
17 KiB
JavaScript
433 lines
17 KiB
JavaScript
// win-crash-survival-e2e — packaged crash-survival proof harness.
|
|
//
|
|
// GitHub #7742: on Windows, when Orca's main/renderer process crashed, open
|
|
// terminal PTYs were orphaned and PowerShell hard-crashed with a 0xE9 "No
|
|
// process is on the other end of the pipe" FailFast, because the terminal daemon
|
|
// (hosting the ConPTYs) died together with the main process and severed the
|
|
// console pipe. The fix relocates the daemon into a standalone, detached
|
|
// host process outside the install dir that survives main death (src/main/daemon/
|
|
// daemon-host-relocation.ts). win-update-e2e proves the daemon survives a
|
|
// Windows UPDATE; this harness proves it survives a CRASH of the main process.
|
|
//
|
|
// Flow: launch the installed app (isolated userData) → open a plain terminal and,
|
|
// typing DIRECTLY into the interactive shell, stamp a per-shell env sentinel plus
|
|
// that shell's own $PID (leaving it idle at a live PSReadLine prompt, the faithful
|
|
// #7742 crash condition) → force-kill ONLY the real app main (no tree-kill, no
|
|
// graceful close) → prove the main actually died, then that the daemon + that same
|
|
// shell PID stay alive with no pwsh FailFast → relaunch, adopt the surviving
|
|
// daemon, and prove the reattached UI is bound to the SAME survivor shell by
|
|
// reading back its env sentinel (a re-spawned shell would not have it).
|
|
// Windows-only. See README.md.
|
|
|
|
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { assertWin32 } from '../win-update-e2e/platform-guard.mjs'
|
|
import {
|
|
launchInstalledApp,
|
|
ensureTerminal,
|
|
dismissOverlays,
|
|
createTerminalTab,
|
|
listTabIds,
|
|
typeLine,
|
|
sendCtrlC,
|
|
waitForTerminalReady,
|
|
closeApp,
|
|
captureFailureDiagnostics,
|
|
resolveElectronMainPid
|
|
} from '../win-update-e2e/app-driver.mjs'
|
|
import {
|
|
findDaemonProcesses,
|
|
isPidAlive,
|
|
readDaemonPidFiles
|
|
} from '../win-update-e2e/daemon-processes.mjs'
|
|
import { createSeededRepo, buildFreshProfile } from '../win-update-e2e/onboarding-profile.mjs'
|
|
import { renderTable, allPassed } from '../win-update-e2e/assertions.mjs'
|
|
import { quotePowerShellLiteral } from '../win-update-e2e/powershell-runner.mjs'
|
|
import { parseArgs } from './cli-args.mjs'
|
|
import { crashMainProcess, scanPwshFailFast } from './crash-step.mjs'
|
|
import { buildCrashAssertions } from './crash-assertions.mjs'
|
|
import { selectScopedDaemon } from './daemon-identity.mjs'
|
|
import { reattachSentinelMatches, selectCreatedTabId } from './reattach-proof.mjs'
|
|
|
|
const SORTABLE_TAB = '[data-testid="sortable-tab"]'
|
|
// The per-shell env var stamped into the interactive shell; reading it back after
|
|
// relaunch proves keystrokes reach the SAME survivor shell (a fresh re-spawn lacks it).
|
|
const SENTINEL_ENV = 'ORCA_CRASH_SENTINEL'
|
|
|
|
function log(step, msg) {
|
|
console.log(`[win-crash-survival-e2e] ${step}: ${msg}`)
|
|
}
|
|
|
|
async function main() {
|
|
const opts = parseArgs(process.argv.slice(2))
|
|
if (opts.help) {
|
|
console.log(opts.usage)
|
|
return 0
|
|
}
|
|
// Assert win32 BEFORE surfacing arg errors so an off-win32 invocation gets the
|
|
// clear platform message, not a confusing "no Orca.exe found" default-resolution
|
|
// failure.
|
|
assertWin32('win-crash-survival-e2e')
|
|
if (opts.errors?.length) {
|
|
console.error(`Argument errors:\n - ${opts.errors.join('\n - ')}\n${opts.usage}`)
|
|
return 2
|
|
}
|
|
|
|
const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
const canary = `ORCA-CRASH-SENTINEL-${runId}`
|
|
const runDir = mkdtempSync(path.join(tmpdir(), `orca-win-crash-e2e-${runId}-`))
|
|
const userDataDir = path.join(runDir, 'userData')
|
|
const shellPidFile = path.join(runDir, 'shell.pid')
|
|
const reattachFile = path.join(runDir, 'reattach.txt')
|
|
|
|
log('setup', `runId=${runId} runDir=${runDir} profile=${opts.expect} exe=${opts.exePath}`)
|
|
|
|
const ctx = { session: null }
|
|
const diagDir = process.env.ORCA_E2E_DIAG_DIR || path.join(runDir, 'diag')
|
|
let passed = false
|
|
try {
|
|
passed = await runProof(ctx, { opts, canary, runDir, userDataDir, shellPidFile, reattachFile })
|
|
if (!passed && ctx.session?.page) {
|
|
const diag = await captureFailureDiagnostics(ctx.session.page, diagDir, 'assertion-failure')
|
|
log('diag', `captured -> ${diagDir} (store=${diag.info?.hasStore ?? 'n/a'})`)
|
|
}
|
|
} catch (err) {
|
|
console.error(`[win-crash-survival-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'})`)
|
|
}
|
|
passed = false
|
|
} finally {
|
|
await teardown({ app: ctx.session?.app, userDataDir, keepProfile: opts.keepProfile, runDir })
|
|
}
|
|
return passed ? 0 : 1
|
|
}
|
|
|
|
/**
|
|
* Run the launch → crash → survive → relaunch → assert proof. `ctx.session` is
|
|
* assigned as each app launches so a caller's finally can tear down a partial
|
|
* session. Returns whether every assertion passed.
|
|
*/
|
|
async function runProof(ctx, args) {
|
|
const { opts, canary, runDir, userDataDir, shellPidFile, reattachFile } = args
|
|
|
|
// Seed a fresh profile (onboarding dismissed + one throwaway repo) ONLY before
|
|
// the first launch. The relaunch must use the app's own persisted state so the
|
|
// reattach/adoption assertions are meaningful.
|
|
const seededRepo = createSeededRepo(path.join(runDir, 'fixture-repo'))
|
|
const seedProfile = buildFreshProfile({ repo: seededRepo })
|
|
|
|
// --- First launch: open a plain terminal and stamp the interactive shell ---
|
|
let session = await launchInstalledApp({ exePath: opts.exePath, userDataDir, seedProfile })
|
|
ctx.session = session
|
|
await ensureTerminal(session.page, { allowCreate: true })
|
|
await dismissOverlays(session.page)
|
|
// Opening the seeded workspace lands on its default tab (an agent, not a bare
|
|
// shell). Add an explicit plain-terminal tab so the sentinel commands run in a
|
|
// real pwsh prompt — typing shell commands into an agent TUI would never run.
|
|
const initialTabIds = await listTabIds(session.page)
|
|
await createTerminalTab(session.page)
|
|
await dismissOverlays(session.page)
|
|
const tabIds = await listTabIds(session.page)
|
|
const terminalTabId = selectCreatedTabId(initialTabIds, tabIds)
|
|
await waitForTerminalReady(session.page, 60_000, terminalTabId)
|
|
log('sessions', `terminal ready; created=${terminalTabId}; tab ids: ${tabIds.join(', ')}`)
|
|
|
|
// Type DIRECTLY into the interactive shell (not a nested powershell) so $env and
|
|
// $PID belong to THIS shell: stamp the env sentinel and record the shell's own
|
|
// PID. The command completes fast, leaving the shell idle at a live PSReadLine
|
|
// prompt — the exact state that FailFasts with 0xE9 on a broken build. The pid
|
|
// file appearing also proves keystrokes reached and ran in the shell.
|
|
await typeLine(
|
|
session.page,
|
|
`$env:${SENTINEL_ENV}='${canary}'; Set-Content -LiteralPath ${quotePowerShellLiteral(shellPidFile)} -Value $PID`,
|
|
terminalTabId
|
|
)
|
|
const shellPid = await waitForIntFile(shellPidFile, 15_000)
|
|
log('shell', `interactive shell pid=${shellPid} (sentinel ${SENTINEL_ENV}=${canary})`)
|
|
|
|
const preDaemon = resolveScopedDaemon(userDataDir)
|
|
log('daemon', `pre-crash daemon pid=${preDaemon.pid} appVersion=${preDaemon.appVersion}`)
|
|
|
|
// Resolve the REAL Electron main pid from INSIDE the main process. On this
|
|
// packaged build app.process().pid is a launcher stub that immediately re-execs
|
|
// the actual browser process; killing the stub would leave the real main (and
|
|
// its single-instance lock) alive and make survival vacuously true. app.evaluate
|
|
// runs in the main process, so process.pid there is the exact main of the
|
|
// instance this harness launched — authoritative, not a machine-wide scan.
|
|
const mainPid = await resolveElectronMainPid(session.app, { allowLauncherFallback: false })
|
|
if (!Number.isInteger(mainPid) || mainPid <= 0) {
|
|
throw new Error(`could not resolve app main pid (got ${mainPid})`)
|
|
}
|
|
|
|
// --- CRASH: force-kill ONLY the real main (no /T tree-kill, no graceful close) ---
|
|
const crashStartMs = Date.now()
|
|
log('crash', `taskkill /F /PID ${mainPid} (real main, no /T) — abrupt main-process death`)
|
|
crashMainProcess(mainPid)
|
|
// The crashed app's driver is dead; drop it so teardown never re-closes it.
|
|
ctx.session = null
|
|
|
|
// Prove the crash actually LANDED before trusting any survival signal — an
|
|
// assertion that never fires would make the whole proof vacuous.
|
|
const mainDied = await waitForPidDead(mainPid, 15_000)
|
|
log('crash', `main pid ${mainPid} dead: ${mainDied}`)
|
|
|
|
// Observe the survival window: the daemon and the SAME shell PID must keep running.
|
|
await delay(opts.soakSeconds * 1000)
|
|
const daemonAliveAfterCrash = preDaemon.pid != null && isPidAlive(preDaemon.pid)
|
|
const shellAliveAfterCrash = shellPid != null && isPidAlive(shellPid)
|
|
log(
|
|
'crash',
|
|
`after crash: daemonAlive=${daemonAliveAfterCrash} shellAlive=${shellAliveAfterCrash}`
|
|
)
|
|
|
|
// --- Relaunch: adopt the surviving daemon and prove the reattached UI is the
|
|
// same survivor shell (env sentinel reads back) ---
|
|
clearSingletonLocks(userDataDir)
|
|
session = await launchInstalledApp({ exePath: opts.exePath, userDataDir })
|
|
ctx.session = session
|
|
let reattachProven = false
|
|
try {
|
|
// No create on relaunch: the terminal must be RESTORED, not freshly made.
|
|
await ensureTerminal(session.page, { allowCreate: false })
|
|
await dismissOverlays(session.page)
|
|
reattachProven = await proveReattachedShell(session.page, {
|
|
file: reattachFile,
|
|
expectedCanary: canary,
|
|
expectedShellPid: shellPid,
|
|
terminalTabId
|
|
})
|
|
} catch (err) {
|
|
log('relaunch', `reattach proof did not complete: ${err.message}`)
|
|
}
|
|
log('relaunch', `reattached UI bound to survivor shell: ${reattachProven}`)
|
|
|
|
const postDaemon = resolveScopedDaemon(userDataDir)
|
|
const postDaemonAlive = postDaemon.pid != null && isPidAlive(postDaemon.pid)
|
|
log('daemon', `post-relaunch daemon pid=${postDaemon.pid} alive=${postDaemonAlive}`)
|
|
|
|
// Why: PowerShell can stay alive on a severed ConPTY until the next console
|
|
// read. Scan after the reattach keystroke so the user-visible 0xE9 is covered.
|
|
const { events: failFastEvents } = scanPwshFailFast(crashStartMs)
|
|
log('event-log', `pwsh FailFast/0xE9 events since crash: ${failFastEvents.length}`)
|
|
for (const e of failFastEvents.slice(0, 3)) {
|
|
log('event-log', ` ${e.provider}#${e.id}@${e.timeCreated}`)
|
|
}
|
|
|
|
const assertions = buildCrashAssertions({
|
|
profile: opts.expect,
|
|
shellPid,
|
|
preDaemonPid: preDaemon.pid,
|
|
postDaemonPid: postDaemon.pid,
|
|
postDaemonAlive,
|
|
mainDied,
|
|
daemonAliveAfterCrash,
|
|
shellAliveAfterCrash,
|
|
reattachProven,
|
|
failFastEvents
|
|
})
|
|
const passed = allPassed(assertions)
|
|
console.log(renderTable(assertions, 'win-crash-survival-e2e'))
|
|
log('result', passed ? 'PASS' : 'FAIL')
|
|
return passed
|
|
}
|
|
|
|
/**
|
|
* Prove the reattached UI is bound to the SAME survivor shell: type a command that
|
|
* writes the shell's own $PID plus the persisted env sentinel to a file, then
|
|
* confirm the sentinel (and PID) match. A freshly re-spawned shell would not carry
|
|
* the env var. Targets the exact pre-crash tab id so the probe cannot type shell
|
|
* commands into an unrelated agent tab. Repeats the idempotent command while the
|
|
* restored pane transport converges; a filesystem match, not elapsed time, wins.
|
|
*/
|
|
async function proveReattachedShell(
|
|
page,
|
|
{ file, expectedCanary, expectedShellPid, terminalTabId }
|
|
) {
|
|
const restoredTabIds = await listTabIds(page)
|
|
log('relaunch', `restored tab ids: ${restoredTabIds.join(', ')}; target=${terminalTabId}`)
|
|
const targetTab = page.locator(`${SORTABLE_TAB}[data-tab-id="${terminalTabId}"]`).first()
|
|
await targetTab.waitFor({ state: 'attached', timeout: 15_000 })
|
|
|
|
const deadline = Date.now() + 30_000
|
|
let attempt = 0
|
|
while (Date.now() < deadline) {
|
|
attempt++
|
|
const readinessBudgetMs = Math.max(deadline - Date.now(), 1)
|
|
await targetTab.click({ force: true, timeout: readinessBudgetMs })
|
|
await waitForTerminalReady(page, readinessBudgetMs, terminalTabId)
|
|
// Why: a partially forwarded earlier attempt can leave text at PSReadLine;
|
|
// clear it before replaying the complete idempotent proof command.
|
|
await sendCtrlC(page, terminalTabId)
|
|
await typeLine(
|
|
page,
|
|
`Set-Content -LiteralPath ${quotePowerShellLiteral(file)} -Value "$($PID)|$($env:${SENTINEL_ENV})"`,
|
|
terminalTabId
|
|
)
|
|
const remainingMs = deadline - Date.now()
|
|
const hit = await waitForSentinel(
|
|
file,
|
|
expectedCanary,
|
|
expectedShellPid,
|
|
Math.min(3_000, Math.max(remainingMs, 0))
|
|
)
|
|
if (hit) {
|
|
log('relaunch', `same-shell sentinel read back on attempt ${attempt}`)
|
|
return true
|
|
}
|
|
log('relaunch', `same-shell probe attempt ${attempt} produced no matching sentinel`)
|
|
}
|
|
return false
|
|
}
|
|
|
|
/** Poll for the reattach file and require both the per-shell canary and exact
|
|
* survivor PID. Either check alone is weaker than the asserted shell identity. */
|
|
async function waitForSentinel(file, expectedCanary, expectedShellPid, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
if (reattachSentinelMatches(readFileSync(file, 'utf8'), expectedCanary, expectedShellPid)) {
|
|
return true
|
|
}
|
|
} catch {
|
|
/* not written yet */
|
|
}
|
|
await delay(500)
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Resolve THIS run's daemon, scoped to its isolated userData dir so unrelated
|
|
* daemons on the machine (including the developer's live Orca) are ignored.
|
|
* The scoped live process scan is authoritative; PID files only contribute
|
|
* metadata after their PID matches that process.
|
|
*/
|
|
function resolveScopedDaemon(userDataDir) {
|
|
const pidFiles = readDaemonPidFiles(userDataDir)
|
|
const scan = findDaemonProcesses(userDataDir)
|
|
return selectScopedDaemon(pidFiles, scan)
|
|
}
|
|
|
|
/**
|
|
* Remove Electron/Chromium single-instance lock files a crashed main can leave
|
|
* behind in the isolated profile, so the relaunch is not refused/redirected by a
|
|
* stale lock. Best-effort — absent files are normal.
|
|
*/
|
|
function clearSingletonLocks(userDataDir) {
|
|
let entries = []
|
|
try {
|
|
entries = readdirSync(userDataDir)
|
|
} catch {
|
|
return
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.startsWith('Singleton')) {
|
|
try {
|
|
rmSync(path.join(userDataDir, entry), { recursive: true, force: true })
|
|
} catch {
|
|
/* leave it; relaunch may still succeed */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tear down only what THIS harness created. Kills are re-scoped at teardown time
|
|
* via a FRESH findDaemonProcesses(userDataDir): the interactive shell is a
|
|
* descendant of this run's daemon, so a /T tree-kill of the freshly-discovered
|
|
* daemon removes the daemon + OpenConsole + shell together. We deliberately do NOT
|
|
* kill any pid captured earlier in the run — a captured pid can be recycled by the
|
|
* OS onto an innocent process, so only pids re-verified as this run's daemon (by
|
|
* scoped command-line match) are ever killed. Never installs/uninstalls and never
|
|
* touches any other Orca on the box (a live user instance uses a different
|
|
* userData and is out of scope by construction).
|
|
*/
|
|
async function teardown({ app, userDataDir, keepProfile, runDir }) {
|
|
try {
|
|
await closeApp(app)
|
|
} catch {
|
|
/* already closed / never launched */
|
|
}
|
|
for (const proc of findDaemonProcesses(userDataDir)) {
|
|
killPidTree(proc.pid)
|
|
}
|
|
if (keepProfile) {
|
|
log('teardown', `--keep-profile set; leaving ${runDir}`)
|
|
return
|
|
}
|
|
// Best-effort: a just-killed daemon/child can briefly hold file handles under
|
|
// the profile, so a locked rmSync must not turn cleanup into a FATAL.
|
|
try {
|
|
rmSync(runDir, { recursive: true, force: true })
|
|
} catch (err) {
|
|
log('teardown', `could not remove ${runDir} (${err.code || err.message}); leaving it`)
|
|
}
|
|
}
|
|
|
|
function killPidTree(pid) {
|
|
if (!Number.isInteger(pid) || pid <= 0) {
|
|
return
|
|
}
|
|
try {
|
|
execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
|
} catch {
|
|
/* already dead */
|
|
}
|
|
}
|
|
|
|
/** Poll until a pid is no longer alive (the crash landed), or timeout. */
|
|
async function waitForPidDead(pid, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
if (!isPidAlive(pid)) {
|
|
return true
|
|
}
|
|
await delay(500)
|
|
}
|
|
return false
|
|
}
|
|
|
|
function readIntFile(filePath) {
|
|
try {
|
|
const n = Number(readFileSync(filePath, 'utf8').trim())
|
|
return Number.isInteger(n) ? n : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Poll for an int-valued file (the shell writes its PID asynchronously once the
|
|
* typed command runs), returning the int or null after timeoutMs. */
|
|
async function waitForIntFile(filePath, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
const n = readIntFile(filePath)
|
|
if (n != null) {
|
|
return n
|
|
}
|
|
await delay(500)
|
|
}
|
|
return null
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
|
|
main()
|
|
.then((code) => {
|
|
// Force-exit: a launched Electron app can keep libuv handles open, which
|
|
// would otherwise pin Node alive until the CI job timeout.
|
|
process.exit(code)
|
|
})
|
|
.catch((err) => {
|
|
console.error('[win-crash-survival-e2e] FATAL:', err.stack || err.message)
|
|
process.exit(1)
|
|
})
|
|
}
|