/** * @file * * Stops an emulator this harness started once its run is gone for good — the * case nothing else covers, **L56**'s path B with no later run: the runner is * killed (SIGKILL, Task Manager, an IDE stop button), no teardown runs, and no * other Android run ever comes along to reclaim the leftover from its marker. * On 2026-09-10 an emulator left like that kept `netsimd` spinning for six hours. * * The reaper is a small detached Node process spawned beside every emulator the * harness starts or takes over. It holds no handle to the emulator and needs * none: the marker (`emulator-marker.ts`) says what to stop, and the verified * stop (`emulator-reclaim.ts`) is the one the run's own teardown uses. * * ## It watches the `android` setup lock, not the process that started it * * The process that starts the emulator is not always the run. A project with no * transport global setup boots it from a **test worker** (**L56** path A), and * Vitest ends workers during a perfectly healthy run — so a reaper tied to its * spawner's lifetime (the **L33** liveness socket, the first idea) would stop * the emulator mid-run, or race the next worker taking it over. The `android` * setup lock (**L7**) is held by the run's main process for the whole run, and * its holder being alive is exactly what "an Android run is in flight" means. * * So the reaper polls that lock, and once it can **take** it — no live run * holds it — it stops the harness's leftovers while holding it, which is the * precondition every leftover stop documents: no run can adopt an emulator that * is mid-shutdown. That is precisely what the next run's preflight would do, * done at the first moment that run could have done it. * * A dead same-host holder is abandoned at once. A live-PID holder that has gone * silent is only abandoned after * {@link EMULATOR_REAPER_LOCK_SILENCE_IN_MILLISECONDS}, far wider than the * two minutes a waiting run steals on: a reaper is always waiting, so at two * minutes a live run that blocked its event loop would lose its emulator. * * ## Fail-open, like the renderer watchdog * * A reaper is armed only when a live run holds the lock at the moment the * emulator is recorded. A run that takes no lock — a hand-wired * `createTransportFromOptions` — gets none, and says so, rather than a reaper * that would read its missing lock as "the run is over" and stop an emulator * somebody is using. A spawn that fails is logged and never fails the launch. * * ## How it runs * * `process.execPath -e `: * the bootstrap `import()`s this very module and calls {@link runEmulatorReaper}. * The URL is the built `.mjs` / `.cjs` in a consumer's install, and the `.ts` * source in this repo's own suites, which Node's type stripping runs as-is — so * nothing this module imports may reach a module that reads the build-time * `OBSIDIAN_METADATA` global. Its stdout/stderr go to a per-AVD log file, capped * at {@link EMULATOR_REAPER_LOG_MAX_SIZE_IN_BYTES}, so the verdict lines the stop * prints are on record even though no terminal is left to show them. * * ## It is started through a relay, so a process-tree kill cannot take it * * `detached` does not reparent on Windows: a child spawned straight from the run * stays in the run's process tree, so the kill shapes that walk that tree take * the safety net along with the thing it was guarding — Task Manager's *End * task*, `taskkill /T`, an IDE stop button, a `ParentProcessId` descendant * sweep. Measured on 2026-09-11: a reaper died with the run it was watching, and * the emulator it would have stopped idled on for ten minutes. * * So the spawn goes through a relay — {@link buildEmulatorReaperRelayBootstrap} — * which starts the reaper and exits at once. By the time any tree-walker takes * its snapshot the relay is gone, so there is no edge from the run to the reaper * left to follow. This is the double-fork idiom, and it holds on POSIX for the * same reason: once the relay exits, the reaper belongs to `init`. */ import type { EmulatorMarker } from './emulator-marker.cjs'; /** * How often the reaper looks at the marker and the lock — the lock's own * heartbeat interval, so a killed run is noticed within one beat. */ export declare const EMULATOR_REAPER_POLL_INTERVAL_IN_MILLISECONDS = 5000; /** * How long a same-host lock holder whose PID is still alive may stay silent * before the reaper treats its run as gone: 30 minutes, the threshold the lock * already uses across hosts. Only a recycled PID should ever reach it. */ export declare const EMULATOR_REAPER_LOCK_SILENCE_IN_MILLISECONDS = 1800000; /** * The size past which the reaper log is started afresh rather than appended to. */ export declare const EMULATOR_REAPER_LOG_MAX_SIZE_IN_BYTES = 1048576; /** * The export the bootstrap calls. Named here so the bootstrap and the export * cannot drift apart. */ export declare const EMULATOR_REAPER_ENTRY_NAME = "runEmulatorReaper"; /** * What the reaper watches for: the emulator a marker recorded when it was armed. */ export interface EmulatorReaperArguments { /** The AVD whose marker it watches. */ readonly avdName: string; /** * When the watched emulator was launched. A takeover keeps it, so it names one * emulator across every run that owns it, and tells a later emulator of the * same AVD apart. */ readonly startedAtInMilliseconds: number; } /** * What the marker says the reaper should do next. * * - `emulator-stopped` — the marker is gone, or none of its PIDs is alive: the * emulator was stopped, so there is nothing left to watch. * - `superseded` — the marker now records a different emulator of the same AVD, * which has its own reaper. * - `watch` — the emulator is still up. */ export type EmulatorReaperWatchVerdict = 'emulator-stopped' | 'superseded' | 'watch'; /** * Parameters for {@link resolveEmulatorReaperWatch}. */ export interface ResolveEmulatorReaperWatchParams { /** The launch time of the emulator this reaper was armed for. */ readonly armedStartedAtInMilliseconds: number; /** Whether a PID is still running. */ readonly checkIsPidAlive: (pid: number) => boolean; /** The AVD's marker as it reads now, if any. */ readonly marker: EmulatorMarker | undefined; } /** * Parameters for {@link spawnEmulatorReaper}. */ export interface SpawnEmulatorReaperParams { /** The AVD whose emulator was just recorded. */ readonly avdName: string; /** Receives the armed / not-armed line. */ readonly log: (message: string) => void; /** The recorded emulator's launch time, as written into its marker. */ readonly startedAtInMilliseconds: number; } /** * Encodes the reaper's arguments for its command line. * * @param reaperArguments - What the reaper watches for. * @returns The command-line arguments, in the order {@link parseEmulatorReaperArguments} reads them. */ export declare function buildEmulatorReaperArguments(reaperArguments: EmulatorReaperArguments): string[]; /** * Builds the script `node -e` runs to start the reaper. * * It `import()`s the module whose URL is its first argument and hands the rest * to {@link runEmulatorReaper}. `import()` rather than `require`, because it * loads all three forms the module ships as — ESM, CJS, and this repo's own * `.ts` source under Node's type stripping. A CJS module's named exports are * found by Node's static analysis; `default` is the fallback when they are not. * * @returns The script. */ export declare function buildEmulatorReaperBootstrap(): string; /** * Builds the script `node -e` runs as the **relay**: it starts the reaper and * exits at once, so the reaper is left with a dead parent and no tree-walking * kill of the run can reach it. See the relay section in this module's header * for why that indirection exists at all. * * Its own arguments are the reaper's whole command line — the bootstrap script * first, then everything that bootstrap reads — so the relay never has to know * what any of them mean. It hands the reaper its own stdout and stderr, which * are the capped log file the spawner opened, so the log is unaffected by the * extra hop. * * @returns The script. */ export declare function buildEmulatorReaperRelayBootstrap(): string; /** * Decodes the reaper's command-line arguments. * * @param argv - The arguments after the module URL. * @returns The arguments, or `undefined` when they are not the two {@link buildEmulatorReaperArguments} writes. */ export declare function parseEmulatorReaperArguments(argv: readonly string[]): EmulatorReaperArguments | undefined; /** * Decides how the reaper log is opened, so it can never grow without bound. * * @param sizeInBytes - The log's current size, or `undefined` when there is none. * @returns `'a'` to append, or `'w'` to start it afresh once it has passed the cap. */ export declare function resolveEmulatorReaperLogOpenFlag(sizeInBytes: number | undefined): 'a' | 'w'; /** * Decides what the marker says the reaper should do next. * * @param params - The marker as it reads now, and the emulator the reaper was armed for. * @returns The verdict. */ export declare function resolveEmulatorReaperWatch(params: ResolveEmulatorReaperWatchParams): EmulatorReaperWatchVerdict; /** * The reaper process's body: watches the marker and the lock until either the * emulator is gone or no run holds the lock, and in the second case stops the * harness's leftovers while holding it. * * Exported for the bootstrap, which is its only caller. * * @param argv - The reaper's arguments, as {@link buildEmulatorReaperArguments} wrote them. */ export declare function runEmulatorReaper(argv: readonly string[]): Promise; /** * Starts the reaper for an emulator the harness just recorded as its own, when * a live run holds the `android` setup lock — and says which it did. * * Never throws: a reaper that cannot be spawned is a lost safety net, not a * reason to fail the launch it would have guarded. * * @param params - The recorded emulator, and where to report. */ export declare function spawnEmulatorReaper(params: SpawnEmulatorReaperParams): void;