mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
Merge remote-tracking branch 'origin/main' into fix/renderer-crash-white-screen
# Conflicts: # config/reliability-gates.jsonc # src/main/runtime/orca-runtime.ts
This commit is contained in:
@@ -168,6 +168,7 @@ export function formatCliStatus(status: CliStatusResult): string {
|
||||
return [
|
||||
`appRunning: ${status.app.running}`,
|
||||
`pid: ${status.app.pid ?? 'none'}`,
|
||||
`desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`,
|
||||
`runtimeState: ${status.runtime.state}`,
|
||||
`runtimeReachable: ${status.runtime.reachable}`,
|
||||
`runtimeId: ${status.runtime.runtimeId ?? 'none'}`,
|
||||
|
||||
@@ -2,13 +2,19 @@ import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer, type Socket } from 'node:net'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RuntimeClient, RuntimeRpcFailureError } from './runtime-client'
|
||||
import { launchOrcaApp } from './runtime/launch'
|
||||
|
||||
vi.mock('./runtime/launch', () => ({
|
||||
launchOrcaApp: vi.fn()
|
||||
}))
|
||||
|
||||
const servers = new Set<ReturnType<typeof createServer>>()
|
||||
const sockets = new Set<Socket>()
|
||||
|
||||
afterEach(async () => {
|
||||
vi.mocked(launchOrcaApp).mockClear()
|
||||
for (const socket of sockets) {
|
||||
socket.destroy()
|
||||
}
|
||||
@@ -170,7 +176,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => {
|
||||
expect(status.result.graph.state).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('openOrca succeeds immediately when the runtime is already reachable', async () => {
|
||||
it('openOrca activates the app even when a desktop runtime is already reachable', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
@@ -204,6 +210,87 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => {
|
||||
|
||||
expect(status.result.runtime.state).toBe('ready')
|
||||
expect(status.result.runtime.reachable).toBe(true)
|
||||
expect(launchOrcaApp).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('openOrca waits for a reachable headless runtime to expose a desktop window', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
let statusRequests = 0
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as { id: string }
|
||||
statusRequests += 1
|
||||
const available = statusRequests > 1
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-1',
|
||||
rendererGraphEpoch: available ? 1 : 0,
|
||||
graphStatus: available ? 'reloading' : 'ready',
|
||||
authoritativeWindowId: available ? 1 : 0,
|
||||
desktopWindowStatus: available ? 'available' : 'initializing',
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 100)
|
||||
const status = await client.openOrca(1_000)
|
||||
|
||||
expect(launchOrcaApp).toHaveBeenCalledOnce()
|
||||
expect(status.result.app.desktopWindowStatus).toBe('available')
|
||||
expect(statusRequests).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('openOrca fails explicitly when the serve owner cannot promote safely', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as { id: string }
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-1',
|
||||
rendererGraphEpoch: 0,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: 0,
|
||||
desktopWindowStatus: 'blocked',
|
||||
liveTabCount: 1,
|
||||
liveLeafCount: 1
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 100)
|
||||
|
||||
await expect(client.openOrca(100)).rejects.toMatchObject({
|
||||
code: 'desktop_activation_blocked'
|
||||
})
|
||||
// A blocked runtime can't promote, so we bail before spawning the app.
|
||||
expect(launchOrcaApp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('times out if the runtime never responds', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { parsePairingCode, type PairingOffer } from '../../shared/pairing'
|
||||
import { launchOrcaApp } from './launch'
|
||||
import { getDefaultUserDataPath, readMetadata } from './metadata'
|
||||
import { getCliStatus } from './status'
|
||||
import { getCliStatus, resolveDesktopWindowStatus } from './status'
|
||||
import { sendRequest } from './transport'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
|
||||
import { sendWebSocketRequest } from './websocket-transport'
|
||||
@@ -115,7 +115,13 @@ export class RuntimeClient {
|
||||
// that this client machine has a local Orca desktop process.
|
||||
app: {
|
||||
running: false,
|
||||
pid: null
|
||||
pid: null,
|
||||
// Why: reuse the shared resolver so remote status honors the same
|
||||
// authoritativeWindowId fallback as local status for old runtimes.
|
||||
...(() => {
|
||||
const desktopWindowStatus = resolveDesktopWindowStatus(response.result)
|
||||
return desktopWindowStatus ? { desktopWindowStatus } : {}
|
||||
})()
|
||||
},
|
||||
runtime: {
|
||||
state: graphState === 'ready' ? 'ready' : 'graph_not_ready',
|
||||
@@ -169,15 +175,27 @@ export class RuntimeClient {
|
||||
|
||||
async openOrca(timeoutMs = 15_000): Promise<RuntimeRpcSuccess<CliStatusResult>> {
|
||||
const initial = await this.getCliStatus()
|
||||
if (initial.result.runtime.reachable) {
|
||||
if (this.remotePairing) {
|
||||
return initial
|
||||
}
|
||||
|
||||
// Why: a blocked runtime can't open a window, so spawning the app would
|
||||
// only hit the single-instance lock and exit — bail before launching.
|
||||
if (initial.result.app.desktopWindowStatus === 'blocked') {
|
||||
throwDesktopActivationBlocked()
|
||||
}
|
||||
launchOrcaApp()
|
||||
if (initial.result.app.desktopWindowStatus === 'available') {
|
||||
return initial
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const status = await this.getCliStatus()
|
||||
if (status.result.runtime.reachable) {
|
||||
if (status.result.app.desktopWindowStatus === 'blocked') {
|
||||
throwDesktopActivationBlocked()
|
||||
}
|
||||
if (status.result.app.desktopWindowStatus === 'available') {
|
||||
return status
|
||||
}
|
||||
await delay(250)
|
||||
@@ -185,11 +203,18 @@ export class RuntimeClient {
|
||||
|
||||
throw new RuntimeClientError(
|
||||
'runtime_open_timeout',
|
||||
'Timed out waiting for Orca to start. Run the Orca app manually and try again.'
|
||||
'Timed out waiting for an Orca desktop window. The runtime may still be running headlessly.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function throwDesktopActivationBlocked(): never {
|
||||
throw new RuntimeClientError(
|
||||
'desktop_activation_blocked',
|
||||
'Orca is running headlessly, but it cannot open a desktop window safely because the persistent terminal provider is unavailable. Quit Orca normally and start the app again; do not use open -n.'
|
||||
)
|
||||
}
|
||||
|
||||
function resolveRemotePairing(
|
||||
userDataPath: string,
|
||||
pairingCode: string | null,
|
||||
|
||||
@@ -35,10 +35,12 @@ export async function getCliStatus(
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
}
|
||||
const graphState = response.result.graphStatus
|
||||
const desktopWindowStatus = resolveDesktopWindowStatus(response.result)
|
||||
return buildCliStatusResponse({
|
||||
app: {
|
||||
running: true,
|
||||
pid: metadata.pid
|
||||
pid: metadata.pid,
|
||||
...(desktopWindowStatus ? { desktopWindowStatus } : {})
|
||||
},
|
||||
runtime: {
|
||||
state: graphState === 'ready' ? 'ready' : 'graph_not_ready',
|
||||
@@ -68,6 +70,19 @@ export async function getCliStatus(
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDesktopWindowStatus(
|
||||
status: RuntimeStatus
|
||||
): CliStatusResult['app']['desktopWindowStatus'] {
|
||||
if (status.desktopWindowStatus) {
|
||||
return status.desktopWindowStatus
|
||||
}
|
||||
// Why: older desktop runtimes predate the explicit status but a positive
|
||||
// Electron id still proves that a real window owns the graph.
|
||||
return status.authoritativeWindowId !== null && status.authoritativeWindowId > 0
|
||||
? 'available'
|
||||
: undefined
|
||||
}
|
||||
|
||||
function buildCliStatusResponse(result: CliStatusResult): RuntimeRpcSuccess<CliStatusResult> {
|
||||
return {
|
||||
id: 'local-status',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createServer, type Server } from 'node:http'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import { encodePairingOffer, type PairingOffer } from '../../shared/pairing'
|
||||
import {
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
publicKeyToBase64
|
||||
} from '../../shared/e2ee-crypto'
|
||||
import { RuntimeClient } from './client'
|
||||
import { launchOrcaApp } from './launch'
|
||||
import { addEnvironmentFromPairingCode } from './environments'
|
||||
import { RuntimeClientError } from './types'
|
||||
import {
|
||||
@@ -20,6 +21,10 @@ import {
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../shared/protocol-version'
|
||||
|
||||
vi.mock('./launch', () => ({
|
||||
launchOrcaApp: vi.fn()
|
||||
}))
|
||||
|
||||
type TestRuntime = {
|
||||
endpoint: string
|
||||
publicKeyB64: string
|
||||
@@ -31,6 +36,7 @@ describe('CLI remote WebSocket transport', () => {
|
||||
const servers: TestRuntime[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
vi.mocked(launchOrcaApp).mockClear()
|
||||
await Promise.all(servers.splice(0).map((server) => server.close()))
|
||||
})
|
||||
|
||||
@@ -79,6 +85,28 @@ describe('CLI remote WebSocket transport', () => {
|
||||
expect(status.result.runtime.runtimeId).toBe('runtime-ws-2')
|
||||
})
|
||||
|
||||
it('does not launch a local desktop app for remote-paired open', async () => {
|
||||
const runtime = await startTestRuntime('runtime-remote-headless', {
|
||||
desktopWindowStatus: 'initializing'
|
||||
})
|
||||
servers.push(runtime)
|
||||
const client = new RuntimeClient(
|
||||
'/tmp/unused',
|
||||
5_000,
|
||||
encodePairingOffer({
|
||||
v: 2,
|
||||
endpoint: runtime.endpoint,
|
||||
deviceToken: runtime.deviceToken,
|
||||
publicKeyB64: runtime.publicKeyB64
|
||||
})
|
||||
)
|
||||
|
||||
const status = await client.openOrca()
|
||||
|
||||
expect(status.result.app.desktopWindowStatus).toBe('initializing')
|
||||
expect(launchOrcaApp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('connects through a saved environment selector', async () => {
|
||||
const runtime = await startTestRuntime('runtime-env-1')
|
||||
servers.push(runtime)
|
||||
@@ -128,6 +156,7 @@ async function startTestRuntime(
|
||||
statusOverrides: {
|
||||
runtimeProtocolVersion?: number
|
||||
minCompatibleRuntimeClientVersion?: number
|
||||
desktopWindowStatus?: 'available' | 'openable' | 'initializing' | 'blocked'
|
||||
} = {}
|
||||
): Promise<TestRuntime> {
|
||||
const serverKeyPair = generateKeyPair()
|
||||
@@ -177,6 +206,7 @@ async function startTestRuntime(
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: null,
|
||||
desktopWindowStatus: statusOverrides.desktopWindowStatus,
|
||||
liveTabCount: 0,
|
||||
liveLeafCount: 0,
|
||||
runtimeProtocolVersion:
|
||||
|
||||
@@ -2,7 +2,11 @@ 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 { ServeSimStateWatcher, type ServeSimStateDetectedEvent } from './serve-sim-state-watcher'
|
||||
import {
|
||||
SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS,
|
||||
ServeSimStateWatcher,
|
||||
type ServeSimStateDetectedEvent
|
||||
} from './serve-sim-state-watcher'
|
||||
|
||||
const TEST_UDID = '11111111-2222-3333-4444-555555555555'
|
||||
|
||||
@@ -45,7 +49,9 @@ describe('ServeSimStateWatcher', () => {
|
||||
const parentDir = await mkdtemp(join(tmpdir(), 'orca-serve-sim-watch-'))
|
||||
cleanupPaths.push(parentDir)
|
||||
const stateDir = join(parentDir, 'serve-sim')
|
||||
const watcher = new ServeSimStateWatcher({ stateDir })
|
||||
// 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')
|
||||
@@ -78,6 +84,29 @@ describe('ServeSimStateWatcher', () => {
|
||||
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
|
||||
|
||||
@@ -23,6 +23,10 @@ export type ServeSimStateDetectedEvent = {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -73,6 +77,8 @@ function trailingIncompletePtyJsonObject(data: string): string {
|
||||
|
||||
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>()
|
||||
@@ -81,8 +87,12 @@ export class ServeSimStateWatcher {
|
||||
private stateWatcher: FSWatcher | null = null
|
||||
private stateDirPoll: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
constructor(options: { stateDir?: string } = {}) {
|
||||
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 {
|
||||
@@ -171,6 +181,13 @@ export class ServeSimStateWatcher {
|
||||
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
|
||||
@@ -182,13 +199,16 @@ export class ServeSimStateWatcher {
|
||||
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()
|
||||
}, 250)
|
||||
}, this.existencePollMs)
|
||||
this.stateDirPoll.unref?.()
|
||||
} catch {
|
||||
// Non-mac or permission issues: watcher is best-effort.
|
||||
// Permission issues: watcher is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+65
-56
@@ -92,7 +92,8 @@ import {
|
||||
acquireSingleInstanceLock,
|
||||
logSingleInstanceLockBypass,
|
||||
logSingleInstanceLockFailure,
|
||||
shouldBypassSingleInstanceLock
|
||||
shouldBypassSingleInstanceLock,
|
||||
shouldSkipSingleInstanceLock
|
||||
} from './startup/single-instance-lock'
|
||||
import { startEventLoopStallProbe } from './startup/event-loop-stall-probe'
|
||||
import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe'
|
||||
@@ -103,6 +104,7 @@ import {
|
||||
} from './startup/startup-diagnostics'
|
||||
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
|
||||
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
|
||||
import { createServeDesktopActivationGate } from './startup/serve-desktop-activation'
|
||||
import { RateLimitService } from './rate-limits/service'
|
||||
import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store'
|
||||
import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target'
|
||||
@@ -183,6 +185,11 @@ import {
|
||||
import type { AgentStatusState } from '../shared/agent-status-types'
|
||||
import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions'
|
||||
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
|
||||
import {
|
||||
HEADLESS_RUNTIME_WINDOW_ID,
|
||||
type RuntimeDesktopWindowStatus
|
||||
} from '../shared/runtime-types'
|
||||
import { LocalPtyProvider } from './providers/local-pty-provider'
|
||||
import { KeybindingService } from './keybindings/keybinding-service'
|
||||
import { applyElectronProxySettings } from './network/proxy-settings'
|
||||
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
|
||||
@@ -244,6 +251,16 @@ let gpuFallbackActiveThisLaunch = false
|
||||
let localPtyStartupReady: Promise<void> = Promise.resolve()
|
||||
const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000
|
||||
const isServeMode = process.argv.includes('--serve')
|
||||
const desktopActivationGate = createServeDesktopActivationGate({
|
||||
initialState: isServeMode ? 'initializing' : 'ready',
|
||||
activateWindow: () => {
|
||||
// Why: an updater replacement must not resurrect the old app bundle.
|
||||
if (!isQuittingForUpdate()) {
|
||||
focusExistingWindow()
|
||||
}
|
||||
},
|
||||
onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`)
|
||||
})
|
||||
// Why: on Windows a CLI-shaped launch (Orca.exe <unpacked CLI entry>) that lost
|
||||
// ELECTRON_RUN_AS_NODE would otherwise boot the GUI, lose the single-instance
|
||||
// lock to a running window, and exit silently. Redirect it to node mode here,
|
||||
@@ -461,6 +478,23 @@ function focusExistingWindow(): void {
|
||||
})
|
||||
}
|
||||
|
||||
function requestDesktopActivation(): void {
|
||||
desktopActivationGate.requestActivation()
|
||||
}
|
||||
|
||||
function getDesktopWindowStatus(): RuntimeDesktopWindowStatus {
|
||||
const state = desktopActivationGate.getState()
|
||||
return state === 'ready' ? 'openable' : state
|
||||
}
|
||||
|
||||
function settleServeDesktopActivation(): void {
|
||||
if (getLocalPtyProvider() instanceof LocalPtyProvider) {
|
||||
desktopActivationGate.markBlocked('persistent PTY provider unavailable')
|
||||
return
|
||||
}
|
||||
desktopActivationGate.markReady()
|
||||
}
|
||||
|
||||
// Why: a webContents-scoped flag that auto-expires so an intent set for one renderer
|
||||
// can't leak to a later load. `consume` clears on a positive match for one-shot
|
||||
// signals (the recovery reload fires exactly one did-finish-load).
|
||||
@@ -551,22 +585,25 @@ const bypassSingleInstanceLock = shouldBypassSingleInstanceLock({
|
||||
isDev: is.dev,
|
||||
isServeMode
|
||||
})
|
||||
const skipSingleInstanceLock = shouldSkipSingleInstanceLock({
|
||||
isDev: is.dev,
|
||||
isServeMode
|
||||
})
|
||||
if (bypassSingleInstanceLock) {
|
||||
// Why: this is an explicit diagnostic escape hatch for macOS builds where
|
||||
// Electron reports a false lock loss before any normal app logs exist.
|
||||
logSingleInstanceLockBypass()
|
||||
}
|
||||
const hasSingleInstanceLock =
|
||||
is.dev && !isServeMode
|
||||
const hasSingleInstanceLock = skipSingleInstanceLock
|
||||
? true
|
||||
: bypassSingleInstanceLock
|
||||
? true
|
||||
: bypassSingleInstanceLock
|
||||
? true
|
||||
: acquireSingleInstanceLock(app, focusExistingWindow)
|
||||
: acquireSingleInstanceLock(app, requestDesktopActivation)
|
||||
if (startupDiagnosticsEnabled) {
|
||||
logStartupDiagnostic('single-instance-lock-result', {
|
||||
acquired: hasSingleInstanceLock,
|
||||
bypassed: bypassSingleInstanceLock,
|
||||
skippedForDev: is.dev && !isServeMode
|
||||
skippedForDev: skipSingleInstanceLock
|
||||
})
|
||||
}
|
||||
if (!hasSingleInstanceLock) {
|
||||
@@ -633,12 +670,11 @@ ipcMain.handle(
|
||||
}
|
||||
)
|
||||
|
||||
function startDesktopFirstWindowStartupServices(): Promise<void> {
|
||||
function startTerminalRuntimeStartupServices(): Promise<void> {
|
||||
logStartupMilestone('first-window-startup-services-start')
|
||||
const startupServices = startFirstWindowStartupServices({
|
||||
// Why: the persistent-terminal daemon is desktop-only. Headless `orca serve`
|
||||
// registers its PTY runtime separately and must not spawn the desktop daemon
|
||||
// or hook loopback listener.
|
||||
// Why: desktop and headless serve must adopt the same persistent provider
|
||||
// before either path is allowed to create terminals or a renderer.
|
||||
startDaemonPtyProvider: async (signal) => {
|
||||
logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' })
|
||||
await initDaemonPtyProvider(signal)
|
||||
@@ -647,6 +683,9 @@ function startDesktopFirstWindowStartupServices(): Promise<void> {
|
||||
// Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state, so
|
||||
// the renderer awaits this barrier before restored terminals reconnect.
|
||||
startAgentHookServer: async () => {
|
||||
if (!isAgentStatusHooksEnabled(store?.getSettings())) {
|
||||
return
|
||||
}
|
||||
logStartupMilestone('startup-service-start', { service: 'agent-hook-server' })
|
||||
await agentHookServer.start({
|
||||
env: app.isPackaged ? 'production' : 'development',
|
||||
@@ -687,23 +726,6 @@ function startDesktopFirstWindowStartupServices(): Promise<void> {
|
||||
return firstWindowStartupServicesReady
|
||||
}
|
||||
|
||||
async function startServeAgentHookServer(): Promise<void> {
|
||||
if (!isAgentStatusHooksEnabled(store?.getSettings())) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await agentHookServer.start({
|
||||
env: app.isPackaged ? 'production' : 'development',
|
||||
userDataPath: app.getPath('userData'),
|
||||
endpointNamespace: devAgentHookEndpointNamespace
|
||||
})
|
||||
} catch (error) {
|
||||
// Why: remote hook callbacks enrich agent status only. A headless runtime
|
||||
// should still serve terminals if the loopback receiver cannot bind.
|
||||
console.error('[agent-hooks] Failed to start serve hook server:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null {
|
||||
const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target)
|
||||
const hookTarget =
|
||||
@@ -1791,20 +1813,14 @@ app.whenReady().then(async () => {
|
||||
onTerminalAgentStatus: (event) => {
|
||||
agentHookServer.ingestTerminalStatus(event)
|
||||
},
|
||||
// Why: derived title/bell/agent facts ride one batched main→renderer
|
||||
// channel (terminal-side-effect-authority.md). The renderer's authority
|
||||
// kill switch decides whether to consume. Headless serve never creates a
|
||||
// window, so the dep is omitted entirely — the runtime then skips fact
|
||||
// batch construction and the per-chunk bell walk.
|
||||
...(isServeMode
|
||||
? {}
|
||||
: {
|
||||
onTerminalSideEffects: (batch: TerminalSideEffectBatch) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('pty:sideEffect', batch)
|
||||
}
|
||||
}
|
||||
}),
|
||||
// Why: serve can be promoted in place, so keep the listener wired from
|
||||
// startup; runtime enables desktop-only scanners only for a ready renderer.
|
||||
onTerminalSideEffects: (batch: TerminalSideEffectBatch) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('pty:sideEffect', batch)
|
||||
}
|
||||
},
|
||||
getDesktopWindowStatus: getDesktopWindowStatus,
|
||||
// Why: hook-reported agent status is the same source the desktop sidebar
|
||||
// reads. worktree.ps pulls it at query time so mobile shows the same agents.
|
||||
getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot(),
|
||||
@@ -2095,9 +2111,8 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
registerMobileHandlers(runtimeRpc)
|
||||
|
||||
if (!isServeMode) {
|
||||
startDesktopFirstWindowStartupServices()
|
||||
}
|
||||
startTerminalRuntimeStartupServices()
|
||||
app.on('activate', requestDesktopActivation)
|
||||
|
||||
if (serveOptions) {
|
||||
// Why: give managed WSL launchers a brief chance to migrate before headless
|
||||
@@ -2107,7 +2122,9 @@ app.whenReady().then(async () => {
|
||||
logStartupMilestone('wsl-cli-barrier-resolved', {
|
||||
reconciliation: managedWslCliReconciliationStatus
|
||||
})
|
||||
await startServeAgentHookServer()
|
||||
// Why: headless PTYs must never start on the fallback provider and then be
|
||||
// swept when an activated renderer registers desktop lifecycle handlers.
|
||||
await localPtyStartupReady
|
||||
registerHeadlessPtyRuntime(
|
||||
runtime,
|
||||
prepareCodexRuntimeHomeForLaunch,
|
||||
@@ -2125,11 +2142,12 @@ app.whenReady().then(async () => {
|
||||
// Why: headless servers have no renderer graph publisher. Publish an
|
||||
// explicit empty graph so status clients see a ready server while
|
||||
// renderer-only operations still fail at their own window boundary.
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
|
||||
await runtimeRpc.start().catch((error) => {
|
||||
console.error('[runtime] Failed to start headless RPC transport:', error)
|
||||
throw error
|
||||
})
|
||||
settleServeDesktopActivation()
|
||||
installServeSignalHandlers()
|
||||
// Why: the orca CLI command is normally installed by the renderer onboarding /
|
||||
// Settings "Install CLI" flow via the cli:install IPC. Headless serve has no
|
||||
@@ -2212,15 +2230,6 @@ app.whenReady().then(async () => {
|
||||
triggerStartupNotificationRegistration(store)
|
||||
}
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
// Don't re-open a window while Squirrel's ShipIt is replacing the .app
|
||||
// bundle. Without this guard the old version gets resurrected and the
|
||||
// update never applies.
|
||||
if (BrowserWindow.getAllWindows().length === 0 && !isQuittingForUpdate()) {
|
||||
openMainWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({
|
||||
handleMock: vi.fn(),
|
||||
getSshFilesystemProviderMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: handleMock }
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({ stat: vi.fn() }))
|
||||
vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() }))
|
||||
vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() }))
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: getSshFilesystemProviderMock
|
||||
}))
|
||||
|
||||
import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher'
|
||||
|
||||
type HandlerMap = Record<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>
|
||||
|
||||
describe('remote filesystem watcher cancellation', () => {
|
||||
const handlers: HandlerMap = {}
|
||||
|
||||
beforeEach(async () => {
|
||||
handleMock.mockReset()
|
||||
getSshFilesystemProviderMock.mockReset()
|
||||
for (const key of Object.keys(handlers)) {
|
||||
delete handlers[key]
|
||||
}
|
||||
handleMock.mockImplementation((channel, handler) => {
|
||||
handlers[channel] = handler
|
||||
})
|
||||
registerFilesystemWatcherHandlers()
|
||||
await closeAllWatchers()
|
||||
})
|
||||
|
||||
it('aborts pending SSH setup after the last same-root listener leaves and cleans late success', async () => {
|
||||
let installSignal: AbortSignal | undefined
|
||||
let resolveInstall: ((unwatch: () => void) => void) | undefined
|
||||
const lateUnwatch = vi.fn()
|
||||
const watchMock = vi.fn(
|
||||
(_rootPath, _callback, options?: { signal?: AbortSignal }) =>
|
||||
new Promise<() => void>((resolve) => {
|
||||
installSignal = options?.signal
|
||||
resolveInstall = resolve
|
||||
})
|
||||
)
|
||||
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
|
||||
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
|
||||
const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
||||
const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 }
|
||||
const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise<unknown>
|
||||
const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
try {
|
||||
expect(watchMock).toHaveBeenCalledTimes(1)
|
||||
expect(installSignal?.aborted).toBe(false)
|
||||
|
||||
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
|
||||
await Promise.resolve()
|
||||
expect(installSignal?.aborted).toBe(false)
|
||||
|
||||
handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args)
|
||||
await Promise.resolve()
|
||||
expect(installSignal?.aborted).toBe(true)
|
||||
} finally {
|
||||
resolveInstall?.(lateUnwatch)
|
||||
await Promise.all([first, second])
|
||||
}
|
||||
expect(lateUnwatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('starts a fresh same-root generation when a listener arrives after physical abort', async () => {
|
||||
let firstSignal: AbortSignal | undefined
|
||||
let secondCallback: ((events: unknown[]) => void) | undefined
|
||||
const secondUnwatch = vi.fn()
|
||||
const watchMock = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
(_rootPath, _callback, options?: { signal?: AbortSignal }) =>
|
||||
new Promise<() => void>((_resolve, reject) => {
|
||||
firstSignal = options?.signal
|
||||
options?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
const error = new Error('cancelled')
|
||||
error.name = 'AbortError'
|
||||
reject(error)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
)
|
||||
.mockImplementationOnce((_rootPath, callback) => {
|
||||
secondCallback = callback
|
||||
return Promise.resolve(secondUnwatch)
|
||||
})
|
||||
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
|
||||
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
|
||||
const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
||||
const secondSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 }
|
||||
const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
|
||||
await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true))
|
||||
const second = handlers['fs:watchWorktree']({ sender: secondSender }, args) as Promise<unknown>
|
||||
|
||||
await Promise.all([first, second])
|
||||
expect(watchMock).toHaveBeenCalledTimes(2)
|
||||
secondCallback?.([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }])
|
||||
expect(secondSender.send).toHaveBeenCalledTimes(1)
|
||||
|
||||
handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args)
|
||||
expect(secondUnwatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('aborts pending SSH setup on sender destruction and watcher shutdown', async () => {
|
||||
const installs = new Map<
|
||||
string,
|
||||
{ signal: AbortSignal | undefined; resolve: (unwatch: () => void) => void }
|
||||
>()
|
||||
const watchMock = vi.fn(
|
||||
(rootPath: string, _callback, options?: { signal?: AbortSignal }) =>
|
||||
new Promise<() => void>((resolve) => {
|
||||
installs.set(rootPath, { signal: options?.signal, resolve })
|
||||
})
|
||||
)
|
||||
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
|
||||
const destroyedCallbacks: (() => void)[] = []
|
||||
const destroyedSender = {
|
||||
isDestroyed: () => false,
|
||||
send: vi.fn(),
|
||||
once: vi.fn((event: string, callback: () => void) => {
|
||||
if (event === 'destroyed') {
|
||||
destroyedCallbacks.push(callback)
|
||||
}
|
||||
}),
|
||||
id: 1
|
||||
}
|
||||
const destroyedArgs = { worktreePath: '/destroyed', connectionId: 'conn-1' }
|
||||
const destroyedWatch = handlers['fs:watchWorktree'](
|
||||
{ sender: destroyedSender },
|
||||
destroyedArgs
|
||||
) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
destroyedCallbacks[0]()
|
||||
await Promise.resolve()
|
||||
expect(installs.get('/destroyed')?.signal?.aborted).toBe(true)
|
||||
installs.get('/destroyed')?.resolve(vi.fn())
|
||||
await destroyedWatch
|
||||
|
||||
const shutdownSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 }
|
||||
const shutdownArgs = { worktreePath: '/shutdown', connectionId: 'conn-1' }
|
||||
const shutdownWatch = handlers['fs:watchWorktree'](
|
||||
{ sender: shutdownSender },
|
||||
shutdownArgs
|
||||
) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
await closeAllWatchers()
|
||||
expect(installs.get('/shutdown')?.signal?.aborted).toBe(true)
|
||||
installs.get('/shutdown')?.resolve(vi.fn())
|
||||
await shutdownWatch
|
||||
})
|
||||
|
||||
it('keeps the shared install alive when a replacement sender joins before the deferred abort fires', async () => {
|
||||
let installSignal: AbortSignal | undefined
|
||||
let resolveInstall: ((unwatch: () => void) => void) | undefined
|
||||
const watchMock = vi.fn(
|
||||
(_rootPath, _callback, options?: { signal?: AbortSignal }) =>
|
||||
new Promise<() => void>((resolve) => {
|
||||
installSignal = options?.signal
|
||||
resolveInstall = resolve
|
||||
})
|
||||
)
|
||||
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
|
||||
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
|
||||
const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
||||
const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 }
|
||||
const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
expect(watchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The last listener leaves and a replacement joins in the SAME tick — before
|
||||
// the queued abort microtask runs. The shared install must survive.
|
||||
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
|
||||
const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(installSignal?.aborted).toBe(false)
|
||||
expect(watchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveInstall?.(vi.fn())
|
||||
await Promise.all([first, second])
|
||||
})
|
||||
|
||||
it('refuses a post-shutdown joiner recursion instead of resurrecting the install', async () => {
|
||||
let firstSignal: AbortSignal | undefined
|
||||
let resolveFirst: ((unwatch: () => void) => void) | undefined
|
||||
const lateUnwatch = vi.fn()
|
||||
const watchMock = vi.fn(
|
||||
(_rootPath, _callback, options?: { signal?: AbortSignal }) =>
|
||||
new Promise<() => void>((resolve) => {
|
||||
firstSignal = options?.signal
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
|
||||
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
|
||||
const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
||||
const secondSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 }
|
||||
const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
expect(watchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Last listener leaves -> deferred abort fires while the install is still pending.
|
||||
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
|
||||
await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true))
|
||||
|
||||
// Joiner arrives after physical abort (canJoinInstall === false); it awaits the
|
||||
// 'cancelled' resolution and would recurse into a fresh install.
|
||||
const second = handlers['fs:watchWorktree']({ sender: secondSender }, args) as Promise<unknown>
|
||||
await Promise.resolve()
|
||||
|
||||
// Shutdown latches the subsystem before the joiner's recursion runs.
|
||||
await closeAllWatchers()
|
||||
|
||||
// Late success of the aborted generation must be unwatched, not registered.
|
||||
resolveFirst?.(lateUnwatch)
|
||||
await Promise.all([first, second])
|
||||
|
||||
// The recursion is refused post-shutdown: provider.watch() is never called again.
|
||||
expect(watchMock).toHaveBeenCalledTimes(1)
|
||||
expect(lateUnwatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refuses a pre-shutdown joiner recursion even after a new watch reopens the subsystem', async () => {
|
||||
const installs = new Map<
|
||||
string,
|
||||
{ signal: AbortSignal | undefined; resolve: (unwatch: () => void) => void }
|
||||
>()
|
||||
const watchMock = vi.fn(
|
||||
(rootPath: string, _callback, options?: { signal?: AbortSignal }) =>
|
||||
new Promise<() => void>((resolve) => {
|
||||
installs.set(rootPath, { signal: options?.signal, resolve })
|
||||
})
|
||||
)
|
||||
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
|
||||
const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' }
|
||||
const reopenArgs = { worktreePath: '/home/me/other', connectionId: 'conn-1' }
|
||||
const lateUnwatch = vi.fn()
|
||||
const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
||||
const joinerSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 }
|
||||
const reopenSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 3 }
|
||||
const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise<unknown>
|
||||
|
||||
await Promise.resolve()
|
||||
expect(watchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Last listener leaves -> deferred abort fires while the install is still pending.
|
||||
handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args)
|
||||
await vi.waitFor(() => expect(installs.get('/home/me/repo')?.signal?.aborted).toBe(true))
|
||||
|
||||
// Joiner arrives after physical abort (canJoinInstall === false); it captures the
|
||||
// current lifecycle generation and awaits the 'cancelled' resolution.
|
||||
const joiner = handlers['fs:watchWorktree']({ sender: joinerSender }, args) as Promise<unknown>
|
||||
await Promise.resolve()
|
||||
|
||||
// Shutdown bumps the generation, then a genuine new watch reopens the subsystem
|
||||
// (clearing the boolean latch) before the joiner resumes.
|
||||
await closeAllWatchers()
|
||||
const reopen = handlers['fs:watchWorktree'](
|
||||
{ sender: reopenSender },
|
||||
reopenArgs
|
||||
) as Promise<unknown>
|
||||
await Promise.resolve()
|
||||
expect(watchMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Now let the aborted install resolve; the joiner recurses on the stale generation.
|
||||
installs.get('/home/me/repo')?.resolve(lateUnwatch)
|
||||
installs.get('/home/me/other')?.resolve(vi.fn())
|
||||
await Promise.all([first, joiner, reopen])
|
||||
|
||||
// The joiner's recursion is refused despite the reopen: no third provider.watch().
|
||||
expect(watchMock).toHaveBeenCalledTimes(2)
|
||||
expect(watchMock.mock.calls.filter(([rootPath]) => rootPath === '/home/me/repo')).toHaveLength(
|
||||
1
|
||||
)
|
||||
expect(lateUnwatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -124,7 +124,9 @@ describe('registerFilesystemWatcherHandlers', () => {
|
||||
getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock })
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function))
|
||||
expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
const onEvents = watchMock.mock.calls[0][1]
|
||||
onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }])
|
||||
expect(sendMock).toHaveBeenCalledWith('fs:changed', {
|
||||
@@ -161,7 +163,9 @@ describe('registerFilesystemWatcherHandlers', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(retryWatchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function))
|
||||
expect(retryWatchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), {
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
handlers['fs:unwatchWorktree'](
|
||||
{ sender: { id: 1 } },
|
||||
{ worktreePath: '/home/me/repo', connectionId: 'conn-1' }
|
||||
|
||||
@@ -652,22 +652,31 @@ type RemoteWatcherState = {
|
||||
type RemoteWatcherInstallToken = {
|
||||
cancelled: boolean
|
||||
listeners: Map<number, WebContents>
|
||||
abortController: AbortController
|
||||
abortScheduled: boolean
|
||||
}
|
||||
|
||||
// Key: `${connectionId}:${worktreePath}`, Value: shared remote watch state.
|
||||
const remoteWatchers = new Map<string, RemoteWatcherState>()
|
||||
const loggedUnavailableRemoteWatchers = new Set<string>()
|
||||
const pendingRemoteWatcherRetries = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
// Why: track in-flight `provider.watch()` calls so an unwatch/shutdown that
|
||||
// arrives while a watch is still resolving can mark the install cancelled.
|
||||
// Without this, the awaited unwatch handle would be installed after the
|
||||
// renderer thinks the watch is gone, leaking a native watcher.
|
||||
// Why: track in-flight `provider.watch()` calls so last-listener cleanup can
|
||||
// abort relay setup, while late success is still unwatched instead of leaked.
|
||||
const inFlightRemoteInstalls = new Map<string, RemoteWatcherInstallToken>()
|
||||
// Why: dedupe concurrent installRemoteWatcher calls for the same key so
|
||||
// overlapping fs:watchWorktree IPCs share one native watcher and one listener
|
||||
// map, instead of each call independently invoking provider.watch() and
|
||||
// overwriting the per-key state on resolution.
|
||||
const pendingRemoteInstallPromises = new Map<string, Promise<RemoteWatcherInstallResult>>()
|
||||
// Why: block installs that begin AFTER closeAllWatchers — an in-flight joiner
|
||||
// recursion or a fired retry tick calls installRemoteWatcher directly, bypassing
|
||||
// the token-abort loop. A genuine new fs:watchWorktree clears the latch.
|
||||
let remoteWatchersClosed = false
|
||||
// Why: the boolean latch alone can't tell a pre-shutdown waiter apart from a
|
||||
// fresh call once a genuine new watch reopens the subsystem. Each call captures
|
||||
// the generation at entry; closeAllWatchers bumps it, so a joiner that awaited
|
||||
// across a shutdown+reopen recurses on a stale generation and is refused.
|
||||
let remoteWatcherLifecycleGeneration = 0
|
||||
const REMOTE_WATCH_RETRY_MS = 1_000
|
||||
const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000
|
||||
|
||||
@@ -675,7 +684,7 @@ function addInFlightRemoteInstallListener(
|
||||
token: RemoteWatcherInstallToken,
|
||||
sender: WebContents
|
||||
): void {
|
||||
if (sender.isDestroyed()) {
|
||||
if (sender.isDestroyed() || token.abortController.signal.aborted) {
|
||||
return
|
||||
}
|
||||
token.listeners.set(sender.id, sender)
|
||||
@@ -683,12 +692,26 @@ function addInFlightRemoteInstallListener(
|
||||
registerSenderCleanup(sender)
|
||||
}
|
||||
|
||||
function cancelInFlightRemoteInstallIfUnowned(token: RemoteWatcherInstallToken): void {
|
||||
token.cancelled = token.listeners.size === 0
|
||||
if (!token.cancelled || token.abortScheduled || token.abortController.signal.aborted) {
|
||||
return
|
||||
}
|
||||
token.abortScheduled = true
|
||||
// Why: a replacement sender can synchronously revive the shared install
|
||||
// during a renderer handoff; otherwise stop the relay crawl next microtask.
|
||||
queueMicrotask(() => {
|
||||
token.abortScheduled = false
|
||||
if (token.cancelled && token.listeners.size === 0) {
|
||||
token.abortController.abort()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function cleanupInFlightRemoteInstallsForSender(senderId: number): void {
|
||||
for (const token of inFlightRemoteInstalls.values()) {
|
||||
token.listeners.delete(senderId)
|
||||
if (token.listeners.size === 0) {
|
||||
token.cancelled = true
|
||||
}
|
||||
cancelInFlightRemoteInstallIfUnowned(token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,8 +749,16 @@ type RemoteWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled'
|
||||
async function installRemoteWatcher(
|
||||
sender: WebContents,
|
||||
connectionId: string,
|
||||
worktreePath: string
|
||||
worktreePath: string,
|
||||
generation = remoteWatcherLifecycleGeneration
|
||||
): Promise<RemoteWatcherInstallResult> {
|
||||
// Why: refuse installs racing in after teardown (joiner recursion, fired retry
|
||||
// tick) so provider.watch() is never called and registered post-shutdown. The
|
||||
// generation guard also refuses a waiter that captured an earlier lifecycle,
|
||||
// even after a new watch reopened the subsystem.
|
||||
if (remoteWatchersClosed || generation !== remoteWatcherLifecycleGeneration) {
|
||||
return 'cancelled'
|
||||
}
|
||||
const provider = getSshFilesystemProvider(connectionId)
|
||||
if (!provider || sender.isDestroyed()) {
|
||||
return 'unavailable'
|
||||
@@ -747,7 +778,8 @@ async function installRemoteWatcher(
|
||||
const pendingInstall = pendingRemoteInstallPromises.get(key)
|
||||
if (pendingInstall) {
|
||||
const inFlight = inFlightRemoteInstalls.get(key)
|
||||
if (inFlight) {
|
||||
const canJoinInstall = inFlight && !inFlight.abortController.signal.aborted
|
||||
if (canJoinInstall) {
|
||||
// Why: a new watcher can join after all previous pending listeners
|
||||
// unwatched but before provider.watch() resolves; revive that install
|
||||
// instead of inheriting the stale cancellation.
|
||||
@@ -762,9 +794,27 @@ async function installRemoteWatcher(
|
||||
) {
|
||||
addRemoteWatchListener(key, sender)
|
||||
}
|
||||
if (
|
||||
result === 'cancelled' &&
|
||||
!canJoinInstall &&
|
||||
!sender.isDestroyed() &&
|
||||
generation === remoteWatcherLifecycleGeneration
|
||||
) {
|
||||
// Why: AbortSignal cannot be revived. A listener arriving after physical
|
||||
// cancellation waits out that generation, then owns a fresh install.
|
||||
if (pendingRemoteInstallPromises.get(key) === pendingInstall) {
|
||||
pendingRemoteInstallPromises.delete(key)
|
||||
}
|
||||
return installRemoteWatcher(sender, connectionId, worktreePath, generation)
|
||||
}
|
||||
return result
|
||||
}
|
||||
const cancelToken: RemoteWatcherInstallToken = { cancelled: false, listeners: new Map() }
|
||||
const cancelToken: RemoteWatcherInstallToken = {
|
||||
cancelled: false,
|
||||
listeners: new Map(),
|
||||
abortController: new AbortController(),
|
||||
abortScheduled: false
|
||||
}
|
||||
inFlightRemoteInstalls.set(key, cancelToken)
|
||||
addInFlightRemoteInstallListener(cancelToken, sender)
|
||||
const installPromise = doInstallRemoteWatcher(provider, key, worktreePath, cancelToken)
|
||||
@@ -786,22 +836,29 @@ async function doInstallRemoteWatcher(
|
||||
): Promise<RemoteWatcherInstallResult> {
|
||||
let unwatch: () => void
|
||||
try {
|
||||
unwatch = await provider.watch(worktreePath, (events) => {
|
||||
const state = remoteWatchers.get(key)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
for (const listener of state.listeners.values()) {
|
||||
if (listener.isDestroyed()) {
|
||||
continue
|
||||
unwatch = await provider.watch(
|
||||
worktreePath,
|
||||
(events) => {
|
||||
const state = remoteWatchers.get(key)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
listener.send('fs:changed', {
|
||||
worktreePath,
|
||||
events
|
||||
} satisfies FsChangedPayload)
|
||||
}
|
||||
})
|
||||
for (const listener of state.listeners.values()) {
|
||||
if (listener.isDestroyed()) {
|
||||
continue
|
||||
}
|
||||
listener.send('fs:changed', {
|
||||
worktreePath,
|
||||
events
|
||||
} satisfies FsChangedPayload)
|
||||
}
|
||||
},
|
||||
{ signal: cancelToken.abortController.signal }
|
||||
)
|
||||
} catch (err) {
|
||||
if (cancelToken.cancelled || cancelToken.abortController.signal.aborted) {
|
||||
return 'cancelled'
|
||||
}
|
||||
console.warn(`[filesystem-watcher] SSH watcher unavailable for ${key}:`, err)
|
||||
return 'unavailable'
|
||||
} finally {
|
||||
@@ -884,6 +941,9 @@ export function registerFilesystemWatcherHandlers(): void {
|
||||
'fs:watchWorktree',
|
||||
async (event, args: { worktreePath: string; connectionId?: string }): Promise<void> => {
|
||||
if (args.connectionId) {
|
||||
// Why: a real new watch reopens the subsystem after closeAllWatchers
|
||||
// latched it shut (also how tests reset between cases).
|
||||
remoteWatchersClosed = false
|
||||
const key = `${args.connectionId}:${args.worktreePath}`
|
||||
const result = await installRemoteWatcher(
|
||||
event.sender,
|
||||
@@ -923,7 +983,7 @@ export function registerFilesystemWatcherHandlers(): void {
|
||||
const inFlight = inFlightRemoteInstalls.get(key)
|
||||
if (inFlight) {
|
||||
inFlight.listeners.delete(_event.sender.id)
|
||||
inFlight.cancelled = inFlight.listeners.size === 0
|
||||
cancelInFlightRemoteInstallIfUnowned(inFlight)
|
||||
}
|
||||
loggedUnavailableRemoteWatchers.delete(key)
|
||||
releaseRemoteWatchListener(key, _event?.sender?.id ?? 0)
|
||||
@@ -951,10 +1011,19 @@ export async function closeAllWatchers(): Promise<void> {
|
||||
}
|
||||
pendingRemoteWatcherRetries.clear()
|
||||
loggedUnavailableRemoteWatchers.clear()
|
||||
// Why: latch the subsystem shut and drop the dedup map so a late install that
|
||||
// begins after teardown is refused instead of registering post-shutdown. Bump
|
||||
// the generation so a waiter that resumes after a later reopen still recurses
|
||||
// on a stale lifecycle and is refused.
|
||||
remoteWatchersClosed = true
|
||||
remoteWatcherLifecycleGeneration += 1
|
||||
pendingRemoteInstallPromises.clear()
|
||||
// Why: cancel any in-flight provider.watch() calls so their resolved
|
||||
// unwatch handles are discarded instead of being installed after shutdown.
|
||||
for (const token of inFlightRemoteInstalls.values()) {
|
||||
token.listeners.clear()
|
||||
token.cancelled = true
|
||||
token.abortController.abort()
|
||||
}
|
||||
for (const token of inFlightLocalInstalls.values()) {
|
||||
token.listeners.clear()
|
||||
|
||||
@@ -162,6 +162,30 @@ describe('RuntimeWatcherProcessPool', () => {
|
||||
expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow'])
|
||||
})
|
||||
|
||||
it('allows only one quarantine generation when isolated setup also times out', async () => {
|
||||
const timeout = new WatcherProcessFailure(
|
||||
'file watcher subscription timed out',
|
||||
'subscription',
|
||||
'subscribe_timeout'
|
||||
)
|
||||
pool = new RuntimeWatcherProcessPool({
|
||||
maxSharedSupervisors: 1,
|
||||
createSupervisor: () => {
|
||||
const supervisor = new FakeSupervisor()
|
||||
supervisor.subscribeError = timeout
|
||||
supervisors.push(supervisor)
|
||||
return supervisor
|
||||
}
|
||||
})
|
||||
|
||||
await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toBe(timeout)
|
||||
await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toBe(timeout)
|
||||
await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toMatchObject({
|
||||
code: 'supervisor_crash_fuse'
|
||||
})
|
||||
expect(supervisors).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('moves a live root into quarantine when crash resubscription times out', async () => {
|
||||
const timeout = new WatcherProcessFailure(
|
||||
'file watcher resubscription timed out',
|
||||
@@ -179,6 +203,37 @@ describe('RuntimeWatcherProcessPool', () => {
|
||||
expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow-recovery'])
|
||||
})
|
||||
|
||||
it('does not replace an isolated live root after its resubscription times out', async () => {
|
||||
pool = new RuntimeWatcherProcessPool({
|
||||
maxSharedSupervisors: 1,
|
||||
createSupervisor: () => {
|
||||
const supervisor = new FakeSupervisor()
|
||||
supervisors.push(supervisor)
|
||||
return supervisor
|
||||
}
|
||||
})
|
||||
const fused = new WatcherProcessFailure(
|
||||
'file watcher process crashed repeatedly',
|
||||
'supervisor',
|
||||
'supervisor_crash_fuse'
|
||||
)
|
||||
const timeout = new WatcherProcessFailure(
|
||||
'file watcher resubscription timed out',
|
||||
'subscription',
|
||||
'subscribe_timeout'
|
||||
)
|
||||
await pool.subscribe('/slow-recovery', vi.fn(), {}, {})
|
||||
supervisors[0].subscriptions[0].hooks.onTerminalError?.(fused)
|
||||
await pool.subscribe('/slow-recovery', vi.fn(), {}, {})
|
||||
|
||||
supervisors[1].subscriptions[0].hooks.onTerminalError?.(timeout)
|
||||
|
||||
await expect(pool.subscribe('/slow-recovery', vi.fn(), {}, {})).rejects.toMatchObject({
|
||||
code: 'supervisor_crash_fuse'
|
||||
})
|
||||
expect(supervisors).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps healthy shard assignments after a root-specific failure', async () => {
|
||||
pool = new RuntimeWatcherProcessPool({
|
||||
maxSharedSupervisors: 1,
|
||||
|
||||
@@ -77,10 +77,7 @@ export class RuntimeWatcherProcessPool {
|
||||
if (isWatcherProcessFailure(error) && error.scope === 'supervisor') {
|
||||
this.retireSlot(slot)
|
||||
} else {
|
||||
releaseAssignment()
|
||||
if (isWatcherProcessFailure(error) && error.code === 'subscribe_timeout') {
|
||||
this.isolatedRoots.add(dir)
|
||||
}
|
||||
this.releaseFailedRoot(assignment, dir, error, releaseAssignment)
|
||||
}
|
||||
hooks.onTerminalError?.(error)
|
||||
}
|
||||
@@ -94,10 +91,7 @@ export class RuntimeWatcherProcessPool {
|
||||
if (isWatcherProcessFailure(error) && error.scope === 'supervisor') {
|
||||
this.retireSlot(slot)
|
||||
} else {
|
||||
releaseAssignment()
|
||||
if (isWatcherProcessFailure(error) && error.code === 'subscribe_timeout') {
|
||||
this.isolatedRoots.add(dir)
|
||||
}
|
||||
this.releaseFailedRoot(assignment, dir, error, releaseAssignment)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -207,14 +201,7 @@ export class RuntimeWatcherProcessPool {
|
||||
if (this.assignments.get(root)?.slot === slot) {
|
||||
this.assignments.delete(root)
|
||||
}
|
||||
if (slot.isolated) {
|
||||
// Why: one bounded quarantine attempt is the recovery budget for a
|
||||
// watch lifetime; repeated fused replacements would recreate churn.
|
||||
this.isolatedRoots.delete(root)
|
||||
this.failedQuarantineRoots.add(root)
|
||||
} else {
|
||||
this.isolatedRoots.add(root)
|
||||
}
|
||||
this.quarantineOrFuse(root, slot.isolated)
|
||||
}
|
||||
slot.roots.clear()
|
||||
// Why: failAllSubscriptions is still iterating callbacks; defer disposal
|
||||
@@ -240,6 +227,31 @@ export class RuntimeWatcherProcessPool {
|
||||
}
|
||||
}
|
||||
|
||||
private releaseFailedRoot(
|
||||
assignment: RuntimeWatcherPoolAssignment,
|
||||
dir: string,
|
||||
error: unknown,
|
||||
releaseAssignment: () => void
|
||||
): void {
|
||||
releaseAssignment()
|
||||
if (!isWatcherProcessFailure(error) || error.code !== 'subscribe_timeout') {
|
||||
return
|
||||
}
|
||||
this.quarantineOrFuse(dir, assignment.slot.isolated)
|
||||
}
|
||||
|
||||
// Why: one bounded quarantine attempt is the recovery budget per watch
|
||||
// lifetime; an already-isolated root that fails again is fused, not re-isolated,
|
||||
// so it cannot spawn another child generation.
|
||||
private quarantineOrFuse(dir: string, isolated: boolean): void {
|
||||
if (isolated) {
|
||||
this.isolatedRoots.delete(dir)
|
||||
this.failedQuarantineRoots.add(dir)
|
||||
return
|
||||
}
|
||||
this.isolatedRoots.add(dir)
|
||||
}
|
||||
|
||||
private disposeSlot(slot: RuntimeWatcherPoolSlot): void {
|
||||
if (slot.disposed) {
|
||||
return
|
||||
|
||||
@@ -56,7 +56,10 @@ import {
|
||||
type RuntimeTerminalAgentStatusEvent
|
||||
} from './orca-runtime'
|
||||
import { HeadlessEmulator } from '../daemon/headless-emulator'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
|
||||
import {
|
||||
HEADLESS_RUNTIME_WINDOW_ID,
|
||||
type RuntimeMobileSessionTabsResult
|
||||
} from '../../shared/runtime-types'
|
||||
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
|
||||
import {
|
||||
TERMINAL_INPUT_CHUNK_MAX_BYTES,
|
||||
@@ -1454,6 +1457,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(runtime.getStatus()).toMatchObject({
|
||||
graphStatus: 'unavailable',
|
||||
authoritativeWindowId: null,
|
||||
desktopWindowStatus: 'openable',
|
||||
rendererGraphEpoch: 0
|
||||
})
|
||||
expect(runtime.getRuntimeId()).toBeTruthy()
|
||||
@@ -1548,6 +1552,118 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(runtime.getStatus().authoritativeWindowId).toBe(TEST_WINDOW_ID)
|
||||
})
|
||||
|
||||
it('transfers authority from the headless sentinel to the first real window', () => {
|
||||
const runtime = createRuntime()
|
||||
electronMocks.BrowserWindow.fromId.mockImplementation((windowId: number) =>
|
||||
windowId === TEST_WINDOW_ID ? ({ isDestroyed: () => false } as never) : null
|
||||
)
|
||||
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
|
||||
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
runtime.attachWindow(2)
|
||||
|
||||
expect(runtime.getStatus()).toMatchObject({
|
||||
authoritativeWindowId: TEST_WINDOW_ID,
|
||||
desktopWindowStatus: 'available',
|
||||
graphStatus: 'reloading',
|
||||
rendererGraphEpoch: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('marks live headless PTYs for renderer reattach before desktop promotion', () => {
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
activeWorktreeIdsOnShutdown: []
|
||||
})
|
||||
)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
|
||||
runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, {
|
||||
tabId: 'host-tab',
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
|
||||
expect(getSession().activeWorktreeIdsOnShutdown).toEqual([TEST_WORKTREE_ID])
|
||||
})
|
||||
|
||||
it('marks live bindings again when reopening after a promoted window closes', () => {
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
activeWorktreeIdsOnShutdown: []
|
||||
})
|
||||
)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
|
||||
runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, {
|
||||
tabId: 'host-tab',
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
;(runtimeStore.setWorkspaceSession as unknown as (next: WorkspaceSessionState) => void)({
|
||||
...getSession(),
|
||||
activeWorktreeIdsOnShutdown: []
|
||||
})
|
||||
runtime.markGraphUnavailable(TEST_WINDOW_ID)
|
||||
|
||||
runtime.attachWindow(2)
|
||||
|
||||
expect(getSession().activeWorktreeIdsOnShutdown).toEqual([TEST_WORKTREE_ID])
|
||||
})
|
||||
|
||||
it('preserves live SSH session identities when promoting a headless runtime', () => {
|
||||
const remotePtyId = 'ssh:ssh-1@@persisted-pty'
|
||||
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
activeWorktreeIdsOnShutdown: [],
|
||||
activeConnectionIdsAtShutdown: [],
|
||||
remoteSessionIdsByTabId: {},
|
||||
tabsByWorktree: {
|
||||
[TEST_WORKTREE_ID]: [
|
||||
{
|
||||
id: 'host-tab',
|
||||
ptyId: remotePtyId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Remote Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: remotePtyId })
|
||||
}
|
||||
})
|
||||
)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
|
||||
runtime.registerPty(remotePtyId, TEST_WORKTREE_ID, 'ssh-1', {
|
||||
tabId: 'host-tab',
|
||||
leafId: HEADLESS_LEAF_ID
|
||||
})
|
||||
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
|
||||
expect(getSession()).toMatchObject({
|
||||
activeWorktreeIdsOnShutdown: [TEST_WORKTREE_ID],
|
||||
activeConnectionIdsAtShutdown: ['ssh-1'],
|
||||
remoteSessionIdsByTabId: { 'host-tab': remotePtyId }
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the activation gate state while no desktop window is available', () => {
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getDesktopWindowStatus: () => 'blocked'
|
||||
})
|
||||
|
||||
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
|
||||
|
||||
expect(runtime.getStatus().desktopWindowStatus).toBe('blocked')
|
||||
})
|
||||
|
||||
it('bumps the epoch and enters reloading when the authoritative window reloads', () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
@@ -6875,6 +6991,34 @@ describe('OrcaRuntimeService', () => {
|
||||
return { runtime, batches }
|
||||
}
|
||||
|
||||
it('defers desktop-only output scanners until a headless runtime is promoted', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
const trackerEntries = (
|
||||
runtime as unknown as {
|
||||
ptyTitleTrackersByPtyId: Map<string, { commandCodeDetector: unknown }>
|
||||
}
|
||||
).ptyTitleTrackersByPtyId
|
||||
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
|
||||
|
||||
runtime.onPtyData('pty-1', '\x07', 100)
|
||||
|
||||
expect(batches).toEqual([])
|
||||
expect(trackerEntries.get('pty-1')?.commandCodeDetector).toBeNull()
|
||||
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
runtime.onPtyData('pty-1', '\x07', 101)
|
||||
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: 'bell' }])
|
||||
expect(trackerEntries.get('pty-1')?.commandCodeDetector).not.toBeNull()
|
||||
|
||||
runtime.markGraphUnavailable(1)
|
||||
runtime.onPtyData('pty-1', '\x07', 102)
|
||||
|
||||
expect(batches).toHaveLength(1)
|
||||
expect(trackerEntries.get('pty-1')?.commandCodeDetector).toBeNull()
|
||||
})
|
||||
|
||||
it('emits one batched event per chunk with facts in byte order and attribution', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
@@ -9932,11 +10076,37 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
persistHostSessionBinding: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps ordinary desktop background terminal persistence opt-in', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const webContents = { send: vi.fn() }
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents
|
||||
} as never)
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
|
||||
|
||||
const spawnOptions = spawn.mock.calls[0]?.[0] as
|
||||
| { persistHostSessionBinding?: boolean }
|
||||
| undefined
|
||||
expect(spawnOptions?.persistHostSessionBinding).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to background terminal creation for renderer-backed requests without a renderer window', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
@@ -174,6 +174,10 @@ import type {
|
||||
LinearTeamStatesResult,
|
||||
LinearStatusSetResult
|
||||
} from '../../shared/linear-agent-access'
|
||||
import {
|
||||
HEADLESS_RUNTIME_WINDOW_ID,
|
||||
type RuntimeDesktopWindowStatus
|
||||
} from '../../shared/runtime-types'
|
||||
import {
|
||||
LINEAR_SEARCH_MAX_LIMIT,
|
||||
LINEAR_WRITE_BODY_CAP,
|
||||
@@ -2555,8 +2559,10 @@ export class OrcaRuntimeService {
|
||||
private readonly onPtyStopped: ((ptyId: string) => void) | null
|
||||
private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null
|
||||
private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null
|
||||
private terminalSideEffectConsumerAvailable = false
|
||||
private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null
|
||||
private readonly buildAgentHookPtyEnv: (() => Record<string, string>) | null
|
||||
private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus
|
||||
private accountServices: RuntimeAccountServices | null = null
|
||||
private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null
|
||||
private automationService: AutomationService | null = null
|
||||
@@ -2590,6 +2596,7 @@ export class OrcaRuntimeService {
|
||||
// managed-Codex sessions. The runtime ctor runs in BOTH window and serve.
|
||||
getAdditionalAiVaultCodexHomePaths?: () => readonly string[]
|
||||
buildAgentHookPtyEnv?: () => Record<string, string>
|
||||
getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus
|
||||
}
|
||||
) {
|
||||
this.store = store
|
||||
@@ -2616,6 +2623,7 @@ export class OrcaRuntimeService {
|
||||
this.onPtyStopped = deps?.onPtyStopped ?? null
|
||||
this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null
|
||||
this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null
|
||||
this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable')
|
||||
this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null
|
||||
// Why: the ConPTY spawn mark can land after daemon stream data already
|
||||
// created this PTY's emulator; the mark retrofits the DA1 override here
|
||||
@@ -3018,6 +3026,7 @@ export class OrcaRuntimeService {
|
||||
rendererGraphEpoch: this.rendererGraphEpoch,
|
||||
graphStatus: this.graphStatus,
|
||||
authoritativeWindowId: this.authoritativeWindowId,
|
||||
desktopWindowStatus: hasRenderer ? 'available' : this.getDesktopWindowStatusFn(),
|
||||
liveTabCount: this.tabs.size,
|
||||
liveLeafCount: this.leaves.size,
|
||||
runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
@@ -3132,11 +3141,78 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
attachWindow(windowId: number): void {
|
||||
if (this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID) {
|
||||
// Why: promotion is a renderer reload of the same graph owner, not a new
|
||||
// runtime; stale handles must transition before the real window publishes.
|
||||
this.persistWindowlessPtyBindingsForDesktopAttach()
|
||||
this.markRendererReloading(HEADLESS_RUNTIME_WINDOW_ID)
|
||||
this.authoritativeWindowId = windowId
|
||||
return
|
||||
}
|
||||
if (this.authoritativeWindowId === null) {
|
||||
// Why: a promoted serve can close and later reopen its window while new
|
||||
// background PTYs keep arriving; every windowless gap needs this handoff.
|
||||
this.persistWindowlessPtyBindingsForDesktopAttach()
|
||||
this.authoritativeWindowId = windowId
|
||||
}
|
||||
}
|
||||
|
||||
private persistWindowlessPtyBindingsForDesktopAttach(): void {
|
||||
const session = this.store?.getWorkspaceSession?.()
|
||||
if (!session || !this.store?.setWorkspaceSession) {
|
||||
return
|
||||
}
|
||||
const promotablePtys = [...this.ptysById.values()].filter((pty) => {
|
||||
if (!pty.connected || !pty.tabId) {
|
||||
return false
|
||||
}
|
||||
const tab = session.tabsByWorktree[pty.worktreeId]?.find(
|
||||
(candidate) => candidate.id === pty.tabId
|
||||
)
|
||||
if (!tab) {
|
||||
return false
|
||||
}
|
||||
const layoutPtyIds = Object.values(
|
||||
session.terminalLayoutsByTabId[pty.tabId]?.ptyIdsByLeafId ?? {}
|
||||
)
|
||||
return tab.ptyId === pty.ptyId || layoutPtyIds.includes(pty.ptyId)
|
||||
})
|
||||
if (promotablePtys.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: renderer hydration treats an explicitly-present shutdown list as
|
||||
// authoritative. A windowless owner has no renderer shutdown pass, so seed
|
||||
// that existing reattach contract before its next desktop window loads.
|
||||
const activeWorktreeIdsOnShutdown = [
|
||||
...new Set([
|
||||
...(session.activeWorktreeIdsOnShutdown ?? []),
|
||||
...promotablePtys.map((pty) => pty.worktreeId)
|
||||
])
|
||||
]
|
||||
const activeConnectionIdsAtShutdown = [
|
||||
...new Set([
|
||||
...(session.activeConnectionIdsAtShutdown ?? []),
|
||||
...promotablePtys
|
||||
.map((pty) => pty.connectionId)
|
||||
.filter((connectionId): connectionId is string => connectionId !== null)
|
||||
])
|
||||
]
|
||||
const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId }
|
||||
for (const pty of promotablePtys) {
|
||||
if (pty.connectionId && pty.tabId) {
|
||||
remoteSessionIdsByTabId[pty.tabId] = pty.ptyId
|
||||
}
|
||||
}
|
||||
|
||||
this.store.setWorkspaceSession({
|
||||
...session,
|
||||
activeWorktreeIdsOnShutdown,
|
||||
...(activeConnectionIdsAtShutdown.length > 0 ? { activeConnectionIdsAtShutdown } : {}),
|
||||
...(Object.keys(remoteSessionIdsByTabId).length > 0 ? { remoteSessionIdsByTabId } : {})
|
||||
})
|
||||
}
|
||||
|
||||
syncWindowGraph(windowId: number, graph: RuntimeSyncWindowGraph): RuntimeSyncWindowGraphResult {
|
||||
if (this.authoritativeWindowId === null) {
|
||||
this.authoritativeWindowId = windowId
|
||||
@@ -3261,6 +3337,7 @@ export class OrcaRuntimeService {
|
||||
this.rebuildLeafPtyIndex()
|
||||
this.notifyMobileSessionTabSnapshots()
|
||||
this.graphStatus = 'ready'
|
||||
this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID)
|
||||
this.refreshWritableFlags()
|
||||
for (const leaf of this.leaves.values()) {
|
||||
this.adoptPreAllocatedHandle(leaf)
|
||||
@@ -6048,7 +6125,7 @@ export class OrcaRuntimeService {
|
||||
/** Record one derived side-effect fact: batched per chunk while applying
|
||||
* bytes, emitted immediately for between-chunk facts (stale-title timer). */
|
||||
private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void {
|
||||
if (!this.onTerminalSideEffects) {
|
||||
if (!this.onTerminalSideEffects || !this.terminalSideEffectConsumerAvailable) {
|
||||
return
|
||||
}
|
||||
const entry = this.ptyTitleTrackersByPtyId.get(ptyId)
|
||||
@@ -6064,7 +6141,11 @@ export class OrcaRuntimeService {
|
||||
facts: TerminalSideEffectFact[],
|
||||
options: { replay?: boolean } = {}
|
||||
): void {
|
||||
if (!this.onTerminalSideEffects || facts.length === 0) {
|
||||
if (
|
||||
!this.onTerminalSideEffects ||
|
||||
!this.terminalSideEffectConsumerAvailable ||
|
||||
facts.length === 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
const batch: TerminalSideEffectBatch = {
|
||||
@@ -6243,7 +6324,7 @@ export class OrcaRuntimeService {
|
||||
// Why: bell/command-finished/pr-link/2031 facts exist only for the
|
||||
// pty:sideEffect channel. Headless serve has no consumer, so skip the
|
||||
// per-chunk bell walk and 133/URL/2031 scans entirely.
|
||||
...(this.onTerminalSideEffects
|
||||
...(this.terminalSideEffectConsumerAvailable
|
||||
? {
|
||||
onBell: () => {
|
||||
this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' })
|
||||
@@ -6276,7 +6357,7 @@ export class OrcaRuntimeService {
|
||||
// headless serve skips the per-chunk scrape entirely. The detector
|
||||
// self-arms on the Command Code banner; the spawn command (when main
|
||||
// saw one) mirrors the renderer detector's startupCommand fast-arm.
|
||||
commandCodeDetector: this.onTerminalSideEffects
|
||||
commandCodeDetector: this.terminalSideEffectConsumerAvailable
|
||||
? createCommandCodeOutputStatusDetector({
|
||||
startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null,
|
||||
onWorking: (prompt) => {
|
||||
@@ -6375,6 +6456,19 @@ export class OrcaRuntimeService {
|
||||
this.ptyTitleTrackersByPtyId.delete(ptyId)
|
||||
}
|
||||
|
||||
private setTerminalSideEffectConsumerAvailable(available: boolean): void {
|
||||
const nextAvailable = available && this.onTerminalSideEffects !== null
|
||||
if (nextAvailable === this.terminalSideEffectConsumerAvailable) {
|
||||
return
|
||||
}
|
||||
this.terminalSideEffectConsumerAvailable = nextAvailable
|
||||
// Why: optional bell/command/link scanners are selected when a tracker is
|
||||
// created. Rebuild at the window boundary so pure headless output stays cheap.
|
||||
for (const ptyId of [...this.ptyTitleTrackersByPtyId.keys()]) {
|
||||
this.disposePtyTitleTracker(ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
private extractLastOsc7CwdForPty(
|
||||
ptyId: string,
|
||||
data: string
|
||||
@@ -18032,12 +18126,12 @@ export class OrcaRuntimeService {
|
||||
): Promise<RuntimeTerminalCreate> {
|
||||
const presentation = resolveTerminalPresentation(opts)
|
||||
const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true
|
||||
const availableAuthoritativeWindow = this.getAvailableAuthoritativeWindow()
|
||||
// Why: pre-diff createTerminal fell back to the renderer's active worktree
|
||||
// when no selector was provided. The new background-spawn branch hard-
|
||||
// requires a resolvable selector, so route the no-selector case through
|
||||
// the renderer IPC path to preserve that behavior.
|
||||
const rendererWindow =
|
||||
opts.rendererBacked === true ? this.getAvailableAuthoritativeWindow() : null
|
||||
const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null
|
||||
const shouldCreateInBackground =
|
||||
worktreeSelector !== undefined &&
|
||||
((!requiresRendererFocus && opts.rendererBacked !== true) ||
|
||||
@@ -18174,7 +18268,12 @@ export class OrcaRuntimeService {
|
||||
tabId,
|
||||
leafId,
|
||||
...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}),
|
||||
...(launchOpts.persistHostSessionBinding ? { persistHostSessionBinding: true } : {})
|
||||
// Why: a headless-created pane has no renderer session writer.
|
||||
// Re-check after preparation in case the window closed meanwhile.
|
||||
...(launchOpts.persistHostSessionBinding ||
|
||||
this.getAvailableAuthoritativeWindow() === null
|
||||
? { persistHostSessionBinding: true }
|
||||
: {})
|
||||
})
|
||||
this.registerPreAllocatedHandleForPty(spawned.id, preAllocatedHandle)
|
||||
this.registerPty(spawned.id, workspace.id, workspace.connectionId)
|
||||
@@ -19673,6 +19772,7 @@ export class OrcaRuntimeService {
|
||||
// against whatever the renderer rebuilds next.
|
||||
this.rendererGraphEpoch += 1
|
||||
this.graphStatus = 'reloading'
|
||||
this.setTerminalSideEffectConsumerAvailable(false)
|
||||
this.rememberDetachedPreAllocatedLeaves()
|
||||
this.handles.clear()
|
||||
this.handleByLeafKey.clear()
|
||||
@@ -19689,6 +19789,7 @@ export class OrcaRuntimeService {
|
||||
return
|
||||
}
|
||||
this.graphStatus = 'ready'
|
||||
this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID)
|
||||
this.refreshWritableFlags()
|
||||
}
|
||||
|
||||
@@ -19702,6 +19803,7 @@ export class OrcaRuntimeService {
|
||||
this.rendererGraphEpoch += 1
|
||||
}
|
||||
this.graphStatus = 'unavailable'
|
||||
this.setTerminalSideEffectConsumerAvailable(false)
|
||||
this.authoritativeWindowId = null
|
||||
this.rememberDetachedPreAllocatedLeaves()
|
||||
this.tabs.clear()
|
||||
|
||||
@@ -305,6 +305,25 @@ describe('SshChannelMultiplexer', () => {
|
||||
expect(() => vi.advanceTimersByTime(5_000)).not.toThrow()
|
||||
expect(mux.isDisposed()).toBe(true)
|
||||
})
|
||||
|
||||
it('drives keepalive sends AND dead-link detection from a single interval', () => {
|
||||
// The keepalive send and the liveness/timeout check were merged from two
|
||||
// 5s intervals into one; there must be exactly one recurring timer.
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
// Send half: a keepalive is written on the tick.
|
||||
const before = transport.written.length
|
||||
vi.advanceTimersByTime(5_000)
|
||||
expect(transport.written.length).toBeGreaterThan(before)
|
||||
expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
// Check half: with no inbound frames or acks, the same interval declares
|
||||
// the link dead (no-data + oldest-unacked both exceed the 20s window).
|
||||
expect(mux.isDisposed()).toBe(false)
|
||||
vi.advanceTimersByTime(25_000)
|
||||
expect(mux.isDisposed()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wake guard (timer pause across system sleep, #7773)', () => {
|
||||
|
||||
@@ -54,8 +54,7 @@ export class SshChannelMultiplexer {
|
||||
// generic notification listener that already serves fs.changed.
|
||||
private methodNotificationHandlers = new Map<string, Set<MethodNotificationHandler>>()
|
||||
private disposeHandlers: ((reason: 'shutdown' | 'connection_lost') => void)[] = []
|
||||
private keepaliveTimer: ReturnType<typeof setInterval> | null = null
|
||||
private timeoutTimer: ReturnType<typeof setInterval> | null = null
|
||||
private connectionHealthTimer: ReturnType<typeof setInterval> | null = null
|
||||
private disposed = false
|
||||
|
||||
// Track the oldest unacked outgoing message timestamp
|
||||
@@ -88,8 +87,7 @@ export class SshChannelMultiplexer {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.startKeepalive()
|
||||
this.startTimeoutCheck()
|
||||
this.startConnectionHealthTimer()
|
||||
}
|
||||
|
||||
onNotification(handler: NotificationHandler): () => void {
|
||||
@@ -276,13 +274,9 @@ export class SshChannelMultiplexer {
|
||||
}
|
||||
this.disposed = true
|
||||
|
||||
if (this.keepaliveTimer) {
|
||||
clearInterval(this.keepaliveTimer)
|
||||
this.keepaliveTimer = null
|
||||
}
|
||||
if (this.timeoutTimer) {
|
||||
clearInterval(this.timeoutTimer)
|
||||
this.timeoutTimer = null
|
||||
if (this.connectionHealthTimer) {
|
||||
clearInterval(this.connectionHealthTimer)
|
||||
this.connectionHealthTimer = null
|
||||
}
|
||||
|
||||
// Why: the renderer uses the error code to distinguish temporary disconnects
|
||||
@@ -488,15 +482,17 @@ export class SshChannelMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
private startKeepalive(): void {
|
||||
this.keepaliveTimer = setInterval(() => {
|
||||
this.sendKeepAlive()
|
||||
}, KEEPALIVE_SEND_MS)
|
||||
}
|
||||
|
||||
private startTimeoutCheck(): void {
|
||||
// Why: one 5s interval owns BOTH the periodic keepalive send and the
|
||||
// liveness/dead-link check. They used to be two separate 5s intervals that
|
||||
// always fired back-to-back (keepalive created first); folding them into one
|
||||
// tick — send first (as the keepalive timer did), then run the check (as the
|
||||
// timeout timer did) — halves the per-connection timer count with identical
|
||||
// behavior, including the #7773 wake-gap recovery below.
|
||||
private startConnectionHealthTimer(): void {
|
||||
let lastTickAt = Date.now()
|
||||
this.timeoutTimer = setInterval(() => {
|
||||
this.connectionHealthTimer = setInterval(() => {
|
||||
this.sendKeepAlive()
|
||||
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ function formatStatusResult(status: CliStatusResult): { stdout: string; stderr:
|
||||
stdout: `${[
|
||||
`appRunning: ${status.app.running}`,
|
||||
`pid: ${status.app.pid ?? 'none'}`,
|
||||
`desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`,
|
||||
`runtimeState: ${status.runtime.state}`,
|
||||
`runtimeReachable: ${status.runtime.reachable}`,
|
||||
`runtimeId: ${status.runtime.runtimeId ?? 'none'}`,
|
||||
|
||||
@@ -232,6 +232,7 @@ export function createRemoteCliInstallPlan(env: RemoteCliInstallEnv): RemoteCliI
|
||||
].join('\n')
|
||||
}
|
||||
],
|
||||
postWriteCommands: [`chmod +x ${quoteSh(launcherPath)} 2>/dev/null; true`]
|
||||
// Surface chmod failures: a non-executable launcher must fail install loudly, not silently.
|
||||
postWriteCommands: [`chmod +x ${quoteSh(launcherPath)}`]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,11 @@ async function dispatchRemoteCli(
|
||||
}
|
||||
const status = response.result as RuntimeStatus
|
||||
const cliStatus: CliStatusResult = {
|
||||
app: { running: true, pid: null },
|
||||
app: {
|
||||
running: true,
|
||||
pid: null,
|
||||
...(status.desktopWindowStatus ? { desktopWindowStatus: status.desktopWindowStatus } : {})
|
||||
},
|
||||
runtime: {
|
||||
state: status.graphStatus === 'ready' ? 'ready' : 'graph_not_ready',
|
||||
reachable: true,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('serve desktop activation wiring', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
|
||||
|
||||
it('routes second-instance and app activation through one safety gate', () => {
|
||||
expect(source).toContain('createServeDesktopActivationGate({')
|
||||
expect(source).toContain('acquireSingleInstanceLock(app, requestDesktopActivation)')
|
||||
expect(source).toContain("app.on('activate', requestDesktopActivation)")
|
||||
expect(source).toContain('getDesktopWindowStatus: getDesktopWindowStatus')
|
||||
})
|
||||
|
||||
it('settles the persistent provider before headless PTY registration', () => {
|
||||
const appReadyIndex = source.indexOf('app.whenReady().then(async () => {')
|
||||
const startupIndex = source.indexOf(
|
||||
'\n startTerminalRuntimeStartupServices()\n',
|
||||
appReadyIndex
|
||||
)
|
||||
const serveIndex = source.indexOf('if (serveOptions) {', appReadyIndex)
|
||||
const ptyReadyIndex = source.indexOf('await localPtyStartupReady', serveIndex)
|
||||
const headlessRegistrationIndex = source.indexOf('registerHeadlessPtyRuntime(', serveIndex)
|
||||
|
||||
expect(startupIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(startupIndex).toBeLessThan(serveIndex)
|
||||
expect(ptyReadyIndex).toBeGreaterThan(serveIndex)
|
||||
expect(headlessRegistrationIndex).toBeGreaterThan(ptyReadyIndex)
|
||||
expect(source).not.toContain(
|
||||
'if (!isServeMode) {\n startDesktopFirstWindowStartupServices()'
|
||||
)
|
||||
})
|
||||
|
||||
it('publishes the named headless sentinel and only enables promotion after RPC is ready', () => {
|
||||
const serveIndex = source.indexOf('if (serveOptions) {')
|
||||
const sentinelIndex = source.indexOf(
|
||||
'runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID',
|
||||
serveIndex
|
||||
)
|
||||
const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex)
|
||||
const settleIndex = source.indexOf('settleServeDesktopActivation()', rpcIndex)
|
||||
|
||||
expect(serveIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(sentinelIndex).toBeGreaterThan(serveIndex)
|
||||
expect(rpcIndex).toBeGreaterThan(sentinelIndex)
|
||||
expect(settleIndex).toBeGreaterThan(rpcIndex)
|
||||
expect(source).not.toContain('runtime.syncWindowGraph(0,')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createServeDesktopActivationGate } from './serve-desktop-activation'
|
||||
|
||||
describe('createServeDesktopActivationGate', () => {
|
||||
it('coalesces activation requests while serve is initializing and drains once when ready', () => {
|
||||
const activateWindow = vi.fn()
|
||||
const gate = createServeDesktopActivationGate({
|
||||
initialState: 'initializing',
|
||||
activateWindow
|
||||
})
|
||||
|
||||
gate.requestActivation()
|
||||
gate.requestActivation()
|
||||
|
||||
expect(activateWindow).not.toHaveBeenCalled()
|
||||
expect(gate.getState()).toBe('initializing')
|
||||
|
||||
gate.markReady()
|
||||
|
||||
expect(activateWindow).toHaveBeenCalledOnce()
|
||||
expect(gate.getState()).toBe('ready')
|
||||
})
|
||||
|
||||
it('activates immediately after the persistent provider is ready', () => {
|
||||
const activateWindow = vi.fn()
|
||||
const gate = createServeDesktopActivationGate({
|
||||
initialState: 'ready',
|
||||
activateWindow
|
||||
})
|
||||
|
||||
gate.requestActivation()
|
||||
gate.requestActivation()
|
||||
|
||||
expect(activateWindow).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('drops pending activation and fails closed when promotion is blocked', () => {
|
||||
const activateWindow = vi.fn()
|
||||
const onBlocked = vi.fn()
|
||||
const gate = createServeDesktopActivationGate({
|
||||
initialState: 'initializing',
|
||||
activateWindow,
|
||||
onBlocked
|
||||
})
|
||||
|
||||
gate.requestActivation()
|
||||
gate.markBlocked('persistent PTY provider unavailable')
|
||||
gate.requestActivation()
|
||||
|
||||
expect(activateWindow).not.toHaveBeenCalled()
|
||||
expect(onBlocked).toHaveBeenCalledTimes(2)
|
||||
expect(gate.getState()).toBe('blocked')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types'
|
||||
|
||||
type ActivationGateState = Exclude<RuntimeDesktopWindowStatus, 'available' | 'openable'> | 'ready'
|
||||
|
||||
export type ServeDesktopActivationGate = {
|
||||
getState: () => ActivationGateState
|
||||
requestActivation: () => void
|
||||
markReady: () => void
|
||||
markBlocked: (reason: string) => void
|
||||
}
|
||||
|
||||
export function createServeDesktopActivationGate(options: {
|
||||
initialState: 'initializing' | 'ready'
|
||||
activateWindow: () => void
|
||||
onBlocked?: (reason: string) => void
|
||||
}): ServeDesktopActivationGate {
|
||||
let state: ActivationGateState = options.initialState
|
||||
let pendingActivation = false
|
||||
let blockedReason = 'desktop activation is unavailable'
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
requestActivation: () => {
|
||||
if (state === 'ready') {
|
||||
options.activateWindow()
|
||||
return
|
||||
}
|
||||
if (state === 'initializing') {
|
||||
pendingActivation = true
|
||||
return
|
||||
}
|
||||
options.onBlocked?.(blockedReason)
|
||||
},
|
||||
markReady: () => {
|
||||
if (state !== 'initializing') {
|
||||
return
|
||||
}
|
||||
state = 'ready'
|
||||
if (pendingActivation) {
|
||||
pendingActivation = false
|
||||
options.activateWindow()
|
||||
}
|
||||
},
|
||||
markBlocked: (reason) => {
|
||||
if (state !== 'initializing') {
|
||||
return
|
||||
}
|
||||
state = 'blocked'
|
||||
blockedReason = reason
|
||||
if (pendingActivation) {
|
||||
pendingActivation = false
|
||||
options.onBlocked?.(blockedReason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
logSingleInstanceLockBypass,
|
||||
logSingleInstanceLockFailure,
|
||||
shouldBypassSingleInstanceLock,
|
||||
shouldSkipSingleInstanceLock,
|
||||
SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE,
|
||||
SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE
|
||||
} from './single-instance-lock'
|
||||
@@ -73,6 +74,24 @@ describe('acquireSingleInstanceLock', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldSkipSingleInstanceLock', () => {
|
||||
it('keeps ordinary dev multi-instance behavior but never skips for serve', () => {
|
||||
expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, env: {} })).toBe(true)
|
||||
expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: true, env: {} })).toBe(false)
|
||||
expect(shouldSkipSingleInstanceLock({ isDev: false, isServeMode: false, env: {} })).toBe(false)
|
||||
})
|
||||
|
||||
it('lets isolated E2E exercise the production single-instance path', () => {
|
||||
expect(
|
||||
shouldSkipSingleInstanceLock({
|
||||
isDev: true,
|
||||
isServeMode: false,
|
||||
env: { ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logSingleInstanceLockFailure', () => {
|
||||
it('emits a production-visible synchronous diagnostic for the early quit path', () => {
|
||||
const write = vi.fn()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { writeStartupDiagnosticLine, type StartupDiagnosticSink } from './startu
|
||||
export const SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE =
|
||||
'[single-instance] Another Orca instance is already running for this userData profile; exiting this launch after requesting the existing window. If no Orca process is running, this may be an Electron/macOS single-instance lock failure.'
|
||||
export const SINGLE_INSTANCE_LOCK_BYPASS_ENV = 'ORCA_BYPASS_SINGLE_INSTANCE_LOCK'
|
||||
export const SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV = 'ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK'
|
||||
export const SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE =
|
||||
'[single-instance] ORCA_BYPASS_SINGLE_INSTANCE_LOCK=1 is set; bypassing the packaged macOS single-instance lock for diagnostics. Do not use this with another Orca instance running for the same profile.'
|
||||
|
||||
@@ -49,6 +50,15 @@ export function shouldBypassSingleInstanceLock(options: {
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldSkipSingleInstanceLock(options: {
|
||||
env?: NodeJS.ProcessEnv
|
||||
isDev: boolean
|
||||
isServeMode: boolean
|
||||
}): boolean {
|
||||
const env = options.env ?? process.env
|
||||
return options.isDev && !options.isServeMode && env[SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV] !== '1'
|
||||
}
|
||||
|
||||
export function logSingleInstanceLockFailure(write?: StartupDiagnosticSink): void {
|
||||
writeStartupDiagnosticLine(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE, write)
|
||||
}
|
||||
|
||||
@@ -41,4 +41,14 @@ describe('shouldQuitWhenAllWindowsClosed', () => {
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('continues a committed quit after a serve owner was promoted to desktop', () => {
|
||||
expect(
|
||||
shouldQuitWhenAllWindowsClosed({
|
||||
platform: 'darwin',
|
||||
isQuitting: true,
|
||||
isServeMode: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ export function shouldQuitWhenAllWindowsClosed(options: {
|
||||
isQuitting: boolean
|
||||
isServeMode: boolean
|
||||
}): boolean {
|
||||
if (options.isServeMode) {
|
||||
if (options.isServeMode && !options.isQuitting) {
|
||||
return false
|
||||
}
|
||||
return options.platform !== 'darwin' || options.isQuitting
|
||||
|
||||
@@ -18,6 +18,7 @@ import { toast } from 'sonner'
|
||||
import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
|
||||
import type { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -1056,8 +1057,11 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
}, [fetchAllWorktrees, refresh])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setRelativeNow(Date.now()), 60 * 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
// Pause the relative-time clock while the window is hidden.
|
||||
return installWindowVisibilityInterval({
|
||||
run: () => setRelativeNow(Date.now()),
|
||||
intervalMs: 60 * 1000
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { OpenFile } from '@/store/slices/editor'
|
||||
|
||||
const lifecycle = vi.hoisted(() => ({
|
||||
events: [] as string[],
|
||||
models: new Map<string, { content: string; undo: string[] }>()
|
||||
models: new Map<string, { content: string; undo: string[] }>(),
|
||||
mountedProps: [] as { filePath: string; readOnly?: boolean; liveTail?: boolean }[]
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/lazy-with-retry', async () => {
|
||||
@@ -16,10 +17,20 @@ vi.mock('@/lib/lazy-with-retry', async () => {
|
||||
if (!factory.toString().includes('/MonacoEditor.tsx')) {
|
||||
return () => null
|
||||
}
|
||||
return function MockRetainedMonaco(props: { filePath: string; content: string }) {
|
||||
return function MockRetainedMonaco(props: {
|
||||
filePath: string
|
||||
content: string
|
||||
readOnly?: boolean
|
||||
liveTail?: boolean
|
||||
}) {
|
||||
/* oxlint-disable react-hooks/exhaustive-deps -- Mount-only by design: a prop-effect would hide a missing outer React remount. */
|
||||
React.useEffect(() => {
|
||||
lifecycle.events.push(`mount:${props.filePath}`)
|
||||
lifecycle.mountedProps.push({
|
||||
filePath: props.filePath,
|
||||
readOnly: props.readOnly,
|
||||
liveTail: props.liveTail
|
||||
})
|
||||
const retained = lifecycle.models.get(props.filePath) ?? { content: '', undo: [] }
|
||||
const model = {
|
||||
getValue: () => retained.content,
|
||||
@@ -93,7 +104,7 @@ vi.mock('@/store', () => ({
|
||||
|
||||
import { EditorContent } from './EditorContent'
|
||||
|
||||
function file(filePath: string): OpenFile {
|
||||
function file(filePath: string, overrides: Partial<OpenFile> = {}): OpenFile {
|
||||
return {
|
||||
id: filePath,
|
||||
filePath,
|
||||
@@ -101,7 +112,8 @@ function file(filePath: string): OpenFile {
|
||||
worktreeId: 'repo::/repo',
|
||||
language: 'typescript',
|
||||
isDirty: false,
|
||||
mode: 'edit'
|
||||
mode: 'edit',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +148,7 @@ afterEach(() => {
|
||||
cleanup()
|
||||
lifecycle.events.length = 0
|
||||
lifecycle.models.clear()
|
||||
lifecycle.mountedProps.length = 0
|
||||
})
|
||||
|
||||
describe('EditorContent Monaco lifecycle boundary', () => {
|
||||
@@ -162,4 +175,14 @@ describe('EditorContent Monaco lifecycle boundary', () => {
|
||||
undo: ['first undo']
|
||||
})
|
||||
})
|
||||
|
||||
it('passes live-tail ownership only for a read-only live log', () => {
|
||||
const liveLog = file('/repo/session.jsonl', { readOnly: true, liveTail: true })
|
||||
|
||||
render(<EditorContent {...props(liveLog, 'session content')} />)
|
||||
|
||||
expect(lifecycle.mountedProps).toEqual([
|
||||
{ filePath: liveLog.filePath, readOnly: true, liveTail: true }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -327,6 +327,7 @@ export function EditorContent({
|
||||
// the change/save callbacks so no draft, dirty state, or write can occur —
|
||||
// mirrors the conflict-review read-only rendering pattern.
|
||||
readOnly={activeFile.readOnly === true}
|
||||
liveTail={activeFile.liveTail === true}
|
||||
onContentChange={activeFile.readOnly === true ? noopEditorContentChange : handleContentChange}
|
||||
onSave={activeFile.readOnly === true ? noopEditorSave : isMarkdown ? md.mdSave : handleSave}
|
||||
worktreeId={activeFile.worktreeId}
|
||||
|
||||
@@ -15,7 +15,11 @@ import { registerFileSearchSelectedTextProvider } from '@/lib/file-search-select
|
||||
|
||||
import { useContextualCopySetup } from './useContextualCopySetup'
|
||||
import { MAX_REVEAL_CONTENT_WAIT_FRAMES, performReveal } from './monaco-reveal'
|
||||
import { syncContentOnMount, syncContentUpdate } from './monaco-content-sync'
|
||||
import {
|
||||
syncContentOnMount,
|
||||
syncContentUpdate,
|
||||
type MonacoContentSyncMode
|
||||
} from './monaco-content-sync'
|
||||
import { getMonacoCodebaseSearchQuery } from './monaco-codebase-search'
|
||||
import {
|
||||
beginProgrammaticContentSync,
|
||||
@@ -77,6 +81,7 @@ type MonacoEditorProps = {
|
||||
markdownAnnotationsEnabled?: boolean
|
||||
conflictDecorationsEnabled?: boolean
|
||||
readOnly?: boolean
|
||||
liveTail?: boolean
|
||||
autoHeight?: boolean
|
||||
}
|
||||
|
||||
@@ -101,6 +106,7 @@ export default function MonacoEditor({
|
||||
markdownAnnotationsEnabled = false,
|
||||
conflictDecorationsEnabled = false,
|
||||
readOnly = false,
|
||||
liveTail = false,
|
||||
autoHeight = false
|
||||
}: MonacoEditorProps): React.JSX.Element {
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null)
|
||||
@@ -131,6 +137,8 @@ export default function MonacoEditor({
|
||||
propsRef.current = { relativePath, language, onSave, onContentChange }
|
||||
const readOnlyRef = useRef(readOnly)
|
||||
readOnlyRef.current = readOnly
|
||||
const contentSyncModeRef = useRef<MonacoContentSyncMode>('undoable')
|
||||
contentSyncModeRef.current = readOnly && liveTail ? 'read-only-live-tail' : 'undoable'
|
||||
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
|
||||
@@ -370,7 +378,11 @@ export default function MonacoEditor({
|
||||
beginProgrammaticContentSync(filePath)
|
||||
isApplyingProgrammaticContentRef.current = true
|
||||
try {
|
||||
const didSyncOnMount = syncContentOnMount(editorInstance, contentRef.current)
|
||||
const didSyncOnMount = syncContentOnMount(
|
||||
editorInstance,
|
||||
contentRef.current,
|
||||
contentSyncModeRef.current
|
||||
)
|
||||
if (didSyncOnMount) {
|
||||
lastSyncedContentRef.current = contentRef.current
|
||||
}
|
||||
@@ -672,7 +684,7 @@ export default function MonacoEditor({
|
||||
beginProgrammaticContentSync(filePath)
|
||||
isApplyingProgrammaticContentRef.current = true
|
||||
try {
|
||||
syncContentUpdate(ed, content)
|
||||
syncContentUpdate(ed, content, contentSyncModeRef.current)
|
||||
lastSyncedContentRef.current = content
|
||||
} finally {
|
||||
isApplyingProgrammaticContentRef.current = false
|
||||
|
||||
@@ -9,6 +9,7 @@ function createHarness(
|
||||
editorInstance: editor.IStandaloneCodeEditor
|
||||
getValue: ReturnType<typeof vi.fn>
|
||||
getFullModelRange: ReturnType<typeof vi.fn>
|
||||
applyEdits: ReturnType<typeof vi.fn>
|
||||
pushEditOperations: ReturnType<typeof vi.fn>
|
||||
pushUndoStop: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
@@ -20,18 +21,27 @@ function createHarness(
|
||||
endColumn: 5
|
||||
}))
|
||||
const pushEditOperations = vi.fn()
|
||||
const applyEdits = vi.fn()
|
||||
const model = {
|
||||
getValue,
|
||||
getEOL: () => eol,
|
||||
getFullModelRange,
|
||||
pushEditOperations
|
||||
pushEditOperations,
|
||||
applyEdits
|
||||
} as unknown as editor.ITextModel
|
||||
const pushUndoStop = vi.fn()
|
||||
const editorInstance = {
|
||||
getModel: () => model,
|
||||
pushUndoStop
|
||||
} as unknown as editor.IStandaloneCodeEditor
|
||||
return { editorInstance, getValue, getFullModelRange, pushEditOperations, pushUndoStop }
|
||||
return {
|
||||
editorInstance,
|
||||
getValue,
|
||||
getFullModelRange,
|
||||
applyEdits,
|
||||
pushEditOperations,
|
||||
pushUndoStop
|
||||
}
|
||||
}
|
||||
|
||||
describe('monaco content sync', () => {
|
||||
@@ -60,6 +70,26 @@ describe('monaco content sync', () => {
|
||||
expect(harness.pushUndoStop).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('inserts a read-only live-tail suffix without recording undo history', () => {
|
||||
const harness = createHarness('first\nsecond\nlast')
|
||||
|
||||
syncContentUpdate(harness.editorInstance, 'first\nsecond\nlast\nnext', 'read-only-live-tail')
|
||||
|
||||
expect(harness.applyEdits).toHaveBeenCalledWith([
|
||||
{
|
||||
range: {
|
||||
startLineNumber: 3,
|
||||
startColumn: 5,
|
||||
endLineNumber: 3,
|
||||
endColumn: 5
|
||||
},
|
||||
text: '\nnext'
|
||||
}
|
||||
])
|
||||
expect(harness.pushEditOperations).not.toHaveBeenCalled()
|
||||
expect(harness.pushUndoStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing for identical content', () => {
|
||||
const harness = createHarness('unchanged')
|
||||
|
||||
@@ -127,6 +157,22 @@ describe('monaco content sync', () => {
|
||||
expect(harness.pushUndoStop).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['same-length rewrite', 'before', 'after!'],
|
||||
['prefix mismatch', 'before', 'changed content'],
|
||||
['truncation', 'longer content', 'short']
|
||||
])('non-undoingly replaces a read-only live-tail %s', (_label, initialContent, nextContent) => {
|
||||
const harness = createHarness(initialContent)
|
||||
|
||||
syncContentUpdate(harness.editorInstance, nextContent, 'read-only-live-tail')
|
||||
|
||||
expect(harness.applyEdits).toHaveBeenCalledWith([
|
||||
{ range: harness.getFullModelRange.mock.results[0]?.value, text: nextContent }
|
||||
])
|
||||
expect(harness.pushEditOperations).not.toHaveBeenCalled()
|
||||
expect(harness.pushUndoStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reconciles a stale retained model on mount without undo stops', () => {
|
||||
const harness = createHarness('stale')
|
||||
|
||||
@@ -136,6 +182,16 @@ describe('monaco content sync', () => {
|
||||
expect(harness.pushUndoStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reconciles a stale read-only live-tail model on mount without undo history', () => {
|
||||
const harness = createHarness('stale')
|
||||
|
||||
expect(syncContentOnMount(harness.editorInstance, 'fresh', 'read-only-live-tail')).toBe(true)
|
||||
|
||||
expect(harness.applyEdits).toHaveBeenCalledTimes(1)
|
||||
expect(harness.pushEditOperations).not.toHaveBeenCalled()
|
||||
expect(harness.pushUndoStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing on mount when content already matches', () => {
|
||||
const harness = createHarness('same')
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
export type MonacoContentSyncMode = 'undoable' | 'read-only-live-tail'
|
||||
|
||||
function normalizeToModelEol(content: string, model: editor.ITextModel): string {
|
||||
const eol = model.getEOL()
|
||||
// Why: Monaco normalizes model line endings, while filesystem content keeps
|
||||
@@ -10,28 +12,41 @@ function normalizeToModelEol(content: string, model: editor.ITextModel): string
|
||||
return content.replace(/\r\n|\r|\n/g, eol)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite a Monaco model's text via pushEditOperations so the change
|
||||
* participates in the undo stack (unlike setValue, which blows it away).
|
||||
*/
|
||||
function applyModelEdit(
|
||||
editorInstance: editor.IStandaloneCodeEditor,
|
||||
model: editor.ITextModel,
|
||||
edit: editor.IIdentifiedSingleEditOperation,
|
||||
mode: MonacoContentSyncMode,
|
||||
withUndoStops: boolean
|
||||
): void {
|
||||
if (mode === 'read-only-live-tail') {
|
||||
// Why: live-tail updates are machine-owned and cannot be undone by users;
|
||||
// recording them would retain the growing log again in Monaco's undo service.
|
||||
model.applyEdits([edit])
|
||||
return
|
||||
}
|
||||
if (withUndoStops) {
|
||||
editorInstance.pushUndoStop()
|
||||
}
|
||||
model.pushEditOperations([], [edit], () => null)
|
||||
if (withUndoStops) {
|
||||
editorInstance.pushUndoStop()
|
||||
}
|
||||
}
|
||||
|
||||
function replaceModelContent(
|
||||
editorInstance: editor.IStandaloneCodeEditor,
|
||||
model: editor.ITextModel,
|
||||
currentContent: string,
|
||||
content: string,
|
||||
mode: MonacoContentSyncMode,
|
||||
withUndoStops: boolean
|
||||
): void {
|
||||
if (currentContent === content) {
|
||||
return
|
||||
}
|
||||
const fullRange = model.getFullModelRange()
|
||||
if (withUndoStops) {
|
||||
editorInstance.pushUndoStop()
|
||||
}
|
||||
model.pushEditOperations([], [{ range: fullRange, text: content }], () => null)
|
||||
if (withUndoStops) {
|
||||
editorInstance.pushUndoStop()
|
||||
}
|
||||
applyModelEdit(editorInstance, model, { range: fullRange, text: content }, mode, withUndoStops)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,7 +61,8 @@ function replaceModelContent(
|
||||
*/
|
||||
export function syncContentOnMount(
|
||||
editorInstance: editor.IStandaloneCodeEditor,
|
||||
content: string
|
||||
content: string,
|
||||
mode: MonacoContentSyncMode = 'undoable'
|
||||
): boolean {
|
||||
const model = editorInstance.getModel()
|
||||
if (!model) {
|
||||
@@ -60,7 +76,7 @@ export function syncContentOnMount(
|
||||
// Why: no undo stop on mount — the retained model's text was already the
|
||||
// user's last-known state, and adding an undo entry here would make Cmd+Z
|
||||
// revert to the pre-remount text, which is confusing.
|
||||
replaceModelContent(editorInstance, model, currentContent, normalizedContent, false)
|
||||
replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, false)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -74,7 +90,8 @@ export function syncContentOnMount(
|
||||
*/
|
||||
export function syncContentUpdate(
|
||||
editorInstance: editor.IStandaloneCodeEditor,
|
||||
content: string
|
||||
content: string,
|
||||
mode: MonacoContentSyncMode = 'undoable'
|
||||
): void {
|
||||
const model = editorInstance.getModel()
|
||||
if (!model) {
|
||||
@@ -83,7 +100,7 @@ export function syncContentUpdate(
|
||||
const currentContent = model.getValue()
|
||||
const normalizedContent = normalizeToModelEol(content, model)
|
||||
if (currentContent.length === normalizedContent.length) {
|
||||
replaceModelContent(editorInstance, model, currentContent, normalizedContent, true)
|
||||
replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, true)
|
||||
return
|
||||
}
|
||||
if (
|
||||
@@ -93,24 +110,22 @@ export function syncContentUpdate(
|
||||
// Why: preserving the existing prefix lets Monaco retain viewport,
|
||||
// selection, find-widget, and tokenization state above a live-file append.
|
||||
const fullRange = model.getFullModelRange()
|
||||
editorInstance.pushUndoStop()
|
||||
model.pushEditOperations(
|
||||
[],
|
||||
[
|
||||
{
|
||||
range: {
|
||||
startLineNumber: fullRange.endLineNumber,
|
||||
startColumn: fullRange.endColumn,
|
||||
endLineNumber: fullRange.endLineNumber,
|
||||
endColumn: fullRange.endColumn
|
||||
},
|
||||
text: normalizedContent.slice(currentContent.length)
|
||||
}
|
||||
],
|
||||
() => null
|
||||
applyModelEdit(
|
||||
editorInstance,
|
||||
model,
|
||||
{
|
||||
range: {
|
||||
startLineNumber: fullRange.endLineNumber,
|
||||
startColumn: fullRange.endColumn,
|
||||
endLineNumber: fullRange.endLineNumber,
|
||||
endColumn: fullRange.endColumn
|
||||
},
|
||||
text: normalizedContent.slice(currentContent.length)
|
||||
},
|
||||
mode,
|
||||
true
|
||||
)
|
||||
editorInstance.pushUndoStop()
|
||||
return
|
||||
}
|
||||
replaceModelContent(editorInstance, model, currentContent, normalizedContent, true)
|
||||
replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment happy-dom
|
||||
import * as monaco from 'monaco-editor'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { syncContentUpdate } from './monaco-content-sync'
|
||||
|
||||
const models: monaco.editor.ITextModel[] = []
|
||||
|
||||
function createEditor(initialContent: string): {
|
||||
editorInstance: monaco.editor.IStandaloneCodeEditor
|
||||
model: monaco.editor.ITextModel
|
||||
} {
|
||||
const model = monaco.editor.createModel(initialContent, 'plaintext')
|
||||
models.push(model)
|
||||
return {
|
||||
model,
|
||||
editorInstance: {
|
||||
getModel: () => model,
|
||||
pushUndoStop: () => {
|
||||
model.pushStackElement()
|
||||
return true
|
||||
}
|
||||
} as unknown as monaco.editor.IStandaloneCodeEditor
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const model of models.splice(0)) {
|
||||
model.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
describe('Monaco external-content undo history', () => {
|
||||
it('does not make a read-only live-tail append undoable', () => {
|
||||
const { editorInstance, model } = createEditor('first line')
|
||||
|
||||
syncContentUpdate(editorInstance, 'first line\nappended', 'read-only-live-tail')
|
||||
|
||||
expect(model.getValue()).toBe('first line\nappended')
|
||||
expect(model.canUndo()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps ordinary external updates undoable', async () => {
|
||||
const { editorInstance, model } = createEditor('editable')
|
||||
|
||||
syncContentUpdate(editorInstance, 'external update')
|
||||
|
||||
expect(model.canUndo()).toBe(true)
|
||||
await model.undo()
|
||||
expect(model.getValue()).toBe('editable')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { bench, expect } from 'vitest'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import { syncContentUpdate, type MonacoContentSyncMode } from './monaco-content-sync'
|
||||
|
||||
const MEBIBYTE = 1024 * 1024
|
||||
const BATCH_COUNT = 5
|
||||
const BATCH_BYTES = 10 * MEBIBYTE
|
||||
|
||||
type UndoStackElement = { heapSize: () => number }
|
||||
type UndoRedoService = {
|
||||
getElements: (resource: monaco.Uri) => { past: UndoStackElement[]; future: UndoStackElement[] }
|
||||
}
|
||||
|
||||
function undoHistoryBytes(model: monaco.editor.ITextModel): number {
|
||||
const service = (model as unknown as { _undoRedoService: UndoRedoService })._undoRedoService
|
||||
const elements = service.getElements(model.uri)
|
||||
return [...elements.past, ...elements.future].reduce(
|
||||
(total, element) => total + element.heapSize(),
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
async function forceGcAndSettle(): Promise<void> {
|
||||
const gc = (globalThis as { gc?: () => void }).gc
|
||||
if (!gc) {
|
||||
throw new Error('Forced GC unavailable; run the benchmark with node --expose-gc')
|
||||
}
|
||||
gc()
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
async function measureUndoRetention(mode: MonacoContentSyncMode): Promise<{
|
||||
arrayBufferDelta: number
|
||||
canUndo: boolean
|
||||
undoBytes: number
|
||||
}> {
|
||||
await forceGcAndSettle()
|
||||
const beforeArrayBuffers = process.memoryUsage().arrayBuffers
|
||||
const model = monaco.editor.createModel('', 'plaintext')
|
||||
const editorInstance = {
|
||||
getModel: () => model,
|
||||
pushUndoStop: () => {
|
||||
model.pushStackElement()
|
||||
return true
|
||||
}
|
||||
} as unknown as monaco.editor.IStandaloneCodeEditor
|
||||
let content = ''
|
||||
for (let batch = 0; batch < BATCH_COUNT; batch++) {
|
||||
content += String(batch % 10).repeat(BATCH_BYTES)
|
||||
syncContentUpdate(editorInstance, content, mode)
|
||||
}
|
||||
const canUndo = model.canUndo()
|
||||
const undoBytes = undoHistoryBytes(model)
|
||||
await forceGcAndSettle()
|
||||
const arrayBufferDelta = process.memoryUsage().arrayBuffers - beforeArrayBuffers
|
||||
model.dispose()
|
||||
await forceGcAndSettle()
|
||||
return { arrayBufferDelta, canUndo, undoBytes }
|
||||
}
|
||||
|
||||
bench(
|
||||
'50 MiB read-only live-tail undo retention',
|
||||
async () => {
|
||||
const undoable = await measureUndoRetention('undoable')
|
||||
const readOnlyLiveTail = await measureUndoRetention('read-only-live-tail')
|
||||
console.log(
|
||||
`[monaco-content-sync] 50 MiB undo retention ${JSON.stringify({ undoable, readOnlyLiveTail })}`
|
||||
)
|
||||
expect(undoable.canUndo).toBe(true)
|
||||
expect(undoable.undoBytes).toBeGreaterThanOrEqual(BATCH_COUNT * BATCH_BYTES)
|
||||
expect(undoable.arrayBufferDelta).toBeGreaterThanOrEqual(BATCH_COUNT * BATCH_BYTES)
|
||||
expect(readOnlyLiveTail.canUndo).toBe(false)
|
||||
expect(readOnlyLiveTail.undoBytes).toBe(0)
|
||||
expect(readOnlyLiveTail.arrayBufferDelta).toBe(0)
|
||||
},
|
||||
{ iterations: 1, time: 1, warmupIterations: 0, warmupTime: 0 }
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { editor, IRange, ISelection } from 'monaco-editor'
|
||||
|
||||
export type MonacoE2ESnapshot = {
|
||||
canUndo: boolean
|
||||
contentHeight: number
|
||||
scrollHeight: number
|
||||
scrollTop: number
|
||||
@@ -74,6 +75,7 @@ export function installMonacoE2EProbe(
|
||||
? lastLine
|
||||
: (model?.getLineContent(lastLineNumber - 1) ?? '')
|
||||
return {
|
||||
canUndo: model?.canUndo() ?? false,
|
||||
contentHeight: editorInstance.getContentHeight(),
|
||||
scrollHeight: editorInstance.getScrollHeight(),
|
||||
scrollTop: editorInstance.getScrollTop(),
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
WorkspaceSpaceWorktree
|
||||
} from '../../../../shared/workspace-space-types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
|
||||
import { toast } from 'sonner'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { useAppStore } from '../../store'
|
||||
@@ -321,9 +322,11 @@ function UpdatedMetric({
|
||||
if (scannedAt === null) {
|
||||
return
|
||||
}
|
||||
// Refresh once immediately (unconditional, as before) so a rescan that lands
|
||||
// while hidden isn't shown stale, then pause the ongoing 60s tick while the
|
||||
// window is hidden — same visibility-gated pattern as useNow.
|
||||
setNow(Date.now())
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 60_000)
|
||||
return () => window.clearInterval(timer)
|
||||
return installWindowVisibilityInterval({ run: () => setNow(Date.now()), intervalMs: 60_000 })
|
||||
}, [scannedAt])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { AlertTriangle, GitBranch, Loader2, RotateCcw, X } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
|
||||
import { retryBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow'
|
||||
import { getCreationProgressLabel } from '@/lib/pending-worktree-creation'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -31,8 +32,9 @@ export default function WorktreeCreationPanel({
|
||||
if (entryStatus !== 'creating') {
|
||||
return
|
||||
}
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
// Pause the 1s clock while the window is hidden so a backgrounded creation
|
||||
// panel stops re-rendering for ticks no one can see.
|
||||
return installWindowVisibilityInterval({ run: () => setNow(Date.now()), intervalMs: 1000 })
|
||||
}, [entryStatus])
|
||||
if (!entry) {
|
||||
return null
|
||||
|
||||
@@ -37,6 +37,12 @@ export type { RuntimeMarkdownReadTabResult, RuntimeMarkdownSaveTabResult }
|
||||
|
||||
export type RuntimeGraphStatus = 'ready' | 'reloading' | 'unavailable'
|
||||
|
||||
export type RuntimeDesktopWindowStatus = 'available' | 'openable' | 'initializing' | 'blocked'
|
||||
|
||||
// Why: headless serve still owns one runtime graph, but zero can never collide
|
||||
// with Electron BrowserWindow ids and can be transferred safely on promotion.
|
||||
export const HEADLESS_RUNTIME_WINDOW_ID = 0
|
||||
|
||||
// Why: the access scope a paired device token grants. Lives in shared so
|
||||
// pairing offers, status.get, and the device registry use one vocabulary.
|
||||
export type DeviceScope = 'mobile' | 'runtime'
|
||||
@@ -55,6 +61,7 @@ export type RuntimeStatus = {
|
||||
rendererGraphEpoch: number
|
||||
graphStatus: RuntimeGraphStatus
|
||||
authoritativeWindowId: number | null
|
||||
desktopWindowStatus?: RuntimeDesktopWindowStatus
|
||||
liveTabCount: number
|
||||
liveLeafCount: number
|
||||
// Why: optional so clients can read both new and pre-contract runtimes.
|
||||
@@ -85,6 +92,7 @@ export type CliStatusResult = {
|
||||
app: {
|
||||
running: boolean
|
||||
pid: number | null
|
||||
desktopWindowStatus?: RuntimeDesktopWindowStatus
|
||||
}
|
||||
runtime: {
|
||||
state: CliRuntimeState
|
||||
|
||||
Reference in New Issue
Block a user