mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
fix: fence desktop terminal teardown admissions
This commit is contained in:
@@ -3639,17 +3639,18 @@
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "ssh"],
|
||||
"coverageNotes": "A macOS unit harness loads the installed patched node-pty WindowsTerminal prototype without constructing native state, proving pre-ready kill/destroy dispatch immediately. A relay artifact harness reproduces vanilla npm node-pty retaining one deferred kill with zero native close calls, then proves both Windows relay artifacts hash, ship, apply, include the local ConPTY console-list fallback, and idempotently verify the fix. Relay tests simulate win32 shutdown, repeated stale-spawn native failure, one-shot ownership, and retry after native failure without deleting the only handle before close is proven. Relay boot UUIDs scope recycled pty-N counters through initial spawn, durable-lease persistence, reattach, and SSH target-id migration; mismatched generations expire before raw attach, while pane identity protects legacy leases. Renderer retry ownership keeps one coalesced record per still-live unconfirmed PTY and lifecycle retry performs at most one attempt with backoff; it never drops a live owner merely to cap bookkeeping. Local Windows shutdown retains provider records and listeners after native throw unless onExit independently proves death. Project removal keeps local or remote ownership until provider-verified shutdown and successful VM cleanup; runtime repo removal drains create-first worktree creation plus headless, renderer-backed mobile, renderer split, and selector-less renderer terminal admissions before fresh ownership/liveness snapshots, stop, and metadata mutation. Targeted terminal.stop probes skip unrelated sessions. The same relay contract serves paired web/mobile clients; no live Windows ConPTY or Windows-over-SSH run is recorded yet.",
|
||||
"coverageNotes": "A macOS unit harness loads the installed patched node-pty WindowsTerminal prototype without constructing native state, proving pre-ready kill/destroy dispatch immediately. A relay artifact harness reproduces vanilla npm node-pty retaining one deferred kill with zero native close calls, then proves both Windows relay artifacts hash, ship, apply, include the local ConPTY console-list fallback, and idempotently verify the fix. Relay tests simulate win32 shutdown, repeated stale-spawn native failure, one-shot ownership, and retry after native failure without deleting the only handle before close is proven. Relay boot UUIDs scope recycled pty-N counters through initial spawn, durable-lease persistence, reattach, and SSH target-id migration; mismatched generations expire before raw attach, while pane identity protects legacy leases. Renderer retry ownership keeps one coalesced record per still-live unconfirmed PTY and lifecycle retry performs at most one attempt with backoff; it never drops a live owner merely to cap bookkeeping. Local Windows shutdown retains provider records and listeners after native throw unless onExit independently proves death. Project removal keeps local or remote ownership until provider-verified shutdown and successful VM cleanup; runtime repo removal drains create-first worktree creation plus headless, renderer-backed mobile, renderer split, and selector-less renderer terminal admissions before fresh ownership/liveness snapshots, stop, and metadata mutation. Desktop repo removal routes through the same transaction; legacy worktree creation, renderer PTY spawn, and local/SSH/folder worktree removal are fenced at their IPC admission boundaries. Targeted terminal.stop probes skip unrelated sessions. The same relay contract serves paired web/mobile clients; no live Windows ConPTY or Windows-over-SSH run is recorded yet.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/8260",
|
||||
"https://github.com/stablyai/orca/pull/8279"
|
||||
],
|
||||
"invariant": "A Windows PTY closed before its first output executes exactly one no-argument node-pty shutdown immediately; Orca never deletes ownership without accepted native shutdown or an observed exit, and a later retry can target only the same relay generation and pane while project removal fences both worktree and terminal creation.",
|
||||
"oracle": "Load the installed patched WindowsTerminal prototype in a pre-ready state and assert kill and destroy each close the terminal and native agent once with zero deferred work, while signal arguments still throw. Build both Windows SSH relay artifacts, reproduce vanilla npm node-pty with close=0/native-kill=0/deferred=1, apply the shipped patch, and assert close=1/native-kill=1/deferred=0, AttachConsole failure falls back to the shell pid, and the artifact version derives from all patch bytes. Assert remote npm install executes patch enforcement before its runtime probe and never finalizes a failed patch. In local and relay providers, assert native throws retain exact ownership/listeners and retry succeeds, while a synchronous onExit is accepted as independent death proof. Persist relay generation with every durable lease, expire mismatches before attach, and preserve generation through target migration. Reject 65 renderer kills, retain all 65 exact owners, then prove one lifecycle event retries at most one and 65 events clear all 65. Start worktree and terminal creation surfaces before project removal and assert removal performs no ownership/liveness snapshot until they publish; removal-first and failed/concurrent admissions must reject or release without deadlock.",
|
||||
"oracle": "Load the installed patched WindowsTerminal prototype in a pre-ready state and assert kill and destroy each close the terminal and native agent once with zero deferred work, while signal arguments still throw. Build both Windows SSH relay artifacts, reproduce vanilla npm node-pty with close=0/native-kill=0/deferred=1, apply the shipped patch, and assert close=1/native-kill=1/deferred=0, AttachConsole failure falls back to the shell pid, and the artifact version derives from all patch bytes. Assert remote npm install executes patch enforcement before its runtime probe and never finalizes a failed patch. In local and relay providers, assert native throws retain exact ownership/listeners and retry succeeds, while a synchronous onExit is accepted as independent death proof. Persist relay generation with every durable lease, expire mismatches before attach, and preserve generation through target migration. Reject 65 renderer kills, retain all 65 exact owners, then prove one lifecycle event retries at most one and 65 events clear all 65. Start worktree and terminal creation surfaces before project removal and assert removal performs no ownership/liveness snapshot until they publish; removal-first and failed/concurrent admissions must reject or release without deadlock. Start a real worktree removal, pause its preflight, and assert a late renderer terminal admission is rejected; assert desktop repo removal, worktree create/remove, and PTY spawn handlers all enter their runtime-owned transactions before mutation or provider work.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts config/scripts/build-relay-windows-node-pty-patch.test.mjs src/main/ssh/ssh-relay-native-deps-install.test.ts src/relay/node-pty-windows-pre-ready-shutdown.test.ts src/relay/pty-handler.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/store/slices/repos-remove-project-purge-leak.test.ts src/renderer/src/store/slices/repos.test.ts src/renderer/src/store/slices/repos-runtime-vm-removal.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/shared/ssh-types.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-generation-reattach.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/store/slices/repos.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/shared/ssh-types.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-generation-reattach.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/store/slices/repos.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-create.test.ts src/main/window/attach-main-window-services.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/worktrees-windows.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"config/scripts/build-relay-windows-node-pty-patch.test.mjs",
|
||||
@@ -3673,7 +3674,11 @@
|
||||
"src/relay/subprocess.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts",
|
||||
"src/renderer/src/lib/pty-kill-retry-ownership.test.ts",
|
||||
"src/main/ipc/pty.test.ts"
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/main/ipc/repos-create.test.ts",
|
||||
"src/main/ipc/worktrees.test.ts",
|
||||
"src/main/ipc/worktrees-windows.test.ts",
|
||||
"src/main/window/attach-main-window-services.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -3744,6 +3749,7 @@
|
||||
"project removal fences paired terminal admission through provider stop and repo mutation",
|
||||
"project removal drains create-first headless, renderer-backed mobile, renderer split, and selector-less renderer admissions before its liveness snapshot",
|
||||
"project removal drains create-first worktree creation before its ownership snapshot and rejects removal-first worktree creation",
|
||||
"worktree removal rejects a late renderer terminal admission while destructive preflight is still pending",
|
||||
"failed and concurrent admissions release without deadlocking project removal",
|
||||
"targeted terminal stop omits foreground probes for unrelated sessions"
|
||||
]
|
||||
@@ -3806,7 +3812,27 @@
|
||||
{
|
||||
"file": "src/main/ipc/pty.test.ts",
|
||||
"assertions": [
|
||||
"renderer pane identity reaches SSH provider shutdown"
|
||||
"renderer pane identity reaches SSH provider shutdown",
|
||||
"renderer PTY spawn enters runtime terminal admission before provider work"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/repos-create.test.ts",
|
||||
"assertions": [
|
||||
"desktop repo removal routes through the runtime project-removal transaction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/worktrees.test.ts",
|
||||
"assertions": [
|
||||
"legacy desktop worktree create and remove enter runtime admission before filesystem or Git mutation",
|
||||
"local worktree removal verifies terminal stop before destructive removal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/window/attach-main-window-services.test.ts",
|
||||
"assertions": [
|
||||
"production main-window wiring supplies the runtime authority to desktop repo handlers"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -3836,7 +3862,7 @@
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/ssh-pty-id.test.ts src/shared/ssh-types.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/persistence.test.ts src/main/ssh/ssh-relay-session.test.ts src/main/ssh/ssh-relay-generation-reattach.test.ts src/main/ssh/ssh-target-id-migration.test.ts src/relay/pty-handler.test.ts src/relay/subprocess.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts src/renderer/src/lib/pty-kill-retry-ownership.test.ts src/renderer/src/lib/ephemeral-vm-runtime-cleanup.test.ts src/renderer/src/store/slices/repos.test.ts src/main/runtime/orca-runtime.test.ts src/main/ipc/pty.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 12,
|
||||
"summary": "1678 tests passed with durable relay-generation persistence and mismatch rejection, local native-throw retry ownership, all 65 renderer shutdown owners retained and drained, stale-spawn retained ownership, worktree/terminal admission ordering, VM cleanup success ordering, and targeted liveness probes."
|
||||
"summary": "1680 tests passed with durable relay-generation persistence and mismatch rejection, local native-throw retry ownership, all 65 renderer shutdown owners retained and drained, stale-spawn retained ownership, worktree/terminal admission ordering, VM cleanup success ordering, and targeted liveness probes."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-12",
|
||||
@@ -3845,7 +3871,16 @@
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 18,
|
||||
"summary": "669 runtime tests passed, including removal-first rejection and create-first admission drain for worktree creation, headless terminals, renderer-backed mobile, renderer split, and selector-less renderer paths before ownership/liveness snapshots."
|
||||
"summary": "670 runtime tests passed, including removal-first rejection and create-first admission drain for worktree creation, headless terminals, renderer-backed mobile, renderer split, and selector-less renderer paths before ownership/liveness snapshots, plus late terminal rejection during worktree-removal preflight."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-12",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/repos-create.test.ts src/main/window/attach-main-window-services.test.ts src/main/ipc/worktrees.test.ts src/main/ipc/worktrees-windows.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 10,
|
||||
"summary": "1198 tests passed, proving production desktop repo-removal wiring, legacy worktree create/remove admission, renderer PTY-spawn admission, verified stop before local removal, and runtime rejection of late terminal creation during worktree-removal preflight."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -3858,11 +3893,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The built relay artifact harness records the vanilla npm dependency at close=0, native-kill=0, deferred=1 and the shipped patched artifact at close=1, native-kill=1, deferred=0. Without relay one-shot ownership, graceful fallback and dispose issue duplicate kills. The former 65-id renderer oracle proved the oldest still-live owner was dropped; the corrected oracle retries all 65. Native local kill throw now remains visible in listProcesses until retry, and a stale durable lease is rejected before attach when its persisted generation differs from the relay boot."
|
||||
"evidence": "The built relay artifact harness records the vanilla npm dependency at close=0, native-kill=0, deferred=1 and the shipped patched artifact at close=1, native-kill=1, deferred=0. Without relay one-shot ownership, graceful fallback and dispose issue duplicate kills. The former 65-id renderer oracle proved the oldest still-live owner was dropped; the corrected oracle retries all 65. Native local kill throw now remains visible in listProcesses until retry, and a stale durable lease is rejected before attach when its persisted generation differs from the relay boot. A real worktree-removal preflight now deterministically rejects a late renderer terminal admission before any destructive mutation."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Shutdown removes indefinite deferred retention and preserves at most one in-flight native kill plus one existing graceful timer per PTY request. Failed renderer kills coalesce by exact live PTY id: repeated failures for one id remain one record, while distinct records correspond one-for-one with still-unconfirmed provider resources and are never discarded before accepted shutdown. One lifecycle event attempts at most one retry with exponential backoff; 65 events deterministically drain 65 owners after recovery. Pre-handler exits remain capped at 64. Worktree-create and terminal admissions add no polling and exist only for active operations. Targeted runtime stop still performs one provider verification per target PTY plus two liveness snapshots, while foreground probes are limited to the target worktree."
|
||||
"evidence": "Shutdown removes indefinite deferred retention and preserves at most one in-flight native kill plus one existing graceful timer per PTY request. Failed renderer kills coalesce by exact live PTY id: repeated failures for one id remain one record, while distinct records correspond one-for-one with still-unconfirmed provider resources and are never discarded before accepted shutdown. One lifecycle event attempts at most one retry with exponential backoff; 65 events deterministically drain 65 owners after recovery. Pre-handler exits remain capped at 64. Worktree-create, worktree-removal, and terminal admissions add no polling and retain one promise only per active operation; removal drains only the target worktree's current admissions. Targeted runtime stop still performs one provider verification per target PTY plus two liveness snapshots, while foreground probes are limited to the target worktree."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the deterministic command across the required CI platforms without flakes.",
|
||||
|
||||
@@ -510,6 +510,29 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('enters runtime terminal-create admission before renderer spawn work', async () => {
|
||||
const admissionError = new Error('terminal admission blocked')
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
runWithTerminalCreateAdmission: vi.fn().mockRejectedValue(admissionError)
|
||||
}
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
|
||||
await expect(
|
||||
handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId: 'repo-1::/workspace/feature'
|
||||
})
|
||||
).rejects.toBe(admissionError)
|
||||
|
||||
expect(runtime.runWithTerminalCreateAdmission).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/feature',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
function createMockProc() {
|
||||
let dataHandler: ((data: string) => void) | null = null
|
||||
let exitHandler: ((event: { exitCode: number }) => void) | null = null
|
||||
|
||||
+12
-3
@@ -3578,9 +3578,17 @@ export function registerPtyHandlers(
|
||||
resetPtyRendererDeliveryDebug()
|
||||
})
|
||||
|
||||
const withTerminalCreateAdmission = <T>(
|
||||
worktreeId: string | undefined,
|
||||
action: () => Promise<T>
|
||||
): Promise<T> =>
|
||||
typeof runtime?.runWithTerminalCreateAdmission === 'function'
|
||||
? runtime.runWithTerminalCreateAdmission(worktreeId, action)
|
||||
: action()
|
||||
|
||||
ipcMain.handle(
|
||||
'pty:spawn',
|
||||
async (
|
||||
(
|
||||
_event,
|
||||
args: {
|
||||
cols: number
|
||||
@@ -3631,7 +3639,8 @@ export function registerPtyHandlers(
|
||||
request_kind?: unknown
|
||||
}
|
||||
}
|
||||
) => {
|
||||
) =>
|
||||
withTerminalCreateAdmission(args.worktreeId, async () => {
|
||||
const spawnTiming = createPtySpawnTiming()
|
||||
const startupPromise = getLocalPtyStartupPromise(args.connectionId)
|
||||
if (startupPromise) {
|
||||
@@ -4407,7 +4416,7 @@ export function registerPtyHandlers(
|
||||
rejectPaneSpawnReservation(reservationPaneKey, paneSpawnReservation, err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const writePtyProviderInputWithinLimit = (
|
||||
|
||||
@@ -164,6 +164,16 @@ describe('repos:create', () => {
|
||||
expect(removeHandlerMock).toHaveBeenCalledWith('repos:getDefaultCreateProjectParent')
|
||||
})
|
||||
|
||||
it('routes desktop repo removal through the runtime teardown transaction', async () => {
|
||||
const runtime = { removeProject: vi.fn().mockResolvedValue({ removed: true }) }
|
||||
registerRepoHandlers(mockWindow as never, mockStore as never, runtime as never)
|
||||
|
||||
await handlers.get('repos:remove')?.(null, { repoId: 'repo-1' })
|
||||
|
||||
expect(runtime.removeProject).toHaveBeenCalledWith('id:repo-1')
|
||||
expect(mockStore.removeProject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── input validation ──────────────────────────────────────────────
|
||||
|
||||
it('rejects empty names', async () => {
|
||||
|
||||
+11
-2
@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import { homedir } from 'node:os'
|
||||
import { z } from 'zod'
|
||||
import type { Store } from '../persistence'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type {
|
||||
BaseRefSearchResult,
|
||||
Project,
|
||||
@@ -1114,7 +1115,11 @@ async function runNestedRepoScanForIpc(
|
||||
}
|
||||
}
|
||||
|
||||
export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): void {
|
||||
export function registerRepoHandlers(
|
||||
mainWindow: BrowserWindow,
|
||||
store: Store,
|
||||
runtime?: OrcaRuntimeService
|
||||
): void {
|
||||
// Remove any previously registered handlers so we can re-register them
|
||||
// (e.g. when macOS re-activates the app and creates a new window).
|
||||
ipcMain.removeHandler('repos:list')
|
||||
@@ -1924,7 +1929,11 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
)
|
||||
|
||||
ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => {
|
||||
store.removeProject(args.repoId)
|
||||
if (runtime) {
|
||||
await runtime.removeProject(`id:${args.repoId}`)
|
||||
} else {
|
||||
store.removeProject(args.repoId)
|
||||
}
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
})
|
||||
|
||||
@@ -290,6 +290,9 @@ describe('registerWorktreeHandlers', () => {
|
||||
createTerminal: ReturnType<typeof vi.fn>
|
||||
splitTerminal: ReturnType<typeof vi.fn>
|
||||
notifyWorktreesChangedForRemoteClients: ReturnType<typeof vi.fn>
|
||||
runWithWorktreeCreateAdmission: ReturnType<typeof vi.fn>
|
||||
runWithWorktreeRemovalAdmission: ReturnType<typeof vi.fn>
|
||||
verifyTerminalsStoppedForRemoval: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -501,7 +504,14 @@ describe('registerWorktreeHandlers', () => {
|
||||
tabId: 'tab-startup',
|
||||
paneRuntimeId: -1
|
||||
}),
|
||||
notifyWorktreesChangedForRemoteClients: vi.fn()
|
||||
notifyWorktreesChangedForRemoteClients: vi.fn(),
|
||||
runWithWorktreeCreateAdmission: vi.fn(
|
||||
async (_repoId: string, action: () => Promise<unknown>) => action()
|
||||
),
|
||||
runWithWorktreeRemovalAdmission: vi.fn(
|
||||
async (_worktreeId: string, action: () => Promise<unknown>) => action()
|
||||
),
|
||||
verifyTerminalsStoppedForRemoval: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never)
|
||||
})
|
||||
@@ -621,6 +631,10 @@ describe('registerWorktreeHandlers', () => {
|
||||
{ cwd: '/workspace/repo' }
|
||||
)
|
||||
expect(runtimeStub.fetchRemoteWithCache).not.toHaveBeenCalled()
|
||||
expect(runtimeStub.runWithWorktreeCreateAdmission).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
'/workspace/pr-title',
|
||||
@@ -6505,6 +6519,13 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
|
||||
repoId: 'repo-1'
|
||||
})
|
||||
expect(runtimeStub.runWithWorktreeRemovalAdmission).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/feature-wt',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(runtimeStub.verifyTerminalsStoppedForRemoval).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/feature-wt'
|
||||
)
|
||||
})
|
||||
|
||||
it('recovers forced Windows long-path worktree removal through local deletion and prune', async () => {
|
||||
|
||||
+103
-83
@@ -971,6 +971,18 @@ export function registerWorktreeHandlers(
|
||||
store: Store,
|
||||
runtime: OrcaRuntimeService
|
||||
): void {
|
||||
const withCreateAdmission = <T>(repoId: string, action: () => Promise<T>): Promise<T> =>
|
||||
typeof runtime.runWithWorktreeCreateAdmission === 'function'
|
||||
? runtime.runWithWorktreeCreateAdmission(repoId, action)
|
||||
: action()
|
||||
const withRemovalAdmission = <T>(worktreeId: string, action: () => Promise<T>): Promise<T> =>
|
||||
typeof runtime.runWithWorktreeRemovalAdmission === 'function'
|
||||
? runtime.runWithWorktreeRemovalAdmission(worktreeId, action)
|
||||
: action()
|
||||
const verifyTerminalsStopped = (worktreeId: string): Promise<void> =>
|
||||
typeof runtime.verifyTerminalsStoppedForRemoval === 'function'
|
||||
? runtime.verifyTerminalsStoppedForRemoval(worktreeId)
|
||||
: Promise.resolve()
|
||||
// Remove any previously registered handlers so we can re-register them
|
||||
// (e.g. when macOS re-activates the app and creates a new window).
|
||||
ipcMain.removeHandler('worktrees:listAll')
|
||||
@@ -1228,72 +1240,74 @@ export function registerWorktreeHandlers(
|
||||
// attributes — branch names can carry user-content (e.g. an issue
|
||||
// title) and the redactor would have to learn yet another rule;
|
||||
// the repo ID is the safer correlator for the bundle.
|
||||
return withWorktreeSpan({ stage: 'create' }, async () => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) {
|
||||
throw new Error(`Repo not found: ${args.repoId}`)
|
||||
}
|
||||
return withCreateAdmission(args.repoId, () =>
|
||||
withWorktreeSpan({ stage: 'create' }, async () => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) {
|
||||
throw new Error(`Repo not found: ${args.repoId}`)
|
||||
}
|
||||
|
||||
const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource)
|
||||
const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
|
||||
const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource)
|
||||
const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
|
||||
|
||||
const automationProvenance = resolveAutomationWorkspaceProvenance({
|
||||
authority: runtime,
|
||||
repoSelector: args.repoId,
|
||||
repo,
|
||||
request: args.automationProvenanceRequest
|
||||
})
|
||||
const createArgs: CreateWorktreeArgsWithSystemProvenance = {
|
||||
...args,
|
||||
automationProvenance
|
||||
}
|
||||
const automationProvenance = resolveAutomationWorkspaceProvenance({
|
||||
authority: runtime,
|
||||
repoSelector: args.repoId,
|
||||
repo,
|
||||
request: args.automationProvenanceRequest
|
||||
})
|
||||
const createArgs: CreateWorktreeArgsWithSystemProvenance = {
|
||||
...args,
|
||||
automationProvenance
|
||||
}
|
||||
|
||||
let result: CreateWorktreeResult
|
||||
try {
|
||||
// Why: only wrap the helpers themselves. The pre-validation throws
|
||||
// above (`Repo not found`, `Folder mode does not support creating
|
||||
// worktrees`) signal IPC-shape bugs, not the user-visible
|
||||
// git/filesystem failures the funnel cares about — bucketing them
|
||||
// into `unknown` would pollute the failure taxonomy.
|
||||
result = isFolderRepo(repo)
|
||||
? createFolderWorkspace(createArgs, repo, store)
|
||||
: repo.connectionId
|
||||
? await createRemoteWorktree(createArgs, repo, store, mainWindow)
|
||||
: await createLocalWorktree(createArgs, repo, store, mainWindow, runtime)
|
||||
} catch (error) {
|
||||
releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
|
||||
track('workspace_create_failed', {
|
||||
let result: CreateWorktreeResult
|
||||
try {
|
||||
// Why: only wrap the helpers themselves. The pre-validation throws
|
||||
// above (`Repo not found`, `Folder mode does not support creating
|
||||
// worktrees`) signal IPC-shape bugs, not the user-visible
|
||||
// git/filesystem failures the funnel cares about — bucketing them
|
||||
// into `unknown` would pollute the failure taxonomy.
|
||||
result = isFolderRepo(repo)
|
||||
? createFolderWorkspace(createArgs, repo, store)
|
||||
: repo.connectionId
|
||||
? await createRemoteWorktree(createArgs, repo, store, mainWindow)
|
||||
: await createLocalWorktree(createArgs, repo, store, mainWindow, runtime)
|
||||
} catch (error) {
|
||||
releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
|
||||
track('workspace_create_failed', {
|
||||
source,
|
||||
error_class: classifyWorkspaceCreateError(error),
|
||||
...getCohortAtEmit()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
|
||||
|
||||
// Why: emit `workspace_created` only after the underlying create has
|
||||
// resolved (the helpers throw on failure, so reaching this line means
|
||||
// git-add succeeded — we deliberately do not also emit a separate
|
||||
// `workspace_initialized`, see telemetry-plan.md§Deferred events).
|
||||
// `from_existing_branch` is true iff the caller specified a non-empty
|
||||
// baseBranch; an unspecified baseBranch means "branch from default
|
||||
// HEAD", which is the not-from-existing-branch case. We never send
|
||||
// the branch name itself.
|
||||
track('workspace_created', {
|
||||
source,
|
||||
error_class: classifyWorkspaceCreateError(error),
|
||||
from_existing_branch:
|
||||
!isFolderRepo(repo) &&
|
||||
typeof args.baseBranch === 'string' &&
|
||||
args.baseBranch.length > 0,
|
||||
...getCohortAtEmit()
|
||||
})
|
||||
throw error
|
||||
}
|
||||
finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest)
|
||||
|
||||
// Why: emit `workspace_created` only after the underlying create has
|
||||
// resolved (the helpers throw on failure, so reaching this line means
|
||||
// git-add succeeded — we deliberately do not also emit a separate
|
||||
// `workspace_initialized`, see telemetry-plan.md§Deferred events).
|
||||
// `from_existing_branch` is true iff the caller specified a non-empty
|
||||
// baseBranch; an unspecified baseBranch means "branch from default
|
||||
// HEAD", which is the not-from-existing-branch case. We never send
|
||||
// the branch name itself.
|
||||
track('workspace_created', {
|
||||
source,
|
||||
from_existing_branch:
|
||||
!isFolderRepo(repo) &&
|
||||
typeof args.baseBranch === 'string' &&
|
||||
args.baseBranch.length > 0,
|
||||
...getCohortAtEmit()
|
||||
if (isFolderRepo(repo)) {
|
||||
notifyWorktreesChanged(mainWindow, repo.id)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
if (isFolderRepo(repo)) {
|
||||
notifyWorktreesChanged(mainWindow, repo.id)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1418,13 +1432,14 @@ export function registerWorktreeHandlers(
|
||||
// Why: stale toast actions, double-clicks, and Space/sidebar races can
|
||||
// target the same worktree concurrently. Share the destructive backend
|
||||
// operation so only one path touches Git and the filesystem.
|
||||
const removal = (async (): Promise<RemoveWorktreeResult> => {
|
||||
const removal = withRemovalAdmission(args.worktreeId, async () => {
|
||||
if (isFolderRepo(repo)) {
|
||||
if (args.worktreeId === getFolderWorkspaceRootId(repo)) {
|
||||
throw new Error(
|
||||
'Cannot delete the project root workspace. Remove the folder project instead.'
|
||||
)
|
||||
}
|
||||
await verifyTerminalsStopped(args.worktreeId)
|
||||
// Why: folder workspaces share one filesystem root, so there is no Git
|
||||
// remove step to close shells; sweep PTYs before dropping metadata.
|
||||
await killAllProcessesForWorktree(args.worktreeId, {
|
||||
@@ -1682,6 +1697,7 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
await verifyTerminalsStopped(args.worktreeId)
|
||||
const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {}
|
||||
const rawRemovalResult = await (Object.keys(remoteRemoveOptions).length > 0
|
||||
? provider!.removeWorktree(canonicalWorktreePath, args.force, remoteRemoveOptions)
|
||||
@@ -1762,6 +1778,7 @@ export function registerWorktreeHandlers(
|
||||
await closeLocalWatcherForRemoval(canonicalWorktreePath)
|
||||
|
||||
if (shouldTearDownPtys) {
|
||||
await verifyTerminalsStopped(args.worktreeId)
|
||||
// Why: once preflight proves normal deletion is clean, kill PTYs before
|
||||
// git-level removal so Windows handles cannot keep the directory busy.
|
||||
await killAllProcessesForWorktree(args.worktreeId, {
|
||||
@@ -1885,7 +1902,7 @@ export function registerWorktreeHandlers(
|
||||
|
||||
notifyWorktreesChanged(mainWindow, repoId)
|
||||
return removalResult ?? {}
|
||||
})()
|
||||
})
|
||||
worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal })
|
||||
try {
|
||||
return await removal
|
||||
@@ -1930,30 +1947,33 @@ export function registerWorktreeHandlers(
|
||||
throw new Error(`Worktree deletion already in progress: ${args.worktreeId}`)
|
||||
}
|
||||
|
||||
const forget = (async (): Promise<RemoveWorktreeResult> => {
|
||||
if (isFolderRepo(repo) && args.worktreeId === getFolderWorkspaceRootId(repo)) {
|
||||
throw new Error(
|
||||
'Cannot delete the project root workspace. Remove the folder project instead.'
|
||||
)
|
||||
const forget = withRemovalAdmission(
|
||||
args.worktreeId,
|
||||
async (): Promise<RemoveWorktreeResult> => {
|
||||
if (isFolderRepo(repo) && args.worktreeId === getFolderWorkspaceRootId(repo)) {
|
||||
throw new Error(
|
||||
'Cannot delete the project root workspace. Remove the folder project instead.'
|
||||
)
|
||||
}
|
||||
|
||||
// Why: best-effort PTY sweep. killAllProcessesForWorktree resolves
|
||||
// synchronously for a dead SSH relay (the provider tombstones the lease
|
||||
// and returns without awaiting the remote), so this never hangs.
|
||||
await killAllProcessesForWorktree(args.worktreeId, {
|
||||
runtime,
|
||||
localProvider: getLocalPtyProvider(),
|
||||
onPtyStopped: clearProviderPtyState
|
||||
}).catch((err) => {
|
||||
console.warn(`[worktree-teardown] forget-local failed for ${args.worktreeId}:`, err)
|
||||
})
|
||||
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId)
|
||||
preservedBranchCleanupByWorktreeId.delete(args.worktreeId)
|
||||
notifyWorktreesChanged(mainWindow, repoId)
|
||||
return {}
|
||||
}
|
||||
|
||||
// Why: best-effort PTY sweep. killAllProcessesForWorktree resolves
|
||||
// synchronously for a dead SSH relay (the provider tombstones the lease
|
||||
// and returns without awaiting the remote), so this never hangs.
|
||||
await killAllProcessesForWorktree(args.worktreeId, {
|
||||
runtime,
|
||||
localProvider: getLocalPtyProvider(),
|
||||
onPtyStopped: clearProviderPtyState
|
||||
}).catch((err) => {
|
||||
console.warn(`[worktree-teardown] forget-local failed for ${args.worktreeId}:`, err)
|
||||
})
|
||||
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
removeWorktreeMetadataAndTransientState(store, args.worktreeId)
|
||||
preservedBranchCleanupByWorktreeId.delete(args.worktreeId)
|
||||
notifyWorktreesChanged(mainWindow, repoId)
|
||||
return {}
|
||||
})()
|
||||
)
|
||||
worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: forget })
|
||||
try {
|
||||
return await forget
|
||||
|
||||
@@ -29624,6 +29624,28 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(gitIdx).toBeGreaterThan(killIdx)
|
||||
})
|
||||
|
||||
it('blocks late terminal admission while worktree removal is in preflight', async () => {
|
||||
const preflightStarted = deferred<void>()
|
||||
const allowPreflight = deferred<void>()
|
||||
vi.mocked(removeWorktree).mockResolvedValue({})
|
||||
vi.mocked(assertWorktreeCleanForRemoval).mockImplementation(async () => {
|
||||
preflightStarted.resolve()
|
||||
await allowPreflight.promise
|
||||
})
|
||||
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
const removal = runtime.removeManagedWorktree(TEST_WORKTREE_ID)
|
||||
await preflightStarted.promise
|
||||
|
||||
await expect(
|
||||
runtime.runWithTerminalCreateAdmission(TEST_WORKTREE_ID, async () => undefined)
|
||||
).rejects.toThrow('terminal_admission_blocked_for_project_removal')
|
||||
|
||||
allowPreflight.resolve()
|
||||
await expect(removal).resolves.toEqual({})
|
||||
})
|
||||
|
||||
it('thunk resolves the installed provider lazily, not at construction time', async () => {
|
||||
// Simulates the daemon adapter being installed AFTER OrcaRuntimeService
|
||||
// construction (setLocalPtyProvider(routedAdapter) in daemon-init).
|
||||
|
||||
@@ -12286,6 +12286,13 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
async runWithTerminalCreateAdmission<T>(
|
||||
worktreeId: string | undefined,
|
||||
action: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return worktreeId ? this.withTerminalAdmission(worktreeId, action) : action()
|
||||
}
|
||||
|
||||
private beginWorktreeCreateAdmission(repoId: string): () => void {
|
||||
if (this.projectRemovalBlockedRepoIds.has(repoId)) {
|
||||
throw new Error('project_removal_in_progress')
|
||||
@@ -12311,10 +12318,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
private async withWorktreeCreateAdmission<T>(
|
||||
repoId: string,
|
||||
action: () => Promise<T>
|
||||
): Promise<T> {
|
||||
async runWithWorktreeCreateAdmission<T>(repoId: string, action: () => Promise<T>): Promise<T> {
|
||||
const release = this.beginWorktreeCreateAdmission(repoId)
|
||||
try {
|
||||
return await action()
|
||||
@@ -12327,6 +12331,32 @@ export class OrcaRuntimeService {
|
||||
await Promise.all([...(this.worktreeCreateAdmissionsByRepoId.get(repoId) ?? [])])
|
||||
}
|
||||
|
||||
async runWithWorktreeRemovalAdmission<T>(
|
||||
worktreeId: string,
|
||||
action: () => Promise<T>
|
||||
): Promise<T> {
|
||||
if (this.terminalAdmissionBlockedWorktreeIds.has(worktreeId)) {
|
||||
throw new Error('worktree_removal_in_progress')
|
||||
}
|
||||
this.terminalAdmissionBlockedWorktreeIds.add(worktreeId)
|
||||
try {
|
||||
await this.drainTerminalAdmissions([worktreeId])
|
||||
return await action()
|
||||
} finally {
|
||||
this.terminalAdmissionBlockedWorktreeIds.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
async verifyTerminalsStoppedForRemoval(worktreeId: string): Promise<void> {
|
||||
if (!this.ptyController) {
|
||||
if (this.getLivePtyIdsForWorktree(worktreeId).size > 0) {
|
||||
throw new Error('terminal_verified_stop_unavailable')
|
||||
}
|
||||
return
|
||||
}
|
||||
await this.stopTerminalsForWorktree(`id:${worktreeId}`, { allowUnavailableGraph: true })
|
||||
}
|
||||
|
||||
async removeProject(repoSelector: string): Promise<{ removed: true }> {
|
||||
if (!this.store?.removeProject) {
|
||||
throw new Error('runtime_unavailable')
|
||||
@@ -14700,7 +14730,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
const repo = await this.resolveRepoSelector(args.repoSelector)
|
||||
return await this.withWorktreeCreateAdmission(repo.id, () =>
|
||||
return await this.runWithWorktreeCreateAdmission(repo.id, () =>
|
||||
this.runCreateManagedWorktree(args)
|
||||
)
|
||||
}
|
||||
@@ -17038,7 +17068,7 @@ export class OrcaRuntimeService {
|
||||
|
||||
// Why: runtime callers can race the same workspace through CLI/mobile
|
||||
// retries. Share one destructive Git/filesystem operation per worktree ID.
|
||||
const removal = (async (): Promise<RemoveWorktreeResult & { warning?: string }> => {
|
||||
const removal = this.runWithWorktreeRemovalAdmission(removalTarget.id, async () => {
|
||||
const repo = store.getRepo(removalTarget.repoId)
|
||||
if (!repo) {
|
||||
throw new Error('repo_not_found')
|
||||
@@ -17050,6 +17080,7 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
}
|
||||
const localProvider = this.getLocalProvider()
|
||||
await this.verifyTerminalsStoppedForRemoval(removalTarget.id)
|
||||
if (localProvider) {
|
||||
// Why: folder workspace deletion has no Git removal phase where PTYs
|
||||
// would otherwise be swept; tear them down before hiding the workspace.
|
||||
@@ -17273,6 +17304,7 @@ export class OrcaRuntimeService {
|
||||
return removalResult ?? {}
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
await this.verifyTerminalsStoppedForRemoval(removalTarget.id)
|
||||
const remoteRemoveOptions = !deleteBranch ? { deleteBranch } : {}
|
||||
const rawRemovalResult = await (Object.keys(remoteRemoveOptions).length > 0
|
||||
? provider!.removeWorktree(canonicalWorktreePath, force, remoteRemoveOptions)
|
||||
@@ -17363,6 +17395,9 @@ export class OrcaRuntimeService {
|
||||
await closeLocalWatcherForWorktreePath(canonicalWorktreePath).catch((err) => {
|
||||
console.warn(`[filesystem-watcher] failed to close ${canonicalWorktreePath}:`, err)
|
||||
})
|
||||
if (shouldTearDownPtys) {
|
||||
await this.verifyTerminalsStoppedForRemoval(removalTarget.id)
|
||||
}
|
||||
if (localProvider && shouldTearDownPtys) {
|
||||
// Why: once preflight proves normal deletion is clean, kill PTYs before
|
||||
// git-level removal so Windows handles cannot keep the directory busy. This also
|
||||
@@ -17493,7 +17528,7 @@ export class OrcaRuntimeService {
|
||||
...removalResult,
|
||||
...(warning ? { warning } : {})
|
||||
}
|
||||
})()
|
||||
})
|
||||
this.removeManagedWorktreeInFlight.set(removalTarget.id, { optionsKey, promise: removal })
|
||||
try {
|
||||
return await removal
|
||||
@@ -18917,14 +18952,26 @@ export class OrcaRuntimeService {
|
||||
})
|
||||
}
|
||||
|
||||
async stopTerminalsForWorktree(worktreeSelector: string): Promise<{ stopped: number }> {
|
||||
// Why: this mutates live PTYs, so the runtime must reject it while the
|
||||
// renderer graph is reloading instead of acting on cached leaf ownership.
|
||||
const graphEpoch = this.captureReadyGraphEpoch()
|
||||
async stopTerminalsForWorktree(
|
||||
worktreeSelector: string,
|
||||
options: { allowUnavailableGraph?: boolean } = {}
|
||||
): Promise<{ stopped: number }> {
|
||||
// Why: fresh provider liveness, not a cached renderer graph, is shutdown
|
||||
// authority. When a graph is ready, still reject epoch changes mid-proof;
|
||||
// headless/boot removal can proceed from the same fresh provider snapshot.
|
||||
const graphEpoch = options.allowUnavailableGraph
|
||||
? this.graphStatus === 'ready'
|
||||
? this.captureReadyGraphEpoch()
|
||||
: null
|
||||
: this.captureReadyGraphEpoch()
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
this.assertStableReadyGraph(graphEpoch)
|
||||
if (graphEpoch !== null) {
|
||||
this.assertStableReadyGraph(graphEpoch)
|
||||
}
|
||||
const resolvedWorktrees = [...(await this.getResolvedWorktreeMap()).values()]
|
||||
this.assertStableReadyGraph(graphEpoch)
|
||||
if (graphEpoch !== null) {
|
||||
this.assertStableReadyGraph(graphEpoch)
|
||||
}
|
||||
const refreshedPtyLiveness = await this.refreshPtyWorktreeRecordsFromController(
|
||||
resolvedWorktrees,
|
||||
worktree.id
|
||||
|
||||
@@ -202,15 +202,14 @@ describe('attachMainWindowServices', () => {
|
||||
it('reloads the app renderer through main and marks expected renderer teardown', async () => {
|
||||
const onBeforeRendererReload = vi.fn()
|
||||
const mainWindow = createMainWindow()
|
||||
const store = createStore()
|
||||
const runtime = createRuntime()
|
||||
|
||||
attachMainWindowServices(
|
||||
mainWindow as never,
|
||||
createStore(),
|
||||
createRuntime() as never,
|
||||
undefined,
|
||||
undefined,
|
||||
{ onBeforeRendererReload }
|
||||
)
|
||||
attachMainWindowServices(mainWindow as never, store, runtime as never, undefined, undefined, {
|
||||
onBeforeRendererReload
|
||||
})
|
||||
|
||||
expect(registerRepoHandlersMock).toHaveBeenCalledWith(mainWindow, store, runtime)
|
||||
|
||||
expect(removeHandlerMock).toHaveBeenCalledWith('app:reload')
|
||||
const reloadHandler = handleMock.mock.calls.find(([channel]) => channel === 'app:reload')?.[1]
|
||||
|
||||
@@ -81,7 +81,7 @@ export function attachMainWindowServices(
|
||||
}
|
||||
): void {
|
||||
registerAppReloadHandler(mainWindow, options?.onBeforeRendererReload)
|
||||
registerRepoHandlers(mainWindow, store)
|
||||
registerRepoHandlers(mainWindow, store, runtime)
|
||||
registerWorktreeHandlers(mainWindow, store, runtime)
|
||||
// Why: repo/settings mutations resync watchers through this attached main-window context.
|
||||
setWorktreeBaseDirectoryWatcherSyncContext(store, mainWindow)
|
||||
|
||||
Reference in New Issue
Block a user