mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(emulator): ignore external serve-sim helpers (#9071)
* fix(emulator): ignore external serve-sim helpers Co-authored-by: Orca <help@stably.ai> * docs(reliability): align backpressure evidence Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -1950,7 +1950,6 @@
|
||||
"daemon stream",
|
||||
"main PTY batching",
|
||||
"runtime path provenance",
|
||||
"runtime serve-sim detection",
|
||||
"runtime terminal wait detection",
|
||||
"renderer ACK",
|
||||
"xterm scheduler",
|
||||
@@ -1971,7 +1970,7 @@
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse, idle-path serve-sim detection, and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.",
|
||||
"coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6836",
|
||||
"https://github.com/stablyai/orca/pull/6858",
|
||||
@@ -1979,17 +1978,15 @@
|
||||
"https://github.com/stablyai/orca/pull/7054"
|
||||
],
|
||||
"invariant": "High-volume terminal output must stay bounded across daemon socket writes, main runtime metadata, detectors, and tail checks, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.",
|
||||
"oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, unchanged path-provenance history reuse for pathless output, idle serve-sim detection with split-JSON continuity, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.",
|
||||
"oracle": "The current executable slice injects main-process renderer backlog pressure, then asserts unchanged path-provenance history reuse for pathless output, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/main/runtime/orca-runtime-path-candidate-history.test.ts",
|
||||
"src/main/emulator/serve-sim-state-watcher.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/runtime/orca-runtime-tail-wait-memo.test.ts"
|
||||
],
|
||||
@@ -2009,13 +2006,6 @@
|
||||
"copies history when new output adds a path candidate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/emulator/serve-sim-state-watcher.test.ts",
|
||||
"assertions": [
|
||||
"does not buffer repeated brace-free PTY output while waiting for metadata",
|
||||
"detects serve-sim metadata split across PTY chunks and releases the partial object"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime-tail-wait-memo.test.ts",
|
||||
"assertions": [
|
||||
@@ -2053,15 +2043,6 @@
|
||||
"durationSeconds": 3.49,
|
||||
"summary": "1 test file passed, 2 tests passed on the exact rebased tree based on main@dc468f0ded."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.37,
|
||||
"summary": "1 test file passed, 6 tests passed on pushed commit f0e3a185593b."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
@@ -2078,11 +2059,11 @@
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Daemon stream backpressure and focused main/renderer backlog tests are deterministic unit slices. The combined perf soak still needs runtime history before promotion."
|
||||
"evidence": "Focused main-process backlog tests are deterministic unit slices. The combined perf soak still needs runtime history before promotion."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary output leaves the niche serve-sim detector idle while split JSON still emits and releases its partial buffer, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; restoring the old serve-sim buffer failed its scale test after 180ms with 4,096 buffer writes; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs broader hidden-output/input-latency perf artifacts before promotion."
|
||||
"evidence": "Tests assert pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs daemon-stream contract coverage and broader hidden-output/input-latency perf artifacts before promotion."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
@@ -2094,9 +2075,8 @@
|
||||
"Keep stress variants non-blocking until stable runtime history exists."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Current command asserts daemon stream write(false)/drain/cleanup at the batcher contract layer and main pending renderer caps at the IPC contract layer.",
|
||||
"Daemon stream write(false)/drain/cleanup and bounded queued-tail contracts are not registered in the current command.",
|
||||
"Runtime provenance coverage is deterministic and does not include a live high-throughput provider artifact.",
|
||||
"Serve-sim detector coverage is deterministic and does not include a live external simulator helper.",
|
||||
"Terminal-wait scale evidence is deterministic and does not yet include a live saturated-tail event-loop artifact.",
|
||||
"Current command does not prove renderer parse pressure, scheduler queue depth, event-loop delay, or active key latency.",
|
||||
"Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved."
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Benchmark: main-process retained-Set growth of the serve-sim state watcher's
|
||||
// external-helper dedupe across worktree switches.
|
||||
//
|
||||
// emitIfExternal() records one entry per (worktreeId, deviceUdid, pid, wsUrl,
|
||||
// streamUrl) tuple in `seenExternalKeys` so a helper is only auto-tabbed once.
|
||||
// Before the fix, forgetWorktree() pruned ptyToWorktree and ptyBuffers but never
|
||||
// touched seenExternalKeys, so its keys accumulated for the renderer/main
|
||||
// process lifetime — only cleared at app shutdown (stop()). This script
|
||||
// simulates N bind→detect→forget cycles and reports retained Set size with the
|
||||
// prune disabled vs enabled.
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
const CYCLES = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_CYCLES ?? '5000', 10)
|
||||
const HELPERS_PER_WORKTREE = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_HELPERS ?? '2', 10)
|
||||
|
||||
for (const [name, value] of [
|
||||
['ORCA_SEEN_KEYS_BENCH_CYCLES', CYCLES],
|
||||
['ORCA_SEEN_KEYS_BENCH_HELPERS', HELPERS_PER_WORKTREE]
|
||||
]) {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer, received ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror of the watcher's dedupe bookkeeping (serve-sim-state-watcher.ts): a
|
||||
// Set of `${worktreeId}::${instanceKey}` keys, plus the prefix-prune the fix
|
||||
// adds to forgetWorktree().
|
||||
function makeWatcher({ prune }) {
|
||||
const seenExternalKeys = new Set()
|
||||
return {
|
||||
set: seenExternalKeys,
|
||||
emitIfExternal(worktreeId, instanceKey) {
|
||||
const dedupeKey = `${worktreeId}::${instanceKey}`
|
||||
if (seenExternalKeys.has(dedupeKey)) {
|
||||
return false
|
||||
}
|
||||
seenExternalKeys.add(dedupeKey)
|
||||
return true
|
||||
},
|
||||
forgetWorktree(worktreeId) {
|
||||
if (!prune) {
|
||||
return
|
||||
}
|
||||
const prefix = `${worktreeId}::`
|
||||
for (const key of seenExternalKeys) {
|
||||
if (key.startsWith(prefix)) {
|
||||
seenExternalKeys.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function run({ prune }) {
|
||||
const watcher = makeWatcher({ prune })
|
||||
const start = performance.now()
|
||||
for (let cycle = 0; cycle < CYCLES; cycle++) {
|
||||
const worktreeId = `worktree-${cycle}`
|
||||
for (let h = 0; h < HELPERS_PER_WORKTREE; h++) {
|
||||
// A realistic instance key: device udid :: pid :: wsUrl :: streamUrl.
|
||||
const instanceKey = `udid-${cycle}-${h}::pid-${1000 + h}::ws://127.0.0.1:${3100 + h}/ws::http://127.0.0.1:${3100 + h}/stream.mjpeg`
|
||||
watcher.emitIfExternal(worktreeId, instanceKey)
|
||||
}
|
||||
watcher.forgetWorktree(worktreeId)
|
||||
}
|
||||
return { retained: watcher.set.size, elapsedMs: performance.now() - start }
|
||||
}
|
||||
|
||||
console.log(
|
||||
`serve-sim seenExternalKeys leak benchmark: ${CYCLES} worktree open/forget cycles, ` +
|
||||
`${HELPERS_PER_WORKTREE} helper(s)/worktree\n`
|
||||
)
|
||||
|
||||
const before = run({ prune: false })
|
||||
const after = run({ prune: true })
|
||||
|
||||
// Each key is ~120-160 bytes (worktreeId + udid + pid + two URLs).
|
||||
const APPROX_BYTES_PER_KEY = 140
|
||||
const leakedKb = (before.retained * APPROX_BYTES_PER_KEY) / 1024
|
||||
|
||||
console.log(
|
||||
`before (no prune) retained=${before.retained} keys ` +
|
||||
`(~${leakedKb.toFixed(0)} KiB) time=${before.elapsedMs.toFixed(1)}ms`
|
||||
)
|
||||
console.log(
|
||||
`after (prune) retained=${after.retained} keys ` +
|
||||
`(~0 KiB) time=${after.elapsedMs.toFixed(1)}ms`
|
||||
)
|
||||
console.log(
|
||||
`\nBounded the dedupe Set to live worktrees: ${before.retained} → ${after.retained} ` +
|
||||
`retained keys after ${CYCLES} worktree switches.`
|
||||
)
|
||||
@@ -1,239 +0,0 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS,
|
||||
ServeSimStateWatcher,
|
||||
type ServeSimStateDetectedEvent
|
||||
} from './serve-sim-state-watcher'
|
||||
|
||||
const TEST_UDID = '11111111-2222-3333-4444-555555555555'
|
||||
|
||||
let isolatedStateDirSeq = 0
|
||||
// Why: the default state dir is the real $TMPDIR/serve-sim — a leftover state
|
||||
// file from an actual simulator session would leak extra events into tests
|
||||
// that only exercise PTY ingestion. Point at a dir that never exists.
|
||||
function createIsolatedWatcher(): ServeSimStateWatcher {
|
||||
isolatedStateDirSeq += 1
|
||||
return new ServeSimStateWatcher({
|
||||
stateDir: join(tmpdir(), `orca-serve-sim-test-none-${process.pid}-${isolatedStateDirSeq}`)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForEvent(
|
||||
events: ServeSimStateDetectedEvent[],
|
||||
predicate: (event: ServeSimStateDetectedEvent) => boolean
|
||||
): Promise<ServeSimStateDetectedEvent> {
|
||||
const deadline = Date.now() + 1500
|
||||
while (Date.now() < deadline) {
|
||||
const match = events.find(predicate)
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
throw new Error(`Timed out waiting for serve-sim watcher event. Received ${events.length}`)
|
||||
}
|
||||
|
||||
describe('ServeSimStateWatcher', () => {
|
||||
const cleanupPaths: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
cleanupPaths.splice(0).map((path) => rm(path, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
it('attaches to the serve-sim state directory when it appears after startup', async () => {
|
||||
const parentDir = await mkdtemp(join(tmpdir(), 'orca-serve-sim-watch-'))
|
||||
cleanupPaths.push(parentDir)
|
||||
const stateDir = join(parentDir, 'serve-sim')
|
||||
// Force darwin (the poll is macOS-only) and a fast interval so this
|
||||
// cross-platform test exercises the existence-poll -> attach path quickly.
|
||||
const watcher = new ServeSimStateWatcher({ stateDir, platform: 'darwin', existencePollMs: 50 })
|
||||
const events: ServeSimStateDetectedEvent[] = []
|
||||
|
||||
watcher.bindPty('pty-1', 'worktree-1')
|
||||
watcher.onDetected((event) => events.push(event))
|
||||
watcher.start()
|
||||
|
||||
await mkdir(stateDir)
|
||||
await writeFile(
|
||||
join(stateDir, `server-${TEST_UDID}.json`),
|
||||
JSON.stringify({
|
||||
device: TEST_UDID,
|
||||
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg',
|
||||
wsUrl: 'ws://127.0.0.1:3100/ws',
|
||||
pid: 12345
|
||||
})
|
||||
)
|
||||
|
||||
const event = await waitForEvent(events, (candidate) => candidate.info.deviceUdid === TEST_UDID)
|
||||
expect(event).toMatchObject({
|
||||
worktreeId: 'worktree-1',
|
||||
source: 'state-file',
|
||||
info: {
|
||||
deviceUdid: TEST_UDID,
|
||||
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg',
|
||||
wsUrl: 'ws://127.0.0.1:3100/ws',
|
||||
helperPid: 12345
|
||||
}
|
||||
})
|
||||
|
||||
watcher.stop()
|
||||
})
|
||||
|
||||
it('does not arm the existence poll on non-macOS platforms', () => {
|
||||
// serve-sim state never appears off macOS, so start() must not leave a
|
||||
// recurring timer waking the daemon for a directory that can never exist.
|
||||
for (const platform of ['win32', 'linux'] as const) {
|
||||
const watcher = new ServeSimStateWatcher({
|
||||
stateDir: join(tmpdir(), `orca-serve-sim-nonmac-${process.pid}-${platform}`),
|
||||
platform
|
||||
})
|
||||
watcher.start()
|
||||
const poll = (watcher as unknown as { stateDirPoll: unknown }).stateDirPoll
|
||||
expect(poll).toBeNull()
|
||||
watcher.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults the existence poll to a coarse (battery-friendly) interval', () => {
|
||||
expect(SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS).toBe(2_000)
|
||||
const watcher = new ServeSimStateWatcher()
|
||||
expect((watcher as unknown as { existencePollMs: number }).existencePollMs).toBe(
|
||||
SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS
|
||||
)
|
||||
})
|
||||
|
||||
it('does not buffer repeated brace-free PTY output while waiting for metadata', () => {
|
||||
const watcher = createIsolatedWatcher()
|
||||
const buffers = (watcher as unknown as { ptyBuffers: Map<string, string> }).ptyBuffers
|
||||
const originalSet = buffers.set
|
||||
let setCalls = 0
|
||||
buffers.set = function (key, value): Map<string, string> {
|
||||
setCalls += 1
|
||||
return originalSet.call(this, key, value)
|
||||
}
|
||||
watcher.bindPty('pty-1', 'worktree-1')
|
||||
|
||||
try {
|
||||
for (let index = 0; index < 4096; index += 1) {
|
||||
watcher.ingestPtyOutput('pty-1', 'ordinary compiler progress without JSON metadata\n')
|
||||
}
|
||||
} finally {
|
||||
buffers.set = originalSet
|
||||
}
|
||||
|
||||
expect(setCalls).toBe(0)
|
||||
expect(buffers.has('pty-1')).toBe(false)
|
||||
watcher.stop()
|
||||
})
|
||||
|
||||
it('detects serve-sim metadata split across PTY chunks and releases the partial object', () => {
|
||||
const watcher = createIsolatedWatcher()
|
||||
const events: ServeSimStateDetectedEvent[] = []
|
||||
const buffers = (watcher as unknown as { ptyBuffers: Map<string, string> }).ptyBuffers
|
||||
const payload = JSON.stringify({
|
||||
device: TEST_UDID,
|
||||
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg',
|
||||
wsUrl: 'ws://127.0.0.1:3100/ws'
|
||||
})
|
||||
const splitIndex = payload.indexOf('streamUrl') + 4
|
||||
watcher.bindPty('pty-1', 'worktree-1')
|
||||
watcher.onDetected((event) => events.push(event))
|
||||
|
||||
watcher.ingestPtyOutput('pty-1', payload.slice(0, splitIndex))
|
||||
expect(events).toHaveLength(0)
|
||||
expect(buffers.has('pty-1')).toBe(true)
|
||||
|
||||
watcher.ingestPtyOutput('pty-1', payload.slice(splitIndex))
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.info.deviceUdid).toBe(TEST_UDID)
|
||||
expect(buffers.has('pty-1')).toBe(false)
|
||||
watcher.stop()
|
||||
})
|
||||
|
||||
it('suppresses Orca-managed sessions only while they are marked managed', async () => {
|
||||
const watcher = createIsolatedWatcher()
|
||||
const events: ServeSimStateDetectedEvent[] = []
|
||||
const payload = JSON.stringify({
|
||||
device: TEST_UDID,
|
||||
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg',
|
||||
wsUrl: 'ws://127.0.0.1:3100/ws'
|
||||
})
|
||||
|
||||
watcher.bindPty('pty-1', 'worktree-1')
|
||||
watcher.onDetected((event) => events.push(event))
|
||||
watcher.markOrcaManaged({
|
||||
deviceUdid: TEST_UDID,
|
||||
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg',
|
||||
wsUrl: 'ws://127.0.0.1:3100/ws'
|
||||
})
|
||||
watcher.ingestPtyOutput('pty-1', payload)
|
||||
expect(events).toHaveLength(0)
|
||||
|
||||
watcher.unmarkOrcaManaged(TEST_UDID)
|
||||
watcher.ingestPtyOutput('pty-1', payload)
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]?.info.deviceUdid).toBe(TEST_UDID)
|
||||
|
||||
watcher.stop()
|
||||
})
|
||||
|
||||
it('prunes worktree-scoped dedupe keys on forget so a re-bound worktree re-emits', () => {
|
||||
const watcher = createIsolatedWatcher()
|
||||
const events: ServeSimStateDetectedEvent[] = []
|
||||
const payload = JSON.stringify({
|
||||
device: TEST_UDID,
|
||||
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg',
|
||||
wsUrl: 'ws://127.0.0.1:3100/ws',
|
||||
pid: 12345
|
||||
})
|
||||
|
||||
watcher.onDetected((event) => events.push(event))
|
||||
|
||||
watcher.bindPty('pty-1', 'worktree-1')
|
||||
watcher.ingestPtyOutput('pty-1', payload)
|
||||
watcher.ingestPtyOutput('pty-1', payload) // deduped within the same worktree
|
||||
expect(events).toHaveLength(1)
|
||||
|
||||
// Forgetting the worktree must drop its dedupe keys; a re-bind is a fresh
|
||||
// context and should re-emit (otherwise the Set leaks for the session).
|
||||
watcher.forgetWorktree('worktree-1')
|
||||
watcher.bindPty('pty-2', 'worktree-1')
|
||||
watcher.ingestPtyOutput('pty-2', payload)
|
||||
expect(events).toHaveLength(2)
|
||||
|
||||
watcher.stop()
|
||||
})
|
||||
|
||||
it('dedupes one helper without hiding a later helper for the same simulator', () => {
|
||||
const watcher = createIsolatedWatcher()
|
||||
const events: ServeSimStateDetectedEvent[] = []
|
||||
const firstPayload = JSON.stringify({
|
||||
device: TEST_UDID,
|
||||
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg',
|
||||
wsUrl: 'ws://127.0.0.1:3100/ws',
|
||||
pid: 12345
|
||||
})
|
||||
const secondPayload = JSON.stringify({
|
||||
device: TEST_UDID,
|
||||
streamUrl: 'http://127.0.0.1:3101/stream.mjpeg',
|
||||
wsUrl: 'ws://127.0.0.1:3101/ws',
|
||||
pid: 23456
|
||||
})
|
||||
|
||||
watcher.bindPty('pty-1', 'worktree-1')
|
||||
watcher.onDetected((event) => events.push(event))
|
||||
|
||||
watcher.ingestPtyOutput('pty-1', firstPayload)
|
||||
watcher.ingestPtyOutput('pty-1', firstPayload)
|
||||
watcher.ingestPtyOutput('pty-1', secondPayload)
|
||||
|
||||
expect(events.map((event) => event.info.helperPid)).toEqual([12345, 23456])
|
||||
|
||||
watcher.stop()
|
||||
})
|
||||
})
|
||||
@@ -1,309 +0,0 @@
|
||||
import { existsSync, readFileSync, readdirSync, watch } from 'node:fs'
|
||||
import { basename, join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type { FSWatcher } from 'node:fs'
|
||||
|
||||
// Why: terminal-started `serve-sim --detach` writes state under $TMPDIR/serve-sim/ and may
|
||||
// print JSON to the PTY. Orca-managed attach (CLI/pane) already registers via EmulatorBridge;
|
||||
// this watcher only reacts to *external* helper starts so the UI can open a simulator tab
|
||||
// without focus-steal (mirror advertised-url-watcher PTY binding model).
|
||||
|
||||
export type ServeSimHelperInfo = {
|
||||
deviceUdid: string
|
||||
wsUrl: string
|
||||
streamUrl: string
|
||||
axUrl?: string
|
||||
helperPid?: number
|
||||
}
|
||||
|
||||
export type ServeSimStateDetectedEvent = {
|
||||
worktreeId: string
|
||||
info: ServeSimHelperInfo
|
||||
source: 'pty' | 'state-file'
|
||||
}
|
||||
|
||||
const DEFAULT_STATE_DIR = join(tmpdir(), 'serve-sim')
|
||||
// Why: this only waits for a rarely-created dir to appear; sub-second detection
|
||||
// of a detached emulator is not user-perceptible, so a coarse interval avoids
|
||||
// waking the daemon 4x/sec for the whole session on machines that never use it.
|
||||
export const SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS = 2_000
|
||||
const STATE_FILE_RE = /^server-([0-9A-F-]{36})\.json$/i
|
||||
const PTY_JSON_RE = /\{[^{}]*"streamUrl"\s*:\s*"[^"]+"[^{}]*"wsUrl"\s*:\s*"[^"]+"[^{}]*\}/g
|
||||
|
||||
function parseHelperInfo(raw: unknown, fallbackUdid?: string): ServeSimHelperInfo | null {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null
|
||||
}
|
||||
const obj = raw as Record<string, unknown>
|
||||
const deviceUdid =
|
||||
(typeof obj.device === 'string' && obj.device) ||
|
||||
(typeof obj.deviceUdid === 'string' && obj.deviceUdid) ||
|
||||
fallbackUdid
|
||||
const streamUrl =
|
||||
(typeof obj.streamUrl === 'string' && obj.streamUrl) || (typeof obj.url === 'string' && obj.url)
|
||||
const wsUrl = typeof obj.wsUrl === 'string' ? obj.wsUrl : undefined
|
||||
if (!deviceUdid || !streamUrl || !wsUrl) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
deviceUdid,
|
||||
wsUrl,
|
||||
streamUrl,
|
||||
axUrl: typeof obj.axUrl === 'string' ? obj.axUrl : undefined,
|
||||
helperPid: typeof obj.pid === 'number' ? obj.pid : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function readStateFilePath(filePath: string): ServeSimHelperInfo | null {
|
||||
try {
|
||||
const name = basename(filePath)
|
||||
const match = STATE_FILE_RE.exec(name)
|
||||
const fallbackUdid = match?.[1]
|
||||
const raw = JSON.parse(readFileSync(filePath, 'utf8'))
|
||||
return parseHelperInfo(raw, fallbackUdid)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function helperInstanceKey(info: ServeSimHelperInfo): string {
|
||||
return `${info.deviceUdid}::${info.helperPid ?? 'pidless'}::${info.wsUrl}::${info.streamUrl}`
|
||||
}
|
||||
|
||||
function trailingIncompletePtyJsonObject(data: string): string {
|
||||
const lastObjectStart = data.lastIndexOf('{')
|
||||
return lastObjectStart > data.lastIndexOf('}') ? data.slice(lastObjectStart) : ''
|
||||
}
|
||||
|
||||
export class ServeSimStateWatcher {
|
||||
private readonly stateDir: string
|
||||
private readonly platform: NodeJS.Platform
|
||||
private readonly existencePollMs: number
|
||||
private readonly ptyToWorktree = new Map<string, string>()
|
||||
private readonly ptyBuffers = new Map<string, string>()
|
||||
private readonly seenExternalKeys = new Set<string>()
|
||||
private readonly orcaManagedHelperKeys = new Set<string>()
|
||||
private readonly listeners = new Set<(event: ServeSimStateDetectedEvent) => void>()
|
||||
private stateWatcher: FSWatcher | null = null
|
||||
private stateDirPoll: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
constructor(
|
||||
options: { stateDir?: string; platform?: NodeJS.Platform; existencePollMs?: number } = {}
|
||||
) {
|
||||
this.stateDir = options.stateDir ?? DEFAULT_STATE_DIR
|
||||
this.platform = options.platform ?? process.platform
|
||||
this.existencePollMs = options.existencePollMs ?? SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS
|
||||
}
|
||||
|
||||
onDetected(listener: (event: ServeSimStateDetectedEvent) => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
// Why: bridge calls this when Orca CLI/pane attaches so we do not duplicate-tab on our own session.
|
||||
markOrcaManaged(info: ServeSimHelperInfo): void {
|
||||
this.orcaManagedHelperKeys.add(helperInstanceKey(info))
|
||||
}
|
||||
|
||||
unmarkOrcaManaged(deviceUdid: string): void {
|
||||
for (const key of this.orcaManagedHelperKeys) {
|
||||
if (key.startsWith(`${deviceUdid}::`)) {
|
||||
this.orcaManagedHelperKeys.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bindPty(ptyId: string, worktreeId: string): void {
|
||||
this.ptyToWorktree.set(ptyId, worktreeId)
|
||||
this.scanExistingStateFiles()
|
||||
}
|
||||
|
||||
unbindPty(ptyId: string): void {
|
||||
this.ptyToWorktree.delete(ptyId)
|
||||
this.ptyBuffers.delete(ptyId)
|
||||
}
|
||||
|
||||
forgetWorktree(worktreeId: string): void {
|
||||
for (const [ptyId, wt] of this.ptyToWorktree.entries()) {
|
||||
if (wt === worktreeId) {
|
||||
this.ptyToWorktree.delete(ptyId)
|
||||
this.ptyBuffers.delete(ptyId)
|
||||
}
|
||||
}
|
||||
// Why: dedupe keys are worktree-scoped (`${worktreeId}::...`); prune them on
|
||||
// forget so the Set does not grow for the session across worktree switches.
|
||||
// A later re-bind of the same worktree is a fresh context and should re-emit.
|
||||
const prefix = `${worktreeId}::`
|
||||
for (const key of this.seenExternalKeys) {
|
||||
if (key.startsWith(prefix)) {
|
||||
this.seenExternalKeys.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ingestPtyOutput(ptyId: string, data: string): void {
|
||||
const worktreeId = this.ptyToWorktree.get(ptyId)
|
||||
if (!worktreeId) {
|
||||
return
|
||||
}
|
||||
|
||||
const previous = this.ptyBuffers.get(ptyId)
|
||||
if (!previous && !data.includes('{')) {
|
||||
// Why: serve-sim metadata is a JSON object, while ordinary PTY output is
|
||||
// the hot path. Stay idle instead of rebuilding and regex-scanning 16 KiB.
|
||||
return
|
||||
}
|
||||
const combined = `${previous ?? ''}${data}`.slice(-16_384)
|
||||
const trailingObject = trailingIncompletePtyJsonObject(combined)
|
||||
if (trailingObject) {
|
||||
this.ptyBuffers.set(ptyId, trailingObject)
|
||||
} else {
|
||||
this.ptyBuffers.delete(ptyId)
|
||||
}
|
||||
|
||||
const matches = combined.match(PTY_JSON_RE)
|
||||
if (!matches) {
|
||||
return
|
||||
}
|
||||
for (const fragment of matches) {
|
||||
try {
|
||||
const info = parseHelperInfo(JSON.parse(fragment))
|
||||
if (info) {
|
||||
this.emitIfExternal(worktreeId, info, 'pty')
|
||||
}
|
||||
} catch {
|
||||
/* ignore partial JSON */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.stateDirPoll || this.stateWatcher) {
|
||||
return
|
||||
}
|
||||
// Why: serve-sim (the iOS Simulator bridge) only ever writes state on macOS,
|
||||
// so $TMPDIR/serve-sim never appears on Windows/Linux. Skip arming the
|
||||
// existence poll there instead of waking the daemon every interval for a
|
||||
// directory that can never exist.
|
||||
if (this.platform !== 'darwin') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: $TMPDIR/serve-sim/ may not exist until the first terminal `serve-sim --detach`.
|
||||
// Poll for it instead of fs.watch on the parent tmpdir: watching $TMPDIR
|
||||
// registers a permanent FSEvents client on the system's highest-churn
|
||||
// directory, while an existence poll costs the daemon nothing.
|
||||
this.attachStateDirWatch()
|
||||
if (this.stateWatcher) {
|
||||
this.scanExistingStateFiles()
|
||||
return
|
||||
}
|
||||
|
||||
// attachStateDirWatch() clears this poll once the dir appears and the
|
||||
// native watcher takes over, so it only ticks while waiting for a
|
||||
// rarely-created dir — a coarse interval keeps that wait off the idle floor.
|
||||
this.stateDirPoll = setInterval(() => {
|
||||
this.attachStateDirWatch()
|
||||
this.scanExistingStateFiles()
|
||||
}, this.existencePollMs)
|
||||
this.stateDirPoll.unref?.()
|
||||
} catch {
|
||||
// Permission issues: watcher is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stateWatcher?.close()
|
||||
if (this.stateDirPoll) {
|
||||
clearInterval(this.stateDirPoll)
|
||||
}
|
||||
this.stateWatcher = null
|
||||
this.stateDirPoll = null
|
||||
this.ptyToWorktree.clear()
|
||||
this.ptyBuffers.clear()
|
||||
this.seenExternalKeys.clear()
|
||||
this.orcaManagedHelperKeys.clear()
|
||||
this.listeners.clear()
|
||||
}
|
||||
|
||||
private attachStateDirWatch(): void {
|
||||
if (this.stateWatcher || !existsSync(this.stateDir)) {
|
||||
return
|
||||
}
|
||||
this.stateWatcher = watch(this.stateDir, (_event, filename) => {
|
||||
if (!filename) {
|
||||
return
|
||||
}
|
||||
const name = String(filename)
|
||||
if (!STATE_FILE_RE.test(name)) {
|
||||
return
|
||||
}
|
||||
const info = readStateFilePath(join(this.stateDir, name))
|
||||
if (!info) {
|
||||
return
|
||||
}
|
||||
const worktreeId = this.latestBoundWorktree()
|
||||
if (worktreeId) {
|
||||
this.emitIfExternal(worktreeId, info, 'state-file')
|
||||
}
|
||||
})
|
||||
if (this.stateDirPoll) {
|
||||
clearInterval(this.stateDirPoll)
|
||||
this.stateDirPoll = null
|
||||
}
|
||||
}
|
||||
|
||||
private latestBoundWorktree(): string | undefined {
|
||||
let latest: string | undefined
|
||||
for (const wt of this.ptyToWorktree.values()) {
|
||||
latest = wt
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
private scanExistingStateFiles(): void {
|
||||
const worktreeId = this.latestBoundWorktree()
|
||||
if (!worktreeId || !existsSync(this.stateDir)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
for (const name of readdirSync(this.stateDir)) {
|
||||
if (!STATE_FILE_RE.test(name)) {
|
||||
continue
|
||||
}
|
||||
const info = readStateFilePath(join(this.stateDir, name))
|
||||
if (info) {
|
||||
this.emitIfExternal(worktreeId, info, 'state-file')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
private emitIfExternal(
|
||||
worktreeId: string,
|
||||
info: ServeSimHelperInfo,
|
||||
source: ServeSimStateDetectedEvent['source']
|
||||
): void {
|
||||
const instanceKey = helperInstanceKey(info)
|
||||
if (this.orcaManagedHelperKeys.has(instanceKey)) {
|
||||
return
|
||||
}
|
||||
const dedupeKey = `${worktreeId}::${instanceKey}`
|
||||
if (this.seenExternalKeys.has(dedupeKey)) {
|
||||
return
|
||||
}
|
||||
this.seenExternalKeys.add(dedupeKey)
|
||||
const event: ServeSimStateDetectedEvent = { worktreeId, info, source }
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event)
|
||||
} catch {
|
||||
/* listener errors must not break watcher */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const serveSimStateWatcher = new ServeSimStateWatcher()
|
||||
+2
-10
@@ -163,7 +163,6 @@ import {
|
||||
} from './ipc/pty'
|
||||
import { AgentBrowserBridge } from './browser/agent-browser-bridge'
|
||||
import { EmulatorBridge } from './emulator/emulator-bridge'
|
||||
import { serveSimStateWatcher } from './emulator/serve-sim-state-watcher'
|
||||
import { browserManager } from './browser/browser-manager'
|
||||
import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend'
|
||||
import { initializeBrowserSessionsForApp } from './browser/browser-session-startup'
|
||||
@@ -2032,16 +2031,10 @@ app.whenReady().then(async () => {
|
||||
)
|
||||
|
||||
// Emulator bridge (serve-sim). macOS-only feature (gated in CLI/runtime); always ship like agent-browser.
|
||||
// Why: only Orca-managed or explicitly attached helpers belong to a workspace;
|
||||
// externally started serve-sim processes must remain independent from Orca.
|
||||
const emulatorBridge = new EmulatorBridge()
|
||||
runtimeService.setEmulatorBridge(emulatorBridge)
|
||||
serveSimStateWatcher.start()
|
||||
serveSimStateWatcher.onDetected(({ worktreeId, info }) => {
|
||||
runtimeService.getEmulatorBridge()?.registerActiveEmulator(worktreeId, info, {
|
||||
managed: false
|
||||
})
|
||||
serveSimStateWatcher.markOrcaManaged(info)
|
||||
runtimeService.notifyEmulatorAutoAttachFromWatcher(worktreeId, info)
|
||||
})
|
||||
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
|
||||
if (shouldInstallManagedHooks(is.dev)) {
|
||||
// Why: the persisted off switch must run before any auto-install path so
|
||||
@@ -2401,7 +2394,6 @@ app.on('will-quit', (e) => {
|
||||
// down explicitly on quit alongside the other browser/session shutdowns.
|
||||
runtime?.getOffscreenBrowserBackend()?.destroyAll?.()
|
||||
const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve()
|
||||
serveSimStateWatcher.stop()
|
||||
killAllPty()
|
||||
const watcherShutdown = shutdownWatchersOnce()
|
||||
store?.flush()
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '../emulator/emulator-availability'
|
||||
import { resolveDefaultAttachDevice } from '../emulator/emulator-default-attach-device'
|
||||
import { setConfiguredAndroidSdkPath } from '../emulator/android/android-sdk-host-discovery'
|
||||
import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher'
|
||||
import type { EmulatorGesturePoint } from '../emulator/emulator-gesture-sender'
|
||||
import type { EmulatorSessionInfo } from '../emulator/emulator-types'
|
||||
import type { SimulatorDevice } from '../emulator/simctl-simulator-devices'
|
||||
@@ -148,7 +147,6 @@ export class RuntimeEmulatorCommands {
|
||||
if (reusable) {
|
||||
// Why: renderer remounts should reconnect to the existing stream, not
|
||||
// kill it and create the stream-disconnected reload loop users see.
|
||||
serveSimStateWatcher.markOrcaManaged(reusable)
|
||||
this.notifyRendererEmulatorAutoAttach(worktreeId, reusable)
|
||||
if (params.focus) {
|
||||
this.notifyRendererEmulatorPaneFocus(worktreeId)
|
||||
@@ -157,15 +155,11 @@ export class RuntimeEmulatorCommands {
|
||||
}
|
||||
// A different requested device is an explicit switch; the bridge keeps a
|
||||
// slow-to-boot Android emulator alive for instant switch-back.
|
||||
const stoppedUdid = await bridge.stopActiveForSwitch(worktreeId)
|
||||
if (stoppedUdid) {
|
||||
serveSimStateWatcher.unmarkOrcaManaged(stoppedUdid)
|
||||
}
|
||||
await bridge.stopActiveForSwitch(worktreeId)
|
||||
}
|
||||
const info = await bridge.startHelperForDevice(device)
|
||||
if (worktreeId) {
|
||||
bridge.registerActiveEmulator(worktreeId, info, { managed: true })
|
||||
serveSimStateWatcher.markOrcaManaged(info)
|
||||
this.notifyRendererEmulatorAutoAttach(worktreeId, info)
|
||||
if (params.focus) {
|
||||
this.notifyRendererEmulatorPaneFocus(worktreeId)
|
||||
@@ -281,7 +275,6 @@ export class RuntimeEmulatorCommands {
|
||||
const bridge = this.requireEmulatorBridge()
|
||||
const worktreeId = await this.resolveWorktreeId(params.worktree)
|
||||
const killedUdid = await bridge.kill(params.device ?? params.emulator, worktreeId)
|
||||
serveSimStateWatcher.unmarkOrcaManaged(killedUdid)
|
||||
return { ok: true, deviceUdid: killedUdid }
|
||||
}
|
||||
|
||||
@@ -295,13 +288,9 @@ export class RuntimeEmulatorCommands {
|
||||
const worktreeId = await this.resolveWorktreeId(params.worktree)
|
||||
if (params.managedOnly && worktreeId && !params.device && !params.emulator) {
|
||||
const shutdownUdid = await bridge.shutdownActiveManagedForWorktree(worktreeId)
|
||||
if (shutdownUdid) {
|
||||
serveSimStateWatcher.unmarkOrcaManaged(shutdownUdid)
|
||||
}
|
||||
return { ok: true, deviceUdid: shutdownUdid ?? undefined }
|
||||
}
|
||||
const shutdownUdid = await bridge.shutdown(params.device ?? params.emulator, worktreeId)
|
||||
serveSimStateWatcher.unmarkOrcaManaged(shutdownUdid)
|
||||
return { ok: true, deviceUdid: shutdownUdid }
|
||||
}
|
||||
|
||||
|
||||
@@ -349,7 +349,6 @@ import {
|
||||
buildHeadlessTabGroupSplit
|
||||
} from './headless-tab-group-split-layout'
|
||||
import { RuntimeEmulatorCommands, setEmulatorBridge } from './orca-runtime-emulator'
|
||||
import { serveSimStateWatcher } from '../emulator/serve-sim-state-watcher'
|
||||
import type { EmulatorBridge } from '../emulator/emulator-bridge'
|
||||
import { RuntimeFileCommands } from './orca-runtime-files'
|
||||
import { RuntimeGitCommands } from './orca-runtime-git'
|
||||
@@ -5882,7 +5881,6 @@ export class OrcaRuntimeService {
|
||||
// `Network: https://local.example.com:3001/`) so the workspace ports
|
||||
// panel can surface them in place of the kernel bind address.
|
||||
advertisedUrlWatcher.ingest(ptyId, data, at)
|
||||
serveSimStateWatcher.ingestPtyOutput(ptyId, data)
|
||||
// Why: reply ownership is captured per chunk, here at ingestion — the
|
||||
// same module state and tick as the hidden-gate drop sites — and rides
|
||||
// the writeChain link. A mark/setting/subscriber flip before the queued
|
||||
@@ -8705,7 +8703,6 @@ export class OrcaRuntimeService {
|
||||
|
||||
onPtyExit(ptyId: string, exitCode: number): void {
|
||||
advertisedUrlWatcher.unbindPty(ptyId)
|
||||
serveSimStateWatcher.unbindPty(ptyId)
|
||||
// Clean up new mobile state for this PTY
|
||||
this.mobileSubscribers.delete(ptyId)
|
||||
this.remoteTerminalViewSubscriberCounts.delete(ptyId)
|
||||
@@ -17608,7 +17605,6 @@ export class OrcaRuntimeService {
|
||||
// purge history and process-local caches before the ID points at new state.
|
||||
store.removeWorktreeMeta(worktreeId)
|
||||
advertisedUrlWatcher.forgetWorktree(worktreeId)
|
||||
serveSimStateWatcher.forgetWorktree(worktreeId)
|
||||
deleteWorktreeHistoryDir(worktreeId)
|
||||
this.closeHeadlessBrowserPagesForWorktree(worktreeId)
|
||||
}
|
||||
@@ -21084,7 +21080,6 @@ export class OrcaRuntimeService {
|
||||
// Why: restored/controller-discovered PTYs learn their worktree here
|
||||
// without registerPty(), so URL enrichment must bind at this source.
|
||||
advertisedUrlWatcher.bindPty(ptyId, worktreeId)
|
||||
serveSimStateWatcher.bindPty(ptyId, worktreeId)
|
||||
return pty
|
||||
}
|
||||
|
||||
@@ -21120,7 +21115,6 @@ export class OrcaRuntimeService {
|
||||
// Why: recordPtyWorktree is the common lifecycle point for every path that
|
||||
// resolves a PTY's worktree, including renderer restore and controller list.
|
||||
advertisedUrlWatcher.bindPty(ptyId, worktreeId)
|
||||
serveSimStateWatcher.bindPty(ptyId, worktreeId)
|
||||
return pty
|
||||
}
|
||||
|
||||
@@ -21233,8 +21227,6 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
private dropDisconnectedPtyRecord(ptyId: string): void {
|
||||
// Why: pruning can remove a PTY without the normal exit callback.
|
||||
serveSimStateWatcher.unbindPty(ptyId)
|
||||
this.ptysById.delete(ptyId)
|
||||
this.recentPtyOutputById.delete(ptyId)
|
||||
this.clearWaitBlockedCheckState(ptyId)
|
||||
@@ -25726,18 +25718,6 @@ export class OrcaRuntimeService {
|
||||
emulatorUnregisterActive: RuntimeEmulatorCommands['emulatorUnregisterActive'] =
|
||||
this.emulatorCommands.emulatorUnregisterActive.bind(this.emulatorCommands)
|
||||
|
||||
// Why: serve-sim-state-watcher runs from main/index.ts startup; keep window IPC behind runtime (getAuthoritativeWindow is private).
|
||||
notifyEmulatorAutoAttachFromWatcher(
|
||||
worktreeId: string,
|
||||
info: { deviceUdid: string; streamUrl: string; wsUrl: string; axUrl?: string }
|
||||
): void {
|
||||
try {
|
||||
this.getAuthoritativeWindow().webContents.send('ui:emulatorAutoAttach', { worktreeId, info })
|
||||
} catch {
|
||||
// Window may not exist during shutdown
|
||||
}
|
||||
}
|
||||
|
||||
private getAuthoritativeWindow(): BrowserWindow {
|
||||
const win = this.getAvailableAuthoritativeWindow()
|
||||
if (!win || win.isDestroyed()) {
|
||||
|
||||
Reference in New Issue
Block a user