mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
docs: remove checked-in audit artifacts (#32903)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
-11
@@ -105,16 +105,8 @@ docs/**
|
||||
# The deployable docs app is source, not local engineering notes.
|
||||
!docs/site/
|
||||
!docs/site/**
|
||||
!docs/audits/
|
||||
!docs/audits/closed-editor-model-lifetime/
|
||||
!docs/audits/closed-editor-model-lifetime/**
|
||||
!docs/audits/module-organization/
|
||||
!docs/audits/module-organization/**
|
||||
!docs/assets/
|
||||
!docs/assets/**
|
||||
!docs/audits/
|
||||
!docs/audits/plugin-uninstall-log-retirement/
|
||||
!docs/audits/plugin-uninstall-log-retirement/**
|
||||
!docs/readme/
|
||||
!docs/readme/**
|
||||
!docs/bug-reproductions/
|
||||
@@ -124,9 +116,6 @@ docs/**
|
||||
!docs/reference/cross-platform-support.md
|
||||
!docs/reference/git-workflow.md
|
||||
!docs/reference/test-antipatterns.md
|
||||
!docs/audits/
|
||||
!docs/audits/crashpad-read-limit/
|
||||
!docs/audits/crashpad-read-limit/source-hashes.json
|
||||
!docs/agent-skill-sharing-implementation-checklist.md
|
||||
!docs/mobile-terminal-shortcut-bar.md
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Acknowledged terminal-tab retirement
|
||||
|
||||
A host-requested whole-tab close can return `closed: true` while its row remains in `orca-data.json`. Once a repository's host topology revision is positive, the Store preserves host membership against a renderer save that merely omits a tab. The renderer acknowledges after its session flush; its graph removal can arrive later. The existing runtime fallback committed retirement only after that graph stopped owning the parent.
|
||||
|
||||
This fixes that acknowledgement ordering path relevant to [#17344](https://github.com/stablyai/orca/issues/17344). It does not establish that every tab in that report followed this path or explain large RSS from the small persisted row alone.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/acknowledged-tab-retirement/reproduce.mjs
|
||||
```
|
||||
|
||||
The runner uses temporary Vite configurations and the repository's cross-platform process runner. It reverses only `fix.patch` in memory for the baseline; the checkout remains unchanged. Tests use temporary Store files and inert desktop/provider ports. No application window, shell, SSH connection, or external host is started.
|
||||
|
||||
The 20-case artifact fixture drives the actual renderer close and session builders, Store persistence/reload, and runtime close/graph methods. Eleven source regressions exercise actual Store/runtime boundaries, including provider exit and spawn. The baseline has **28 failing fixed-behavior assertions and 3 passing controls**; the fix passes **31 cases**. Results include source hashes, exact failed cases, process exit codes, and timeout status.
|
||||
|
||||
## Ownership checks
|
||||
|
||||
The acknowledged-close path captures the original execution-host partition, persisted tab creation/generation, leaf/PTY bindings, and current process incarnations. It rechecks these after the renderer acknowledgement before committing the existing host retirement transaction. Original leaves may disappear during physical exit, and the tab's aggregate PTY or remote-session ID may promote an original surviving sibling. A new leaf, binding, incarnation, tab generation, or host is refused. Current pins are checked by the existing commit primitive; explicit force retains its existing meaning.
|
||||
|
||||
The process guard covers original persisted row, remote-session and leaf IDs, plus snapshot leaf/layout IDs. Thus a partial mobile snapshot cannot hide an original sibling's replacement. Missing runtime records alone are not treated as evidence of process death. The existing headless fallback still collects persisted SSH kill IDs before it commits; the added renderer-owned path issues no provider kill.
|
||||
|
||||
Controls include local and SSH-backed repositories and folder workspaces, unrelated host partitions, same-ID replacements, physical split exits, stale renderer saves/graphs, current pins, force, renderer refusal, and disk failure. The renderer-owned live snapshot can remain until its ordinary graph removal arrives; an older queued graph cannot restore it afterward.
|
||||
|
||||
## Scope limits
|
||||
|
||||
- A direct renderer-only close of a never-bound local tab still sends no explicit host retirement request. That separate gap remains reproduced by a negative control.
|
||||
- The new durable identity capture requires the legacy terminal row in `tabsByWorktree`. Unified-only terminal metadata is outside this fix.
|
||||
- The existing retirement primitive propagates a failed disk flush, but its attempted rollback can be rebased against the staged newer topology revision. The failure control verifies rejection and the original disk row; it does not claim successful in-memory rollback.
|
||||
- No wire fields, process-liveness verdicts, or membership-fence rules change. SSH persistence stays in its existing host partition; this does not reconcile the separate historical renderer/host partition divergence.
|
||||
- The related [original audit](../local-tab-close-rebase/README.md) traced the branch in reported **v1.4.192**. Executable comparisons use current source with this patch reversed, not the historical packaged application.
|
||||
@@ -1,184 +0,0 @@
|
||||
--- a/src/main/runtime/orca-runtime-close-mobile-session-tab.ts
|
||||
+++ b/src/main/runtime/orca-runtime-close-mobile-session-tab.ts
|
||||
@@ -20,6 +20,7 @@
|
||||
import type { RuntimeCommandSurfaceHost } from './orca-runtime-core'
|
||||
import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection'
|
||||
import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close'
|
||||
+import { captureAcknowledgedTerminalTabRetirement } from './workspace-session-terminal-tab-retirement-identity'
|
||||
|
||||
export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose {
|
||||
async closeMobileSessionTab(
|
||||
@@ -177,8 +178,18 @@
|
||||
return finishCommittedClose()
|
||||
}
|
||||
if (closingWholeParent && this.notifier?.closeTerminalTab) {
|
||||
- // Why: whole-tab close is a lifecycle transaction. The renderer reply
|
||||
- // arrives only after canonical retirement and a forced session flush.
|
||||
+ // The renderer flush can rebase its omission; the host commits the acknowledged identity.
|
||||
+ const acknowledgeRetirement = captureAcknowledgedTerminalTabRetirement(
|
||||
+ worktreeId,
|
||||
+ tab.parentTabId,
|
||||
+ () => ({
|
||||
+ hostId: this.getWorkspaceSessionHostIdForWorktree(worktreeId),
|
||||
+ session: this.getWorkspaceSessionForWorktree(worktreeId),
|
||||
+ snapshot: this.mobileSessionTabsByWorktree.get(worktreeId),
|
||||
+ incarnationOf: (ptyId) => this.ptysById.get(ptyId)?.incarnationId
|
||||
+ })
|
||||
+ )
|
||||
+ // Wait for the renderer's pin guard, retirement and forced session flush.
|
||||
const win = this.getAvailableAuthoritativeWindow()
|
||||
if (win?.webContents.isDestroyed?.()) {
|
||||
throw new Error('runtime_unavailable')
|
||||
@@ -200,6 +211,11 @@
|
||||
releasePublicationThrottle()
|
||||
}
|
||||
const remainingSnapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
+ const retirement = acknowledgeRetirement()
|
||||
+ if (!retirement.matches) {
|
||||
+ this.republishMobileSessionTabsSnapshot(worktreeId)
|
||||
+ return refusedMobileSessionTabClose('stale-terminal', { snapshotRepublished: true })
|
||||
+ }
|
||||
const remainingTab = remainingSnapshot?.tabs.find(
|
||||
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
|
||||
candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId
|
||||
@@ -220,6 +236,10 @@
|
||||
...(remainingPtyCloseAuthority ? { authorizedPty: remainingPtyCloseAuthority.pty } : {})
|
||||
})
|
||||
this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId)
|
||||
+ } else if (retirement.hasPersistedTab) {
|
||||
+ this.commitHeadlessTerminalTabRetirement(worktreeId, tab.parentTabId, {
|
||||
+ force: options.force
|
||||
+ })
|
||||
}
|
||||
this.clearRuntimeSessionOwnershipForMobileTab(worktreeId, snapshot, tab.parentTabId)
|
||||
return finishCommittedClose()
|
||||
--- /dev/null
|
||||
+++ b/src/main/runtime/workspace-session-terminal-tab-retirement-identity.ts
|
||||
@@ -0,0 +1,127 @@
|
||||
+import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
|
||||
+import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
+import { collectPersistedTerminalLeafIds } from './mobile-session-layout-projection'
|
||||
+
|
||||
+type PersistedTerminalTabIdentity = {
|
||||
+ createdAt: number
|
||||
+ generation: number
|
||||
+ ptyId: string | null
|
||||
+ remoteSessionId: string | null
|
||||
+ leaves: Map<string, { ptyId: string | null; incarnationId: string | null }>
|
||||
+}
|
||||
+function capturePersistedTerminalTabRetirementIdentity(
|
||||
+ session: WorkspaceSessionState | null,
|
||||
+ worktreeId: string,
|
||||
+ tabId: string
|
||||
+): PersistedTerminalTabIdentity | null {
|
||||
+ const row = session?.tabsByWorktree[worktreeId]?.find((candidate) => candidate.id === tabId)
|
||||
+ if (!row || !session) {
|
||||
+ return null
|
||||
+ }
|
||||
+ const layout = session.terminalLayoutsByTabId[tabId]
|
||||
+ return {
|
||||
+ createdAt: row.createdAt,
|
||||
+ generation: row.generation ?? 0,
|
||||
+ ptyId: row.ptyId,
|
||||
+ remoteSessionId: session.remoteSessionIdsByTabId?.[tabId] ?? null,
|
||||
+ leaves: new Map(
|
||||
+ collectPersistedTerminalLeafIds(layout).map((leafId) => [
|
||||
+ leafId,
|
||||
+ {
|
||||
+ ptyId: layout?.ptyIdsByLeafId?.[leafId] ?? null,
|
||||
+ incarnationId: session.terminalPtyIncarnationsByPaneKey?.[`${tabId}:${leafId}`] ?? null
|
||||
+ }
|
||||
+ ])
|
||||
+ )
|
||||
+ }
|
||||
+}
|
||||
+function isRetainedTerminalTabRetirementIdentity(
|
||||
+ captured: PersistedTerminalTabIdentity | null,
|
||||
+ current: PersistedTerminalTabIdentity | null
|
||||
+): boolean {
|
||||
+ if (!current) {
|
||||
+ return true
|
||||
+ }
|
||||
+ if (
|
||||
+ !captured ||
|
||||
+ captured.createdAt !== current.createdAt ||
|
||||
+ captured.generation !== current.generation ||
|
||||
+ (current.ptyId !== null &&
|
||||
+ current.ptyId !== captured.ptyId &&
|
||||
+ ![...captured.leaves.values()].some((leaf) => leaf.ptyId === current.ptyId)) ||
|
||||
+ (current.remoteSessionId !== null &&
|
||||
+ current.remoteSessionId !== captured.remoteSessionId &&
|
||||
+ ![...captured.leaves.values()].some((leaf) => leaf.ptyId === current.remoteSessionId))
|
||||
+ ) {
|
||||
+ return false
|
||||
+ }
|
||||
+ // Physical exits may retire original leaves while the renderer close is awaiting its flush.
|
||||
+ return [...current.leaves].every(([leafId, binding]) => {
|
||||
+ const previous = captured.leaves.get(leafId)
|
||||
+ return (
|
||||
+ previous !== undefined &&
|
||||
+ (binding.ptyId === null || binding.ptyId === previous.ptyId) &&
|
||||
+ (binding.incarnationId === null || binding.incarnationId === previous.incarnationId)
|
||||
+ )
|
||||
+ })
|
||||
+}
|
||||
+
|
||||
+type TerminalTabRetirementState = {
|
||||
+ hostId: string
|
||||
+ session: WorkspaceSessionState | null
|
||||
+ snapshot: RuntimeMobileSessionTabsSnapshot | undefined
|
||||
+ incarnationOf: (ptyId: string) => string | null | undefined
|
||||
+}
|
||||
+
|
||||
+export function captureAcknowledgedTerminalTabRetirement(
|
||||
+ worktreeId: string,
|
||||
+ tabId: string,
|
||||
+ readState: () => TerminalTabRetirementState
|
||||
+): () => { matches: boolean; hasPersistedTab: boolean } {
|
||||
+ const { hostId, session, snapshot, incarnationOf } = readState()
|
||||
+ const identity = capturePersistedTerminalTabRetirementIdentity(session, worktreeId, tabId)
|
||||
+ const surfaces = new Map(
|
||||
+ snapshot?.tabs.flatMap((tab) =>
|
||||
+ tab.type === 'terminal' && tab.parentTabId === tabId
|
||||
+ ? [[tab.leafId, tab.ptyId ?? null] as const]
|
||||
+ : []
|
||||
+ )
|
||||
+ )
|
||||
+ const ptyIds = new Set([
|
||||
+ identity?.ptyId,
|
||||
+ identity?.remoteSessionId,
|
||||
+ ...[...(identity?.leaves.values() ?? [])].map((leaf) => leaf.ptyId),
|
||||
+ ...surfaces.values(),
|
||||
+ ...(snapshot?.tabs.flatMap((tab) =>
|
||||
+ tab.type === 'terminal' && tab.parentTabId === tabId
|
||||
+ ? Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {})
|
||||
+ : []
|
||||
+ ) ?? [])
|
||||
+ ])
|
||||
+ const incarnations = new Map(
|
||||
+ [...ptyIds].flatMap((ptyId) => (ptyId ? [[ptyId, incarnationOf(ptyId) ?? null] as const] : []))
|
||||
+ )
|
||||
+ return () => {
|
||||
+ const current = readState()
|
||||
+ const remaining = capturePersistedTerminalTabRetirementIdentity(
|
||||
+ current.session,
|
||||
+ worktreeId,
|
||||
+ tabId
|
||||
+ )
|
||||
+ const matches =
|
||||
+ current.hostId === hostId &&
|
||||
+ isRetainedTerminalTabRetirementIdentity(identity, remaining) &&
|
||||
+ [...incarnations].every(([ptyId, incarnationId]) => {
|
||||
+ const next = current.incarnationOf(ptyId)
|
||||
+ return next === undefined || next === incarnationId
|
||||
+ }) &&
|
||||
+ !current.snapshot?.tabs.some(
|
||||
+ (tab) =>
|
||||
+ tab.type === 'terminal' &&
|
||||
+ tab.parentTabId === tabId &&
|
||||
+ (!surfaces.has(tab.leafId) ||
|
||||
+ (tab.ptyId != null && surfaces.get(tab.leafId) !== tab.ptyId))
|
||||
+ )
|
||||
+ return { matches, hasPersistedTab: remaining !== null }
|
||||
+ }
|
||||
+}
|
||||
@@ -1,486 +0,0 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Store } from '../../../src/main/persistence/loading-store/store'
|
||||
import {
|
||||
createTestStore,
|
||||
seedStore,
|
||||
makeWorktree
|
||||
} from '../../../src/renderer/src/store/slices/store-test-helpers'
|
||||
import { createStoreCascadesMockApi } from '../../../src/renderer/src/store/slices/store-cascades-test-harness'
|
||||
import { buildWorkspaceSessionPayload } from '../../../src/renderer/src/lib/workspace-session'
|
||||
import { buildHeadlessMobileSessionTerminalTabs } from '../../../src/main/runtime/mobile-session-terminal-projection'
|
||||
import { setRuntimeDesktopSurface } from '../../../src/main/runtime/runtime-desktop-surface'
|
||||
import { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime'
|
||||
import { advanceTerminalTopologyRevision } from '../../../src/main/persistence/terminal-topology/terminal-topology-membership'
|
||||
import type { ExecutionHostId } from '../../../src/shared/execution-host'
|
||||
|
||||
class AuditRuntime extends OrcaRuntimeService {
|
||||
surfaceParents(worktree: string) {
|
||||
return (
|
||||
this.mobileSessionTabsByWorktree
|
||||
.get(worktree)
|
||||
?.tabs.flatMap((t) => (t.type === 'terminal' ? [t.parentTabId] : [])) ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
const TAB = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF = '22222222-2222-4222-8222-222222222222'
|
||||
const LEAF_B = '44444444-4444-4444-8444-444444444444'
|
||||
const INC = '33333333-3333-4333-8333-333333333333'
|
||||
const INC_B = '55555555-5555-4555-8555-555555555555'
|
||||
const cleanup: (() => Promise<void>)[] = []
|
||||
afterEach(async () => {
|
||||
for (const fn of cleanup.splice(0)) {
|
||||
await fn()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
setRuntimeDesktopSurface(null)
|
||||
})
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
function fixture(kind: 'repo' | 'folder' = 'repo', host: ExecutionHostId = 'local', bound = false) {
|
||||
const api = createStoreCascadesMockApi()
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-close-ack-'))
|
||||
const file = join(dir, 'orca-data.json')
|
||||
const main = new Store({ dataFile: file })
|
||||
const renderer = createTestStore()
|
||||
seedStore(renderer, {})
|
||||
const repo = { ...renderer.getState().repos[0], executionHostId: host }
|
||||
main.addRepo(repo)
|
||||
let worktree = `${repo.id}::/tmp/worktree`
|
||||
let folder
|
||||
if (kind === 'folder') {
|
||||
const group = main.createProjectGroup({
|
||||
name: 'Folder',
|
||||
parentPath: '/tmp/folder',
|
||||
createdFrom: 'manual'
|
||||
})
|
||||
folder = main.createFolderWorkspace({
|
||||
projectGroupId: group.id,
|
||||
folderPath: '/tmp/folder',
|
||||
connectionId: host === 'ssh:target' ? 'target' : null
|
||||
})
|
||||
folder = main.getFolderWorkspace(folder.id)!
|
||||
worktree = `folder:${folder.id}`
|
||||
}
|
||||
seedStore(renderer, {
|
||||
repos: [repo],
|
||||
activeRepoId: repo.id,
|
||||
activeWorktreeId: worktree,
|
||||
activeTabId: TAB,
|
||||
worktreesByRepo: {
|
||||
[repo.id]: [makeWorktree({ id: worktree, repoId: repo.id, path: '/tmp/worktree' })]
|
||||
},
|
||||
...(folder ? { folderWorkspaces: [folder] } : {}),
|
||||
tabsByWorktree: { [worktree]: [] }
|
||||
})
|
||||
renderer.getState().createTab(worktree, undefined, undefined, { id: TAB, activate: false })
|
||||
if (bound) {
|
||||
renderer.setState((state) => ({
|
||||
tabsByWorktree: {
|
||||
[worktree]: state.tabsByWorktree[worktree].map((row) => ({ ...row, ptyId: 'pty-a' }))
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB]: {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.5,
|
||||
first: { type: 'leaf', leafId: LEAF },
|
||||
second: { type: 'leaf', leafId: LEAF_B }
|
||||
},
|
||||
activeLeafId: LEAF,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF]: 'pty-a', [LEAF_B]: 'pty-b' }
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
const initial = {
|
||||
...buildWorkspaceSessionPayload(renderer.getState()),
|
||||
...(bound
|
||||
? {
|
||||
terminalPtyIncarnationsByPaneKey: { [`${TAB}:${LEAF}`]: INC, [`${TAB}:${LEAF_B}`]: INC_B }
|
||||
}
|
||||
: {})
|
||||
}
|
||||
main.setWorkspaceSession(initial, host)
|
||||
if (host !== 'local') {
|
||||
main.setWorkspaceSession(initial, 'local')
|
||||
}
|
||||
setRuntimeDesktopSurface({
|
||||
showNotification: () => false,
|
||||
findWindowById: () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this proof only reads window and WebContents destruction.
|
||||
return { isDestroyed: () => false, webContents: { isDestroyed: () => false } } as never
|
||||
},
|
||||
onIpc: () => {},
|
||||
removeIpcListener: () => {}
|
||||
})
|
||||
const runtime = new AuditRuntime(main)
|
||||
runtime.attachWindow(1)
|
||||
if (bound) {
|
||||
runtime.registerPty('pty-a', worktree, null, { tabId: TAB, leafId: LEAF, incarnationId: INC })
|
||||
runtime.registerPty('pty-b', worktree, null, {
|
||||
tabId: TAB,
|
||||
leafId: LEAF_B,
|
||||
incarnationId: INC_B
|
||||
})
|
||||
}
|
||||
const publish = (present: boolean, version: number) => {
|
||||
const session = main.getWorkspaceSession(host)
|
||||
const tabs = present
|
||||
? buildHeadlessMobileSessionTerminalTabs(
|
||||
worktree,
|
||||
session.tabsByWorktree[worktree] ?? [],
|
||||
session
|
||||
)
|
||||
: []
|
||||
const graph = {
|
||||
tabs: present
|
||||
? [{ tabId: TAB, worktreeId: worktree, title: 'Target', activeLeafId: null, layout: null }]
|
||||
: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree,
|
||||
publicationEpoch: 'renderer:ack',
|
||||
activeGroupId: null,
|
||||
snapshotVersion: version,
|
||||
activeTabId: tabs[0]?.id ?? null,
|
||||
activeTabType: tabs.length ? ('terminal' as const) : null,
|
||||
tabs
|
||||
}
|
||||
]
|
||||
}
|
||||
runtime.syncWindowGraph(1, graph)
|
||||
return graph
|
||||
}
|
||||
const initialGraph = publish(true, 1)
|
||||
main.setWorkspaceSession(
|
||||
advanceTerminalTopologyRevision(main.getWorkspaceSession(host), worktree),
|
||||
host
|
||||
)
|
||||
const gate = deferred()
|
||||
const entered = deferred()
|
||||
const closeTerminalTab = vi.fn(async () => {
|
||||
renderer.getState().closeTab(TAB)
|
||||
main.setWorkspaceSession(buildWorkspaceSessionPayload(renderer.getState()), host)
|
||||
main.flushOrThrow()
|
||||
entered.resolve()
|
||||
await gate.promise
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this proof exercises only the terminal-close notifier ports.
|
||||
runtime.setNotifier({ closeTerminalTab, closeTerminal: () => {} } as never)
|
||||
cleanup.push(async () => {
|
||||
runtime.setNotifier(null)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
|
||||
main.flush()
|
||||
main.freezeWrites()
|
||||
await main.waitForPendingWrite()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
const hasTab = () =>
|
||||
main.getWorkspaceSession(host).tabsByWorktree[worktree]?.some((t) => t.id === TAB) ?? false
|
||||
const change = (
|
||||
update: (
|
||||
session: ReturnType<Store['getWorkspaceSession']>
|
||||
) => ReturnType<Store['getWorkspaceSession']>
|
||||
) =>
|
||||
main.setWorkspaceSession(
|
||||
advanceTerminalTopologyRevision(update(main.getWorkspaceSession(host)), worktree),
|
||||
host
|
||||
)
|
||||
return {
|
||||
api,
|
||||
main,
|
||||
renderer,
|
||||
runtime,
|
||||
worktree,
|
||||
host,
|
||||
file,
|
||||
publish,
|
||||
initialGraph,
|
||||
gate,
|
||||
entered,
|
||||
hasTab,
|
||||
change,
|
||||
closeTerminalTab
|
||||
}
|
||||
}
|
||||
|
||||
for (const kind of ['repo', 'folder'] as const) {
|
||||
for (const host of ['local', 'ssh:target'] as const) {
|
||||
it(`durably commits acknowledged close for ${kind}/${host} before graph removal`, async () => {
|
||||
const f = fixture(kind, host)
|
||||
const pending = f.runtime.closeMobileSessionTab(`id:${f.worktree}`, TAB, { reason: 'user' })
|
||||
await expect.poll(() => f.closeTerminalTab.mock.calls.length, { timeout: 2000 }).toBe(1)
|
||||
await f.entered.promise
|
||||
expect(f.hasTab()).toBe(true)
|
||||
f.gate.resolve()
|
||||
await expect(pending).resolves.toEqual({ closed: true })
|
||||
expect(f.hasTab()).toBe(false)
|
||||
// A queued stale renderer save/graph cannot restore durable host membership.
|
||||
f.main.setWorkspaceSession(
|
||||
{
|
||||
...buildWorkspaceSessionPayload(f.renderer.getState()),
|
||||
tabsByWorktree: {
|
||||
[f.worktree]: [
|
||||
{
|
||||
id: TAB,
|
||||
worktreeId: f.worktree,
|
||||
ptyId: null,
|
||||
title: 'Old',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
sortOrder: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
host
|
||||
)
|
||||
f.runtime.syncWindowGraph(1, {
|
||||
...f.initialGraph,
|
||||
mobileSessionTabs: f.initialGraph.mobileSessionTabs.map((s) => ({
|
||||
...s,
|
||||
snapshotVersion: 2
|
||||
}))
|
||||
})
|
||||
expect(f.hasTab()).toBe(false)
|
||||
f.publish(false, 3)
|
||||
f.main.flushOrThrow()
|
||||
const restarted = new Store({ dataFile: f.file })
|
||||
expect(
|
||||
restarted.getWorkspaceSession(host).tabsByWorktree[f.worktree]?.some((t) => t.id === TAB) ??
|
||||
false
|
||||
).toBe(false)
|
||||
if (host !== 'local') {
|
||||
expect(
|
||||
restarted
|
||||
.getWorkspaceSession('local')
|
||||
.tabsByWorktree[f.worktree].some((t) => t.id === TAB)
|
||||
).toBe(true)
|
||||
}
|
||||
restarted.flush()
|
||||
expect(f.api.pty.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const mutation of ['createdAt', 'generation', 'binding', 'leaf', 'incarnation'] as const) {
|
||||
it(`preserves ${mutation} replacement while acknowledgement is pending`, async () => {
|
||||
const f = fixture()
|
||||
const pending = f.runtime.closeMobileSessionTab(`id:${f.worktree}`, TAB, { reason: 'user' })
|
||||
await expect.poll(() => f.closeTerminalTab.mock.calls.length, { timeout: 2000 }).toBe(1)
|
||||
await f.entered.promise
|
||||
f.change((session) => ({
|
||||
...session,
|
||||
tabsByWorktree: {
|
||||
...session.tabsByWorktree,
|
||||
[f.worktree]: session.tabsByWorktree[f.worktree].map((row) => ({
|
||||
...row,
|
||||
...(mutation === 'createdAt' ? { createdAt: row.createdAt + 1 } : {}),
|
||||
...(mutation === 'generation' ? { generation: 1 } : {}),
|
||||
...(mutation === 'binding' ? { ptyId: 'replacement' } : {})
|
||||
}))
|
||||
},
|
||||
...(mutation === 'leaf' || mutation === 'incarnation'
|
||||
? {
|
||||
terminalLayoutsByTabId: {
|
||||
...session.terminalLayoutsByTabId,
|
||||
[TAB]: {
|
||||
root: { type: 'leaf', leafId: LEAF },
|
||||
activeLeafId: LEAF,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF]: 'replacement' }
|
||||
}
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(mutation === 'incarnation'
|
||||
? {
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
[`${TAB}:${LEAF}`]: '33333333-3333-4333-8333-333333333333'
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}))
|
||||
f.gate.resolve()
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
closed: true,
|
||||
refused: true,
|
||||
refusalReason: 'stale-terminal'
|
||||
})
|
||||
expect(f.hasTab()).toBe(true)
|
||||
expect(f.api.pty.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
}
|
||||
it('keeps current pin guard after renderer acknowledges', async () => {
|
||||
const f = fixture()
|
||||
const pending = f.runtime.closeMobileSessionTab(`id:${f.worktree}`, TAB, { reason: 'user' })
|
||||
await expect.poll(() => f.closeTerminalTab.mock.calls.length, { timeout: 2000 }).toBe(1)
|
||||
await f.entered.promise
|
||||
f.change((s) => ({
|
||||
...s,
|
||||
tabsByWorktree: {
|
||||
...s.tabsByWorktree,
|
||||
[f.worktree]: s.tabsByWorktree[f.worktree].map((t) => ({ ...t, isPinned: true }))
|
||||
}
|
||||
}))
|
||||
f.gate.resolve()
|
||||
await expect(pending).rejects.toThrow('terminal_tab_pinned')
|
||||
expect(f.hasTab()).toBe(true)
|
||||
})
|
||||
it('does not confuse title/color metadata with replacement', async () => {
|
||||
const f = fixture()
|
||||
const pending = f.runtime.closeMobileSessionTab(`id:${f.worktree}`, TAB, { reason: 'user' })
|
||||
await expect.poll(() => f.closeTerminalTab.mock.calls.length, { timeout: 2000 }).toBe(1)
|
||||
await f.entered.promise
|
||||
f.change((s) => ({
|
||||
...s,
|
||||
tabsByWorktree: {
|
||||
...s.tabsByWorktree,
|
||||
[f.worktree]: s.tabsByWorktree[f.worktree].map((t) => ({
|
||||
...t,
|
||||
title: 'Updated',
|
||||
customTitle: 'Renamed',
|
||||
color: 'blue'
|
||||
}))
|
||||
}
|
||||
}))
|
||||
f.gate.resolve()
|
||||
await expect(pending).resolves.toEqual({ closed: true })
|
||||
expect(f.hasTab()).toBe(false)
|
||||
})
|
||||
|
||||
it('finishes durable close after physical exit retires only one original split leaf', async () => {
|
||||
const f = fixture('repo', 'local', true)
|
||||
const pending = f.runtime.closeMobileSessionTab(`id:${f.worktree}`, TAB, { reason: 'user' })
|
||||
await expect.poll(() => f.closeTerminalTab.mock.calls.length, { timeout: 2000 }).toBe(1)
|
||||
await f.entered.promise
|
||||
f.runtime.onPtyExit('pty-a', 0, INC, { providerExitObserved: true })
|
||||
expect(f.main.getWorkspaceSession().terminalLayoutsByTabId[TAB].ptyIdsByLeafId).toEqual({
|
||||
[LEAF_B]: 'pty-b'
|
||||
})
|
||||
f.gate.resolve()
|
||||
await expect(pending).resolves.toEqual({ closed: true })
|
||||
expect(f.hasTab()).toBe(false)
|
||||
})
|
||||
it('preserves registered same-ID successor even after old snapshot was removed', async () => {
|
||||
const f = fixture('repo', 'local', true)
|
||||
const pending = f.runtime.closeMobileSessionTab(`id:${f.worktree}`, TAB, { reason: 'user' })
|
||||
await expect.poll(() => f.closeTerminalTab.mock.calls.length, { timeout: 2000 }).toBe(1)
|
||||
await f.entered.promise
|
||||
f.publish(false, 2)
|
||||
f.runtime.onPtySpawned('pty-a', '66666666-6666-4666-8666-666666666666')
|
||||
f.gate.resolve()
|
||||
await expect(pending).resolves.toMatchObject({ refused: true, refusalReason: 'stale-terminal' })
|
||||
expect(f.hasTab()).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves direct null-PTY renderer close outside the acknowledged-host scope', () => {
|
||||
const f = fixture()
|
||||
f.renderer.getState().closeTab(TAB)
|
||||
f.main.setWorkspaceSession(buildWorkspaceSessionPayload(f.renderer.getState()))
|
||||
expect(f.hasTab()).toBe(true)
|
||||
expect(f.closeTerminalTab).not.toHaveBeenCalled()
|
||||
expect(f.api.pty.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
it('does not durably retire when the renderer rejects its close request', async () => {
|
||||
const f = fixture()
|
||||
f.closeTerminalTab.mockRejectedValueOnce(new Error('terminal_tab_pinned'))
|
||||
await expect(
|
||||
f.runtime.closeMobileSessionTab(`id:${f.worktree}`, TAB, { reason: 'user' })
|
||||
).rejects.toThrow('terminal_tab_pinned')
|
||||
expect(f.hasTab()).toBe(true)
|
||||
})
|
||||
it('reports host persistence failure after renderer ack without claiming durable success', async () => {
|
||||
const f = fixture()
|
||||
const pending = f.runtime.closeMobileSessionTab(`id:${f.worktree}`, TAB, { reason: 'user' })
|
||||
await expect.poll(() => f.closeTerminalTab.mock.calls.length, { timeout: 2000 }).toBe(1)
|
||||
await f.entered.promise
|
||||
const fail = vi.spyOn(f.main, 'flushOrThrow').mockImplementationOnce(() => {
|
||||
throw new Error('disk_full')
|
||||
})
|
||||
f.gate.resolve()
|
||||
await expect(pending).rejects.toThrow('disk_full')
|
||||
fail.mockRestore()
|
||||
const restarted = new Store({ dataFile: f.file })
|
||||
expect(restarted.getWorkspaceSession().tabsByWorktree[f.worktree].some((t) => t.id === TAB)).toBe(
|
||||
true
|
||||
)
|
||||
restarted.flush()
|
||||
})
|
||||
it('retains force permission across an acknowledged close with a newly pinned row', async () => {
|
||||
const f = fixture()
|
||||
const pending = f.runtime.closeMobileSessionTab(`id:${f.worktree}`, TAB, {
|
||||
reason: 'user',
|
||||
force: true
|
||||
})
|
||||
await expect.poll(() => f.closeTerminalTab.mock.calls.length, { timeout: 2000 }).toBe(1)
|
||||
await f.entered.promise
|
||||
f.change((s) => ({
|
||||
...s,
|
||||
tabsByWorktree: {
|
||||
...s.tabsByWorktree,
|
||||
[f.worktree]: s.tabsByWorktree[f.worktree].map((t) => ({ ...t, isPinned: true }))
|
||||
}
|
||||
}))
|
||||
f.gate.resolve()
|
||||
await expect(pending).resolves.toEqual({ closed: true })
|
||||
expect(f.hasTab()).toBe(false)
|
||||
})
|
||||
|
||||
it('protects a replacement execution-host partition while acknowledgement is pending', async () => {
|
||||
const f = fixture()
|
||||
const pending = f.runtime.closeMobileSessionTab(`id:${f.worktree}`, TAB, { reason: 'user' })
|
||||
await expect.poll(() => f.closeTerminalTab.mock.calls.length, { timeout: 2000 }).toBe(1)
|
||||
await f.entered.promise
|
||||
f.main.setWorkspaceSession(f.main.getWorkspaceSession(), 'ssh:replacement')
|
||||
f.main.updateRepo('repo1', { executionHostId: 'ssh:replacement' })
|
||||
f.gate.resolve()
|
||||
await expect(pending).resolves.toMatchObject({ refused: true, refusalReason: 'stale-terminal' })
|
||||
expect(
|
||||
f.main
|
||||
.getWorkspaceSession('ssh:replacement')
|
||||
.tabsByWorktree[f.worktree].some((t) => t.id === TAB)
|
||||
).toBe(true)
|
||||
expect(f.hasTab()).toBe(true)
|
||||
})
|
||||
it('also commits when the renderer graph removal arrives before acknowledgement', async () => {
|
||||
const f = fixture()
|
||||
const pending = f.runtime.closeMobileSessionTab(`id:${f.worktree}`, TAB, { reason: 'user' })
|
||||
await expect.poll(() => f.closeTerminalTab.mock.calls.length, { timeout: 2000 }).toBe(1)
|
||||
await f.entered.promise
|
||||
f.publish(false, 2)
|
||||
f.gate.resolve()
|
||||
await expect(pending).resolves.toEqual({ closed: true })
|
||||
expect(f.hasTab()).toBe(false)
|
||||
})
|
||||
it('documents live snapshot pending until graph removal, then rejects the older queued graph', async () => {
|
||||
const f = fixture()
|
||||
const pending = f.runtime.closeMobileSessionTab(`id:${f.worktree}`, TAB, { reason: 'user' })
|
||||
await expect.poll(() => f.closeTerminalTab.mock.calls.length, { timeout: 2000 }).toBe(1)
|
||||
await f.entered.promise
|
||||
f.gate.resolve()
|
||||
await expect(pending).resolves.toEqual({ closed: true })
|
||||
expect(f.runtime.surfaceParents(f.worktree)).toEqual([TAB])
|
||||
expect(f.hasTab()).toBe(false)
|
||||
f.publish(false, 3)
|
||||
expect(f.runtime.surfaceParents(f.worktree), 'after current removal graph').toEqual([])
|
||||
f.runtime.syncWindowGraph(1, {
|
||||
...f.initialGraph,
|
||||
mobileSessionTabs: f.initialGraph.mobileSessionTabs.map((s) => ({ ...s, snapshotVersion: 2 }))
|
||||
})
|
||||
expect(f.runtime.surfaceParents(f.worktree)).toEqual([])
|
||||
expect(f.hasTab()).toBe(false)
|
||||
})
|
||||
@@ -1,155 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { applyPatch, parsePatch, reversePatch } from 'diff'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
|
||||
throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.')
|
||||
}
|
||||
|
||||
const root = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const patch = await readFile(new URL('./fix.patch', import.meta.url), 'utf8')
|
||||
const beforeSources = {}
|
||||
const sourceHashes = {}
|
||||
for (const parsed of parsePatch(patch)) {
|
||||
const path = parsed.newFileName.replace(/^b\//, '')
|
||||
const absolute = resolve(root, path)
|
||||
const current = await readFile(absolute, 'utf8')
|
||||
const before = applyPatch(current, reversePatch(parsed))
|
||||
if (before === false) {
|
||||
throw new Error(`Source changed; review the proof patch: ${path}`)
|
||||
}
|
||||
beforeSources[absolute.replaceAll('\\', '/')] = before
|
||||
sourceHashes[path] = {
|
||||
before: createHash('sha256').update(before).digest('hex'),
|
||||
after: createHash('sha256').update(current).digest('hex')
|
||||
}
|
||||
}
|
||||
|
||||
for (const path of [
|
||||
'src/main/runtime/acknowledged-terminal-tab-retirement.test.ts',
|
||||
'src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts',
|
||||
'docs/audits/acknowledged-tab-retirement/fixture.test.ts'
|
||||
]) {
|
||||
sourceHashes[path] = {
|
||||
current: createHash('sha256')
|
||||
.update(await readFile(resolve(root, path)))
|
||||
.digest('hex')
|
||||
}
|
||||
}
|
||||
|
||||
const scratch = await mkdtemp(join(tmpdir(), 'orca-acknowledged-tab-retirement-'))
|
||||
const require = createRequire(import.meta.url)
|
||||
let runnerModuleId
|
||||
try {
|
||||
const runnerPath = join(scratch, 'run-process.cjs')
|
||||
await build({
|
||||
absWorkingDir: root,
|
||||
entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')],
|
||||
outfile: runnerPath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
logLevel: 'silent'
|
||||
})
|
||||
runnerModuleId = require.resolve(runnerPath)
|
||||
const { runProcess } = require(runnerModuleId)
|
||||
const baselineConfig = join(scratch, 'before.config.mjs')
|
||||
const fixedConfig = join(scratch, 'after.config.mjs')
|
||||
const includes = [
|
||||
'src/main/runtime/acknowledged-terminal-tab-retirement.test.ts',
|
||||
'docs/audits/acknowledged-tab-retirement/fixture.test.ts'
|
||||
]
|
||||
const configImport = JSON.stringify(pathToFileURL(resolve(root, 'config/vitest.config.ts')).href)
|
||||
await writeFile(
|
||||
baselineConfig,
|
||||
`import base from ${configImport};
|
||||
const beforeSources = ${JSON.stringify(beforeSources)};
|
||||
export default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}, plugins: [{
|
||||
name: 'acknowledged-tab-retirement-before-fix', enforce: 'pre',
|
||||
transform(_code, id) {
|
||||
const before = beforeSources[id.replaceAll('\\\\', '/').split('?')[0]];
|
||||
return before === undefined ? null : {code: before, map: null};
|
||||
}
|
||||
}]};\n`
|
||||
)
|
||||
|
||||
await writeFile(
|
||||
fixedConfig,
|
||||
`import base from ${configImport};\nexport default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}};\n`
|
||||
)
|
||||
|
||||
async function run(label, config) {
|
||||
const report = join(scratch, `${label}.json`)
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: [
|
||||
resolve(root, 'node_modules/vitest/vitest.mjs'),
|
||||
'run',
|
||||
'--config',
|
||||
config,
|
||||
'--reporter=json',
|
||||
`--outputFile=${report}`
|
||||
],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
timeoutMs: 90_000,
|
||||
maxOutputBytes: 4 * 1024 * 1024
|
||||
})
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(report, 'utf8'))
|
||||
} catch (error) {
|
||||
throw new Error(`${label} runner failed: ${result.stderr || result.stdout}`, { cause: error })
|
||||
}
|
||||
return {
|
||||
exitCode: result.code,
|
||||
timedOut: result.timedOut,
|
||||
passed: parsed.numPassedTests,
|
||||
failed: parsed.numFailedTests,
|
||||
failedCases: parsed.testResults.flatMap((suite) =>
|
||||
suite.assertionResults
|
||||
.filter((test) => test.status === 'failed')
|
||||
.map((test) => test.fullName)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const before = await run('before', baselineConfig)
|
||||
const after = await run('after', fixedConfig)
|
||||
const passed =
|
||||
before.exitCode === 1 &&
|
||||
after.exitCode === 0 &&
|
||||
!before.timedOut &&
|
||||
!after.timedOut &&
|
||||
before.failed === 28 &&
|
||||
before.passed === 3 &&
|
||||
after.passed === 31 &&
|
||||
after.failed === 0
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
comparison:
|
||||
'Actual renderer close, durable Store, runtime close/graph/physical-exit methods; before reverses only fix.patch in a temporary Vite transform',
|
||||
sourceHashes,
|
||||
before,
|
||||
after,
|
||||
passed
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
if (!passed) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
} finally {
|
||||
if (runnerModuleId) {
|
||||
delete require.cache[runnerModuleId]
|
||||
}
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"comparison": "Actual renderer close, durable Store, runtime close/graph/physical-exit methods; before reverses only fix.patch in a temporary Vite transform",
|
||||
"sourceHashes": {
|
||||
"src/main/runtime/orca-runtime-close-mobile-session-tab.ts": {
|
||||
"before": "fcf1a22bc7aa72af40593c00008a83695fc3ebd78b76a6707ecca300beaedece",
|
||||
"after": "b5f5ff9df47165c6c9b41b49ee78f4d84b4d6210e69c04d3ca63bc53e9d32ad5"
|
||||
},
|
||||
"src/main/runtime/workspace-session-terminal-tab-retirement-identity.ts": {
|
||||
"before": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"after": "333a0d11659e4034a634c423253b20f3aab25686c20894804b6a1f08aefcd702"
|
||||
},
|
||||
"src/main/runtime/acknowledged-terminal-tab-retirement.test.ts": {
|
||||
"current": "efcd9dca5f4ceff69c968d886bf01ed87762301a4470fcadaae257c4e4881c81"
|
||||
},
|
||||
"src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts": {
|
||||
"current": "632d0dd0535b136f0474ed7d0f8bcac6830a9a9e165e7696f6a7c89304eda058"
|
||||
},
|
||||
"docs/audits/acknowledged-tab-retirement/fixture.test.ts": {
|
||||
"current": "176f26c95a7cf7526e4a1e8131e5ac897f6ecd45876772945b7363db5f622aa6"
|
||||
}
|
||||
},
|
||||
"before": {
|
||||
"exitCode": 1,
|
||||
"timedOut": false,
|
||||
"passed": 3,
|
||||
"failed": 28,
|
||||
"failedCases": [
|
||||
"durably commits acknowledged close for repo/local before graph removal",
|
||||
"durably commits acknowledged close for repo/ssh:target before graph removal",
|
||||
"durably commits acknowledged close for folder/local before graph removal",
|
||||
"durably commits acknowledged close for folder/ssh:target before graph removal",
|
||||
"preserves createdAt replacement while acknowledgement is pending",
|
||||
"preserves generation replacement while acknowledgement is pending",
|
||||
"preserves binding replacement while acknowledgement is pending",
|
||||
"preserves leaf replacement while acknowledgement is pending",
|
||||
"preserves incarnation replacement while acknowledgement is pending",
|
||||
"keeps current pin guard after renderer acknowledges",
|
||||
"does not confuse title/color metadata with replacement",
|
||||
"finishes durable close after physical exit retires only one original split leaf",
|
||||
"preserves registered same-ID successor even after old snapshot was removed",
|
||||
"reports host persistence failure after renderer ack without claiming durable success",
|
||||
"retains force permission across an acknowledged close with a newly pinned row",
|
||||
"protects a replacement execution-host partition while acknowledgement is pending",
|
||||
"also commits when the renderer graph removal arrives before acknowledgement",
|
||||
"documents live snapshot pending until graph removal, then rejects the older queued graph",
|
||||
"durably retires an acknowledged row with graph-before-ack=false",
|
||||
"durably retires an acknowledged row with graph-before-ack=true",
|
||||
"protects a same-ID replacement with newer createdAt",
|
||||
"protects a same-ID replacement with newer generation",
|
||||
"finishes physical split exit with remote-session promotion=false",
|
||||
"finishes physical split exit with remote-session promotion=true",
|
||||
"protects a new incarnation admitted after the old snapshot disappeared",
|
||||
"protects a persisted incarnation replacement on the same leaf and raw PTY ID",
|
||||
"rechecks current pins after renderer acknowledgement",
|
||||
"protects a persisted sibling omitted by a partial mobile snapshot"
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"exitCode": 0,
|
||||
"timedOut": false,
|
||||
"passed": 31,
|
||||
"failed": 0,
|
||||
"failedCases": []
|
||||
},
|
||||
"passed": true
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
# Release aborted shared auth filesystem waits
|
||||
|
||||
When a filesystem operation remains pending, later Codex/Kimi quota polls reuse it and wait with new deadlines. The old waiter uses `Promise.race` for every poll. On the installed Electron runtime, each abandoned race retains its rejection reason until the raw operation settles, despite removing its abort listener. The fix uses the existing `PromiseSettlementWaiters` registry, which attaches one raw-result reaction and removes expired waiters.
|
||||
|
||||
The original ownership symbols, last-waiter cancellation finalizer, one-raw-operation behavior, live callers, and future reads of a late result are preserved. Auth opts into deferred abort settlement so an already-queued raw result keeps its `Promise.race` priority; 24 success/failure/abort schedules compare equal before and after. Existing registry consumers keep their immediate-abort behavior. The abort factory type accepts `unknown` so false, zero, strings, and objects retain the existing auth rejection semantics. No admission or timeout limit changes.
|
||||
|
||||
## Reproduce
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node --expose-gc --max-old-space-size=128 docs/audits/auth-filesystem-wait-retention/reproduce.cjs
|
||||
```
|
||||
|
||||
For Electron, run the installed Electron executable with `ELECTRON_RUN_AS_NODE=1` and the same arguments/environment. This launches no window. The proof reconstructs the original sources by reversing `fix.patch`; expected baseline hashes in `source-versions.json` make source drift fail. Both versions run the actual production scheduler/waiter code, with only the raw filesystem operation replaced by one manually settled promise. A 15-second deadline fails stalled proof execution.
|
||||
|
||||
| Runtime / source | Plain aborted Errors alive while raw result pending | Amplified payload objects alive | After raw result settles | After owner drops |
|
||||
| ---------------------------------------- | --------------------------------------------------- | ------------------------------- | ------------------------ | ----------------- |
|
||||
| Node 26.6.0 / original | 1 of 128 | 1 of 128 | 1 | 0 |
|
||||
| Node 26.6.0 / fixed | 1 of 128 | 1 of 128 | 1 | 0 |
|
||||
| Electron 43.7.0, Node 24.21.0 / original | 128 of 128 | 128 of 128 | 1 | 0 |
|
||||
| Electron 43.7.0, Node 24.21.0 / fixed | 1 of 128 | 1 of 128 | 1 | 0 |
|
||||
|
||||
The one remaining reason belongs to the existing cancellation controller's first abort. Dropping the shared operation releases it. AbortController objects and abort listeners are released by both versions. The amplified arm attaches a **synthetic 64 KiB Uint8Array** to each Error, at most 8 MiB per case. Ordinary timeout errors are much smaller; the separate plain-Error arm verifies that artificial bytes are not needed to reproduce retention. Runtime differences are measured, without attributing them to a particular V8 change.
|
||||
|
||||
Controls check an already-aborted first caller never starting raw work, raw rejection identity, aborted caller identity, future callers receiving late and already-settled results, a live sibling surviving cancellation, arbitrary abort reasons, and removed listeners. The unit regression additionally checks that repeated expired waits add no raw-result reactions and that their plain Error objects are collectible while a live caller still needs the operation.
|
||||
|
||||
Validation: 50 auth/registry tests pass, including 18 new cases; the reverse-patch configuration produces two expected failures and 48 passes. Six existing watcher-consumer suites pass another 39 tests. Node, Web, and CLI typechecks, focused ordinary/type-aware lint, formatting, and the changed-code quality gate pass. `validation.json` records the test paths and scope. To run the prior implementation against the current tests, use `--config docs/audits/auth-filesystem-wait-retention/before.config.mjs` with those six auth/registry test paths.
|
||||
|
||||
## Scope and limits
|
||||
|
||||
The three production consumers are `codex-auth-presence.ts`, `codex-backend-auth.ts`, and `kimi-fetcher.ts`. Each intentionally retains the shared operation until actual filesystem settlement to avoid stacking native requests when UNC/WSL reads stall. This audit does not reproduce a real filesystem stall, historical Electron binary behavior, or an affected-host workload.
|
||||
|
||||
The auth source matches `v1.4.198` and the audited main revision; the existing registry also matches main. The earlier broad accumulator PR #10179, reverted by #10255, added path/waiter/admission limits to this module. This change instead removes abandoned wait reactions and introduces no such limits. Nothing here attributes #19831 or #19768 to this mechanism or claims an incident-scale memory slope.
|
||||
@@ -1,24 +0,0 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { defineConfig, mergeConfig } from 'vitest/config'
|
||||
import baseConfig from '../../../config/vitest.config.ts'
|
||||
|
||||
const loadSources = createRequire(import.meta.url)(
|
||||
resolve('docs/audits/auth-filesystem-wait-retention/sources.cjs')
|
||||
)
|
||||
const { before } = loadSources()
|
||||
export default mergeConfig(
|
||||
baseConfig,
|
||||
defineConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: 'auth-wait-before-fix',
|
||||
enforce: 'pre',
|
||||
transform(_code, id) {
|
||||
const source = before.get(resolve(id.split('?')[0]))
|
||||
return source === undefined ? undefined : { code: source, map: null }
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
@@ -1,598 +0,0 @@
|
||||
{
|
||||
"sourceHashes": {
|
||||
"src/main/rate-limits/auth-filesystem-operation.ts": {
|
||||
"before": "581d7045220e92602ca83ec335576889dcba5ada4a699e866ea1dba10afbecac",
|
||||
"after": "bfac9dc25c3f3c36ab590bc6e01be39ec19dea5a872f5c409ed149e65258f9e2"
|
||||
},
|
||||
"src/shared/promise-settlement-waiters.ts": {
|
||||
"before": "b0b60bae6dcca4bb4f335ef7dda250f4350aa5b0306d3595b887cc4af4493060",
|
||||
"after": "92618db591cf1fc1ad52f257cc9f3314310bf902fbd24e5d18abcf93afb8e722"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"node": "24.21.0",
|
||||
"acorn": "8.18.0",
|
||||
"ada": "4.0.0",
|
||||
"amaro": "1.1.11",
|
||||
"ares": "1.34.8",
|
||||
"brotli": "1.2.0",
|
||||
"cldr": "48.0",
|
||||
"icu": "78.2",
|
||||
"llhttp": "9.4.3",
|
||||
"merve": "1.2.2",
|
||||
"modules": "148",
|
||||
"napi": "10",
|
||||
"nbytes": "0.1.4",
|
||||
"ncrypto": "0.0.1",
|
||||
"nghttp2": "1.70.0",
|
||||
"nghttp3": "",
|
||||
"ngtcp2": "",
|
||||
"openssl": "0.0.0",
|
||||
"simdjson": "4.6.7",
|
||||
"simdutf": "7.7.0",
|
||||
"sqlite": "3.53.4",
|
||||
"tz": "2025c",
|
||||
"undici": "7.29.1",
|
||||
"unicode": "17.0",
|
||||
"uv": "1.52.1",
|
||||
"uvwasi": "0.0.23",
|
||||
"v8": "15.0.245.31-electron.0",
|
||||
"zlib": "1.3.2.1-motley",
|
||||
"zstd": "1.6.0",
|
||||
"electron": "43.7.0",
|
||||
"chrome": "150.0.7871.250"
|
||||
},
|
||||
"scenario": "128 aborted waits on one already-started unresolved operation; amplified arm has 64 KiB synthetic payload per abort Error; real native filesystem stall not reproduced",
|
||||
"before": {
|
||||
"cases": [
|
||||
{
|
||||
"amplify": false,
|
||||
"abortedWaits": 128,
|
||||
"unresolved": {
|
||||
"reasons": 128,
|
||||
"controllers": 0,
|
||||
"payloads": 0,
|
||||
"rawCalls": 1
|
||||
},
|
||||
"settled": {
|
||||
"reasons": 1,
|
||||
"controllers": 0,
|
||||
"payloads": 0
|
||||
},
|
||||
"dropped": {
|
||||
"reasons": 0,
|
||||
"controllers": 0,
|
||||
"payloads": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"amplify": true,
|
||||
"abortedWaits": 128,
|
||||
"unresolved": {
|
||||
"reasons": 128,
|
||||
"controllers": 0,
|
||||
"payloads": 128,
|
||||
"rawCalls": 1
|
||||
},
|
||||
"settled": {
|
||||
"reasons": 1,
|
||||
"controllers": 0,
|
||||
"payloads": 1
|
||||
},
|
||||
"dropped": {
|
||||
"reasons": 0,
|
||||
"controllers": 0,
|
||||
"payloads": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"controls": {
|
||||
"lateResultDelivered": true,
|
||||
"settledResultDelivered": true,
|
||||
"oneRawCall": 1,
|
||||
"rawRejectionPreserved": true,
|
||||
"preAbortedRawCalls": 0,
|
||||
"allAbortListenersRemoved": true,
|
||||
"arbitraryAbortReasonsPreserved": 4,
|
||||
"liveSiblingSurvivesAbort": true
|
||||
},
|
||||
"settlementOrder": [
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
}
|
||||
],
|
||||
"importedHashes": {
|
||||
"src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a"
|
||||
},
|
||||
"bundleSha256": "f895e71ca0f2102e7ff36fa133ae2065fa9810769d7ee6059c826f1a72bcc122"
|
||||
},
|
||||
"after": {
|
||||
"cases": [
|
||||
{
|
||||
"amplify": false,
|
||||
"abortedWaits": 128,
|
||||
"unresolved": {
|
||||
"reasons": 1,
|
||||
"controllers": 0,
|
||||
"payloads": 0,
|
||||
"rawCalls": 1
|
||||
},
|
||||
"settled": {
|
||||
"reasons": 1,
|
||||
"controllers": 0,
|
||||
"payloads": 0
|
||||
},
|
||||
"dropped": {
|
||||
"reasons": 0,
|
||||
"controllers": 0,
|
||||
"payloads": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"amplify": true,
|
||||
"abortedWaits": 128,
|
||||
"unresolved": {
|
||||
"reasons": 1,
|
||||
"controllers": 0,
|
||||
"payloads": 1,
|
||||
"rawCalls": 1
|
||||
},
|
||||
"settled": {
|
||||
"reasons": 1,
|
||||
"controllers": 0,
|
||||
"payloads": 1
|
||||
},
|
||||
"dropped": {
|
||||
"reasons": 0,
|
||||
"controllers": 0,
|
||||
"payloads": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"controls": {
|
||||
"lateResultDelivered": true,
|
||||
"settledResultDelivered": true,
|
||||
"oneRawCall": 1,
|
||||
"rawRejectionPreserved": true,
|
||||
"preAbortedRawCalls": 0,
|
||||
"allAbortListenersRemoved": true,
|
||||
"arbitraryAbortReasonsPreserved": 4,
|
||||
"liveSiblingSurvivesAbort": true
|
||||
},
|
||||
"settlementOrder": [
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
}
|
||||
],
|
||||
"importedHashes": {
|
||||
"src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a"
|
||||
},
|
||||
"bundleSha256": "04324b281a776aa1018995a36d11ff7cf56b4dd737761c3d5b0fb1ebc0047652"
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
diff --git a/src/main/rate-limits/auth-filesystem-operation.ts b/src/main/rate-limits/auth-filesystem-operation.ts
|
||||
index 228234e92c..7d030ab01e 100644
|
||||
--- a/src/main/rate-limits/auth-filesystem-operation.ts
|
||||
+++ b/src/main/rate-limits/auth-filesystem-operation.ts
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
+import { PromiseSettlementWaiters } from '../../shared/promise-settlement-waiters'
|
||||
|
||||
const MAX_CONCURRENT_WSL_AUTH_OPERATIONS = 2
|
||||
const activeWslOperationDistros = new Set<string>()
|
||||
@@ -139,10 +140,9 @@ export function createAuthFilesystemOperation<T>(
|
||||
const waiters = new Set<symbol>()
|
||||
let settled = false
|
||||
const result = scheduleAuthFilesystemOperation(authPath, neededController.signal, operation)
|
||||
- const markSettled = (): void => {
|
||||
+ const settlementWaiters = new PromiseSettlementWaiters(result, () => {
|
||||
settled = true
|
||||
- }
|
||||
- void result.then(markSettled, markSettled)
|
||||
+ })
|
||||
|
||||
return {
|
||||
result,
|
||||
@@ -156,20 +156,18 @@ export function createAuthFilesystemOperation<T>(
|
||||
|
||||
const waiter = Symbol('auth-filesystem-waiter')
|
||||
waiters.add(waiter)
|
||||
- let onAbort: (() => void) | null = null
|
||||
- const aborted = new Promise<never>((_resolve, reject) => {
|
||||
- onAbort = () => reject(getAbortReason(signal))
|
||||
- signal.addEventListener('abort', onAbort, { once: true })
|
||||
- })
|
||||
- return Promise.race([result, aborted]).finally(() => {
|
||||
- if (onAbort) {
|
||||
- signal.removeEventListener('abort', onAbort)
|
||||
- }
|
||||
- waiters.delete(waiter)
|
||||
- if (!settled && waiters.size === 0) {
|
||||
- neededController.abort(getAbortReason(signal))
|
||||
- }
|
||||
- })
|
||||
+ return settlementWaiters
|
||||
+ .wait({
|
||||
+ signal,
|
||||
+ abortInMicrotask: true,
|
||||
+ createAbortError: () => getAbortReason(signal)
|
||||
+ })
|
||||
+ .finally(() => {
|
||||
+ waiters.delete(waiter)
|
||||
+ if (!settled && waiters.size === 0) {
|
||||
+ neededController.abort(getAbortReason(signal))
|
||||
+ }
|
||||
+ })
|
||||
}
|
||||
}
|
||||
}
|
||||
diff --git a/src/shared/promise-settlement-waiters.ts b/src/shared/promise-settlement-waiters.ts
|
||||
index 98ec24b25c..97230d54dd 100644
|
||||
--- a/src/shared/promise-settlement-waiters.ts
|
||||
+++ b/src/shared/promise-settlement-waiters.ts
|
||||
@@ -13,8 +13,10 @@ type PromiseSettlementWaiter<T> = {
|
||||
|
||||
export type PromiseSettlementWaitOptions<T> = {
|
||||
signal?: AbortSignal
|
||||
+ /** Preserve Promise.race ordering when raw settlement and abort share a turn. */
|
||||
+ abortInMicrotask?: boolean
|
||||
timeoutMs?: number
|
||||
- createAbortError?: () => Error
|
||||
+ createAbortError?: () => unknown
|
||||
createTimeoutError?: () => Error
|
||||
onFulfilled?: (value: T) => void
|
||||
onAbandon?: (reason: 'abort' | 'timeout') => void
|
||||
@@ -50,7 +52,7 @@ export class PromiseSettlementWaiters<T> {
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let waiter!: PromiseSettlementWaiter<T>
|
||||
- const abandon = (reason: 'abort' | 'timeout', error: Error): void => {
|
||||
+ const abandon = (reason: 'abort' | 'timeout', error: unknown): void => {
|
||||
if (!this.waiters.delete(waiter)) {
|
||||
return
|
||||
}
|
||||
@@ -58,8 +60,14 @@ export class PromiseSettlementWaiters<T> {
|
||||
options.onAbandon?.(reason)
|
||||
reject(error)
|
||||
}
|
||||
- const onAbort = (): void =>
|
||||
- abandon('abort', options.createAbortError?.() ?? createDefaultAbortError())
|
||||
+ const onAbort = (): void => {
|
||||
+ const error = options.createAbortError?.() ?? createDefaultAbortError()
|
||||
+ if (options.abortInMicrotask) {
|
||||
+ queueMicrotask(() => abandon('abort', error))
|
||||
+ } else {
|
||||
+ abandon('abort', error)
|
||||
+ }
|
||||
+ }
|
||||
waiter = {
|
||||
resolve,
|
||||
reject,
|
||||
@@ -1,597 +0,0 @@
|
||||
{
|
||||
"sourceHashes": {
|
||||
"src/main/rate-limits/auth-filesystem-operation.ts": {
|
||||
"before": "581d7045220e92602ca83ec335576889dcba5ada4a699e866ea1dba10afbecac",
|
||||
"after": "bfac9dc25c3f3c36ab590bc6e01be39ec19dea5a872f5c409ed149e65258f9e2"
|
||||
},
|
||||
"src/shared/promise-settlement-waiters.ts": {
|
||||
"before": "b0b60bae6dcca4bb4f335ef7dda250f4350aa5b0306d3595b887cc4af4493060",
|
||||
"after": "92618db591cf1fc1ad52f257cc9f3314310bf902fbd24e5d18abcf93afb8e722"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"node": "26.6.0",
|
||||
"acorn": "8.17.0",
|
||||
"ada": "4.0.0",
|
||||
"amaro": "1.1.11",
|
||||
"ares": "1.34.8",
|
||||
"brotli": "1.2.0",
|
||||
"cldr": "48.0",
|
||||
"icu": "78.3",
|
||||
"libffi": "3.7.1",
|
||||
"llhttp": "9.4.3",
|
||||
"merve": "1.2.2",
|
||||
"modules": "147",
|
||||
"napi": "10",
|
||||
"nbytes": "0.1.4",
|
||||
"ncrypto": "0.0.1",
|
||||
"nghttp2": "1.70.0",
|
||||
"nghttp3": "",
|
||||
"ngtcp2": "",
|
||||
"openssl": "3.6.3",
|
||||
"simdjson": "4.6.6",
|
||||
"simdutf": "7.7.0",
|
||||
"sqlite": "3.53.4",
|
||||
"tz": "2026a",
|
||||
"undici": "8.9.0",
|
||||
"unicode": "17.0",
|
||||
"uv": "1.52.1",
|
||||
"uvwasi": "0.0.23",
|
||||
"v8": "14.6.202.34-node.26",
|
||||
"zlib": "1.2.12",
|
||||
"zstd": "1.5.7"
|
||||
},
|
||||
"scenario": "128 aborted waits on one already-started unresolved operation; amplified arm has 64 KiB synthetic payload per abort Error; real native filesystem stall not reproduced",
|
||||
"before": {
|
||||
"cases": [
|
||||
{
|
||||
"amplify": false,
|
||||
"abortedWaits": 128,
|
||||
"unresolved": {
|
||||
"reasons": 1,
|
||||
"controllers": 0,
|
||||
"payloads": 0,
|
||||
"rawCalls": 1
|
||||
},
|
||||
"settled": {
|
||||
"reasons": 1,
|
||||
"controllers": 0,
|
||||
"payloads": 0
|
||||
},
|
||||
"dropped": {
|
||||
"reasons": 0,
|
||||
"controllers": 0,
|
||||
"payloads": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"amplify": true,
|
||||
"abortedWaits": 128,
|
||||
"unresolved": {
|
||||
"reasons": 1,
|
||||
"controllers": 0,
|
||||
"payloads": 1,
|
||||
"rawCalls": 1
|
||||
},
|
||||
"settled": {
|
||||
"reasons": 1,
|
||||
"controllers": 0,
|
||||
"payloads": 1
|
||||
},
|
||||
"dropped": {
|
||||
"reasons": 0,
|
||||
"controllers": 0,
|
||||
"payloads": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"controls": {
|
||||
"lateResultDelivered": true,
|
||||
"settledResultDelivered": true,
|
||||
"oneRawCall": 1,
|
||||
"rawRejectionPreserved": true,
|
||||
"preAbortedRawCalls": 0,
|
||||
"allAbortListenersRemoved": true,
|
||||
"arbitraryAbortReasonsPreserved": 4,
|
||||
"liveSiblingSurvivesAbort": true
|
||||
},
|
||||
"settlementOrder": [
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
}
|
||||
],
|
||||
"importedHashes": {
|
||||
"src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a"
|
||||
},
|
||||
"bundleSha256": "f895e71ca0f2102e7ff36fa133ae2065fa9810769d7ee6059c826f1a72bcc122"
|
||||
},
|
||||
"after": {
|
||||
"cases": [
|
||||
{
|
||||
"amplify": false,
|
||||
"abortedWaits": 128,
|
||||
"unresolved": {
|
||||
"reasons": 1,
|
||||
"controllers": 0,
|
||||
"payloads": 0,
|
||||
"rawCalls": 1
|
||||
},
|
||||
"settled": {
|
||||
"reasons": 1,
|
||||
"controllers": 0,
|
||||
"payloads": 0
|
||||
},
|
||||
"dropped": {
|
||||
"reasons": 0,
|
||||
"controllers": 0,
|
||||
"payloads": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"amplify": true,
|
||||
"abortedWaits": 128,
|
||||
"unresolved": {
|
||||
"reasons": 1,
|
||||
"controllers": 0,
|
||||
"payloads": 1,
|
||||
"rawCalls": 1
|
||||
},
|
||||
"settled": {
|
||||
"reasons": 1,
|
||||
"controllers": 0,
|
||||
"payloads": 1
|
||||
},
|
||||
"dropped": {
|
||||
"reasons": 0,
|
||||
"controllers": 0,
|
||||
"payloads": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"controls": {
|
||||
"lateResultDelivered": true,
|
||||
"settledResultDelivered": true,
|
||||
"oneRawCall": 1,
|
||||
"rawRejectionPreserved": true,
|
||||
"preAbortedRawCalls": 0,
|
||||
"allAbortListenersRemoved": true,
|
||||
"arbitraryAbortReasonsPreserved": 4,
|
||||
"liveSiblingSurvivesAbort": true
|
||||
},
|
||||
"settlementOrder": [
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": false,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": true,
|
||||
"rejectRaw": true,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": false,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "fulfilled",
|
||||
"value": "raw success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 0,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "caller aborted"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 1,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 2,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 3,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 4,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
},
|
||||
{
|
||||
"startedBefore": false,
|
||||
"rejectRaw": true,
|
||||
"ticks": 5,
|
||||
"outcome": {
|
||||
"status": "rejected",
|
||||
"reason": "raw failure"
|
||||
}
|
||||
}
|
||||
],
|
||||
"importedHashes": {
|
||||
"src/shared/wsl-paths.ts": "1d9dcf5a1ff6693c02ff60a6b074eb2ebb7e83bda12aceba7e0b208d410b6d1a"
|
||||
},
|
||||
"bundleSha256": "04324b281a776aa1018995a36d11ff7cf56b4dd737761c3d5b0fb1ebc0047652"
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync, writeFileSync } = require('node:fs')
|
||||
const { resolve } = require('node:path')
|
||||
const Module = require('node:module')
|
||||
const { getEventListeners } = require('node:events')
|
||||
const { build } = require('esbuild')
|
||||
const { root, before, after, hashes } = require('./sources.cjs')()
|
||||
const settlementOrder = require('./settlement-order.cjs')
|
||||
|
||||
assert.equal(process.env.ORCA_BACKGROUND_LAUNCH, '1')
|
||||
assert.equal(typeof global.gc, 'function')
|
||||
const sourcePath = 'src/main/rate-limits/auth-filesystem-operation.ts'
|
||||
const entry = resolve(root, sourcePath)
|
||||
let candidate = false
|
||||
let createAuthFilesystemOperation
|
||||
const turn = () => new Promise((resolveTurn) => setImmediate(resolveTurn))
|
||||
async function collect() {
|
||||
for (let index = 0; index < 5; index++) {
|
||||
await turn()
|
||||
global.gc()
|
||||
}
|
||||
await turn()
|
||||
}
|
||||
const count = (refs) => refs.reduce((total, ref) => total + Number(ref.deref() !== undefined), 0)
|
||||
async function abandonedWait(operation, index, amplify) {
|
||||
const controller = new AbortController()
|
||||
const reason = new Error(`synthetic expired poll ${index}`)
|
||||
// Payload amplifies the retained rejection object; normal timeout errors are much smaller.
|
||||
if (amplify) {
|
||||
reason.auditPayload = new Uint8Array(64 * 1024)
|
||||
reason.auditPayload.fill(index & 255)
|
||||
}
|
||||
const references = {
|
||||
reason: new WeakRef(reason),
|
||||
controller: new WeakRef(controller),
|
||||
...(amplify ? { payload: new WeakRef(reason.auditPayload) } : {})
|
||||
}
|
||||
const waiting = operation.wait(controller.signal)
|
||||
controller.abort(reason)
|
||||
await waiting.catch(() => {})
|
||||
assert.equal(getEventListeners(controller.signal, 'abort').length, 0)
|
||||
return references
|
||||
}
|
||||
function snapshot(refs) {
|
||||
return {
|
||||
reasons: count(refs.map((ref) => ref.reason)),
|
||||
controllers: count(refs.map((ref) => ref.controller)),
|
||||
payloads: count(refs.flatMap((ref) => (ref.payload ? [ref.payload] : [])))
|
||||
}
|
||||
}
|
||||
async function retention(amplify) {
|
||||
let settleRaw
|
||||
let rawCalls = 0
|
||||
let operation = createAuthFilesystemOperation('/synthetic-local-auth', () => {
|
||||
rawCalls++
|
||||
return new Promise((resolveRaw) => {
|
||||
settleRaw = resolveRaw
|
||||
})
|
||||
})
|
||||
await turn()
|
||||
assert.equal(rawCalls, 1)
|
||||
const refs = []
|
||||
for (let index = 0; index < 128; index++) {
|
||||
refs.push(await abandonedWait(operation, index, amplify))
|
||||
}
|
||||
await collect()
|
||||
const unresolved = { ...snapshot(refs), rawCalls }
|
||||
if (candidate) {
|
||||
assert.equal(unresolved.reasons, 1)
|
||||
}
|
||||
settleRaw('finished')
|
||||
await operation.result
|
||||
await collect()
|
||||
const settled = snapshot(refs)
|
||||
assert.equal(settled.reasons, 1)
|
||||
operation = null
|
||||
settleRaw = null
|
||||
await collect()
|
||||
const dropped = snapshot(refs)
|
||||
assert.deepEqual(dropped, { reasons: 0, controllers: 0, payloads: 0 })
|
||||
return { amplify, abortedWaits: refs.length, unresolved, settled, dropped }
|
||||
}
|
||||
async function controls() {
|
||||
let rawCalls = 0
|
||||
let finish
|
||||
const operation = createAuthFilesystemOperation('/synthetic-auth-controls', () => {
|
||||
rawCalls++
|
||||
return new Promise((resolveRaw) => {
|
||||
finish = resolveRaw
|
||||
})
|
||||
})
|
||||
const expired = new AbortController()
|
||||
const expiredReason = new Error('expired first poll')
|
||||
const abortedWait = operation.wait(expired.signal)
|
||||
await turn()
|
||||
expired.abort(expiredReason)
|
||||
await assert.rejects(abortedWait, (reason) => reason === expiredReason)
|
||||
const later = new AbortController()
|
||||
const lateWait = operation.wait(later.signal)
|
||||
finish('late raw result')
|
||||
assert.equal(await lateWait, 'late raw result')
|
||||
assert.equal(rawCalls, 1)
|
||||
assert.equal(await operation.wait(later.signal), 'late raw result')
|
||||
assert.equal(getEventListeners(expired.signal, 'abort').length, 0)
|
||||
assert.equal(getEventListeners(later.signal, 'abort').length, 0)
|
||||
let rejectedCalls = 0
|
||||
const rejectedReason = new Error('raw rejected')
|
||||
const rejected = createAuthFilesystemOperation('/synthetic-auth-rejected', async () => {
|
||||
rejectedCalls++
|
||||
throw rejectedReason
|
||||
})
|
||||
await assert.rejects(
|
||||
rejected.wait(new AbortController().signal),
|
||||
(reason) => reason === rejectedReason
|
||||
)
|
||||
let preAbortedCalls = 0
|
||||
const preAborted = createAuthFilesystemOperation('/synthetic-auth-pre-aborted', async () => {
|
||||
preAbortedCalls++
|
||||
return 'unexpected'
|
||||
})
|
||||
const priorAbort = new AbortController()
|
||||
priorAbort.abort(expiredReason)
|
||||
await assert.rejects(preAborted.wait(priorAbort.signal), (reason) => reason === expiredReason)
|
||||
await assert.rejects(preAborted.result, (reason) => reason === expiredReason)
|
||||
assert.equal(preAbortedCalls, 0)
|
||||
let finishReasons
|
||||
const reasonOperation = createAuthFilesystemOperation(
|
||||
'/synthetic-auth-reasons',
|
||||
() =>
|
||||
new Promise((resolveRaw) => {
|
||||
finishReasons = resolveRaw
|
||||
})
|
||||
)
|
||||
await turn()
|
||||
const reasons = [false, 0, 'string abort', { code: 'custom' }]
|
||||
for (const reason of reasons) {
|
||||
const controller = new AbortController()
|
||||
const pending = reasonOperation.wait(controller.signal)
|
||||
controller.abort(reason)
|
||||
await assert.rejects(pending, (observed) => observed === reason)
|
||||
assert.equal(getEventListeners(controller.signal, 'abort').length, 0)
|
||||
}
|
||||
const activeController = new AbortController()
|
||||
const cancelledController = new AbortController()
|
||||
const active = reasonOperation.wait(activeController.signal)
|
||||
const cancelled = reasonOperation.wait(cancelledController.signal)
|
||||
cancelledController.abort(expiredReason)
|
||||
await assert.rejects(cancelled, (reason) => reason === expiredReason)
|
||||
finishReasons('active result')
|
||||
assert.equal(await active, 'active result')
|
||||
assert.equal(getEventListeners(activeController.signal, 'abort').length, 0)
|
||||
return {
|
||||
lateResultDelivered: true,
|
||||
settledResultDelivered: true,
|
||||
oneRawCall: rawCalls,
|
||||
rawRejectionPreserved: rejectedCalls === 1,
|
||||
preAbortedRawCalls: preAbortedCalls,
|
||||
allAbortListenersRemoved: true,
|
||||
arbitraryAbortReasonsPreserved: reasons.length,
|
||||
liveSiblingSurvivesAbort: true
|
||||
}
|
||||
}
|
||||
async function phase(sources, fixed) {
|
||||
candidate = fixed
|
||||
const built = await build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
write: false,
|
||||
metafile: true,
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
{
|
||||
name: 'hash-fenced-proof-source',
|
||||
setup(api) {
|
||||
api.onLoad(
|
||||
{ filter: /(?:auth-filesystem-operation|promise-settlement-waiters)\.ts$/ },
|
||||
(args) =>
|
||||
sources.has(args.path)
|
||||
? {
|
||||
contents: sources.get(args.path),
|
||||
loader: 'ts',
|
||||
resolveDir: resolve(args.path, '..')
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
const bundled = built.outputFiles[0].text
|
||||
const moduleOwner = new Module(entry, module)
|
||||
moduleOwner.filename = entry
|
||||
moduleOwner.paths = module.paths
|
||||
moduleOwner._compile(bundled, entry)
|
||||
createAuthFilesystemOperation = moduleOwner.exports.createAuthFilesystemOperation
|
||||
const importedHashes = Object.fromEntries(
|
||||
Object.keys(built.metafile.inputs)
|
||||
.filter((path) => !sources.has(resolve(root, path)))
|
||||
.map((path) => [
|
||||
path,
|
||||
createHash('sha256')
|
||||
.update(readFileSync(resolve(root, path)))
|
||||
.digest('hex')
|
||||
])
|
||||
)
|
||||
return {
|
||||
cases: [await retention(false), await retention(true)],
|
||||
controls: await controls(),
|
||||
settlementOrder: await settlementOrder(createAuthFilesystemOperation),
|
||||
importedHashes,
|
||||
bundleSha256: createHash('sha256').update(bundled).digest('hex')
|
||||
}
|
||||
}
|
||||
async function run() {
|
||||
const result = {
|
||||
sourceHashes: hashes,
|
||||
runtime: process.versions,
|
||||
scenario:
|
||||
'128 aborted waits on one already-started unresolved operation; amplified arm has 64 KiB synthetic payload per abort Error; real native filesystem stall not reproduced',
|
||||
before: await phase(before, false),
|
||||
after: await phase(after, true)
|
||||
}
|
||||
assert.deepEqual(result.after.settlementOrder, result.before.settlementOrder)
|
||||
const output = process.argv[2]
|
||||
? resolve(process.argv[2])
|
||||
: resolve(__dirname, `${process.versions.electron ? 'electron-' : 'node-'}results.json`)
|
||||
writeFileSync(output, `${JSON.stringify(result, null, 2)}\n`)
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
}
|
||||
const deadline = setTimeout(() => {
|
||||
console.error('Auth wait proof exceeded 15 seconds')
|
||||
process.exit(1)
|
||||
}, 15_000)
|
||||
run()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => clearTimeout(deadline))
|
||||
@@ -1,37 +0,0 @@
|
||||
const turn = () => new Promise((resolveTurn) => setImmediate(resolveTurn))
|
||||
|
||||
module.exports = async function settlementOrder(create) {
|
||||
const cases = []
|
||||
for (const startedBefore of [true, false]) {
|
||||
for (const rejectRaw of [false, true]) {
|
||||
for (let ticks = 0; ticks < 6; ticks++) {
|
||||
let settle
|
||||
const operation = create(
|
||||
'synthetic-auth-order',
|
||||
() =>
|
||||
new Promise((resolveRaw, failRaw) => {
|
||||
settle = () => (rejectRaw ? failRaw('raw failure') : resolveRaw('raw success'))
|
||||
})
|
||||
)
|
||||
await turn()
|
||||
const controller = new AbortController()
|
||||
const start = () =>
|
||||
operation.wait(controller.signal).then(
|
||||
(value) => ({ status: 'fulfilled', value }),
|
||||
(reason) => ({ status: 'rejected', reason })
|
||||
)
|
||||
let waiting = startedBefore ? start() : null
|
||||
settle()
|
||||
for (let index = 0; index < ticks; index++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
if (!startedBefore) {
|
||||
waiting = start()
|
||||
}
|
||||
controller.abort('caller aborted')
|
||||
cases.push({ startedBefore, rejectRaw, ticks, outcome: await waiting })
|
||||
}
|
||||
}
|
||||
}
|
||||
return cases
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"baselineHashes": {
|
||||
"src/main/rate-limits/auth-filesystem-operation.ts": "581d7045220e92602ca83ec335576889dcba5ada4a699e866ea1dba10afbecac",
|
||||
"src/shared/promise-settlement-waiters.ts": "b0b60bae6dcca4bb4f335ef7dda250f4350aa5b0306d3595b887cc4af4493060"
|
||||
},
|
||||
"checkedMainRevision": "77cd61df396f25ec91ee2d5ddcbd1f55aa94f818",
|
||||
"sharedTestBaselineSha256": "1e274da93106f31b9b57aa48fd965a4d81c8adf4ebe1bfa20a196aa3e529a73a",
|
||||
"authSourceIdenticalNamedRefs": ["origin/main", "v1.4.198"],
|
||||
"registrySourceIdenticalNamedRefs": ["origin/main"],
|
||||
"historicalRuntimeReproduced": false
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { resolve } = require('node:path')
|
||||
const { applyPatch, parsePatch, reversePatch } = require('diff')
|
||||
|
||||
module.exports = function loadSources() {
|
||||
const root = resolve(__dirname, '../../..')
|
||||
const expected = JSON.parse(readFileSync(resolve(__dirname, 'source-versions.json'), 'utf8'))
|
||||
const parsed = parsePatch(readFileSync(resolve(__dirname, 'fix.patch'), 'utf8'))
|
||||
const before = new Map()
|
||||
const after = new Map()
|
||||
const hashes = {}
|
||||
assert.equal(parsed.length, 2)
|
||||
for (const patch of parsed) {
|
||||
const path = patch.newFileName.replace(/^b\//, '')
|
||||
assert.ok(Object.hasOwn(expected.baselineHashes, path), `Unexpected patch path: ${path}`)
|
||||
const absolute = resolve(root, path)
|
||||
const current = readFileSync(absolute, 'utf8')
|
||||
const baseline = applyPatch(current, reversePatch(patch))
|
||||
assert.notEqual(baseline, false, `Source changed; review fix.patch: ${path}`)
|
||||
const hash = (source) => createHash('sha256').update(source).digest('hex')
|
||||
assert.equal(hash(baseline), expected.baselineHashes[path], `Baseline drift: ${path}`)
|
||||
before.set(absolute, baseline)
|
||||
after.set(absolute, current)
|
||||
hashes[path] = { before: hash(baseline), after: hash(current) }
|
||||
}
|
||||
return { root, before, after, hashes }
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"backgroundLaunch": true,
|
||||
"authAndRegistryTests": {
|
||||
"before": { "passed": 48, "failed": 2, "exitCode": 1 },
|
||||
"after": { "passed": 50, "failed": 0, "exitCode": 0 },
|
||||
"newCases": 18,
|
||||
"expectedBeforeFailures": [
|
||||
"PromiseSettlementWaiters preserves abort scheduling with abortInMicrotask=true",
|
||||
"shared auth filesystem wait lifetime releases aborted poll reasons while one native operation remains needed"
|
||||
],
|
||||
"paths": [
|
||||
"src/main/rate-limits/auth-filesystem-operation.test.ts",
|
||||
"src/main/rate-limits/auth-filesystem-operation-retention.test.ts",
|
||||
"src/main/rate-limits/codex-auth-presence.test.ts",
|
||||
"src/main/rate-limits/kimi-fetcher-wsl-home.test.ts",
|
||||
"src/main/rate-limits/kimi-fetcher.test.ts",
|
||||
"src/shared/promise-settlement-waiters.test.ts"
|
||||
]
|
||||
},
|
||||
"existingRegistryConsumers": {
|
||||
"passed": 39,
|
||||
"failed": 0,
|
||||
"exitCode": 0,
|
||||
"paths": [
|
||||
"src/relay/relay-watcher-setup-wait.test.ts",
|
||||
"src/relay/relay-filesystem-watch-registry.test.ts",
|
||||
"src/main/providers/ssh-filesystem-provider-watch-waiters.test.ts",
|
||||
"src/main/runtime/file-watcher-host.test.ts",
|
||||
"src/main/ipc/runtime-watcher-pending-assignment.test.ts",
|
||||
"src/main/ipc/parcel-watcher-supervisor-capacity-wait.test.ts"
|
||||
]
|
||||
},
|
||||
"typechecks": {
|
||||
"command": "node config/scripts/run-typecheck-projects-in-parallel.mjs",
|
||||
"projects": [
|
||||
"config/tsconfig.node.json",
|
||||
"config/tsconfig.tc.cli.json",
|
||||
"config/tsconfig.tc.web.json"
|
||||
],
|
||||
"exitCode": 0
|
||||
},
|
||||
"focusedOxlint": { "ordinaryExitCode": 0, "typeAwareExitCode": 0 },
|
||||
"formatCheckExitCode": 0,
|
||||
"changedCodeQuality": {
|
||||
"base": "2fccacadbe23",
|
||||
"changedFiles": 297,
|
||||
"newFindings": 0,
|
||||
"exitCode": 0
|
||||
},
|
||||
"proof": {
|
||||
"node": "26.6.0",
|
||||
"electron": "43.7.0",
|
||||
"electronNode": "24.21.0",
|
||||
"bothExitCode": 0,
|
||||
"orderingCasesPerRuntime": 24,
|
||||
"beforeAfterOrderingEqual": true,
|
||||
"rawFilesystemStall": "injected pending promise, not an affected-host capture",
|
||||
"amplifiedBytesPerCase": 8388608,
|
||||
"ordinaryErrorBytes": "not measured"
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
# Closed browser dispatcher retains completed results behind pending native work
|
||||
|
||||
Before the fix, completed command results stayed in a closed browser dispatcher until its final handler settled. The fix releases settled cache records at close and drops newly settled records while closed. Pending native handlers, page authority, and executor teardown keep their existing lifetime.
|
||||
|
||||
## Actual paths and bounds
|
||||
|
||||
Source references in this section describe the hash-fenced baseline. `BrowserClientHostCommandDispatcher.dispatch` refuses every command once closed, before authority or duplicate lookup (`browser-client-host-command-dispatcher.ts:77–79`). `close` aborts active work and cancels queued work, but retains its pages and cached completed records when the join returns false (`:156–179`). `finishHandler` clears those owners only after the last native handler settles (`:268–272`). A newly settled cancellation record is also cached while a sibling remains active (`:297–302`).
|
||||
|
||||
`BrowserClientHostCommandResultCache.clear` drops only its record-to-page index. PageState.records and PageState.sequencesByCommandId also own the records; clearing just that index does not release result graphs. Existing `releasePage` uses exact settled-record eviction to remove both indexes (`browser-client-host-command-result-cache.ts:27–55`).
|
||||
|
||||
Defaults (`browser-client-host-command-state.ts:7–13`) are 256 pages, 256 active commands, 8 concurrent handlers, 32 queued/page, 64 cached results/page, 1,024 cached results total, and a 5,000 ms close/retirement join. Automation result schema allows at most 768 KiB of JSON-serialized value (`browser-client-automation-protocol.ts:5,89–99,116–131`). These are count/serialized-value limits, not a guaranteed heap/RSS size. The audit uses 32 tiny results, one native wait, and one canceled tiny queued input; it does not allocate near those maxima.
|
||||
|
||||
Production composition calls dispatcher close via `PairedRuntimeBrowserClientHost.closeHost` (`paired-runtime-browser-client-host.ts:165–180`). If close times out, actual `closeBrowserClientHostComposition` defers executor close behind `whenHandlersSettled` (`paired-runtime-browser-client-host-teardown.ts:37–56`). Keeping handler/page/native authority alive is intentional. Completed results cannot serve new or duplicate closed requests and need not share that lifetime.
|
||||
|
||||
## Ordinary handler time boundaries
|
||||
|
||||
- The navigation command checks cancellation before starting, then calls `routeWebContents.navigateGuest` (`browser-client-page-command-execution.ts:20–40`). The actual registry delegates to `navigateBrowserRouteGuest`, which awaits native `guest.loadURL` (`browser-route-guest-lifecycle.ts:99–123`) without adding a JS deadline or taking the AbortSignal. Native completion/rejection remains its settlement owner.
|
||||
- Automation checks cancellation before execution, registers the exact guest, and forwards the signal into RPC (`browser-client-page-automation-runtime.ts:42–57`; startup `main-process-ready-runtime.ts:61–77`). Core handlers such as browser.snapshot destructure runtime and call its method without observing that signal (`runtime/rpc/methods/browser-core.ts:39–43`).
|
||||
- The ordinary agent-browser helper execution has a 90-second default subprocess timeout (`agent-browser-bridge-types.ts:6`; `agent-browser-bridge-raw-process.ts:20–36`), with overrides for some operations. The agent-bridge embedded goto wrapper separately has a 30-second navigation timeout. Those deadlines are not a universal bound on all handler phases, and the direct route navigate path above does not use that wrapper.
|
||||
|
||||
The condition is a handler that outlives the dispatcher's five-second join. No affected-host occurrence, natural indefinite stall, or incident attribution has been established.
|
||||
|
||||
## Bounded actual-source proof
|
||||
|
||||
`scenario.test.mjs` uses the actual dispatcher, page executor, automation runtime, browser.snapshot RPC descriptor, native-navigation wrapper, and composition teardown function. Existing page-executor harness supplies renderer/session/route ports. The runtime's browserSnapshot/native loadURL are small controlled ports; no Electron window, OS child, real web request, or network is used. Logger/mock result arrays do not own the produced payloads: the automation output and dispatcher handler are plain functions, and each completed value is observed only through WeakRef after the helper returns.
|
||||
|
||||
Both Node 26.6 and Electron 43.7 / Node 24.21 pass all 4 cases before and after the two-file fix. A 15 ms join override keeps the proof bounded; the native port is explicitly resolved in finally and all native custody eventually settles.
|
||||
|
||||
| Observation | Before | Fixed |
|
||||
| ---------------------------------------------------------------------- | -----: | ----: |
|
||||
| Completed small payload objects alive after timed-out close | 32 | 0 |
|
||||
| Cached records after close (32 results + create + queued cancellation) | 34 | 0 |
|
||||
| Canceled queued input still reachable | yes | no |
|
||||
| Running native handlers after close | 1 | 1 |
|
||||
| Page/executor and route/session custody retained | yes | yes |
|
||||
| Close repeated before native settlement | false | false |
|
||||
| whenClosed pending before native settlement | yes | yes |
|
||||
| Payload objects alive after explicit native settlement | 0 | 0 |
|
||||
| First late cancellation cached while sibling remains pending | 1 | 0 |
|
||||
|
||||
Open request replay preserves the exact same Promise and runs once. Closed duplicates fail with dispatcher_closed. Normal native resolution and rejection both complete the existing settlement path. Executor close and route/session release happen only after native settlement in both variants.
|
||||
|
||||
The initial candidate compatibility run passed 78 tests in 5 files, including its three lifecycle cases and existing dispatcher, page executor, paired-runtime composition, and paired-runtime host tests. The permanent retention suite adds two object-lifetime regressions: releasing completed results while native navigation stays pending, and releasing one late closed record while a sibling handler remains active. Final validation passed 77 tests across 5 production suites, Node typechecking, and all five explicit quality scans over product and artifact code. Reversing the patch produces the two expected lifetime failures while all 16 existing dispatcher tests pass. Commands and outcomes are recorded in `validation.json`.
|
||||
|
||||
## Fix scope
|
||||
|
||||
`fix.patch` changes only:
|
||||
|
||||
- `src/main/browser/browser-client-host-command-dispatcher.ts`: release each page's already-settled cache during close, after cancellation; discard newly settled records instead of caching them when closed.
|
||||
- `src/main/browser/browser-client-host-command-result-cache.ts`: accept an optional `retain` flag in `record`, defaulting to the existing caching behavior. When false, existing identity-checked eviction drops the settled record before any cache admission.
|
||||
|
||||
Active/cancelling records, pages, native promises, abort behavior, join timing, FIFO, generation/authority checks, and the closed-settlement promise remain owned exactly as before. No row/byte cap changes. The host and executor continue waiting for their existing settlement owners.
|
||||
|
||||
## retirePage is separate
|
||||
|
||||
`selectCommandPage` rejects retiring and retired generations before `findExistingCommand` (`browser-client-host-command-page.ts:34–43`). Thus retired duplicate replay is already unavailable, even though the cache remains until forget/replacement. The control confirms that behavior and verifies explicit forget releases the cache and preserves the stale-generation floor. This fix leaves that existing retirement policy unchanged; freeing results on page retirement is a separate possible follow-up, especially while executor cleanup is still pending. Do not assume a live duplicate replay contract where the actual admission path rejects first.
|
||||
|
||||
## Reproduction and source fences
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 ORCA_BROWSER_CACHE_VARIANT=before pnpm exec vitest run --config docs/audits/browser-closed-result-retention/vitest.config.mjs docs/audits/browser-closed-result-retention/scenario.test.mjs
|
||||
ORCA_BACKGROUND_LAUNCH=1 ORCA_BROWSER_CACHE_VARIANT=fixed pnpm exec vitest run --config docs/audits/browser-closed-result-retention/vitest.config.mjs docs/audits/browser-closed-result-retention/scenario.test.mjs
|
||||
```
|
||||
|
||||
For Electron run the installed binary with `ELECTRON_RUN_AS_NODE=1` and `ORCA_BACKGROUND_LAUNCH=1`, passing node_modules/vitest/vitest.mjs and the same arguments. Reports are separate per runtime/variant; set `ORCA_BROWSER_CACHE_OUTPUT` to another file path to preserve captured reports. `sources.cjs` reverses `fix.patch` in memory and checks exact baseline/fixed hashes plus 21 caller/dependency hashes. The config loads those sources at their real production module IDs without changing checkout files. A synthetic CRLF control checks all 24 source/patch reads against canonical LF hashes. Both variants use the same controlled producer and lifecycle ports.
|
||||
|
||||
`source-versions.json` records 23 canonical-LF source/caller hashes. All 23 match named main checkpoint 291b4ddd6f1c1af480169885e0fda7f9c78ff053; 21 match v1.4.198. Both fixed source baselines match both named versions. The proof executes current source/dependencies, not a historical application binary.
|
||||
|
||||
## Permanent regression validation
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-client-host-command-retention.test.ts src/main/browser/browser-client-host-command-dispatcher.test.ts src/main/browser/browser-client-page-command-executor.test.ts src/main/browser/paired-runtime-browser-client-host-composition.test.ts src/main/browser/paired-runtime-browser-client-host.test.ts
|
||||
ORCA_BACKGROUND_LAUNCH=1 ORCA_BROWSER_CACHE_VARIANT=before pnpm exec vitest run --config docs/audits/browser-closed-result-retention/vitest.config.mjs src/main/browser/browser-client-host-command-retention.test.ts src/main/browser/browser-client-host-command-dispatcher.test.ts
|
||||
ORCA_BACKGROUND_LAUNCH=1 pnpm tc:node
|
||||
```
|
||||
|
||||
The baseline regression command intentionally fails the two new lifetime assertions. The source and object counts prove a code mechanism, not incident-specific browser use, native stall duration, aggregate app RSS, or attribution to #19831.
|
||||
@@ -1,138 +0,0 @@
|
||||
{
|
||||
"sources": {
|
||||
"src/main/browser/browser-client-host-command-dispatcher.ts": {
|
||||
"before": "f8c610a54f7d16c59043250dde5ef399f70e1a974c9492d09d403138e46c1da3",
|
||||
"after": "0e4074a4cb7379de58d75dd93e3f74090dea18a944bd4f038730cf19f6e68a98"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-result-cache.ts": {
|
||||
"before": "9ec3dad78c83f864f74957cad766cbcf41863e92d81d7e7a471702b0f9ab2f34",
|
||||
"after": "f06bd1f31be9ffcbb750f242bacd0c2f16222cde7d011010acbd46ceb70ea83f"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-state.ts": {
|
||||
"before": "a5f6b7a3737f86b2953fc43fa0e3ed5faaa141814d5f94fd192da1bb510f355f",
|
||||
"after": "a5f6b7a3737f86b2953fc43fa0e3ed5faaa141814d5f94fd192da1bb510f355f"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-page.ts": {
|
||||
"before": "f5ca7f90511ee0290d2a1d0b3c5c39d3c9841ebcf90a5564bce83fbc9aca06bb",
|
||||
"after": "f5ca7f90511ee0290d2a1d0b3c5c39d3c9841ebcf90a5564bce83fbc9aca06bb"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-join.ts": {
|
||||
"before": "5bf1a38231100cc416176ef2052bdff7b3fca91b6f6dbefcd222b92be59cbb2f",
|
||||
"after": "5bf1a38231100cc416176ef2052bdff7b3fca91b6f6dbefcd222b92be59cbb2f"
|
||||
},
|
||||
"src/main/browser/browser-client-page-command-executor.ts": {
|
||||
"before": "d249de82f89365371fa4f3b457e0da552910819555a5a0bced0c95010ccacb01",
|
||||
"after": "d249de82f89365371fa4f3b457e0da552910819555a5a0bced0c95010ccacb01"
|
||||
},
|
||||
"src/main/browser/browser-client-page-command-execution.ts": {
|
||||
"before": "82b7c87d17b34c267ebfdf3d6ee25058cbd05dbc163ac24c43aefed00f0e3c6e",
|
||||
"after": "82b7c87d17b34c267ebfdf3d6ee25058cbd05dbc163ac24c43aefed00f0e3c6e"
|
||||
},
|
||||
"src/main/browser/browser-client-page-command-executor-test-harness.ts": {
|
||||
"before": "b43ec764e4d094070fab51381ceb24487999dca97b4a38131be08fab0c5cbe24",
|
||||
"after": "b43ec764e4d094070fab51381ceb24487999dca97b4a38131be08fab0c5cbe24"
|
||||
},
|
||||
"src/main/browser/browser-client-page-automation-runtime.ts": {
|
||||
"before": "7b1c0c527a87b8c22ea8290a2e1efc7ca659f2b4a5e2bd7c740fb5c22a2a6319",
|
||||
"after": "7b1c0c527a87b8c22ea8290a2e1efc7ca659f2b4a5e2bd7c740fb5c22a2a6319"
|
||||
},
|
||||
"src/main/browser/browser-route-guest-lifecycle.ts": {
|
||||
"before": "52adbea664bcfc5d774f129d442f4fc36d4a1e1280a073053fc09ae026ca83cb",
|
||||
"after": "52adbea664bcfc5d774f129d442f4fc36d4a1e1280a073053fc09ae026ca83cb"
|
||||
},
|
||||
"src/main/browser/browser-route-webcontents-registry.ts": {
|
||||
"before": "132c6740539aaeb9b1ceb3c108d2aebfb9e8e5c4ce3ff7cd1191e5b45dff8cad",
|
||||
"after": "132c6740539aaeb9b1ceb3c108d2aebfb9e8e5c4ce3ff7cd1191e5b45dff8cad"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host.ts": {
|
||||
"before": "797c0130e30c5f40551f5d392326fff1ac8752e262653b3f855dfd7df9fa0038",
|
||||
"after": "797c0130e30c5f40551f5d392326fff1ac8752e262653b3f855dfd7df9fa0038"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host-composition.ts": {
|
||||
"before": "f8b55a24035e929664f3c5f61ee8a81ee560250379dcf64b39d35e1b60628b74",
|
||||
"after": "f8b55a24035e929664f3c5f61ee8a81ee560250379dcf64b39d35e1b60628b74"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host-teardown.ts": {
|
||||
"before": "4d9eb53c81ddfa3cb8181735b08c2b90a380bebab1f173ae5d8da9f719671f59",
|
||||
"after": "4d9eb53c81ddfa3cb8181735b08c2b90a380bebab1f173ae5d8da9f719671f59"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host-runtime.ts": {
|
||||
"before": "2d0650d0a5ebe88f1185ab5676762ed93188e7c28a69508137ed7cfa36e3832c",
|
||||
"after": "2d0650d0a5ebe88f1185ab5676762ed93188e7c28a69508137ed7cfa36e3832c"
|
||||
},
|
||||
"src/main/runtime/rpc/methods/browser-core.ts": {
|
||||
"before": "fdc2afeece6689644fbd607a59e484ef9d2ac6db7404e3378f2db10e16984f6d",
|
||||
"after": "fdc2afeece6689644fbd607a59e484ef9d2ac6db7404e3378f2db10e16984f6d"
|
||||
},
|
||||
"src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts": {
|
||||
"before": "0af16d6fe883f6af79754e064b77a1b9ab8e80953657434eacc88cc1c523e038",
|
||||
"after": "0af16d6fe883f6af79754e064b77a1b9ab8e80953657434eacc88cc1c523e038"
|
||||
},
|
||||
"src/main/browser/agent-browser-bridge-types.ts": {
|
||||
"before": "3ea69d15aef862807108835d1cbd536275a105a96bdc86f3be95dfbed55ab45f",
|
||||
"after": "3ea69d15aef862807108835d1cbd536275a105a96bdc86f3be95dfbed55ab45f"
|
||||
},
|
||||
"src/main/browser/agent-browser-bridge-raw-process.ts": {
|
||||
"before": "d0e9929c4a4571d8752efa5e4063500efa75fefd228eb6cb1ea6092748befb0c",
|
||||
"after": "d0e9929c4a4571d8752efa5e4063500efa75fefd228eb6cb1ea6092748befb0c"
|
||||
},
|
||||
"src/main/browser/agent-browser-bridge-core-commands.ts": {
|
||||
"before": "d587a7e7c3bd368c9003802607edd20def98e64442cd6d5914779c633feebd03",
|
||||
"after": "d587a7e7c3bd368c9003802607edd20def98e64442cd6d5914779c633feebd03"
|
||||
},
|
||||
"src/main/startup/main-process-ready-runtime.ts": {
|
||||
"before": "5bb3615e9dc430e0a9ab61cb3d70e57ecbfa4c5ea82d5414fc82d93e81e9819b",
|
||||
"after": "5bb3615e9dc430e0a9ab61cb3d70e57ecbfa4c5ea82d5414fc82d93e81e9819b"
|
||||
},
|
||||
"src/shared/browser-client-host-protocol.ts": {
|
||||
"before": "712fe3eaed30c2de322466f316c966e526dcb60cf387cc0f6ba48012c70a7956",
|
||||
"after": "712fe3eaed30c2de322466f316c966e526dcb60cf387cc0f6ba48012c70a7956"
|
||||
},
|
||||
"src/shared/browser-client-automation-protocol.ts": {
|
||||
"before": "f93331c39fd0f8e08518d7a11a461bf5108c1a65be8f213ab2e2eff2ab977e58",
|
||||
"after": "f93331c39fd0f8e08518d7a11a461bf5108c1a65be8f213ab2e2eff2ab977e58"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"node": "24.21.0",
|
||||
"electron": "43.7.0",
|
||||
"v8": "15.0.245.31-electron.0"
|
||||
},
|
||||
"variant": "before",
|
||||
"cases": [
|
||||
{
|
||||
"kind": "native-navigation-close",
|
||||
"completedPayloads": 32,
|
||||
"heldNativePorts": 1,
|
||||
"joinTimeoutOverrideMs": 15,
|
||||
"retainedPayloadsAfterClose": 32,
|
||||
"cachedResultsAfterClose": 34,
|
||||
"cancelledQueuedInputRetained": true,
|
||||
"signalAborted": true,
|
||||
"executorCustodyPreserved": true,
|
||||
"routeLeasePreserved": true,
|
||||
"closedDuplicateRejected": true,
|
||||
"secondCloseSettled": false,
|
||||
"retainedAfterNativeSettlement": 0,
|
||||
"executorClosedAfterNativeSettlement": true
|
||||
},
|
||||
{
|
||||
"kind": "late-sibling-settlement",
|
||||
"oneHandlerStillOwned": true,
|
||||
"cachedAfterFirstSettlement": 1,
|
||||
"closedSettlementStillPending": true
|
||||
},
|
||||
{
|
||||
"kind": "open-replay-and-retire-contract",
|
||||
"openPromiseIdentityPreserved": true,
|
||||
"retiredDuplicateRejected": true,
|
||||
"retireCachePolicyUnchanged": true,
|
||||
"explicitForgetReleasedCache": true
|
||||
},
|
||||
{
|
||||
"kind": "synthetic-crlf-source-control",
|
||||
"canonicalHashesMatch": true,
|
||||
"reads": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
{
|
||||
"sources": {
|
||||
"src/main/browser/browser-client-host-command-dispatcher.ts": {
|
||||
"before": "f8c610a54f7d16c59043250dde5ef399f70e1a974c9492d09d403138e46c1da3",
|
||||
"after": "0e4074a4cb7379de58d75dd93e3f74090dea18a944bd4f038730cf19f6e68a98"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-result-cache.ts": {
|
||||
"before": "9ec3dad78c83f864f74957cad766cbcf41863e92d81d7e7a471702b0f9ab2f34",
|
||||
"after": "f06bd1f31be9ffcbb750f242bacd0c2f16222cde7d011010acbd46ceb70ea83f"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-state.ts": {
|
||||
"before": "a5f6b7a3737f86b2953fc43fa0e3ed5faaa141814d5f94fd192da1bb510f355f",
|
||||
"after": "a5f6b7a3737f86b2953fc43fa0e3ed5faaa141814d5f94fd192da1bb510f355f"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-page.ts": {
|
||||
"before": "f5ca7f90511ee0290d2a1d0b3c5c39d3c9841ebcf90a5564bce83fbc9aca06bb",
|
||||
"after": "f5ca7f90511ee0290d2a1d0b3c5c39d3c9841ebcf90a5564bce83fbc9aca06bb"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-join.ts": {
|
||||
"before": "5bf1a38231100cc416176ef2052bdff7b3fca91b6f6dbefcd222b92be59cbb2f",
|
||||
"after": "5bf1a38231100cc416176ef2052bdff7b3fca91b6f6dbefcd222b92be59cbb2f"
|
||||
},
|
||||
"src/main/browser/browser-client-page-command-executor.ts": {
|
||||
"before": "d249de82f89365371fa4f3b457e0da552910819555a5a0bced0c95010ccacb01",
|
||||
"after": "d249de82f89365371fa4f3b457e0da552910819555a5a0bced0c95010ccacb01"
|
||||
},
|
||||
"src/main/browser/browser-client-page-command-execution.ts": {
|
||||
"before": "82b7c87d17b34c267ebfdf3d6ee25058cbd05dbc163ac24c43aefed00f0e3c6e",
|
||||
"after": "82b7c87d17b34c267ebfdf3d6ee25058cbd05dbc163ac24c43aefed00f0e3c6e"
|
||||
},
|
||||
"src/main/browser/browser-client-page-command-executor-test-harness.ts": {
|
||||
"before": "b43ec764e4d094070fab51381ceb24487999dca97b4a38131be08fab0c5cbe24",
|
||||
"after": "b43ec764e4d094070fab51381ceb24487999dca97b4a38131be08fab0c5cbe24"
|
||||
},
|
||||
"src/main/browser/browser-client-page-automation-runtime.ts": {
|
||||
"before": "7b1c0c527a87b8c22ea8290a2e1efc7ca659f2b4a5e2bd7c740fb5c22a2a6319",
|
||||
"after": "7b1c0c527a87b8c22ea8290a2e1efc7ca659f2b4a5e2bd7c740fb5c22a2a6319"
|
||||
},
|
||||
"src/main/browser/browser-route-guest-lifecycle.ts": {
|
||||
"before": "52adbea664bcfc5d774f129d442f4fc36d4a1e1280a073053fc09ae026ca83cb",
|
||||
"after": "52adbea664bcfc5d774f129d442f4fc36d4a1e1280a073053fc09ae026ca83cb"
|
||||
},
|
||||
"src/main/browser/browser-route-webcontents-registry.ts": {
|
||||
"before": "132c6740539aaeb9b1ceb3c108d2aebfb9e8e5c4ce3ff7cd1191e5b45dff8cad",
|
||||
"after": "132c6740539aaeb9b1ceb3c108d2aebfb9e8e5c4ce3ff7cd1191e5b45dff8cad"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host.ts": {
|
||||
"before": "797c0130e30c5f40551f5d392326fff1ac8752e262653b3f855dfd7df9fa0038",
|
||||
"after": "797c0130e30c5f40551f5d392326fff1ac8752e262653b3f855dfd7df9fa0038"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host-composition.ts": {
|
||||
"before": "f8b55a24035e929664f3c5f61ee8a81ee560250379dcf64b39d35e1b60628b74",
|
||||
"after": "f8b55a24035e929664f3c5f61ee8a81ee560250379dcf64b39d35e1b60628b74"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host-teardown.ts": {
|
||||
"before": "4d9eb53c81ddfa3cb8181735b08c2b90a380bebab1f173ae5d8da9f719671f59",
|
||||
"after": "4d9eb53c81ddfa3cb8181735b08c2b90a380bebab1f173ae5d8da9f719671f59"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host-runtime.ts": {
|
||||
"before": "2d0650d0a5ebe88f1185ab5676762ed93188e7c28a69508137ed7cfa36e3832c",
|
||||
"after": "2d0650d0a5ebe88f1185ab5676762ed93188e7c28a69508137ed7cfa36e3832c"
|
||||
},
|
||||
"src/main/runtime/rpc/methods/browser-core.ts": {
|
||||
"before": "fdc2afeece6689644fbd607a59e484ef9d2ac6db7404e3378f2db10e16984f6d",
|
||||
"after": "fdc2afeece6689644fbd607a59e484ef9d2ac6db7404e3378f2db10e16984f6d"
|
||||
},
|
||||
"src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts": {
|
||||
"before": "0af16d6fe883f6af79754e064b77a1b9ab8e80953657434eacc88cc1c523e038",
|
||||
"after": "0af16d6fe883f6af79754e064b77a1b9ab8e80953657434eacc88cc1c523e038"
|
||||
},
|
||||
"src/main/browser/agent-browser-bridge-types.ts": {
|
||||
"before": "3ea69d15aef862807108835d1cbd536275a105a96bdc86f3be95dfbed55ab45f",
|
||||
"after": "3ea69d15aef862807108835d1cbd536275a105a96bdc86f3be95dfbed55ab45f"
|
||||
},
|
||||
"src/main/browser/agent-browser-bridge-raw-process.ts": {
|
||||
"before": "d0e9929c4a4571d8752efa5e4063500efa75fefd228eb6cb1ea6092748befb0c",
|
||||
"after": "d0e9929c4a4571d8752efa5e4063500efa75fefd228eb6cb1ea6092748befb0c"
|
||||
},
|
||||
"src/main/browser/agent-browser-bridge-core-commands.ts": {
|
||||
"before": "d587a7e7c3bd368c9003802607edd20def98e64442cd6d5914779c633feebd03",
|
||||
"after": "d587a7e7c3bd368c9003802607edd20def98e64442cd6d5914779c633feebd03"
|
||||
},
|
||||
"src/main/startup/main-process-ready-runtime.ts": {
|
||||
"before": "5bb3615e9dc430e0a9ab61cb3d70e57ecbfa4c5ea82d5414fc82d93e81e9819b",
|
||||
"after": "5bb3615e9dc430e0a9ab61cb3d70e57ecbfa4c5ea82d5414fc82d93e81e9819b"
|
||||
},
|
||||
"src/shared/browser-client-host-protocol.ts": {
|
||||
"before": "712fe3eaed30c2de322466f316c966e526dcb60cf387cc0f6ba48012c70a7956",
|
||||
"after": "712fe3eaed30c2de322466f316c966e526dcb60cf387cc0f6ba48012c70a7956"
|
||||
},
|
||||
"src/shared/browser-client-automation-protocol.ts": {
|
||||
"before": "f93331c39fd0f8e08518d7a11a461bf5108c1a65be8f213ab2e2eff2ab977e58",
|
||||
"after": "f93331c39fd0f8e08518d7a11a461bf5108c1a65be8f213ab2e2eff2ab977e58"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"node": "26.6.0",
|
||||
"electron": null,
|
||||
"v8": "14.6.202.34-node.26"
|
||||
},
|
||||
"variant": "before",
|
||||
"cases": [
|
||||
{
|
||||
"kind": "native-navigation-close",
|
||||
"completedPayloads": 32,
|
||||
"heldNativePorts": 1,
|
||||
"joinTimeoutOverrideMs": 15,
|
||||
"retainedPayloadsAfterClose": 32,
|
||||
"cachedResultsAfterClose": 34,
|
||||
"cancelledQueuedInputRetained": true,
|
||||
"signalAborted": true,
|
||||
"executorCustodyPreserved": true,
|
||||
"routeLeasePreserved": true,
|
||||
"closedDuplicateRejected": true,
|
||||
"secondCloseSettled": false,
|
||||
"retainedAfterNativeSettlement": 0,
|
||||
"executorClosedAfterNativeSettlement": true
|
||||
},
|
||||
{
|
||||
"kind": "late-sibling-settlement",
|
||||
"oneHandlerStillOwned": true,
|
||||
"cachedAfterFirstSettlement": 1,
|
||||
"closedSettlementStillPending": true
|
||||
},
|
||||
{
|
||||
"kind": "open-replay-and-retire-contract",
|
||||
"openPromiseIdentityPreserved": true,
|
||||
"retiredDuplicateRejected": true,
|
||||
"retireCachePolicyUnchanged": true,
|
||||
"explicitForgetReleasedCache": true
|
||||
},
|
||||
{
|
||||
"kind": "synthetic-crlf-source-control",
|
||||
"canonicalHashesMatch": true,
|
||||
"reads": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
--- a/src/main/browser/browser-client-host-command-dispatcher.ts
|
||||
+++ b/src/main/browser/browser-client-host-command-dispatcher.ts
|
||||
@@ -165,0 +166 @@
|
||||
+ this.resultCache.releasePage(page)
|
||||
@@ -300 +301 @@
|
||||
- this.resultCache.record(page, record)
|
||||
+ this.resultCache.record(page, record, !this.closed)
|
||||
--- a/src/main/browser/browser-client-host-command-result-cache.ts
|
||||
+++ b/src/main/browser/browser-client-host-command-result-cache.ts
|
||||
@@ -11 +11,5 @@
|
||||
- record(page: PageState, record: CommandRecord): void {
|
||||
+ record(page: PageState, record: CommandRecord, retain = true): void {
|
||||
+ if (!retain) {
|
||||
+ this.evict(page, record.event.commandSequence, record)
|
||||
+ return
|
||||
+ }
|
||||
@@ -1,138 +0,0 @@
|
||||
{
|
||||
"sources": {
|
||||
"src/main/browser/browser-client-host-command-dispatcher.ts": {
|
||||
"before": "f8c610a54f7d16c59043250dde5ef399f70e1a974c9492d09d403138e46c1da3",
|
||||
"after": "0e4074a4cb7379de58d75dd93e3f74090dea18a944bd4f038730cf19f6e68a98"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-result-cache.ts": {
|
||||
"before": "9ec3dad78c83f864f74957cad766cbcf41863e92d81d7e7a471702b0f9ab2f34",
|
||||
"after": "f06bd1f31be9ffcbb750f242bacd0c2f16222cde7d011010acbd46ceb70ea83f"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-state.ts": {
|
||||
"before": "a5f6b7a3737f86b2953fc43fa0e3ed5faaa141814d5f94fd192da1bb510f355f",
|
||||
"after": "a5f6b7a3737f86b2953fc43fa0e3ed5faaa141814d5f94fd192da1bb510f355f"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-page.ts": {
|
||||
"before": "f5ca7f90511ee0290d2a1d0b3c5c39d3c9841ebcf90a5564bce83fbc9aca06bb",
|
||||
"after": "f5ca7f90511ee0290d2a1d0b3c5c39d3c9841ebcf90a5564bce83fbc9aca06bb"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-join.ts": {
|
||||
"before": "5bf1a38231100cc416176ef2052bdff7b3fca91b6f6dbefcd222b92be59cbb2f",
|
||||
"after": "5bf1a38231100cc416176ef2052bdff7b3fca91b6f6dbefcd222b92be59cbb2f"
|
||||
},
|
||||
"src/main/browser/browser-client-page-command-executor.ts": {
|
||||
"before": "d249de82f89365371fa4f3b457e0da552910819555a5a0bced0c95010ccacb01",
|
||||
"after": "d249de82f89365371fa4f3b457e0da552910819555a5a0bced0c95010ccacb01"
|
||||
},
|
||||
"src/main/browser/browser-client-page-command-execution.ts": {
|
||||
"before": "82b7c87d17b34c267ebfdf3d6ee25058cbd05dbc163ac24c43aefed00f0e3c6e",
|
||||
"after": "82b7c87d17b34c267ebfdf3d6ee25058cbd05dbc163ac24c43aefed00f0e3c6e"
|
||||
},
|
||||
"src/main/browser/browser-client-page-command-executor-test-harness.ts": {
|
||||
"before": "b43ec764e4d094070fab51381ceb24487999dca97b4a38131be08fab0c5cbe24",
|
||||
"after": "b43ec764e4d094070fab51381ceb24487999dca97b4a38131be08fab0c5cbe24"
|
||||
},
|
||||
"src/main/browser/browser-client-page-automation-runtime.ts": {
|
||||
"before": "7b1c0c527a87b8c22ea8290a2e1efc7ca659f2b4a5e2bd7c740fb5c22a2a6319",
|
||||
"after": "7b1c0c527a87b8c22ea8290a2e1efc7ca659f2b4a5e2bd7c740fb5c22a2a6319"
|
||||
},
|
||||
"src/main/browser/browser-route-guest-lifecycle.ts": {
|
||||
"before": "52adbea664bcfc5d774f129d442f4fc36d4a1e1280a073053fc09ae026ca83cb",
|
||||
"after": "52adbea664bcfc5d774f129d442f4fc36d4a1e1280a073053fc09ae026ca83cb"
|
||||
},
|
||||
"src/main/browser/browser-route-webcontents-registry.ts": {
|
||||
"before": "132c6740539aaeb9b1ceb3c108d2aebfb9e8e5c4ce3ff7cd1191e5b45dff8cad",
|
||||
"after": "132c6740539aaeb9b1ceb3c108d2aebfb9e8e5c4ce3ff7cd1191e5b45dff8cad"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host.ts": {
|
||||
"before": "797c0130e30c5f40551f5d392326fff1ac8752e262653b3f855dfd7df9fa0038",
|
||||
"after": "797c0130e30c5f40551f5d392326fff1ac8752e262653b3f855dfd7df9fa0038"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host-composition.ts": {
|
||||
"before": "f8b55a24035e929664f3c5f61ee8a81ee560250379dcf64b39d35e1b60628b74",
|
||||
"after": "f8b55a24035e929664f3c5f61ee8a81ee560250379dcf64b39d35e1b60628b74"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host-teardown.ts": {
|
||||
"before": "4d9eb53c81ddfa3cb8181735b08c2b90a380bebab1f173ae5d8da9f719671f59",
|
||||
"after": "4d9eb53c81ddfa3cb8181735b08c2b90a380bebab1f173ae5d8da9f719671f59"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host-runtime.ts": {
|
||||
"before": "2d0650d0a5ebe88f1185ab5676762ed93188e7c28a69508137ed7cfa36e3832c",
|
||||
"after": "2d0650d0a5ebe88f1185ab5676762ed93188e7c28a69508137ed7cfa36e3832c"
|
||||
},
|
||||
"src/main/runtime/rpc/methods/browser-core.ts": {
|
||||
"before": "fdc2afeece6689644fbd607a59e484ef9d2ac6db7404e3378f2db10e16984f6d",
|
||||
"after": "fdc2afeece6689644fbd607a59e484ef9d2ac6db7404e3378f2db10e16984f6d"
|
||||
},
|
||||
"src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts": {
|
||||
"before": "0af16d6fe883f6af79754e064b77a1b9ab8e80953657434eacc88cc1c523e038",
|
||||
"after": "0af16d6fe883f6af79754e064b77a1b9ab8e80953657434eacc88cc1c523e038"
|
||||
},
|
||||
"src/main/browser/agent-browser-bridge-types.ts": {
|
||||
"before": "3ea69d15aef862807108835d1cbd536275a105a96bdc86f3be95dfbed55ab45f",
|
||||
"after": "3ea69d15aef862807108835d1cbd536275a105a96bdc86f3be95dfbed55ab45f"
|
||||
},
|
||||
"src/main/browser/agent-browser-bridge-raw-process.ts": {
|
||||
"before": "d0e9929c4a4571d8752efa5e4063500efa75fefd228eb6cb1ea6092748befb0c",
|
||||
"after": "d0e9929c4a4571d8752efa5e4063500efa75fefd228eb6cb1ea6092748befb0c"
|
||||
},
|
||||
"src/main/browser/agent-browser-bridge-core-commands.ts": {
|
||||
"before": "d587a7e7c3bd368c9003802607edd20def98e64442cd6d5914779c633feebd03",
|
||||
"after": "d587a7e7c3bd368c9003802607edd20def98e64442cd6d5914779c633feebd03"
|
||||
},
|
||||
"src/main/startup/main-process-ready-runtime.ts": {
|
||||
"before": "5bb3615e9dc430e0a9ab61cb3d70e57ecbfa4c5ea82d5414fc82d93e81e9819b",
|
||||
"after": "5bb3615e9dc430e0a9ab61cb3d70e57ecbfa4c5ea82d5414fc82d93e81e9819b"
|
||||
},
|
||||
"src/shared/browser-client-host-protocol.ts": {
|
||||
"before": "712fe3eaed30c2de322466f316c966e526dcb60cf387cc0f6ba48012c70a7956",
|
||||
"after": "712fe3eaed30c2de322466f316c966e526dcb60cf387cc0f6ba48012c70a7956"
|
||||
},
|
||||
"src/shared/browser-client-automation-protocol.ts": {
|
||||
"before": "f93331c39fd0f8e08518d7a11a461bf5108c1a65be8f213ab2e2eff2ab977e58",
|
||||
"after": "f93331c39fd0f8e08518d7a11a461bf5108c1a65be8f213ab2e2eff2ab977e58"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"node": "24.21.0",
|
||||
"electron": "43.7.0",
|
||||
"v8": "15.0.245.31-electron.0"
|
||||
},
|
||||
"variant": "fixed",
|
||||
"cases": [
|
||||
{
|
||||
"kind": "native-navigation-close",
|
||||
"completedPayloads": 32,
|
||||
"heldNativePorts": 1,
|
||||
"joinTimeoutOverrideMs": 15,
|
||||
"retainedPayloadsAfterClose": 0,
|
||||
"cachedResultsAfterClose": 0,
|
||||
"cancelledQueuedInputRetained": false,
|
||||
"signalAborted": true,
|
||||
"executorCustodyPreserved": true,
|
||||
"routeLeasePreserved": true,
|
||||
"closedDuplicateRejected": true,
|
||||
"secondCloseSettled": false,
|
||||
"retainedAfterNativeSettlement": 0,
|
||||
"executorClosedAfterNativeSettlement": true
|
||||
},
|
||||
{
|
||||
"kind": "late-sibling-settlement",
|
||||
"oneHandlerStillOwned": true,
|
||||
"cachedAfterFirstSettlement": 0,
|
||||
"closedSettlementStillPending": true
|
||||
},
|
||||
{
|
||||
"kind": "open-replay-and-retire-contract",
|
||||
"openPromiseIdentityPreserved": true,
|
||||
"retiredDuplicateRejected": true,
|
||||
"retireCachePolicyUnchanged": true,
|
||||
"explicitForgetReleasedCache": true
|
||||
},
|
||||
{
|
||||
"kind": "synthetic-crlf-source-control",
|
||||
"canonicalHashesMatch": true,
|
||||
"reads": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
{
|
||||
"sources": {
|
||||
"src/main/browser/browser-client-host-command-dispatcher.ts": {
|
||||
"before": "f8c610a54f7d16c59043250dde5ef399f70e1a974c9492d09d403138e46c1da3",
|
||||
"after": "0e4074a4cb7379de58d75dd93e3f74090dea18a944bd4f038730cf19f6e68a98"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-result-cache.ts": {
|
||||
"before": "9ec3dad78c83f864f74957cad766cbcf41863e92d81d7e7a471702b0f9ab2f34",
|
||||
"after": "f06bd1f31be9ffcbb750f242bacd0c2f16222cde7d011010acbd46ceb70ea83f"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-state.ts": {
|
||||
"before": "a5f6b7a3737f86b2953fc43fa0e3ed5faaa141814d5f94fd192da1bb510f355f",
|
||||
"after": "a5f6b7a3737f86b2953fc43fa0e3ed5faaa141814d5f94fd192da1bb510f355f"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-page.ts": {
|
||||
"before": "f5ca7f90511ee0290d2a1d0b3c5c39d3c9841ebcf90a5564bce83fbc9aca06bb",
|
||||
"after": "f5ca7f90511ee0290d2a1d0b3c5c39d3c9841ebcf90a5564bce83fbc9aca06bb"
|
||||
},
|
||||
"src/main/browser/browser-client-host-command-join.ts": {
|
||||
"before": "5bf1a38231100cc416176ef2052bdff7b3fca91b6f6dbefcd222b92be59cbb2f",
|
||||
"after": "5bf1a38231100cc416176ef2052bdff7b3fca91b6f6dbefcd222b92be59cbb2f"
|
||||
},
|
||||
"src/main/browser/browser-client-page-command-executor.ts": {
|
||||
"before": "d249de82f89365371fa4f3b457e0da552910819555a5a0bced0c95010ccacb01",
|
||||
"after": "d249de82f89365371fa4f3b457e0da552910819555a5a0bced0c95010ccacb01"
|
||||
},
|
||||
"src/main/browser/browser-client-page-command-execution.ts": {
|
||||
"before": "82b7c87d17b34c267ebfdf3d6ee25058cbd05dbc163ac24c43aefed00f0e3c6e",
|
||||
"after": "82b7c87d17b34c267ebfdf3d6ee25058cbd05dbc163ac24c43aefed00f0e3c6e"
|
||||
},
|
||||
"src/main/browser/browser-client-page-command-executor-test-harness.ts": {
|
||||
"before": "b43ec764e4d094070fab51381ceb24487999dca97b4a38131be08fab0c5cbe24",
|
||||
"after": "b43ec764e4d094070fab51381ceb24487999dca97b4a38131be08fab0c5cbe24"
|
||||
},
|
||||
"src/main/browser/browser-client-page-automation-runtime.ts": {
|
||||
"before": "7b1c0c527a87b8c22ea8290a2e1efc7ca659f2b4a5e2bd7c740fb5c22a2a6319",
|
||||
"after": "7b1c0c527a87b8c22ea8290a2e1efc7ca659f2b4a5e2bd7c740fb5c22a2a6319"
|
||||
},
|
||||
"src/main/browser/browser-route-guest-lifecycle.ts": {
|
||||
"before": "52adbea664bcfc5d774f129d442f4fc36d4a1e1280a073053fc09ae026ca83cb",
|
||||
"after": "52adbea664bcfc5d774f129d442f4fc36d4a1e1280a073053fc09ae026ca83cb"
|
||||
},
|
||||
"src/main/browser/browser-route-webcontents-registry.ts": {
|
||||
"before": "132c6740539aaeb9b1ceb3c108d2aebfb9e8e5c4ce3ff7cd1191e5b45dff8cad",
|
||||
"after": "132c6740539aaeb9b1ceb3c108d2aebfb9e8e5c4ce3ff7cd1191e5b45dff8cad"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host.ts": {
|
||||
"before": "797c0130e30c5f40551f5d392326fff1ac8752e262653b3f855dfd7df9fa0038",
|
||||
"after": "797c0130e30c5f40551f5d392326fff1ac8752e262653b3f855dfd7df9fa0038"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host-composition.ts": {
|
||||
"before": "f8b55a24035e929664f3c5f61ee8a81ee560250379dcf64b39d35e1b60628b74",
|
||||
"after": "f8b55a24035e929664f3c5f61ee8a81ee560250379dcf64b39d35e1b60628b74"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host-teardown.ts": {
|
||||
"before": "4d9eb53c81ddfa3cb8181735b08c2b90a380bebab1f173ae5d8da9f719671f59",
|
||||
"after": "4d9eb53c81ddfa3cb8181735b08c2b90a380bebab1f173ae5d8da9f719671f59"
|
||||
},
|
||||
"src/main/browser/paired-runtime-browser-client-host-runtime.ts": {
|
||||
"before": "2d0650d0a5ebe88f1185ab5676762ed93188e7c28a69508137ed7cfa36e3832c",
|
||||
"after": "2d0650d0a5ebe88f1185ab5676762ed93188e7c28a69508137ed7cfa36e3832c"
|
||||
},
|
||||
"src/main/runtime/rpc/methods/browser-core.ts": {
|
||||
"before": "fdc2afeece6689644fbd607a59e484ef9d2ac6db7404e3378f2db10e16984f6d",
|
||||
"after": "fdc2afeece6689644fbd607a59e484ef9d2ac6db7404e3378f2db10e16984f6d"
|
||||
},
|
||||
"src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts": {
|
||||
"before": "0af16d6fe883f6af79754e064b77a1b9ab8e80953657434eacc88cc1c523e038",
|
||||
"after": "0af16d6fe883f6af79754e064b77a1b9ab8e80953657434eacc88cc1c523e038"
|
||||
},
|
||||
"src/main/browser/agent-browser-bridge-types.ts": {
|
||||
"before": "3ea69d15aef862807108835d1cbd536275a105a96bdc86f3be95dfbed55ab45f",
|
||||
"after": "3ea69d15aef862807108835d1cbd536275a105a96bdc86f3be95dfbed55ab45f"
|
||||
},
|
||||
"src/main/browser/agent-browser-bridge-raw-process.ts": {
|
||||
"before": "d0e9929c4a4571d8752efa5e4063500efa75fefd228eb6cb1ea6092748befb0c",
|
||||
"after": "d0e9929c4a4571d8752efa5e4063500efa75fefd228eb6cb1ea6092748befb0c"
|
||||
},
|
||||
"src/main/browser/agent-browser-bridge-core-commands.ts": {
|
||||
"before": "d587a7e7c3bd368c9003802607edd20def98e64442cd6d5914779c633feebd03",
|
||||
"after": "d587a7e7c3bd368c9003802607edd20def98e64442cd6d5914779c633feebd03"
|
||||
},
|
||||
"src/main/startup/main-process-ready-runtime.ts": {
|
||||
"before": "5bb3615e9dc430e0a9ab61cb3d70e57ecbfa4c5ea82d5414fc82d93e81e9819b",
|
||||
"after": "5bb3615e9dc430e0a9ab61cb3d70e57ecbfa4c5ea82d5414fc82d93e81e9819b"
|
||||
},
|
||||
"src/shared/browser-client-host-protocol.ts": {
|
||||
"before": "712fe3eaed30c2de322466f316c966e526dcb60cf387cc0f6ba48012c70a7956",
|
||||
"after": "712fe3eaed30c2de322466f316c966e526dcb60cf387cc0f6ba48012c70a7956"
|
||||
},
|
||||
"src/shared/browser-client-automation-protocol.ts": {
|
||||
"before": "f93331c39fd0f8e08518d7a11a461bf5108c1a65be8f213ab2e2eff2ab977e58",
|
||||
"after": "f93331c39fd0f8e08518d7a11a461bf5108c1a65be8f213ab2e2eff2ab977e58"
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"node": "26.6.0",
|
||||
"electron": null,
|
||||
"v8": "14.6.202.34-node.26"
|
||||
},
|
||||
"variant": "fixed",
|
||||
"cases": [
|
||||
{
|
||||
"kind": "native-navigation-close",
|
||||
"completedPayloads": 32,
|
||||
"heldNativePorts": 1,
|
||||
"joinTimeoutOverrideMs": 15,
|
||||
"retainedPayloadsAfterClose": 0,
|
||||
"cachedResultsAfterClose": 0,
|
||||
"cancelledQueuedInputRetained": false,
|
||||
"signalAborted": true,
|
||||
"executorCustodyPreserved": true,
|
||||
"routeLeasePreserved": true,
|
||||
"closedDuplicateRejected": true,
|
||||
"secondCloseSettled": false,
|
||||
"retainedAfterNativeSettlement": 0,
|
||||
"executorClosedAfterNativeSettlement": true
|
||||
},
|
||||
{
|
||||
"kind": "late-sibling-settlement",
|
||||
"oneHandlerStillOwned": true,
|
||||
"cachedAfterFirstSettlement": 0,
|
||||
"closedSettlementStillPending": true
|
||||
},
|
||||
{
|
||||
"kind": "open-replay-and-retire-contract",
|
||||
"openPromiseIdentityPreserved": true,
|
||||
"retiredDuplicateRejected": true,
|
||||
"retireCachePolicyUnchanged": true,
|
||||
"explicitForgetReleasedCache": true
|
||||
},
|
||||
{
|
||||
"kind": "synthetic-crlf-source-control",
|
||||
"canonicalHashesMatch": true,
|
||||
"reads": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { writeFileSync, readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
const { loadSources } = createRequire(import.meta.url)('./sources.cjs')
|
||||
const sourceInfo = loadSources()
|
||||
import { BrowserClientHostCommandDispatcher } from '../../../src/main/browser/browser-client-host-command-dispatcher'
|
||||
import {
|
||||
createHarness,
|
||||
createCommand
|
||||
} from '../../../src/main/browser/browser-client-page-command-executor-test-harness'
|
||||
import { BrowserClientPageAutomationRuntime } from '../../../src/main/browser/browser-client-page-automation-runtime'
|
||||
import { navigateBrowserRouteGuest } from '../../../src/main/browser/browser-route-guest-lifecycle'
|
||||
import { closeBrowserClientHostComposition } from '../../../src/main/browser/paired-runtime-browser-client-host-teardown'
|
||||
import { BROWSER_CORE_METHODS } from '../../../src/main/runtime/rpc/methods/browser-core'
|
||||
|
||||
const fixed = process.env.ORCA_BROWSER_CACHE_VARIANT !== 'before'
|
||||
const variant = fixed ? 'fixed' : 'before',
|
||||
reports = []
|
||||
const authority = {
|
||||
authorityRuntimeId: 'runtime-a',
|
||||
authorityEpoch: 'epoch-a',
|
||||
browserHostClientId: 'client-a',
|
||||
browserHostGeneration: 3,
|
||||
pageCommandProtocolVersion: 1
|
||||
}
|
||||
function gate() {
|
||||
let resolve, reject
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
async function collect() {
|
||||
for (let turn = 0; turn < 8; turn++) {
|
||||
await new Promise(setImmediate)
|
||||
global.gc()
|
||||
}
|
||||
}
|
||||
function alive(refs) {
|
||||
return refs.filter((ref) => ref.deref()).length
|
||||
}
|
||||
function command(sequence, body, page = 'page-a', generation = 7) {
|
||||
return createCommand('createPage', {
|
||||
browserPageId: page,
|
||||
pageHostGeneration: generation,
|
||||
commandSequence: sequence,
|
||||
commandId: `${page}-${generation}-${sequence}`,
|
||||
command: body
|
||||
})
|
||||
}
|
||||
function cached(dispatcher) {
|
||||
return [...dispatcher.pages.values()].reduce((sum, page) => sum + page.settledSequences.length, 0)
|
||||
}
|
||||
function queueUnstartedPayload(dispatcher) {
|
||||
const payload = { queued: 'small-command-input' }
|
||||
return {
|
||||
ref: new WeakRef(payload),
|
||||
promise: dispatcher.dispatch(
|
||||
command(35, { type: 'automation', method: 'browser.snapshot', params: { payload } })
|
||||
)
|
||||
}
|
||||
}
|
||||
async function appendSnapshot(dispatcher, index) {
|
||||
const result = await dispatcher.dispatch(
|
||||
command(index + 2, { type: 'automation', method: 'browser.snapshot', params: {} })
|
||||
)
|
||||
expect(result.status).toBe('completed')
|
||||
return new WeakRef(result.value)
|
||||
}
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
writeFileSync(
|
||||
process.env.ORCA_BROWSER_CACHE_OUTPUT ??
|
||||
`docs/audits/browser-closed-result-retention/${variant}-${process.versions.electron ? 'electron' : 'node'}-results.json`,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
sources: sourceInfo.hashes,
|
||||
runtime: {
|
||||
node: process.versions.node,
|
||||
electron: process.versions.electron ?? null,
|
||||
v8: process.versions.v8
|
||||
},
|
||||
variant,
|
||||
cases: reports
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps completed actual automation results behind one pending native navigation after close timeout', async () => {
|
||||
const h = createHarness(),
|
||||
native = gate(),
|
||||
entered = gate()
|
||||
let signal,
|
||||
ordinal = 0,
|
||||
executorClosed = false,
|
||||
deferredClose
|
||||
const snapshot = BROWSER_CORE_METHODS.find((method) => method.name === 'browser.snapshot')
|
||||
const automation = new BrowserClientPageAutomationRuntime({
|
||||
browserManager: {
|
||||
getGuestWebContentsId: () => 41,
|
||||
registerGuest: () => true,
|
||||
unregisterGuest() {}
|
||||
},
|
||||
getAgentBrowserBridge: () => null,
|
||||
executeRpc: (_method, params, contextSignal) =>
|
||||
snapshot.handler(params, {
|
||||
signal: contextSignal,
|
||||
runtime: {
|
||||
browserSnapshot: async () => ({ title: `ordinary-result-${ordinal++}`, items: [1, 2, 3] })
|
||||
}
|
||||
})
|
||||
})
|
||||
h.dependencies.executeAutomation = (input, contextSignal) =>
|
||||
automation.execute(input, contextSignal)
|
||||
h.dependencies.retireAutomation = (input) => automation.retire(input)
|
||||
h.dependencies.routeWebContents.navigateGuest = (claim, url) =>
|
||||
navigateBrowserRouteGuest(
|
||||
claim.registration,
|
||||
url,
|
||||
{
|
||||
registration: claim.registration,
|
||||
navigationGranted: true,
|
||||
guest: {
|
||||
loadURL: () => {
|
||||
entered.resolve()
|
||||
return native.promise
|
||||
}
|
||||
}
|
||||
},
|
||||
() => true
|
||||
)
|
||||
const dispatcher = new BrowserClientHostCommandDispatcher({
|
||||
authority,
|
||||
handler: (event, contextSignal) => {
|
||||
if (event.command.type === 'navigate') {
|
||||
signal = contextSignal
|
||||
}
|
||||
return h.executor.handle(event, contextSignal)
|
||||
},
|
||||
joinTimeoutMs: 15
|
||||
})
|
||||
await dispatcher.dispatch(createCommand('createPage'))
|
||||
const refs = []
|
||||
for (let index = 0; index < 32; index++) {
|
||||
refs.push(await appendSnapshot(dispatcher, index))
|
||||
}
|
||||
await collect()
|
||||
expect(alive(refs)).toBe(32)
|
||||
const pending = dispatcher.dispatch(
|
||||
command(34, { type: 'navigate', url: 'https://example.invalid/held' })
|
||||
)
|
||||
await entered.promise
|
||||
const queued = queueUnstartedPayload(dispatcher)
|
||||
h.executor.fenceNavigation()
|
||||
const closing = closeBrowserClientHostComposition({
|
||||
host: { close: () => dispatcher.close(), whenHandlersSettled: () => dispatcher.whenClosed() },
|
||||
executor: {
|
||||
async close() {
|
||||
executorClosed = true
|
||||
await h.executor.close()
|
||||
}
|
||||
},
|
||||
routeSets: { async close() {} },
|
||||
error: new Error('controlled disconnect'),
|
||||
deferExecutorClose: (close) => {
|
||||
deferredClose = close
|
||||
},
|
||||
reportCleanupError: (error) => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
try {
|
||||
expect(await closing).toBe(false)
|
||||
expect(await pending).toMatchObject({
|
||||
status: 'failed',
|
||||
errorCode: 'browser_host_command_cancelled'
|
||||
})
|
||||
expect(await queued.promise).toMatchObject({
|
||||
status: 'failed',
|
||||
errorCode: 'browser_host_command_cancelled'
|
||||
})
|
||||
expect(signal.aborted).toBe(true)
|
||||
expect(executorClosed).toBe(false)
|
||||
expect(h.executor.hasPage('page-a', 7)).toBe(true)
|
||||
expect(h.route.release).not.toHaveBeenCalled()
|
||||
expect(h.routeSession.release).not.toHaveBeenCalled()
|
||||
expect(() => dispatcher.dispatch(createCommand('createPage'))).toThrow('dispatcher_closed')
|
||||
expect(await dispatcher.close()).toBe(false)
|
||||
let settled = false
|
||||
void dispatcher.whenClosed().then(() => {
|
||||
settled = true
|
||||
})
|
||||
await collect()
|
||||
expect(settled).toBe(false)
|
||||
const retained = alive(refs),
|
||||
cachedResults = cached(dispatcher)
|
||||
expect(retained).toBe(fixed ? 0 : 32)
|
||||
expect(cachedResults).toBe(fixed ? 0 : 34)
|
||||
expect(Boolean(queued.ref.deref())).toBe(!fixed)
|
||||
expect(dispatcher.runningHandlers).toBe(1)
|
||||
reports.push({
|
||||
kind: 'native-navigation-close',
|
||||
completedPayloads: 32,
|
||||
heldNativePorts: 1,
|
||||
joinTimeoutOverrideMs: 15,
|
||||
retainedPayloadsAfterClose: retained,
|
||||
cachedResultsAfterClose: cachedResults,
|
||||
cancelledQueuedInputRetained: Boolean(queued.ref.deref()),
|
||||
signalAborted: true,
|
||||
executorCustodyPreserved: true,
|
||||
routeLeasePreserved: true,
|
||||
closedDuplicateRejected: true,
|
||||
secondCloseSettled: false
|
||||
})
|
||||
} finally {
|
||||
native.resolve()
|
||||
await dispatcher.whenClosed()
|
||||
await deferredClose
|
||||
await h.executor.close()
|
||||
}
|
||||
await collect()
|
||||
expect(alive(refs)).toBe(0)
|
||||
expect(executorClosed).toBe(true)
|
||||
expect(h.route.release).toHaveBeenCalledOnce()
|
||||
expect(h.routeSession.release).toHaveBeenCalledOnce()
|
||||
reports.at(-1).retainedAfterNativeSettlement = alive(refs)
|
||||
reports.at(-1).executorClosedAfterNativeSettlement = true
|
||||
})
|
||||
|
||||
it('does not retain late completed cancellation records while a sibling native handler remains owned', async () => {
|
||||
const first = gate(),
|
||||
second = gate()
|
||||
const dispatcher = new BrowserClientHostCommandDispatcher({
|
||||
authority,
|
||||
joinTimeoutMs: 15,
|
||||
handler: (event) => (event.browserPageId === 'page-a' ? first.promise : second.promise)
|
||||
})
|
||||
const firstResult = dispatcher.dispatch(
|
||||
command(
|
||||
1,
|
||||
{ type: 'createPage', browserProfileId: 'profile-a', executionHostKey: 'execution-host-a' },
|
||||
'page-a'
|
||||
)
|
||||
)
|
||||
const secondResult = dispatcher.dispatch(
|
||||
command(
|
||||
1,
|
||||
{ type: 'createPage', browserProfileId: 'profile-a', executionHostKey: 'execution-host-a' },
|
||||
'page-b'
|
||||
)
|
||||
)
|
||||
expect(await dispatcher.close()).toBe(false)
|
||||
expect(await firstResult).toMatchObject({ errorCode: 'browser_host_command_cancelled' })
|
||||
expect(await secondResult).toMatchObject({ errorCode: 'browser_host_command_cancelled' })
|
||||
first.resolve({ status: 'completed', value: { late: 'ignored' } })
|
||||
await new Promise(setImmediate)
|
||||
expect(dispatcher.runningHandlers).toBe(1)
|
||||
expect(cached(dispatcher)).toBe(fixed ? 0 : 1)
|
||||
expect(dispatcher.pages.get('page-a').records.size).toBe(fixed ? 0 : 1)
|
||||
let settled = false
|
||||
void dispatcher.whenClosed().then(() => {
|
||||
settled = true
|
||||
})
|
||||
await new Promise(setImmediate)
|
||||
expect(settled).toBe(false)
|
||||
reports.push({
|
||||
kind: 'late-sibling-settlement',
|
||||
oneHandlerStillOwned: true,
|
||||
cachedAfterFirstSettlement: cached(dispatcher),
|
||||
closedSettlementStillPending: true
|
||||
})
|
||||
second.reject(new Error('controlled native failure'))
|
||||
await dispatcher.whenClosed()
|
||||
expect(dispatcher.runningHandlers).toBe(0)
|
||||
expect(dispatcher.pages.size).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves open replay and generation fencing independently of closed cache release', async () => {
|
||||
let calls = 0
|
||||
const dispatcher = new BrowserClientHostCommandDispatcher({
|
||||
authority,
|
||||
handler: () => {
|
||||
calls++
|
||||
return { status: 'completed', value: { ordinary: true } }
|
||||
}
|
||||
})
|
||||
const event = command(1, {
|
||||
type: 'createPage',
|
||||
browserProfileId: 'profile-a',
|
||||
executionHostKey: 'execution-host-a'
|
||||
})
|
||||
const original = dispatcher.dispatch(event),
|
||||
duplicate = dispatcher.dispatch(event)
|
||||
expect(duplicate).toBe(original)
|
||||
await original
|
||||
expect(dispatcher.dispatch(event)).toBe(original)
|
||||
expect(calls).toBe(1)
|
||||
expect(await dispatcher.retirePage('page-a', 7)).toBe(true)
|
||||
expect(() => dispatcher.dispatch(event)).toThrow('generation_stale')
|
||||
expect(cached(dispatcher)).toBe(1)
|
||||
expect(dispatcher.forgetPage('page-a', 7)).toBe(true)
|
||||
expect(cached(dispatcher)).toBe(0)
|
||||
expect(() => dispatcher.dispatch(event)).toThrow('generation_stale')
|
||||
expect(await dispatcher.close()).toBe(true)
|
||||
reports.push({
|
||||
kind: 'open-replay-and-retire-contract',
|
||||
openPromiseIdentityPreserved: true,
|
||||
retiredDuplicateRejected: true,
|
||||
retireCachePolicyUnchanged: true,
|
||||
explicitForgetReleasedCache: true
|
||||
})
|
||||
})
|
||||
|
||||
it('loads identical canonical hashes from synthetic CRLF source and patch reads', () => {
|
||||
let reads = 0
|
||||
const crlf = loadSources({
|
||||
readText: (filename) => {
|
||||
reads++
|
||||
return readFileSync(filename, 'utf8').replace(/\r?\n/g, '\r\n')
|
||||
}
|
||||
})
|
||||
expect(crlf.hashes).toEqual(sourceInfo.hashes)
|
||||
expect([...crlf.before.entries()]).toEqual([...sourceInfo.before.entries()])
|
||||
expect([...crlf.after.entries()]).toEqual([...sourceInfo.after.entries()])
|
||||
reports.push({ kind: 'synthetic-crlf-source-control', canonicalHashesMatch: true, reads })
|
||||
})
|
||||
@@ -1,405 +0,0 @@
|
||||
{
|
||||
"canonicalLF": true,
|
||||
"sources": [
|
||||
{
|
||||
"path": "src/main/browser/browser-client-host-command-dispatcher.ts",
|
||||
"workingSha256": "f8c610a54f7d16c59043250dde5ef399f70e1a974c9492d09d403138e46c1da3",
|
||||
"lineCount": 315,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "f8c610a54f7d16c59043250dde5ef399f70e1a974c9492d09d403138e46c1da3",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "f8c610a54f7d16c59043250dde5ef399f70e1a974c9492d09d403138e46c1da3",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-client-host-command-result-cache.ts",
|
||||
"workingSha256": "9ec3dad78c83f864f74957cad766cbcf41863e92d81d7e7a471702b0f9ab2f34",
|
||||
"lineCount": 51,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "9ec3dad78c83f864f74957cad766cbcf41863e92d81d7e7a471702b0f9ab2f34",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "9ec3dad78c83f864f74957cad766cbcf41863e92d81d7e7a471702b0f9ab2f34",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-client-host-command-state.ts",
|
||||
"workingSha256": "a5f6b7a3737f86b2953fc43fa0e3ed5faaa141814d5f94fd192da1bb510f355f",
|
||||
"lineCount": 171,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "a5f6b7a3737f86b2953fc43fa0e3ed5faaa141814d5f94fd192da1bb510f355f",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "a5f6b7a3737f86b2953fc43fa0e3ed5faaa141814d5f94fd192da1bb510f355f",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-client-host-command-page.ts",
|
||||
"workingSha256": "f5ca7f90511ee0290d2a1d0b3c5c39d3c9841ebcf90a5564bce83fbc9aca06bb",
|
||||
"lineCount": 220,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "f5ca7f90511ee0290d2a1d0b3c5c39d3c9841ebcf90a5564bce83fbc9aca06bb",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "f5ca7f90511ee0290d2a1d0b3c5c39d3c9841ebcf90a5564bce83fbc9aca06bb",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-client-host-command-join.ts",
|
||||
"workingSha256": "5bf1a38231100cc416176ef2052bdff7b3fca91b6f6dbefcd222b92be59cbb2f",
|
||||
"lineCount": 21,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "5bf1a38231100cc416176ef2052bdff7b3fca91b6f6dbefcd222b92be59cbb2f",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "5bf1a38231100cc416176ef2052bdff7b3fca91b6f6dbefcd222b92be59cbb2f",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-client-page-command-executor.ts",
|
||||
"workingSha256": "d249de82f89365371fa4f3b457e0da552910819555a5a0bced0c95010ccacb01",
|
||||
"lineCount": 319,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "d249de82f89365371fa4f3b457e0da552910819555a5a0bced0c95010ccacb01",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "d249de82f89365371fa4f3b457e0da552910819555a5a0bced0c95010ccacb01",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-client-page-command-execution.ts",
|
||||
"workingSha256": "82b7c87d17b34c267ebfdf3d6ee25058cbd05dbc163ac24c43aefed00f0e3c6e",
|
||||
"lineCount": 114,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "82b7c87d17b34c267ebfdf3d6ee25058cbd05dbc163ac24c43aefed00f0e3c6e",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "82b7c87d17b34c267ebfdf3d6ee25058cbd05dbc163ac24c43aefed00f0e3c6e",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-client-page-command-executor-test-harness.ts",
|
||||
"workingSha256": "b43ec764e4d094070fab51381ceb24487999dca97b4a38131be08fab0c5cbe24",
|
||||
"lineCount": 145,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "b43ec764e4d094070fab51381ceb24487999dca97b4a38131be08fab0c5cbe24",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "b43ec764e4d094070fab51381ceb24487999dca97b4a38131be08fab0c5cbe24",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-client-page-automation-runtime.ts",
|
||||
"workingSha256": "7b1c0c527a87b8c22ea8290a2e1efc7ca659f2b4a5e2bd7c740fb5c22a2a6319",
|
||||
"lineCount": 141,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "7b1c0c527a87b8c22ea8290a2e1efc7ca659f2b4a5e2bd7c740fb5c22a2a6319",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "7b1c0c527a87b8c22ea8290a2e1efc7ca659f2b4a5e2bd7c740fb5c22a2a6319",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-route-guest-lifecycle.ts",
|
||||
"workingSha256": "52adbea664bcfc5d774f129d442f4fc36d4a1e1280a073053fc09ae026ca83cb",
|
||||
"lineCount": 172,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "52adbea664bcfc5d774f129d442f4fc36d4a1e1280a073053fc09ae026ca83cb",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "52adbea664bcfc5d774f129d442f4fc36d4a1e1280a073053fc09ae026ca83cb",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-route-webcontents-registry.ts",
|
||||
"workingSha256": "132c6740539aaeb9b1ceb3c108d2aebfb9e8e5c4ce3ff7cd1191e5b45dff8cad",
|
||||
"lineCount": 325,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "132c6740539aaeb9b1ceb3c108d2aebfb9e8e5c4ce3ff7cd1191e5b45dff8cad",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "132c6740539aaeb9b1ceb3c108d2aebfb9e8e5c4ce3ff7cd1191e5b45dff8cad",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/paired-runtime-browser-client-host.ts",
|
||||
"workingSha256": "797c0130e30c5f40551f5d392326fff1ac8752e262653b3f855dfd7df9fa0038",
|
||||
"lineCount": 193,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "797c0130e30c5f40551f5d392326fff1ac8752e262653b3f855dfd7df9fa0038",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "797c0130e30c5f40551f5d392326fff1ac8752e262653b3f855dfd7df9fa0038",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/paired-runtime-browser-client-host-composition.ts",
|
||||
"workingSha256": "f8b55a24035e929664f3c5f61ee8a81ee560250379dcf64b39d35e1b60628b74",
|
||||
"lineCount": 323,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "f8b55a24035e929664f3c5f61ee8a81ee560250379dcf64b39d35e1b60628b74",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "f8b55a24035e929664f3c5f61ee8a81ee560250379dcf64b39d35e1b60628b74",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/paired-runtime-browser-client-host-teardown.ts",
|
||||
"workingSha256": "4d9eb53c81ddfa3cb8181735b08c2b90a380bebab1f173ae5d8da9f719671f59",
|
||||
"lineCount": 68,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "4d9eb53c81ddfa3cb8181735b08c2b90a380bebab1f173ae5d8da9f719671f59",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "4d9eb53c81ddfa3cb8181735b08c2b90a380bebab1f173ae5d8da9f719671f59",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/paired-runtime-browser-client-host-runtime.ts",
|
||||
"workingSha256": "2d0650d0a5ebe88f1185ab5676762ed93188e7c28a69508137ed7cfa36e3832c",
|
||||
"lineCount": 327,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "2d0650d0a5ebe88f1185ab5676762ed93188e7c28a69508137ed7cfa36e3832c",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "2d0650d0a5ebe88f1185ab5676762ed93188e7c28a69508137ed7cfa36e3832c",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/runtime/rpc/methods/browser-core.ts",
|
||||
"workingSha256": "fdc2afeece6689644fbd607a59e484ef9d2ac6db7404e3378f2db10e16984f6d",
|
||||
"lineCount": 293,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "fdc2afeece6689644fbd607a59e484ef9d2ac6db7404e3378f2db10e16984f6d",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "c847be873f4ce19b04796be962985a0234f159da2b38881fb8f6db1a1cbf720b",
|
||||
"matchesWorking": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts",
|
||||
"workingSha256": "0af16d6fe883f6af79754e064b77a1b9ab8e80953657434eacc88cc1c523e038",
|
||||
"lineCount": 209,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "0af16d6fe883f6af79754e064b77a1b9ab8e80953657434eacc88cc1c523e038",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "0af16d6fe883f6af79754e064b77a1b9ab8e80953657434eacc88cc1c523e038",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/agent-browser-bridge-types.ts",
|
||||
"workingSha256": "3ea69d15aef862807108835d1cbd536275a105a96bdc86f3be95dfbed55ab45f",
|
||||
"lineCount": 65,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "3ea69d15aef862807108835d1cbd536275a105a96bdc86f3be95dfbed55ab45f",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "3ea69d15aef862807108835d1cbd536275a105a96bdc86f3be95dfbed55ab45f",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/agent-browser-bridge-raw-process.ts",
|
||||
"workingSha256": "d0e9929c4a4571d8752efa5e4063500efa75fefd228eb6cb1ea6092748befb0c",
|
||||
"lineCount": 108,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "d0e9929c4a4571d8752efa5e4063500efa75fefd228eb6cb1ea6092748befb0c",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "d0e9929c4a4571d8752efa5e4063500efa75fefd228eb6cb1ea6092748befb0c",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/agent-browser-bridge-core-commands.ts",
|
||||
"workingSha256": "d587a7e7c3bd368c9003802607edd20def98e64442cd6d5914779c633feebd03",
|
||||
"lineCount": 169,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "d587a7e7c3bd368c9003802607edd20def98e64442cd6d5914779c633feebd03",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "06d6a61e431680ebd89e9a29b18e2198da3d84df6398b234fa8a255a6fcedf8a",
|
||||
"matchesWorking": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/startup/main-process-ready-runtime.ts",
|
||||
"workingSha256": "5bb3615e9dc430e0a9ab61cb3d70e57ecbfa4c5ea82d5414fc82d93e81e9819b",
|
||||
"lineCount": 156,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "5bb3615e9dc430e0a9ab61cb3d70e57ecbfa4c5ea82d5414fc82d93e81e9819b",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "5bb3615e9dc430e0a9ab61cb3d70e57ecbfa4c5ea82d5414fc82d93e81e9819b",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/shared/browser-client-host-protocol.ts",
|
||||
"workingSha256": "712fe3eaed30c2de322466f316c966e526dcb60cf387cc0f6ba48012c70a7956",
|
||||
"lineCount": 343,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "712fe3eaed30c2de322466f316c966e526dcb60cf387cc0f6ba48012c70a7956",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "712fe3eaed30c2de322466f316c966e526dcb60cf387cc0f6ba48012c70a7956",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/shared/browser-client-automation-protocol.ts",
|
||||
"workingSha256": "f93331c39fd0f8e08518d7a11a461bf5108c1a65be8f213ab2e2eff2ab977e58",
|
||||
"lineCount": 129,
|
||||
"namedRefs": {
|
||||
"mainCheckpoint": {
|
||||
"ref": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"sha256": "f93331c39fd0f8e08518d7a11a461bf5108c1a65be8f213ab2e2eff2ab977e58",
|
||||
"matchesWorking": true
|
||||
},
|
||||
"v1.4.198": {
|
||||
"ref": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"sha256": "f93331c39fd0f8e08518d7a11a461bf5108c1a65be8f213ab2e2eff2ab977e58",
|
||||
"matchesWorking": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"baselineHashes": {
|
||||
"src/main/browser/browser-client-host-command-dispatcher.ts": "f8c610a54f7d16c59043250dde5ef399f70e1a974c9492d09d403138e46c1da3",
|
||||
"src/main/browser/browser-client-host-command-result-cache.ts": "9ec3dad78c83f864f74957cad766cbcf41863e92d81d7e7a471702b0f9ab2f34"
|
||||
},
|
||||
"fixedHashes": {
|
||||
"src/main/browser/browser-client-host-command-dispatcher.ts": "0e4074a4cb7379de58d75dd93e3f74090dea18a944bd4f038730cf19f6e68a98",
|
||||
"src/main/browser/browser-client-host-command-result-cache.ts": "f06bd1f31be9ffcbb750f242bacd0c2f16222cde7d011010acbd46ceb70ea83f"
|
||||
},
|
||||
"auditedHead": "6ab6802df90553d2a26e9a4c7a519ae11d4b5e09"
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { createHash } = require('node:crypto')
|
||||
const { resolve } = require('node:path')
|
||||
const { applyPatch, parsePatch, reversePatch } = require('diff')
|
||||
|
||||
const canonicalLf = (text) => text.replace(/\r\n/g, '\n')
|
||||
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
|
||||
|
||||
function loadSources({ readText = (filename) => readFileSync(filename, 'utf8') } = {}) {
|
||||
const root = resolve(__dirname, '../../..')
|
||||
const expected = JSON.parse(readFileSync(resolve(__dirname, 'source-versions.json'), 'utf8'))
|
||||
const patches = parsePatch(canonicalLf(readText(resolve(__dirname, 'fix.patch'))))
|
||||
const before = new Map()
|
||||
const after = new Map()
|
||||
const hashes = {}
|
||||
assert.equal(patches.length, 2)
|
||||
for (const patch of patches) {
|
||||
const path = patch.newFileName.replace(/^b\//, '')
|
||||
assert.ok(Object.hasOwn(expected.baselineHashes, path), `Unexpected patch path: ${path}`)
|
||||
const absolute = resolve(root, path)
|
||||
const current = canonicalLf(readText(absolute))
|
||||
const baseline = applyPatch(current, reversePatch(patch))
|
||||
assert.notEqual(baseline, false, `Patch no longer reverses: ${path}`)
|
||||
assert.equal(sha256(current), expected.fixedHashes[path], `Fixed source drift: ${path}`)
|
||||
assert.equal(sha256(baseline), expected.baselineHashes[path], `Baseline source drift: ${path}`)
|
||||
before.set(absolute, baseline)
|
||||
after.set(absolute, current)
|
||||
hashes[path] = { before: sha256(baseline), after: sha256(current) }
|
||||
}
|
||||
for (const source of expected.sources) {
|
||||
if (Object.hasOwn(hashes, source.path)) {
|
||||
continue
|
||||
}
|
||||
const text = canonicalLf(readText(resolve(root, source.path)))
|
||||
assert.equal(sha256(text), source.workingSha256, `Caller source drift: ${source.path}`)
|
||||
hashes[source.path] = { before: sha256(text), after: sha256(text) }
|
||||
}
|
||||
return { root, before, after, hashes }
|
||||
}
|
||||
|
||||
module.exports = { loadSources }
|
||||
@@ -1,228 +0,0 @@
|
||||
{
|
||||
"tests": {
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts src/main/browser/browser-client-host-command-retention.test.ts src/main/browser/browser-client-host-command-dispatcher.test.ts src/main/browser/browser-client-page-command-executor.test.ts src/main/browser/paired-runtime-browser-client-host-composition.test.ts src/main/browser/paired-runtime-browser-client-host.test.ts",
|
||||
"passed": 77,
|
||||
"files": 5,
|
||||
"newRegressionCases": 2,
|
||||
"exitCode": 0
|
||||
},
|
||||
"baselineOverlay": {
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 ORCA_BROWSER_CACHE_VARIANT=before pnpm exec vitest run --config docs/audits/browser-closed-result-retention/vitest.config.mjs src/main/browser/browser-client-host-command-retention.test.ts src/main/browser/browser-client-host-command-dispatcher.test.ts",
|
||||
"passed": 16,
|
||||
"expectedFailed": 2,
|
||||
"failures": [
|
||||
"32 completed result objects remain reachable while native navigation stays pending.",
|
||||
"The first late closed input remains reachable while a sibling handler stays pending."
|
||||
],
|
||||
"exitCode": 1
|
||||
},
|
||||
"typecheck": {
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm tc:node",
|
||||
"exitCode": 0
|
||||
},
|
||||
"publicationQuality": {
|
||||
"scans": [
|
||||
{
|
||||
"label": "code quality",
|
||||
"command": [
|
||||
"pnpm",
|
||||
"exec",
|
||||
"oxlint",
|
||||
"--no-ignore",
|
||||
"--deny-warnings",
|
||||
"--report-unused-disable-directives-severity",
|
||||
"warn",
|
||||
"src/main/browser/browser-client-host-command-dispatcher.ts",
|
||||
"src/main/browser/browser-client-host-command-result-cache.ts",
|
||||
"src/main/browser/browser-client-host-command-retention.test.ts",
|
||||
"docs/audits/browser-closed-result-retention/sources.cjs",
|
||||
"docs/audits/browser-closed-result-retention/vitest.config.mjs",
|
||||
"docs/audits/browser-closed-result-retention/scenario.test.mjs"
|
||||
],
|
||||
"exitCode": 0
|
||||
},
|
||||
{
|
||||
"label": "casting code quality",
|
||||
"command": [
|
||||
"pnpm",
|
||||
"exec",
|
||||
"oxlint",
|
||||
"--no-ignore",
|
||||
"--deny-warnings",
|
||||
"--config",
|
||||
"config/oxlint-code-quality-casting.json",
|
||||
"src/main/browser/browser-client-host-command-dispatcher.ts",
|
||||
"src/main/browser/browser-client-host-command-result-cache.ts",
|
||||
"src/main/browser/browser-client-host-command-retention.test.ts",
|
||||
"docs/audits/browser-closed-result-retention/sources.cjs",
|
||||
"docs/audits/browser-closed-result-retention/vitest.config.mjs",
|
||||
"docs/audits/browser-closed-result-retention/scenario.test.mjs"
|
||||
],
|
||||
"exitCode": 0
|
||||
},
|
||||
{
|
||||
"label": "type-aware code quality",
|
||||
"command": [
|
||||
"pnpm",
|
||||
"exec",
|
||||
"oxlint",
|
||||
"--no-ignore",
|
||||
"--deny-warnings",
|
||||
"--type-aware",
|
||||
"--config",
|
||||
"config/oxlint-code-quality-type-aware.json",
|
||||
"src/main/browser/browser-client-host-command-dispatcher.ts",
|
||||
"src/main/browser/browser-client-host-command-result-cache.ts",
|
||||
"src/main/browser/browser-client-host-command-retention.test.ts",
|
||||
"docs/audits/browser-closed-result-retention/sources.cjs",
|
||||
"docs/audits/browser-closed-result-retention/vitest.config.mjs",
|
||||
"docs/audits/browser-closed-result-retention/scenario.test.mjs"
|
||||
],
|
||||
"exitCode": 0
|
||||
},
|
||||
{
|
||||
"label": "React Doctor",
|
||||
"command": [
|
||||
"pnpm",
|
||||
"exec",
|
||||
"oxlint",
|
||||
"--no-ignore",
|
||||
"--deny-warnings",
|
||||
"--config",
|
||||
"config/oxlint-react-doctor.json",
|
||||
"src/main/browser/browser-client-host-command-dispatcher.ts",
|
||||
"src/main/browser/browser-client-host-command-result-cache.ts",
|
||||
"src/main/browser/browser-client-host-command-retention.test.ts",
|
||||
"docs/audits/browser-closed-result-retention/sources.cjs",
|
||||
"docs/audits/browser-closed-result-retention/vitest.config.mjs",
|
||||
"docs/audits/browser-closed-result-retention/scenario.test.mjs"
|
||||
],
|
||||
"exitCode": 0
|
||||
},
|
||||
{
|
||||
"label": "design system",
|
||||
"command": [
|
||||
"pnpm",
|
||||
"exec",
|
||||
"oxlint",
|
||||
"--no-ignore",
|
||||
"--deny-warnings",
|
||||
"--config",
|
||||
"config/oxlint-design-system.json",
|
||||
"src/main/browser/browser-client-host-command-dispatcher.ts",
|
||||
"src/main/browser/browser-client-host-command-result-cache.ts",
|
||||
"src/main/browser/browser-client-host-command-retention.test.ts",
|
||||
"docs/audits/browser-closed-result-retention/sources.cjs",
|
||||
"docs/audits/browser-closed-result-retention/vitest.config.mjs",
|
||||
"docs/audits/browser-closed-result-retention/scenario.test.mjs"
|
||||
],
|
||||
"exitCode": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"changedQuality": {
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 ORCA_CODE_QUALITY_BASE=HEAD pnpm run check:code-quality:changed",
|
||||
"exitCode": 0,
|
||||
"note": "The five explicit-file scans include all six TS/CJS/MJS publication paths. The ordinary changed gate does not see ignored new artifacts before staging."
|
||||
},
|
||||
"proofs": {
|
||||
"runs": [
|
||||
{
|
||||
"runtime": "node",
|
||||
"variant": "before",
|
||||
"command": [
|
||||
"pnpm",
|
||||
"exec",
|
||||
"vitest",
|
||||
"run",
|
||||
"--config",
|
||||
"docs/audits/browser-closed-result-retention/vitest.config.mjs",
|
||||
"docs/audits/browser-closed-result-retention/scenario.test.mjs"
|
||||
],
|
||||
"exitCode": 0
|
||||
},
|
||||
{
|
||||
"runtime": "node",
|
||||
"variant": "fixed",
|
||||
"command": [
|
||||
"pnpm",
|
||||
"exec",
|
||||
"vitest",
|
||||
"run",
|
||||
"--config",
|
||||
"docs/audits/browser-closed-result-retention/vitest.config.mjs",
|
||||
"docs/audits/browser-closed-result-retention/scenario.test.mjs"
|
||||
],
|
||||
"exitCode": 0
|
||||
},
|
||||
{
|
||||
"runtime": "electron",
|
||||
"variant": "before",
|
||||
"command": [
|
||||
"node_modules/electron/dist/Electron.app/Contents/MacOS/Electron",
|
||||
"node_modules/vitest/vitest.mjs",
|
||||
"run",
|
||||
"--config",
|
||||
"docs/audits/browser-closed-result-retention/vitest.config.mjs",
|
||||
"docs/audits/browser-closed-result-retention/scenario.test.mjs"
|
||||
],
|
||||
"exitCode": 0
|
||||
},
|
||||
{
|
||||
"runtime": "electron",
|
||||
"variant": "fixed",
|
||||
"command": [
|
||||
"node_modules/electron/dist/Electron.app/Contents/MacOS/Electron",
|
||||
"node_modules/vitest/vitest.mjs",
|
||||
"run",
|
||||
"--config",
|
||||
"docs/audits/browser-closed-result-retention/vitest.config.mjs",
|
||||
"docs/audits/browser-closed-result-retention/scenario.test.mjs"
|
||||
],
|
||||
"exitCode": 0
|
||||
}
|
||||
],
|
||||
"casesPerVariantPerRuntime": 4,
|
||||
"variants": ["before", "fixed"],
|
||||
"node": "26.6.0",
|
||||
"electron": "43.7.0",
|
||||
"electronNode": "24.21.0",
|
||||
"controlledPendingNativePorts": 1,
|
||||
"smallCompletedResults": 32,
|
||||
"crlfReadControl": 24,
|
||||
"environment": {
|
||||
"ORCA_BACKGROUND_LAUNCH": "1",
|
||||
"ELECTRON_RUN_AS_NODE": "1 for Electron runs",
|
||||
"ORCA_BROWSER_CACHE_VARIANT": "before or fixed"
|
||||
},
|
||||
"outputOverride": "ORCA_BROWSER_CACHE_OUTPUT"
|
||||
},
|
||||
"sourceParity": {
|
||||
"canonicalLF": true,
|
||||
"mainCheckpoint": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"historicalVersion": "v1.4.198",
|
||||
"historicalRef": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"namedBaselineTargetMatches": 2,
|
||||
"mainCheckpointCitedSourceMatches": 23,
|
||||
"historicalCitedSourceMatches": 21,
|
||||
"citedSourceCount": 23,
|
||||
"hashCoverage": "Two product targets plus 21 cited caller/dependency modules, not all transitive imports.",
|
||||
"historicalApplicationReplay": false
|
||||
},
|
||||
"scope": {
|
||||
"trigger": "A handler outlives the dispatcher close join (default 5 seconds).",
|
||||
"nativeSettlementAuthorityPreserved": true,
|
||||
"retirePageCachePolicyChanged": false,
|
||||
"incidentAttribution": false,
|
||||
"measuredRSS": false
|
||||
},
|
||||
"format": "All 14 publication files except fix.patch checked with oxfmt stdin mode; a second pass produced identical bytes.",
|
||||
"gitDiffCheckExitCode": 0,
|
||||
"publicationWhitespace": {
|
||||
"commandTemplate": "git diff --no-index --check <empty-file> <promoted-path>",
|
||||
"files": 14,
|
||||
"expectedExitCode": 1,
|
||||
"diagnostics": 0,
|
||||
"note": "The complete content of every publication path is checked, including ignored new artifacts. Exit 1 only means the content differs from an empty file. fix.patch uses zero-context hunks."
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { defineConfig, mergeConfig } from 'vitest/config'
|
||||
import base from '../../../config/vitest.config.ts'
|
||||
|
||||
const { loadSources } = createRequire(import.meta.url)('./sources.cjs')
|
||||
const { before, after } = loadSources()
|
||||
const sources = process.env.ORCA_BROWSER_CACHE_VARIANT === 'before' ? before : after
|
||||
const config = mergeConfig(
|
||||
base,
|
||||
defineConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: 'closed-browser-cache-source-overlay',
|
||||
enforce: 'pre',
|
||||
transform(_code, id) {
|
||||
const source = sources.get(resolve(id.split('?')[0]))
|
||||
return source === undefined ? undefined : { code: source, map: null }
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
config.test.include = [
|
||||
'docs/audits/browser-closed-result-retention/scenario.test.mjs',
|
||||
'src/main/browser/browser-client-host-command-retention.test.ts',
|
||||
'src/main/browser/browser-client-host-command-dispatcher.test.ts'
|
||||
]
|
||||
config.test.maxWorkers = 1
|
||||
export default config
|
||||
@@ -1,40 +0,0 @@
|
||||
# Destroyed browser guests retain main-process callbacks
|
||||
|
||||
An embedded browser guest's `destroyed` event called `cleanupGuestPolicyAttachment`. That removed its primary page-to-WebContents lookup but left four per-page cleanup callbacks that capture the destroyed WebContents wrapper, plus renderer/workspace/worktree/profile metadata. `unregisterAll` subsequently iterated only the now-empty primary lookup: three callback maps and the renderer/workspace metadata survived that cleanup too.
|
||||
|
||||
Renderer reload can destroy guests without each page sending explicit unregister IPC. A later close of a restored, unmounted page does not send that IPC either: `destroyPersistentWebview` returns early when its renderer registry has no guest. Explicit unregister correctly releases these resources; same-page re-registration also replaces its callbacks. The defect affects destroyed owners that do not take either path.
|
||||
|
||||
The fix routes destruction through the existing `unregisterGuest` with a guest-retirement reason only when that exact guest still owns the primary page ID. Already bound downloads retain their existing renderer routing until they settle; explicit page close still cancels them. Unregistered guests and popups retain policy-only cleanup. A stale callback cannot unregister a replacement. Normal renderer-process loss keeps its live WebContents and metadata for reload recovery; a fresh guest registration supplies its ownership metadata again. Shared browser sessions and sibling pages are untouched.
|
||||
|
||||
## Download lifetime correction
|
||||
|
||||
Review found that the initial fix treated guest destruction as logical page closure and canceled bound downloads. An exact-source before/after control confirmed that difference with an EventEmitter guest and controlled DownloadItem. Guest retirement now releases guest-owned callbacks while preserving ongoing page downloads, their destinations and cancel authorization. A retained numeric renderer route drains after the last download settles, provided no replacement guest, other download or newer routing owner needs it.
|
||||
|
||||
Nine additional controls cover progress and completion/error delivery, explicit close after guest destruction, multiple downloads, replacement guests/routing, repeated guest destruction and renderer loss. Together with four existing browser suites, 55 tests pass; Node typecheck and ordinary/anti-slop lint pass. These controls do not establish native Chromium download survival after destruction on each operating system. No download capacity or wire format changes.
|
||||
|
||||
## Reproduce
|
||||
|
||||
With dependencies already installed, run from the repository root:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/browser-destroyed-guest-retention/reproduce.mjs
|
||||
```
|
||||
|
||||
The script runs the actual manager and guest callback installers with EventEmitter WebContents fixtures. It removes only the destruction guard in memory for the baseline, then runs the same nine tests on the fixed source. A temporary test observer records actual map sizes before each assertion. It launches no Orca window or browser process, limits each worker to 512 MiB and each run to 60 seconds, uses the shared process launcher, and removes temporary files. Results include source hashes and runtime provenance.
|
||||
|
||||
| After 1,000 distinct guest destructions | Before | Fixed |
|
||||
| -------------------------------------------------------------------------- | -----: | ----: |
|
||||
| Primary guest lookup | 0 | 0 |
|
||||
| Each context-menu / grab-shortcut / app-shortcut / wheel cleanup map | 1,000 | 0 |
|
||||
| Each renderer / workspace / worktree / profile map | 1,000 | 0 |
|
||||
| Policy cleanup map | 0 | 0 |
|
||||
| Each context-menu / grab-shortcut / app-shortcut map after `unregisterAll` | 1,000 | 0 |
|
||||
| Renderer and workspace maps after `unregisterAll` | 1,000 | 0 |
|
||||
|
||||
Baseline: six tests pass, three fail. Fixed: all nine pass. Controls cover explicit unregister, same-ID replacement with a captured old callback, popup and pre-registration destruction, renderer-process recovery, fresh guest registration, and two pages sharing one browser session profile. The selected existing browser-manager and offscreen lifecycle suites also passed: 64 tests across seven files including the new suite.
|
||||
|
||||
## Version and limits
|
||||
|
||||
Targeted source reads of `v1.4.198` confirm the same destroyed-event policy-only cleanup, map ownership, and `unregisterAll` omission. The executable comparison uses current production source; it does not launch the historical app. This is a retaining path present in the version reported by #19831/#19768. It does not establish that either incident followed this destruction sequence, or measure native memory retained by a destroyed WebContents. The 1,000 iterations measure retained callbacks and metadata, not 1,000 surviving Chromium processes or a gigabyte allocation.
|
||||
|
||||
Adjacent audit negatives: explicit page close removes the renderer guest registry and main registration; worktree switching deliberately parks guests under the existing hidden-worktree retention policy; offscreen creation is synchronously indexed with shutdown admission and exact-window teardown; client-hosted async page creation checks availability after acquisitions and cleans canceled owners. PDF capture rejects late disconnected-client completion, its stream buffers have a five-minute TTL, and existing screenshot commands have deadlines. No additional native screenshot hang or unbounded native-page acquisition was reproduced. The separate late renderer registration reply can restore small page-ID metadata after close; it is outside this main-process fix.
|
||||
@@ -1,168 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
|
||||
throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.')
|
||||
}
|
||||
|
||||
const root = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const productionPath = 'src/main/browser/browser-manager-guest-navigation-policy.ts'
|
||||
const testPath = 'src/main/browser/browser-manager-destroyed-guest.test.ts'
|
||||
const fixturePath = 'src/main/browser/browser-manager-destroyed-guest-test-fixture.ts'
|
||||
const current = await readFile(resolve(root, productionPath), 'utf8')
|
||||
const fix = ` const browserTabId = this.tabIdByWebContentsId.get(guest.id)
|
||||
// A destroyed primary guest also owns per-page callbacks that capture its WebContents.
|
||||
if (browserTabId && this.webContentsIdByTabId.get(browserTabId) === guest.id) {
|
||||
this.unregisterGuest(browserTabId, 'guest-destroyed')
|
||||
return
|
||||
}
|
||||
`
|
||||
if (current.split(fix).length !== 2) {
|
||||
throw new Error('Expected exactly one primary-guest destruction guard; review the transform.')
|
||||
}
|
||||
const baseline = current.replace(fix, '')
|
||||
const test = await readFile(resolve(root, testPath), 'utf8')
|
||||
const observe = ' const counts = manager.retainedCounts()\n'
|
||||
if (test.split(observe).length !== 2) {
|
||||
throw new Error('Expected exactly one retained-count observer; review the transform.')
|
||||
}
|
||||
const observedTest = `import { appendFileSync } from 'node:fs'\n${test.replace(
|
||||
observe,
|
||||
`${observe} appendFileSync(process.env.ORCA_BROWSER_GUEST_COUNTS_PATH, JSON.stringify({ test: expect.getState().currentTestName, counts }) + '\\n')\n`
|
||||
)}`
|
||||
const sha256 = (source) => createHash('sha256').update(source).digest('hex')
|
||||
const sourceHashes = {
|
||||
[productionPath]: { before: sha256(baseline), after: sha256(current) },
|
||||
[testPath]: { current: sha256(test), observed: sha256(observedTest) },
|
||||
[fixturePath]: { current: sha256(await readFile(resolve(root, fixturePath))) }
|
||||
}
|
||||
for (const path of [
|
||||
'src/main/browser/browser-manager-state.ts',
|
||||
'src/main/browser/browser-manager-registration.ts',
|
||||
'src/main/browser/browser-manager-download-lifecycle.ts'
|
||||
]) {
|
||||
sourceHashes[path] = { current: sha256(await readFile(resolve(root, path))) }
|
||||
}
|
||||
const scratch = await mkdtemp(join(tmpdir(), 'orca-browser-destroyed-guest-'))
|
||||
const require = createRequire(import.meta.url)
|
||||
let runnerModuleId
|
||||
|
||||
try {
|
||||
const runnerPath = join(scratch, 'run-process.cjs')
|
||||
await build({
|
||||
absWorkingDir: root,
|
||||
entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')],
|
||||
outfile: runnerPath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
logLevel: 'silent'
|
||||
})
|
||||
runnerModuleId = require.resolve(runnerPath)
|
||||
const { runProcess } = require(runnerModuleId)
|
||||
const configImport = JSON.stringify(pathToFileURL(resolve(root, 'config/vitest.config.ts')).href)
|
||||
|
||||
async function run(label, production) {
|
||||
const config = join(scratch, `${label}.config.mjs`)
|
||||
const report = join(scratch, `${label}.json`)
|
||||
const countsPath = join(scratch, `${label}.counts.jsonl`)
|
||||
const sources = {
|
||||
[resolve(root, productionPath).replaceAll('\\', '/')]: production,
|
||||
[resolve(root, testPath).replaceAll('\\', '/')]: observedTest
|
||||
}
|
||||
await writeFile(
|
||||
config,
|
||||
`import base from ${configImport};
|
||||
const sources = ${JSON.stringify(sources)};
|
||||
export default {...base, test: {...base.test, include: [${JSON.stringify(testPath)}], maxWorkers: 1}, plugins: [{
|
||||
name: 'browser-destroyed-guest-audit', enforce: 'pre',
|
||||
transform(_code, id) {
|
||||
const source = sources[id.replaceAll('\\\\', '/').split('?')[0]];
|
||||
return source === undefined ? null : {code: source, map: null};
|
||||
}
|
||||
}]};\n`
|
||||
)
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: [
|
||||
resolve(root, 'node_modules/vitest/vitest.mjs'),
|
||||
'run',
|
||||
'--config',
|
||||
config,
|
||||
'--reporter=json',
|
||||
`--outputFile=${report}`
|
||||
],
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: '--max-old-space-size=512',
|
||||
ORCA_BROWSER_GUEST_COUNTS_PATH: countsPath
|
||||
},
|
||||
timeoutMs: 60_000,
|
||||
maxOutputBytes: 2 * 1024 * 1024
|
||||
})
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(report, 'utf8'))
|
||||
} catch (error) {
|
||||
throw new Error(`${label} failed: ${result.stderr || result.stdout}`, { cause: error })
|
||||
}
|
||||
const counts = (await readFile(countsPath, 'utf8'))
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line))
|
||||
return {
|
||||
exitCode: result.code,
|
||||
passed: parsed.numPassedTests,
|
||||
failed: parsed.numFailedTests,
|
||||
counts,
|
||||
failedCases: parsed.testResults.flatMap((suite) =>
|
||||
suite.assertionResults
|
||||
.filter((assertion) => assertion.status === 'failed')
|
||||
.map((assertion) => assertion.fullName)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const before = await run('before', baseline)
|
||||
const after = await run('after', current)
|
||||
const passed =
|
||||
before.passed === 6 &&
|
||||
before.failed === 3 &&
|
||||
after.passed === 9 &&
|
||||
after.failed === 0 &&
|
||||
before.counts.length === 9 &&
|
||||
after.counts.length === 9 &&
|
||||
before.counts[0].counts.contextMenus === 1000 &&
|
||||
before.counts[1].counts.contextMenus === 1000 &&
|
||||
after.counts[0].counts.contextMenus === 0 &&
|
||||
after.counts[1].counts.contextMenus === 0
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
comparison:
|
||||
'Actual BrowserManager registration, destroyed-event handler, callback maps and unregisterAll; Electron methods use EventEmitter fixtures. Baseline removes only the exact-primary destruction cleanup in a temporary source transform.',
|
||||
provenance: { node: process.version, platform: process.platform, arch: process.arch },
|
||||
sourceHashes,
|
||||
before,
|
||||
after,
|
||||
passed
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
if (!passed) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
} finally {
|
||||
if (runnerModuleId) {
|
||||
delete require.cache[runnerModuleId]
|
||||
}
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
{
|
||||
"comparison": "Actual BrowserManager registration, destroyed-event handler, callback maps and unregisterAll; Electron methods use EventEmitter fixtures. Baseline removes only the exact-primary destruction cleanup in a temporary source transform.",
|
||||
"provenance": {
|
||||
"node": "v26.6.0",
|
||||
"platform": "darwin",
|
||||
"arch": "arm64"
|
||||
},
|
||||
"sourceHashes": {
|
||||
"src/main/browser/browser-manager-guest-navigation-policy.ts": {
|
||||
"before": "35aee2b5665a49535897fd0589853248f902061f77b3e142f94e90eabeb7332c",
|
||||
"after": "741ac6d9f30fcf82993fa5c11f40093ba8a75483866407776c983538453f9b32"
|
||||
},
|
||||
"src/main/browser/browser-manager-destroyed-guest.test.ts": {
|
||||
"current": "25806188a208952a1bebd042ec0dc4552784179ed3b738a5d5336789604ef314",
|
||||
"observed": "8e59825237dea1b53294361122f0ea681947d4e6e9ea8fa771b5e15e080d88a6"
|
||||
},
|
||||
"src/main/browser/browser-manager-destroyed-guest-test-fixture.ts": {
|
||||
"current": "4e6bf3589a3888860e6cfa8b6c83e8e50b4344650a5aaa85f221930ee8f9c0fd"
|
||||
},
|
||||
"src/main/browser/browser-manager-state.ts": {
|
||||
"current": "1d82e875461984b3bee2d9dc9b477be7518e9394ace77b24e9b513766cc8a210"
|
||||
},
|
||||
"src/main/browser/browser-manager-registration.ts": {
|
||||
"current": "55d66285b1d3ad29a7be596b1ca3536f333f2e3b2580e4d2a41090928ca8edd1"
|
||||
},
|
||||
"src/main/browser/browser-manager-download-lifecycle.ts": {
|
||||
"current": "8c8d0dd488d31f6098f7ea1000ed8d6ffd8b23704a097db974207c9732d77c2a"
|
||||
}
|
||||
},
|
||||
"before": {
|
||||
"exitCode": 1,
|
||||
"passed": 6,
|
||||
"failed": 3,
|
||||
"counts": [
|
||||
{
|
||||
"test": "browser guest destruction ownership > releases registered callbacks and ownership after 1000 distinct guest destructions",
|
||||
"counts": {
|
||||
"guests": 0,
|
||||
"contextMenus": 1000,
|
||||
"grabShortcuts": 1000,
|
||||
"appShortcuts": 1000,
|
||||
"wheelHandlers": 1000,
|
||||
"renderers": 1000,
|
||||
"workspaces": 1000,
|
||||
"worktrees": 1000,
|
||||
"profiles": 1000,
|
||||
"policies": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > leaves no dead-guest callbacks for the window-close unregisterAll path",
|
||||
"counts": {
|
||||
"guests": 0,
|
||||
"contextMenus": 1000,
|
||||
"grabShortcuts": 1000,
|
||||
"appShortcuts": 1000,
|
||||
"wheelHandlers": 0,
|
||||
"renderers": 1000,
|
||||
"workspaces": 1000,
|
||||
"worktrees": 0,
|
||||
"profiles": 0,
|
||||
"policies": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > keeps explicit unregister before destruction idempotent",
|
||||
"counts": {
|
||||
"guests": 0,
|
||||
"contextMenus": 0,
|
||||
"grabShortcuts": 0,
|
||||
"appShortcuts": 0,
|
||||
"wheelHandlers": 0,
|
||||
"renderers": 0,
|
||||
"workspaces": 0,
|
||||
"worktrees": 0,
|
||||
"profiles": 0,
|
||||
"policies": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > does not let a captured old destroyed callback retire a replacement guest",
|
||||
"counts": {
|
||||
"guests": 1,
|
||||
"contextMenus": 1,
|
||||
"grabShortcuts": 1,
|
||||
"appShortcuts": 1,
|
||||
"wheelHandlers": 1,
|
||||
"renderers": 1,
|
||||
"workspaces": 1,
|
||||
"worktrees": 1,
|
||||
"profiles": 1,
|
||||
"policies": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > cleans a popup without retiring its live primary page",
|
||||
"counts": {
|
||||
"guests": 1,
|
||||
"contextMenus": 1,
|
||||
"grabShortcuts": 1,
|
||||
"appShortcuts": 1,
|
||||
"wheelHandlers": 1,
|
||||
"renderers": 1,
|
||||
"workspaces": 1,
|
||||
"worktrees": 1,
|
||||
"profiles": 1,
|
||||
"policies": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > cleans policies for a guest destroyed before registration",
|
||||
"counts": {
|
||||
"guests": 0,
|
||||
"contextMenus": 0,
|
||||
"grabShortcuts": 0,
|
||||
"appShortcuts": 0,
|
||||
"wheelHandlers": 0,
|
||||
"renderers": 0,
|
||||
"workspaces": 0,
|
||||
"worktrees": 0,
|
||||
"profiles": 0,
|
||||
"policies": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > preserves live guest ownership when its renderer process needs reload recovery",
|
||||
"counts": {
|
||||
"guests": 1,
|
||||
"contextMenus": 1,
|
||||
"grabShortcuts": 1,
|
||||
"appShortcuts": 1,
|
||||
"wheelHandlers": 1,
|
||||
"renderers": 1,
|
||||
"workspaces": 1,
|
||||
"worktrees": 1,
|
||||
"profiles": 1,
|
||||
"policies": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > rebuilds ownership when a restored page registers its fresh guest",
|
||||
"counts": {
|
||||
"guests": 1,
|
||||
"contextMenus": 1,
|
||||
"grabShortcuts": 1,
|
||||
"appShortcuts": 1,
|
||||
"wheelHandlers": 1,
|
||||
"renderers": 1,
|
||||
"workspaces": 1,
|
||||
"worktrees": 1,
|
||||
"profiles": 1,
|
||||
"policies": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > preserves a sibling page using the same browser session profile",
|
||||
"counts": {
|
||||
"guests": 1,
|
||||
"contextMenus": 2,
|
||||
"grabShortcuts": 2,
|
||||
"appShortcuts": 2,
|
||||
"wheelHandlers": 2,
|
||||
"renderers": 2,
|
||||
"workspaces": 2,
|
||||
"worktrees": 2,
|
||||
"profiles": 2,
|
||||
"policies": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"failedCases": [
|
||||
"browser guest destruction ownership releases registered callbacks and ownership after 1000 distinct guest destructions",
|
||||
"browser guest destruction ownership leaves no dead-guest callbacks for the window-close unregisterAll path",
|
||||
"browser guest destruction ownership preserves a sibling page using the same browser session profile"
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"exitCode": 0,
|
||||
"passed": 9,
|
||||
"failed": 0,
|
||||
"counts": [
|
||||
{
|
||||
"test": "browser guest destruction ownership > releases registered callbacks and ownership after 1000 distinct guest destructions",
|
||||
"counts": {
|
||||
"guests": 0,
|
||||
"contextMenus": 0,
|
||||
"grabShortcuts": 0,
|
||||
"appShortcuts": 0,
|
||||
"wheelHandlers": 0,
|
||||
"renderers": 0,
|
||||
"workspaces": 0,
|
||||
"worktrees": 0,
|
||||
"profiles": 0,
|
||||
"policies": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > leaves no dead-guest callbacks for the window-close unregisterAll path",
|
||||
"counts": {
|
||||
"guests": 0,
|
||||
"contextMenus": 0,
|
||||
"grabShortcuts": 0,
|
||||
"appShortcuts": 0,
|
||||
"wheelHandlers": 0,
|
||||
"renderers": 0,
|
||||
"workspaces": 0,
|
||||
"worktrees": 0,
|
||||
"profiles": 0,
|
||||
"policies": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > keeps explicit unregister before destruction idempotent",
|
||||
"counts": {
|
||||
"guests": 0,
|
||||
"contextMenus": 0,
|
||||
"grabShortcuts": 0,
|
||||
"appShortcuts": 0,
|
||||
"wheelHandlers": 0,
|
||||
"renderers": 0,
|
||||
"workspaces": 0,
|
||||
"worktrees": 0,
|
||||
"profiles": 0,
|
||||
"policies": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > does not let a captured old destroyed callback retire a replacement guest",
|
||||
"counts": {
|
||||
"guests": 1,
|
||||
"contextMenus": 1,
|
||||
"grabShortcuts": 1,
|
||||
"appShortcuts": 1,
|
||||
"wheelHandlers": 1,
|
||||
"renderers": 1,
|
||||
"workspaces": 1,
|
||||
"worktrees": 1,
|
||||
"profiles": 1,
|
||||
"policies": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > cleans a popup without retiring its live primary page",
|
||||
"counts": {
|
||||
"guests": 1,
|
||||
"contextMenus": 1,
|
||||
"grabShortcuts": 1,
|
||||
"appShortcuts": 1,
|
||||
"wheelHandlers": 1,
|
||||
"renderers": 1,
|
||||
"workspaces": 1,
|
||||
"worktrees": 1,
|
||||
"profiles": 1,
|
||||
"policies": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > cleans policies for a guest destroyed before registration",
|
||||
"counts": {
|
||||
"guests": 0,
|
||||
"contextMenus": 0,
|
||||
"grabShortcuts": 0,
|
||||
"appShortcuts": 0,
|
||||
"wheelHandlers": 0,
|
||||
"renderers": 0,
|
||||
"workspaces": 0,
|
||||
"worktrees": 0,
|
||||
"profiles": 0,
|
||||
"policies": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > preserves live guest ownership when its renderer process needs reload recovery",
|
||||
"counts": {
|
||||
"guests": 1,
|
||||
"contextMenus": 1,
|
||||
"grabShortcuts": 1,
|
||||
"appShortcuts": 1,
|
||||
"wheelHandlers": 1,
|
||||
"renderers": 1,
|
||||
"workspaces": 1,
|
||||
"worktrees": 1,
|
||||
"profiles": 1,
|
||||
"policies": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > rebuilds ownership when a restored page registers its fresh guest",
|
||||
"counts": {
|
||||
"guests": 1,
|
||||
"contextMenus": 1,
|
||||
"grabShortcuts": 1,
|
||||
"appShortcuts": 1,
|
||||
"wheelHandlers": 1,
|
||||
"renderers": 1,
|
||||
"workspaces": 1,
|
||||
"worktrees": 1,
|
||||
"profiles": 1,
|
||||
"policies": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "browser guest destruction ownership > preserves a sibling page using the same browser session profile",
|
||||
"counts": {
|
||||
"guests": 1,
|
||||
"contextMenus": 1,
|
||||
"grabShortcuts": 1,
|
||||
"appShortcuts": 1,
|
||||
"wheelHandlers": 1,
|
||||
"renderers": 1,
|
||||
"workspaces": 1,
|
||||
"worktrees": 1,
|
||||
"profiles": 1,
|
||||
"policies": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"failedCases": []
|
||||
},
|
||||
"passed": true
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
# Late browser registration replies restore retired renderer state
|
||||
|
||||
`createBrowserPageWebviewGuestSession` awaited `registerGuest` IPC and then wrote the returned guest ID into the renderer's persistent `registeredWebContentsIds` map. An explicit close could remove the webview and map entry before that reply arrived; a delayed success restored the retired entry. An older reply could also overwrite the ID of a replacement guest. Its follow-on callbacks could synchronize an obsolete annotation bridge or mutate recovery state after the listener session was disposed. Separately, recovery validation could issue repair IPC after its initial registration query outlived that owner.
|
||||
|
||||
The fix checks the existing recovery disposal state, current listener ref, persistent registry identity, and captured WebContents ID before accepting a reply or running those continuations. It makes no new registry and sends no late unregister IPC. A current hidden guest still accepts successful registration. When a persistent guest remounts, the new session's existing `validateAfterResume` path retries registration if the old reply was ignored. Current unsuccessful replies and current repair retain their prior behavior.
|
||||
|
||||
## Reproduce
|
||||
|
||||
With dependencies already installed:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/browser-registration-reply-retention/reproduce.mjs
|
||||
```
|
||||
|
||||
This runs the actual renderer session, recovery controller, and persistent guest registry against headless DOM fixtures and deferred IPC replies. The baseline reverses only the included production patch in memory. A temporary observer records map/callback counts after all replies settle. The script uses the shared process launcher, 512 MiB workers, a 60-second deadline, and temporary files removed in `finally`. No Orca window, native guest, or remote host is launched.
|
||||
|
||||
| After 1,000 explicit guest closes and delayed successful replies | Before | Fixed |
|
||||
| ---------------------------------------------------------------- | -----: | ----: |
|
||||
| Live webviews | 0 | 0 |
|
||||
| Retained registration entries | 1,000 | 0 |
|
||||
| Late annotation synchronizations | 1,000 | 0 |
|
||||
| Unregister calls | 1,000 | 1,000 |
|
||||
|
||||
The baseline fails ten tests and passes six controls; fixed source passes all 16. Cases cover distinct closed IDs, replacement elements, a changed guest ID on the same element, a remount reusing the same element/ref, disposed and moved refs, registry removal before listener disposal, a throwing identity getter, hidden current guests, successful/inconclusive replies, and late versus current repair. The repair-completion case verifies that an old success cannot clear a newer guest's recovery error. All host-guest suites also pass: 196 tests across 23 files, including recovery, viewport, registry, worktree retention, and paintability. The web typecheck passes.
|
||||
|
||||
## Version and limits
|
||||
|
||||
Targeted reads of `v1.4.198` confirm the same unconditional registration setter, post-reply callbacks, post-query repair, and close-time map deletion. This establishes a renderer retaining path in the reported version, not that #19831 or #19768 exercised it. Each retained entry is a page ID and numeric guest ID. This proof does not show a surviving native browser process or explain gigabyte-scale memory growth. The independent main-process destroyed-guest callback retention has its own fix and proof.
|
||||
|
||||
The registration reply is the only production setter of `registeredWebContentsIds`; explicit close and replacement remove its key. Following callers found no second setter that could recreate this same metadata after removal. The annotation callback uses current page routing, which is why skipping a stale callback is necessary without issuing cleanup against a replacement.
|
||||
@@ -1,126 +0,0 @@
|
||||
diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.ts
|
||||
index 45f3b354b5..b6e30917d1 100644
|
||||
--- a/src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.ts
|
||||
+++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.ts
|
||||
@@ -21,6 +21,7 @@ type BrowserPageGuestRecoveryOptions = {
|
||||
export type BrowserPageGuestRecovery = {
|
||||
confirmRegistration: () => void
|
||||
dispose: () => void
|
||||
+ isDisposed: () => boolean
|
||||
finish: () => boolean
|
||||
recoverRenderer: () => void
|
||||
retryRecovery: () => void
|
||||
@@ -263,6 +264,7 @@ export function createBrowserPageGuestRecovery(
|
||||
clearValidationRetry()
|
||||
clearValidationTimeout()
|
||||
},
|
||||
+ isDisposed: () => disposed,
|
||||
finish,
|
||||
recoverRenderer,
|
||||
retryRecovery: () => {
|
||||
diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts
|
||||
index 4623b817b2..dd5e243ba2 100644
|
||||
--- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts
|
||||
+++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
type BrowserPageGuestRecovery
|
||||
} from './browser-page-guest-recovery'
|
||||
import { browserPageZoomLevelToPercent, setBrowserPageZoomLevel } from './browser-page-zoom'
|
||||
-import { registeredWebContentsIds, replacePersistentWebview } from './webview-registry'
|
||||
+import {
|
||||
+ registeredWebContentsIds,
|
||||
+ replacePersistentWebview,
|
||||
+ webviewRegistry
|
||||
+} from './webview-registry'
|
||||
import { browserPageExists } from '../describe-page/browser-page-load-error'
|
||||
import type {
|
||||
BrowserPageRecoveryNavigationValidation,
|
||||
@@ -80,11 +84,21 @@ export function createBrowserPageWebviewGuestSession({
|
||||
webContentsId: number
|
||||
promise: Promise<boolean | null>
|
||||
} | null = null
|
||||
- const registerGuest = (): Promise<boolean | null> => {
|
||||
- let webContentsId: number
|
||||
+ const readWebContentsId = (): number | null => {
|
||||
try {
|
||||
- webContentsId = webview.getWebContentsId()
|
||||
+ return webview.getWebContentsId()
|
||||
} catch {
|
||||
+ return null
|
||||
+ }
|
||||
+ }
|
||||
+ const ownsGuest = (webContentsId: number | null): boolean =>
|
||||
+ webContentsId !== null &&
|
||||
+ !guestRecovery.isDisposed() &&
|
||||
+ webviewRef.current === webview &&
|
||||
+ webviewRegistry.get(browserTabId) === webview &&
|
||||
+ readWebContentsId() === webContentsId
|
||||
+ const registerGuest = (webContentsId: number | null): Promise<boolean | null> => {
|
||||
+ if (webContentsId === null || !ownsGuest(webContentsId)) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (registrationInFlight?.webContentsId === webContentsId) {
|
||||
@@ -99,6 +113,9 @@ export function createBrowserPageWebviewGuestSession({
|
||||
webContentsId
|
||||
})
|
||||
.then((registered) => {
|
||||
+ if (!ownsGuest(webContentsId)) {
|
||||
+ return null
|
||||
+ }
|
||||
if (registered) {
|
||||
registeredWebContentsIds.set(browserTabId, webContentsId)
|
||||
return true
|
||||
@@ -146,22 +163,26 @@ export function createBrowserPageWebviewGuestSession({
|
||||
return null
|
||||
}
|
||||
if (registeredWebContentsIds.get(browserTabId) !== webContentsId) {
|
||||
- return registerGuest()
|
||||
+ return registerGuest(webContentsId)
|
||||
}
|
||||
const registered = await window.api.browser.isGuestRegistered({
|
||||
browserPageId: browserTabId,
|
||||
webContentsId
|
||||
})
|
||||
+ if (!ownsGuest(webContentsId)) {
|
||||
+ return null
|
||||
+ }
|
||||
if (registered) {
|
||||
return true
|
||||
}
|
||||
- return window.api.browser.repairGuestRegistration({
|
||||
+ const repaired = await window.api.browser.repairGuestRegistration({
|
||||
browserPageId: browserTabId,
|
||||
workspaceId,
|
||||
worktreeId,
|
||||
sessionProfileId,
|
||||
webContentsId
|
||||
})
|
||||
+ return ownsGuest(webContentsId) ? repaired : null
|
||||
},
|
||||
replaceGuest: () => replacePersistentWebview(browserTabId),
|
||||
onReplacementReady: () => setGuestRecoveryGeneration((generation) => generation + 1),
|
||||
@@ -184,7 +205,11 @@ export function createBrowserPageWebviewGuestSession({
|
||||
|
||||
const handleDidAttach = (): void => {
|
||||
// Why: register at attach since cert failures can precede dom-ready; the dom-ready path stays an idempotent fallback.
|
||||
- void registerGuest().then((registered) => {
|
||||
+ const webContentsId = readWebContentsId()
|
||||
+ void registerGuest(webContentsId).then((registered) => {
|
||||
+ if (!ownsGuest(webContentsId)) {
|
||||
+ return
|
||||
+ }
|
||||
if (registered === true) {
|
||||
guestRecovery.confirmRegistration()
|
||||
}
|
||||
@@ -207,7 +232,10 @@ export function createBrowserPageWebviewGuestSession({
|
||||
const queuedAnnotationViewportBridgeSync =
|
||||
liveWebContentsId === null || registeredWebContentsIds.get(browserTabId) !== liveWebContentsId
|
||||
if (queuedAnnotationViewportBridgeSync) {
|
||||
- void registerGuest().then((registered) => {
|
||||
+ void registerGuest(liveWebContentsId).then((registered) => {
|
||||
+ if (!ownsGuest(liveWebContentsId)) {
|
||||
+ return
|
||||
+ }
|
||||
const completedRecovery = guestRecovery.finish()
|
||||
if (registered === true) {
|
||||
guestRecovery.confirmRegistration()
|
||||
@@ -1,153 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { applyPatch, parsePatch, reversePatch } from 'diff'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
|
||||
throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.')
|
||||
}
|
||||
|
||||
const root = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const patch = await readFile(new URL('./fix.patch', import.meta.url), 'utf8')
|
||||
const beforeSources = {}
|
||||
const sourceHashes = {}
|
||||
const sha256 = (source) => createHash('sha256').update(source).digest('hex')
|
||||
for (const parsed of parsePatch(patch)) {
|
||||
const path = parsed.newFileName.replace(/^b\//, '')
|
||||
const absolute = resolve(root, path)
|
||||
const current = await readFile(absolute, 'utf8')
|
||||
const before = applyPatch(current, reversePatch(parsed))
|
||||
if (before === false) {
|
||||
throw new Error(`Source changed; review the proof patch: ${path}`)
|
||||
}
|
||||
beforeSources[absolute.replaceAll('\\', '/')] = before
|
||||
sourceHashes[path] = { before: sha256(before), after: sha256(current) }
|
||||
}
|
||||
|
||||
const testPath =
|
||||
'src/renderer/src/components/browser-pane/host-guest/browser-page-registration-ownership.test.ts'
|
||||
const test = await readFile(resolve(root, testPath), 'utf8')
|
||||
const countAssertion = ' expect(webviewRegistry.size).toBe(0)\n'
|
||||
if (test.split(countAssertion).length !== 2) {
|
||||
throw new Error('Expected exactly one closed-guest count assertion; review the observer.')
|
||||
}
|
||||
const observedTest = `import { writeFileSync } from 'node:fs'\n${test.replace(
|
||||
countAssertion,
|
||||
` writeFileSync(process.env.ORCA_BROWSER_REGISTRATION_COUNTS_PATH, JSON.stringify({ liveWebviews: webviewRegistry.size, registeredGuestIds: registeredWebContentsIds.size, lateAnnotationSyncs: sessions.reduce((count, page) => count + page.sync.mock.calls.length, 0), unregisterCalls: unregister.mock.calls.length }))\n${countAssertion}`
|
||||
)}`
|
||||
sourceHashes[testPath] = { current: sha256(test), observed: sha256(observedTest) }
|
||||
const scratch = await mkdtemp(join(tmpdir(), 'orca-browser-registration-reply-'))
|
||||
const require = createRequire(import.meta.url)
|
||||
let runnerModuleId
|
||||
try {
|
||||
const runnerPath = join(scratch, 'run-process.cjs')
|
||||
await build({
|
||||
absWorkingDir: root,
|
||||
entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')],
|
||||
outfile: runnerPath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
logLevel: 'silent'
|
||||
})
|
||||
runnerModuleId = require.resolve(runnerPath)
|
||||
const { runProcess } = require(runnerModuleId)
|
||||
const configImport = JSON.stringify(pathToFileURL(resolve(root, 'config/vitest.config.ts')).href)
|
||||
|
||||
async function run(label, productionSources) {
|
||||
const config = join(scratch, `${label}.config.mjs`)
|
||||
const report = join(scratch, `${label}.json`)
|
||||
const countsPath = join(scratch, `${label}.counts.json`)
|
||||
const sources = {
|
||||
...productionSources,
|
||||
[resolve(root, testPath).replaceAll('\\', '/')]: observedTest
|
||||
}
|
||||
await writeFile(
|
||||
config,
|
||||
`import base from ${configImport};
|
||||
const sources = ${JSON.stringify(sources)};
|
||||
export default {...base, test: {...base.test, include: [${JSON.stringify(testPath)}], maxWorkers: 1}, plugins: [{
|
||||
name: 'browser-registration-reply-audit', enforce: 'pre',
|
||||
transform(_code, id) {
|
||||
const source = sources[id.replaceAll('\\\\', '/').split('?')[0]];
|
||||
return source === undefined ? null : {code: source, map: null};
|
||||
}
|
||||
}]};\n`
|
||||
)
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: [
|
||||
resolve(root, 'node_modules/vitest/vitest.mjs'),
|
||||
'run',
|
||||
'--config',
|
||||
config,
|
||||
'--reporter=json',
|
||||
`--outputFile=${report}`
|
||||
],
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: '--max-old-space-size=512',
|
||||
ORCA_BROWSER_REGISTRATION_COUNTS_PATH: countsPath
|
||||
},
|
||||
timeoutMs: 60_000,
|
||||
maxOutputBytes: 2 * 1024 * 1024
|
||||
})
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(report, 'utf8'))
|
||||
} catch (error) {
|
||||
throw new Error(`${label} failed: ${result.stderr || result.stdout}`, { cause: error })
|
||||
}
|
||||
return {
|
||||
exitCode: result.code,
|
||||
passed: parsed.numPassedTests,
|
||||
failed: parsed.numFailedTests,
|
||||
after1000ClosedGuests: JSON.parse(await readFile(countsPath, 'utf8')),
|
||||
failedCases: parsed.testResults.flatMap((suite) =>
|
||||
suite.assertionResults
|
||||
.filter((assertion) => assertion.status === 'failed')
|
||||
.map((assertion) => assertion.fullName)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const before = await run('before', beforeSources)
|
||||
const after = await run('after', {})
|
||||
const passed =
|
||||
before.passed === 6 &&
|
||||
before.failed === 10 &&
|
||||
after.passed === 16 &&
|
||||
after.failed === 0 &&
|
||||
before.after1000ClosedGuests.liveWebviews === 0 &&
|
||||
before.after1000ClosedGuests.registeredGuestIds === 1000 &&
|
||||
after.after1000ClosedGuests.registeredGuestIds === 0 &&
|
||||
after.after1000ClosedGuests.unregisterCalls === 1000
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
comparison:
|
||||
'Actual renderer guest session, recovery controller and persistent guest registry with deferred IPC replies; baseline reverses only fix.patch in a temporary source transform.',
|
||||
provenance: { node: process.version, platform: process.platform, arch: process.arch },
|
||||
sourceHashes,
|
||||
before,
|
||||
after,
|
||||
passed
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
if (!passed) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
} finally {
|
||||
if (runnerModuleId) {
|
||||
delete require.cache[runnerModuleId]
|
||||
}
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"comparison": "Actual renderer guest session, recovery controller and persistent guest registry with deferred IPC replies; baseline reverses only fix.patch in a temporary source transform.",
|
||||
"provenance": {
|
||||
"node": "v26.6.0",
|
||||
"platform": "darwin",
|
||||
"arch": "arm64"
|
||||
},
|
||||
"sourceHashes": {
|
||||
"src/renderer/src/components/browser-pane/host-guest/browser-page-guest-recovery.ts": {
|
||||
"before": "044280571771c4e90d07f5f5426878539f2b1729d9a4482c59d8fd236e7d4177",
|
||||
"after": "2efdea1c4223b2f4114548a7f6ec4b576e1c6bde7819a07dc096d5167c2ba42c"
|
||||
},
|
||||
"src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts": {
|
||||
"before": "2fe17f0fe4f8ef6ca7cff7ca5731d9d725dbf4b5e7944264e30f12241317fc6d",
|
||||
"after": "a78cc98873e93834bf07b47bbf5d92da888dfbccef06551aa6ac3c8f6e9f29f8"
|
||||
},
|
||||
"src/renderer/src/components/browser-pane/host-guest/browser-page-registration-ownership.test.ts": {
|
||||
"current": "992b4730cd5546720b8b52366955386ad900d057f95c13beb6b92e5369bf5c1e",
|
||||
"observed": "ebc38ebb195295170e8c355ecda539f3f2e75bb80548dbf4e97a17a7cd88c661"
|
||||
}
|
||||
},
|
||||
"before": {
|
||||
"exitCode": 1,
|
||||
"passed": 6,
|
||||
"failed": 10,
|
||||
"after1000ClosedGuests": {
|
||||
"liveWebviews": 0,
|
||||
"registeredGuestIds": 1000,
|
||||
"lateAnnotationSyncs": 1000,
|
||||
"unregisterCalls": 1000
|
||||
},
|
||||
"failedCases": [
|
||||
"renderer registration completion ownership does not restore 1000 closed IDs from delayed successful replies",
|
||||
"renderer registration completion ownership keeps the replacement ID after an older reply arrives",
|
||||
"renderer registration completion ownership keeps a new ID when the same DOM webview swaps its guest",
|
||||
"renderer registration completion ownership a new session retries a disposed session registration on the same persistent guest and ref",
|
||||
"renderer registration completion ownership a disposed listener cannot act after the same guest and ref are reused",
|
||||
"renderer registration completion ownership does not restore a registry-removed guest before listener disposal runs",
|
||||
"renderer registration completion ownership does not restore metadata when the current listener ref has moved",
|
||||
"renderer registration completion ownership ignores a reply after reading the guest identity starts throwing",
|
||||
"renderer registration completion ownership does not issue repair after a pending validation outlives its owner",
|
||||
"renderer registration completion ownership does not clear a newer guest recovery error from a pending old repair reply"
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"exitCode": 0,
|
||||
"passed": 16,
|
||||
"failed": 0,
|
||||
"after1000ClosedGuests": {
|
||||
"liveWebviews": 0,
|
||||
"registeredGuestIds": 0,
|
||||
"lateAnnotationSyncs": 0,
|
||||
"unregisterCalls": 1000
|
||||
},
|
||||
"failedCases": []
|
||||
},
|
||||
"passed": true
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
# Retired browser viewport operation ownership
|
||||
|
||||
The viewport operation captures a guest ID, then awaits CDP commands. Closing a tab deletes its viewport state, but the old continuation can subsequently recreate the UA-intent entry. A failed UA clear can also restore the old value over a replacement guest's completed desktop preset, or a late clear can delete the replacement's mobile intent.
|
||||
|
||||
The correction reuses that captured guest ID at three mutation boundaries: before publishing an applied preset's UA intent, before reading/deleting a cleared preset's intent, and before failed-clear rollback. Same-owner rollback, native UA profiles, navigation behavior, and the per-tab promise chain are preserved.
|
||||
|
||||
## Evidence
|
||||
|
||||
The regression fixture calls the actual manager, registration, unregistration, and viewport implementation. Electron WebContents and pending debugger replies are controlled ports; no native browser or window is launched.
|
||||
|
||||
- Baseline: **7 failing ownership regressions, 5 passing controls**.
|
||||
- Fixed: **12/12 ownership cases**, plus **30 existing viewport, navigation, partial-failure, and UA cases**.
|
||||
- Sixteen pending UA-clear rejections after `unregisterAll` leave **16 retired UA entries before, zero after**. Registration, preset, and promise maps remain empty.
|
||||
- Other regressions cover closed-tab late success, failed-clear rollback, mobile/desktop replacement, and native-to-default profile replacement.
|
||||
- Controls preserve ordinary serialized mobile/desktop/null operations, both native-profile presets, same-owner rollback, and the replacement promise tail while old queued operations settle.
|
||||
- An independent reviewer ran all 12 candidate cases and reviewed the three mutation guards before promotion.
|
||||
|
||||
The retained entries are tab ID strings and booleans. This does **not** demonstrate retained native WebContents, a process RSS slope, or gigabyte-scale memory growth. In-flight CDP work still owns its continuation until it settles. Positive and negative post-close command replies are injected schedules, not an affected-host trace.
|
||||
|
||||
## Ordinary callers and compatibility
|
||||
|
||||
The renderer requests overrides when the user selects a viewport preset and on guest `dom-ready`, including null presets. The trusted IPC handler validates dimensions before calling this manager. Navigation later reads the UA-intent map, so stale replacement values can alter the standing mobile/desktop identity. The fixture does not execute the renderer or IPC producer.
|
||||
|
||||
Both local webview and host-side offscreen registrations use these maps. The correction changes no wire fields, protocol, execution-host ownership, native process lifecycle, folder/worktree handling, or UI layout. It only prevents an operation for a different guest from mutating the current registration's state.
|
||||
|
||||
`source-versions.json` records 11 paths at audit checkpoint `4a09b1d1`, independent main `291b4ddd`, and reported v1.4.198 `e0826956`. The viewport implementation, registration, registry declarations, IPC handler, and toolbar producer match all three. Ten sources match independent main and eight match v1.4.198. The guest-session producer contains an earlier audit fix; historical navigation and fixture sources differ. This is a current-dependency replay with the exact historical viewport source, not a historical app-binary replay.
|
||||
|
||||
The browsing activity in #19831 makes this path applicable in principle. The report does not establish the required overlap or tab count, and this small metadata mechanism does not account for its reported memory totals.
|
||||
|
||||
## Reproduction
|
||||
|
||||
From the worktree, run the fixed regression suite:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node node_modules/vitest/vitest.mjs run --config docs/audits/browser-viewport-owner-retention/vitest.config.mjs
|
||||
```
|
||||
|
||||
Run the same tests with the exact baseline viewport implementation; exit status 1 and seven failed cases are expected:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 ORCA_VIEWPORT_BASELINE=1 node node_modules/vitest/vitest.mjs run --config docs/audits/browser-viewport-owner-retention/vitest.config.mjs
|
||||
```
|
||||
|
||||
The import overlay never rewrites product files. `baseline-source.txt` contains only the original viewport module; current support modules remain in use. `baseline-results.json`, `fixed-results.json`, and `validation.json` record the measured results and their scope.
|
||||
@@ -1,94 +0,0 @@
|
||||
{
|
||||
"testFiles": 1,
|
||||
"total": 12,
|
||||
"passed": 5,
|
||||
"failed": 7,
|
||||
"cases": [
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership does not recreate closed-tab UA intent after a late touch completion",
|
||||
"status": "failed",
|
||||
"failures": [
|
||||
"AssertionError: expected true to be undefined\n at ./src/main/browser/browser-manager-viewport-ownership.test.ts:109:37\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file://./node_modules/.pnpm/@vitest+runner@4.1.11/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:20"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership does not restore closed-tab UA intent after a failed clear",
|
||||
"status": "failed",
|
||||
"failures": [
|
||||
"AssertionError: expected true to be undefined\n at ./src/main/browser/browser-manager-viewport-ownership.test.ts:126:42\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file://./node_modules/.pnpm/@vitest+runner@4.1.11/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:20"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership preserves replacement desktop intent after an old clear fails",
|
||||
"status": "failed",
|
||||
"failures": [
|
||||
"AssertionError: expected true to be false // Object.is equality\n at ./src/main/browser/browser-manager-viewport-ownership.test.ts:142:42\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file://./node_modules/.pnpm/@vitest+runner@4.1.11/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:20"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership preserves replacement mobile intent after an old clear resumes",
|
||||
"status": "failed",
|
||||
"failures": [
|
||||
"AssertionError: expected false to be true // Object.is equality\n at ./src/main/browser/browser-manager-viewport-ownership.test.ts:160:42\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file://./node_modules/.pnpm/@vitest+runner@4.1.11/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:20"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership same-owner clear failure still restores the legitimate earlier intent",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership old apply cannot overwrite a replacement guest desktop intent",
|
||||
"status": "failed",
|
||||
"failures": [
|
||||
"AssertionError: expected true to be false // Object.is equality\n at ./src/main/browser/browser-manager-viewport-ownership.test.ts:185:41\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file://./node_modules/.pnpm/@vitest+runner@4.1.11/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:20"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership an old native profile cannot write UA intent after replacement with a default profile",
|
||||
"status": "failed",
|
||||
"failures": [
|
||||
"AssertionError: expected true to be undefined\n at ./src/main/browser/browser-manager-viewport-ownership.test.ts:197:49\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file://./node_modules/.pnpm/@vitest+runner@4.1.11/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:20"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership old queued operations cannot remove or join a replacement promise tail",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership normal same-owner toggles preserve last-requested order and remove the promise tail",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership native UA mode remains unchanged with mobile=false",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership native UA mode remains unchanged with mobile=true",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership late rejected clears cannot repopulate all registries after unregisterAll",
|
||||
"status": "failed",
|
||||
"failures": [
|
||||
"AssertionError: expected 16 to be +0 // Object.is equality\n at ./src/main/browser/browser-manager-viewport-ownership.test.ts:278:28\n at processTicksAndRejections (node:internal/process/task_queues:104:5)\n at file://./node_modules/.pnpm/@vitest+runner@4.1.11/node_modules/@vitest/runner/dist/chunk-artifact.js:1903:20"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
import { webContents } from 'electron'
|
||||
import {
|
||||
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
|
||||
buildBrowserAnnotationViewportBridgeScript,
|
||||
type BrowserAnnotationViewportBridgeOptions
|
||||
} from '../../shared/browser-annotation-viewport-bridge'
|
||||
import type { BrowserViewportOverride } from '../../shared/browser-workspace-types'
|
||||
import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua'
|
||||
import { BrowserManagerDownloadLifecycle } from './browser-manager-download-lifecycle'
|
||||
|
||||
export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifecycle {
|
||||
// Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup.
|
||||
async openDevTools(browserTabId: string): Promise<boolean> {
|
||||
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
|
||||
if (!webContentsId) {
|
||||
return false
|
||||
}
|
||||
const guest = webContents.fromId(webContentsId)
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
// Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
|
||||
this.unregisterGuest(browserTabId)
|
||||
return false
|
||||
}
|
||||
// Offscreen guests have no visible window on this desktop; detaching DevTools would open it
|
||||
// on the host display with no route back to the remote client.
|
||||
if (this.offscreenGuestIds.has(webContentsId)) {
|
||||
return false
|
||||
}
|
||||
guest.openDevTools({ mode: 'detach' })
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: emulate viewport via CDP; never detach the debugger here or the agent bridge's per-guest state is cleared.
|
||||
async setViewportOverride(
|
||||
browserTabId: string,
|
||||
override: BrowserViewportOverride | null
|
||||
): Promise<boolean> {
|
||||
// Why: chain per-tab so rapid toggles don't interleave CDP commands and the last-requested override wins.
|
||||
const expectedWebContentsId = this.webContentsIdByTabId.get(browserTabId)
|
||||
if (expectedWebContentsId !== undefined) {
|
||||
// Keep host panning available while CDP applies the requested dimensions. The guest id fence
|
||||
// prevents this intent from leaking to a replacement guest; clearing the preset removes it.
|
||||
this.viewportPresetActiveByTabId.set(browserTabId, {
|
||||
guestWebContentsId: expectedWebContentsId,
|
||||
active: override !== null
|
||||
})
|
||||
}
|
||||
// The renderer resizes the host before CDP completes; discard the old geometry until it
|
||||
// reports the new pane bounds so a pending preset cannot route wheel input using stale limits.
|
||||
this.viewportScrollStateByTabId.delete(browserTabId)
|
||||
const prev = this.viewportOpsByTabId.get(browserTabId) ?? Promise.resolve()
|
||||
const next = prev
|
||||
.catch(() => {})
|
||||
.then(() => this.doSetViewportOverrideImpl(browserTabId, override, expectedWebContentsId))
|
||||
this.viewportOpsByTabId.set(browserTabId, next)
|
||||
try {
|
||||
return await next
|
||||
} finally {
|
||||
// Why: only clear if we're still the tail; a later call may have replaced the entry, and deleting would break serialization.
|
||||
if (this.viewportOpsByTabId.get(browserTabId) === next) {
|
||||
this.viewportOpsByTabId.delete(browserTabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setAnnotationViewportBridge(
|
||||
browserTabId: string,
|
||||
options: BrowserAnnotationViewportBridgeOptions,
|
||||
resolveGuest: () => Electron.WebContents | null
|
||||
): Promise<boolean> {
|
||||
const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve()
|
||||
const next = prev
|
||||
.catch(() => {})
|
||||
.then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest))
|
||||
this.annotationViewportBridgeOpsByTabId.set(browserTabId, next)
|
||||
try {
|
||||
return await next
|
||||
} finally {
|
||||
if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) === next) {
|
||||
this.annotationViewportBridgeOpsByTabId.delete(browserTabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why the caller resolves the guest: the same bridge serves browsing pages and workspace
|
||||
// documents, which live in different halves of the page registry.
|
||||
// Why a resolver and not the guest itself: this op may have waited behind another one, and a
|
||||
// cross-process navigation meanwhile swaps the tab's contents without destroying the old one —
|
||||
// injecting into the guest the request named would bridge a page nobody is looking at.
|
||||
// Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and
|
||||
// taking an id it cannot act on would invite the next reader to act on it.
|
||||
protected async doSetAnnotationViewportBridgeImpl(
|
||||
options: BrowserAnnotationViewportBridgeOptions,
|
||||
resolveGuest: () => Electron.WebContents | null
|
||||
): Promise<boolean> {
|
||||
// Why no teardown here: the resolver already unregisters a page whose guest died, and the only
|
||||
// case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would
|
||||
// cancel that page's in-flight downloads and grabs over a request that was merely misaddressed.
|
||||
const guest = resolveGuest()
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// Why: run the scroll bridge in an isolated world so page scripts can't read the per-tab token or tamper with it.
|
||||
await guest.executeJavaScriptInIsolatedWorld(
|
||||
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
|
||||
[{ code: buildBrowserAnnotationViewportBridgeScript(options) }],
|
||||
false
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
protected async doSetViewportOverrideImpl(
|
||||
browserTabId: string,
|
||||
override: BrowserViewportOverride | null,
|
||||
expectedWebContentsId: number | undefined
|
||||
): Promise<boolean> {
|
||||
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
|
||||
if (!webContentsId || webContentsId !== expectedWebContentsId) {
|
||||
return false
|
||||
}
|
||||
const guest = webContents.fromId(webContentsId)
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
// Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps.
|
||||
this.unregisterGuest(browserTabId)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
if (!guest.debugger.isAttached()) {
|
||||
guest.debugger.attach('1.3')
|
||||
}
|
||||
} catch (err) {
|
||||
// Why: attach throws if DevTools is open on the guest; log context so this failure mode is diagnosable.
|
||||
console.warn('[browser-manager] setViewportOverride: failed to attach debugger', {
|
||||
browserTabId,
|
||||
webContentsId,
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const dbg = guest.debugger
|
||||
try {
|
||||
if (override) {
|
||||
await dbg.sendCommand('Emulation.setDeviceMetricsOverride', {
|
||||
width: override.width,
|
||||
height: override.height,
|
||||
deviceScaleFactor: override.deviceScaleFactor,
|
||||
mobile: override.mobile
|
||||
})
|
||||
if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) {
|
||||
this.viewportPresetActiveByTabId.set(browserTabId, {
|
||||
guestWebContentsId: webContentsId,
|
||||
active: true
|
||||
})
|
||||
}
|
||||
await dbg.sendCommand('Emulation.setTouchEmulationEnabled', {
|
||||
enabled: override.mobile,
|
||||
maxTouchPoints: override.mobile ? 5 : 0
|
||||
})
|
||||
// Why: viewport sizing must not override a profile's explicit native-UA identity.
|
||||
if (this.userAgentModeByPageId.get(browserTabId) !== 'native') {
|
||||
// Navigation must see the preset intent while the final CDP command is in flight.
|
||||
this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile)
|
||||
// Why: same sender as the navigation path, so both resolve the tab's host identically.
|
||||
await this.sendViewportUserAgentOverride(guest, override.mobile)
|
||||
}
|
||||
} else {
|
||||
await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {})
|
||||
if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) {
|
||||
this.viewportPresetActiveByTabId.set(browserTabId, {
|
||||
guestWebContentsId: webContentsId,
|
||||
active: false
|
||||
})
|
||||
}
|
||||
await dbg.sendCommand('Emulation.setTouchEmulationEnabled', {
|
||||
enabled: false,
|
||||
maxTouchPoints: 0
|
||||
})
|
||||
const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId)
|
||||
// A navigation after this point must not re-install the override behind the clear.
|
||||
this.viewportUaOverrideMobileByTabId.delete(browserTabId)
|
||||
try {
|
||||
if (this.authUserAgentOverrideStateByGuestId.has(guest.id)) {
|
||||
const url = this.resolveTabNavigationUrl(guest)
|
||||
const restored = await this.applyAuthUserAgentOverrideOverCdp(
|
||||
guest,
|
||||
false,
|
||||
url,
|
||||
isGoogleAuthUrl(url) ? googleAuthUserAgent() : guest.session.getUserAgent()
|
||||
)
|
||||
if (!restored) {
|
||||
throw new Error('Failed to preserve auth user agent')
|
||||
}
|
||||
} else {
|
||||
// Why: passing an empty string restores the session default UA.
|
||||
await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' })
|
||||
}
|
||||
} catch (error) {
|
||||
if (trackedMobile !== undefined) {
|
||||
this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
diff --git a/src/main/browser/browser-manager-viewport.ts b/src/main/browser/browser-manager-viewport.ts
|
||||
index ce31dbe37e..3f1fbb68fb 100644
|
||||
--- a/src/main/browser/browser-manager-viewport.ts
|
||||
+++ b/src/main/browser/browser-manager-viewport.ts
|
||||
@@ -165,0 +166,3 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec
|
||||
+ if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) {
|
||||
+ return false
|
||||
+ }
|
||||
@@ -184,0 +188,3 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec
|
||||
+ if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) {
|
||||
+ return false
|
||||
+ }
|
||||
@@ -205 +211,4 @@ export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifec
|
||||
- if (trackedMobile !== undefined) {
|
||||
+ if (
|
||||
+ trackedMobile !== undefined &&
|
||||
+ this.webContentsIdByTabId.get(browserTabId) === webContentsId
|
||||
+ ) {
|
||||
@@ -1,260 +0,0 @@
|
||||
{
|
||||
"testFiles": 4,
|
||||
"total": 42,
|
||||
"passed": 42,
|
||||
"failed": 0,
|
||||
"cases": [
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride returns false when the tab is not registered",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride applies device metrics, touch emulation, and a mobile UA for mobile presets",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride keeps the session UA for native-mode profiles when mobile=false",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride keeps the session UA for native-mode profiles when mobile=true",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride presents the Firefox UA for a preset applied on a Google auth host (mobile=false)",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride presents the Firefox UA for a preset applied on a Google auth host (mobile=true)",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride re-issues the standing UA override when navigating onto and back off an auth host",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride does not leave the Chrome preset UA standing when a mobile preset lands mid-navigation onto an auth host",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride does not leave the Firefox UA standing when a preset lands mid-navigation off an auth host",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride falls back to the committed URL once a navigation commits or fails",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride does not let a superseded navigation failure revert a newer target",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride switches identity for a server redirect and restores it if the redirect fails",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride preserves the auth identity when a viewport preset is cleared after a redirect",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride does not inherit a mobile owner UA in a desktop popup",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride reapplies a preset when navigation starts during its final UA write",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride does not reinstall a preset while its final UA clear is in flight",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride keeps tracking the standing override when the CDP clear fails",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride does not touch the UA override on navigation when no preset is standing",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride stops re-issuing the UA override once the preset is cleared",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride leaves the UA override alone on navigation for native-UA profiles",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride clears device metrics and disables touch for override=null",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride attaches the debugger if not already attached and does not detach after",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-override.test.ts",
|
||||
"title": "browserManager setViewportOverride returns false when debugger.attach throws (e.g. DevTools already open)",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership does not recreate closed-tab UA intent after a late touch completion",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership does not restore closed-tab UA intent after a failed clear",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership preserves replacement desktop intent after an old clear fails",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership preserves replacement mobile intent after an old clear resumes",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership same-owner clear failure still restores the legitimate earlier intent",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership old apply cannot overwrite a replacement guest desktop intent",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership an old native profile cannot write UA intent after replacement with a default profile",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership old queued operations cannot remove or join a replacement promise tail",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership normal same-owner toggles preserve last-requested order and remove the promise tail",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership native UA mode remains unchanged with mobile=false",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership native UA mode remains unchanged with mobile=true",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-ownership.test.ts",
|
||||
"title": "browser viewport operation ownership late rejected clears cannot repopulate all registries after unregisterAll",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-partial-failure.test.ts",
|
||||
"title": "browserManager viewport partial failure keeps wheel routing active when follow-up setup fails after metrics apply",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-manager-viewport-partial-failure.test.ts",
|
||||
"title": "browserManager viewport partial failure keeps host panning available when metrics setup fails",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-viewport-user-agent.test.ts",
|
||||
"title": "buildViewportUserAgentOverride presents the Firefox UA on Google auth hosts regardless of the preset",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-viewport-user-agent.test.ts",
|
||||
"title": "buildViewportUserAgentOverride keeps the clean desktop UA off the auth hosts",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-viewport-user-agent.test.ts",
|
||||
"title": "buildViewportUserAgentOverride splices the real Chrome major into the mobile UA and its client hints",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-viewport-user-agent.test.ts",
|
||||
"title": "buildViewportUserAgentOverride falls back to a known Chrome major when the base UA carries none",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/browser-viewport-user-agent.test.ts",
|
||||
"title": "buildViewportUserAgentOverride treats an unparseable URL as a non-auth host",
|
||||
"status": "passed",
|
||||
"failures": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
{
|
||||
"refs": {
|
||||
"audit": "4a09b1d108cfd8b57ffcc5d727b3a3bd71ae71fb",
|
||||
"main": "291b4ddd6f1c1af480169885e0fda7f9c78ff053",
|
||||
"v1.4.198": "e0826956fcfc532f5a1e55b5e081f2e57e553c43"
|
||||
},
|
||||
"canonicalLineEndings": "LF",
|
||||
"sources": [
|
||||
{
|
||||
"path": "src/main/browser/browser-manager-viewport.ts",
|
||||
"sha256": {
|
||||
"audit": "701f9ea1a4310e509f409e08ee4ea0539f820bd209ff68f41001b8c91e5470e4",
|
||||
"main": "701f9ea1a4310e509f409e08ee4ea0539f820bd209ff68f41001b8c91e5470e4",
|
||||
"v1.4.198": "701f9ea1a4310e509f409e08ee4ea0539f820bd209ff68f41001b8c91e5470e4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-manager-registration.ts",
|
||||
"sha256": {
|
||||
"audit": "12c149b1fb87b67b4192b6c2f23bd7f45b7df2d0c60368182f33b5a47b56f96f",
|
||||
"main": "12c149b1fb87b67b4192b6c2f23bd7f45b7df2d0c60368182f33b5a47b56f96f",
|
||||
"v1.4.198": "12c149b1fb87b67b4192b6c2f23bd7f45b7df2d0c60368182f33b5a47b56f96f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-manager-navigation.ts",
|
||||
"sha256": {
|
||||
"audit": "a4c0e169ac35725b02d050c95843721e4274775a5bcb9ef2a5b7c835c4d8c234",
|
||||
"main": "a4c0e169ac35725b02d050c95843721e4274775a5bcb9ef2a5b7c835c4d8c234",
|
||||
"v1.4.198": "c93c060896351b4bc23db628a732ef4db4acd5b26760e4565e6bf029d5cf7531"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-manager-guest-policy.ts",
|
||||
"sha256": {
|
||||
"audit": "6d567b0c675091ec0a59d39ffe701fd36098ec2730b61ef0b80ab53962da6144",
|
||||
"main": "6d567b0c675091ec0a59d39ffe701fd36098ec2730b61ef0b80ab53962da6144",
|
||||
"v1.4.198": "6d567b0c675091ec0a59d39ffe701fd36098ec2730b61ef0b80ab53962da6144"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-manager-state.ts",
|
||||
"sha256": {
|
||||
"audit": "ea6e847afe227b9acbf432d68a6f21f0d15247748fc8b4f769ab73498d6a13c5",
|
||||
"main": "ea6e847afe227b9acbf432d68a6f21f0d15247748fc8b4f769ab73498d6a13c5",
|
||||
"v1.4.198": "ea6e847afe227b9acbf432d68a6f21f0d15247748fc8b4f769ab73498d6a13c5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-manager-types.ts",
|
||||
"sha256": {
|
||||
"audit": "4d28f7c397aa82b067971ff769a44fff74adadceaac66774345a615f799bb64f",
|
||||
"main": "4d28f7c397aa82b067971ff769a44fff74adadceaac66774345a615f799bb64f",
|
||||
"v1.4.198": "4d28f7c397aa82b067971ff769a44fff74adadceaac66774345a615f799bb64f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-manager-viewport-test-fixtures.ts",
|
||||
"sha256": {
|
||||
"audit": "0ef61054ae131db056b5268c6ed4f417b4486784d58d2c0d7d2285ad785666ee",
|
||||
"main": "0ef61054ae131db056b5268c6ed4f417b4486784d58d2c0d7d2285ad785666ee",
|
||||
"v1.4.198": "36d29f1d78bd559af3b235acc9be8dafe763e3dfb9ea4367272db04449eca707"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/browser/browser-manager-test-harness.ts",
|
||||
"sha256": {
|
||||
"audit": "66de235be9285ec2cd2a6d783c8e8a046d6106d4171aaf808b5d263c08a72038",
|
||||
"main": "66de235be9285ec2cd2a6d783c8e8a046d6106d4171aaf808b5d263c08a72038",
|
||||
"v1.4.198": "66de235be9285ec2cd2a6d783c8e8a046d6106d4171aaf808b5d263c08a72038"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/main/ipc/browser-guest-view-ipc.ts",
|
||||
"sha256": {
|
||||
"audit": "da4a441ce5f0851f1a1c6ec38c872c187ec191a9acad340f05ac557534731959",
|
||||
"main": "da4a441ce5f0851f1a1c6ec38c872c187ec191a9acad340f05ac557534731959",
|
||||
"v1.4.198": "da4a441ce5f0851f1a1c6ec38c872c187ec191a9acad340f05ac557534731959"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts",
|
||||
"sha256": {
|
||||
"audit": "a78cc98873e93834bf07b47bbf5d92da888dfbccef06551aa6ac3c8f6e9f29f8",
|
||||
"main": "2fe17f0fe4f8ef6ca7cff7ca5731d9d725dbf4b5e7944264e30f12241317fc6d",
|
||||
"v1.4.198": "6c089c0c8285b21b3f3c0b3e06bd297a25d941cfa8a4849d03fbd08a6c89c139"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/browser-pane/assemble-chrome/BrowserToolbarMenu.tsx",
|
||||
"sha256": {
|
||||
"audit": "e05e7d8d532d2f43f13a8dc1035540638ab513c9f3d6bdbd1531cf3e0e9462e1",
|
||||
"main": "e05e7d8d532d2f43f13a8dc1035540638ab513c9f3d6bdbd1531cf3e0e9462e1",
|
||||
"v1.4.198": "e05e7d8d532d2f43f13a8dc1035540638ab513c9f3d6bdbd1531cf3e0e9462e1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"scope": "Actual manager and lifecycle; controlled Electron/CDP ports; no native guest, heap/RSS or incident attribution",
|
||||
"baseline": {
|
||||
"total": 12,
|
||||
"failedOwnershipCases": 7,
|
||||
"passedControls": 5
|
||||
},
|
||||
"fixed": {
|
||||
"total": 42,
|
||||
"passed": 42,
|
||||
"testFiles": 4
|
||||
},
|
||||
"independentReview": {
|
||||
"candidateTestsPassed": 12,
|
||||
"findings": "No blocker; three map mutation guards preserve current guest and promise ownership"
|
||||
},
|
||||
"typecheck": {
|
||||
"node": "passed after correcting fixture-only protected-map reads and array typing",
|
||||
"cli": "passed",
|
||||
"web": "passed"
|
||||
},
|
||||
"quality": {
|
||||
"fullFileScans": 5,
|
||||
"codeFiles": 3,
|
||||
"newDiagnostics": 0
|
||||
},
|
||||
"sourceSha256": {
|
||||
"src/main/browser/browser-manager-viewport.ts": "a839fd89cc5e687782323036ca3a8dd9de79bd838e77dd863a5e1ae101424b4c",
|
||||
"src/main/browser/browser-manager-viewport-ownership.test.ts": "ec17f2acd6b766166d13cafc2ecd33f1d4f0de7a4b4e8896d0746b2d528341da"
|
||||
},
|
||||
"limitations": [
|
||||
"Pending CDP response schedules are injected, not an affected-host capture",
|
||||
"Retired map values are booleans; native objects and process RSS were not measured",
|
||||
"Historical viewport source is exact; surrounding dependencies execute current audit versions"
|
||||
]
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import base from '../../../config/vitest.config.ts'
|
||||
|
||||
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
|
||||
throw new Error('Set ORCA_BACKGROUND_LAUNCH=1 for the viewport ownership replay')
|
||||
}
|
||||
|
||||
const target = fileURLToPath(
|
||||
new URL('../../../src/main/browser/browser-manager-viewport.ts', import.meta.url)
|
||||
).replaceAll('\\', '/')
|
||||
|
||||
export default {
|
||||
...base,
|
||||
test: {
|
||||
...base.test,
|
||||
include: ['src/main/browser/browser-manager-viewport-ownership.test.ts']
|
||||
},
|
||||
plugins:
|
||||
process.env.ORCA_VIEWPORT_BASELINE === '1'
|
||||
? [
|
||||
{
|
||||
name: 'viewport-owner-baseline',
|
||||
enforce: 'pre',
|
||||
transform(_source, id) {
|
||||
return id.replaceAll('\\', '/').split('?')[0] === target
|
||||
? {
|
||||
code: readFileSync(new URL('./baseline-source.txt', import.meta.url), 'utf8'),
|
||||
map: null
|
||||
}
|
||||
: null
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
# CDP outbound retention reproduction
|
||||
|
||||
From the worktree root:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/cdp-stream-retention/reproduce.mjs
|
||||
```
|
||||
|
||||
This bundles the current response writer and connects real loopback WebSockets. The reader pauses while the producer sends at most 128 MiB. The script starts no Electron application or PTY and closes all sockets. `before.json` records the same experiment against the unbounded writer; `after.json` records the fixed writer.
|
||||
|
||||
Before the fix, 128 MiB of generated payload left 133,177,280 bytes in the WebSocket buffer, with roughly linear growth at every sample. The fixed writer uses the existing outbound queue: an 8 MiB socket soft threshold, 64 MiB held-queue cap and 4,096 queued-frame cap. The stalled connection terminates on overflow and releases its queue. The socket threshold can overshoot by one frame; a single large reply on a clear connection remains permitted, preserving large PDF/screenshot responses. This bounds accumulated backlog, not the allocation needed to construct an individual response or the aggregate across arbitrarily many browser pages.
|
||||
|
||||
Unit tests also cover a reader that drains a complete burst in order, request/session correlation, queue disposal on close/replacement, and a 65 MiB healthy reply. Debugger events and command responses share this writer.
|
||||
|
||||
This establishes an Electron-main retention mechanism when a CDP automation client stops reading. Neither #19831 nor #19768 establishes that precondition, so it is not a confirmed cause of either incident. RSS reflects GC/allocator timing; queued bytes are the direct measurement.
|
||||
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"node": "v26.6.0",
|
||||
"platform": "darwin",
|
||||
"bundleSha256": "60daf233a12cd9427de1783054e397c1968bb1d23fa9aad0408972337b6d087b",
|
||||
"samples": [
|
||||
{
|
||||
"producedMiB": 16,
|
||||
"bufferedBytes": 8389120,
|
||||
"readyState": 1,
|
||||
"rss": 92291072
|
||||
},
|
||||
{
|
||||
"producedMiB": 32,
|
||||
"bufferedBytes": 8389120,
|
||||
"readyState": 1,
|
||||
"rss": 111542272
|
||||
},
|
||||
{
|
||||
"producedMiB": 48,
|
||||
"bufferedBytes": 7340480,
|
||||
"readyState": 1,
|
||||
"rss": 143523840
|
||||
},
|
||||
{
|
||||
"producedMiB": 64,
|
||||
"bufferedBytes": 7340480,
|
||||
"readyState": 1,
|
||||
"rss": 160694272
|
||||
},
|
||||
{
|
||||
"producedMiB": 80,
|
||||
"bufferedBytes": 0,
|
||||
"readyState": 3,
|
||||
"rss": 172490752
|
||||
},
|
||||
{
|
||||
"producedMiB": 96,
|
||||
"bufferedBytes": 0,
|
||||
"readyState": 3,
|
||||
"rss": 172490752
|
||||
},
|
||||
{
|
||||
"producedMiB": 112,
|
||||
"bufferedBytes": 0,
|
||||
"readyState": 3,
|
||||
"rss": 172490752
|
||||
},
|
||||
{
|
||||
"producedMiB": 128,
|
||||
"bufferedBytes": 0,
|
||||
"readyState": 3,
|
||||
"rss": 172490752
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"node": "v26.6.0",
|
||||
"samples": [
|
||||
{
|
||||
"producedMiB": 16,
|
||||
"bufferedBytes": 16778240,
|
||||
"readyState": 1,
|
||||
"rss": 86884352
|
||||
},
|
||||
{
|
||||
"producedMiB": 32,
|
||||
"bufferedBytes": 32507840,
|
||||
"readyState": 1,
|
||||
"rss": 134807552
|
||||
},
|
||||
{
|
||||
"producedMiB": 48,
|
||||
"bufferedBytes": 49286080,
|
||||
"readyState": 1,
|
||||
"rss": 160497664
|
||||
},
|
||||
{
|
||||
"producedMiB": 64,
|
||||
"bufferedBytes": 66064320,
|
||||
"readyState": 1,
|
||||
"rss": 177618944
|
||||
},
|
||||
{
|
||||
"producedMiB": 80,
|
||||
"bufferedBytes": 82842560,
|
||||
"readyState": 1,
|
||||
"rss": 194740224
|
||||
},
|
||||
{
|
||||
"producedMiB": 96,
|
||||
"bufferedBytes": 99620800,
|
||||
"readyState": 1,
|
||||
"rss": 211877888
|
||||
},
|
||||
{
|
||||
"producedMiB": 112,
|
||||
"bufferedBytes": 116399040,
|
||||
"readyState": 1,
|
||||
"rss": 235634688
|
||||
},
|
||||
{
|
||||
"producedMiB": 128,
|
||||
"bufferedBytes": 133177280,
|
||||
"readyState": 1,
|
||||
"rss": 264519680
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { once } from 'node:events'
|
||||
import { setImmediate as nextTurn } from 'node:timers/promises'
|
||||
import { WebSocket, WebSocketServer } from 'ws'
|
||||
import { build } from 'esbuild'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { resolve } from 'node:path'
|
||||
const root = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const built = await build({
|
||||
entryPoints: [resolve(root, 'src/main/browser/cdp-client-response-writer.ts')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
write: false,
|
||||
banner: {
|
||||
js: `import { createRequire } from 'node:module'; const require = createRequire(${JSON.stringify(import.meta.url)});`
|
||||
}
|
||||
})
|
||||
const bundle = built.outputFiles[0].text
|
||||
const { CdpClientResponseWriter } = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(bundle).toString('base64')}`
|
||||
)
|
||||
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
|
||||
throw new Error('Background policy required')
|
||||
}
|
||||
const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 })
|
||||
await once(wss, 'listening')
|
||||
const accepted = once(wss, 'connection')
|
||||
const address = wss.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Expected TCP address')
|
||||
}
|
||||
const peer = new WebSocket(`ws://127.0.0.1:${address.port}`)
|
||||
await once(peer, 'open')
|
||||
const [socket] = await accepted
|
||||
const writer = new CdpClientResponseWriter(() => socket)
|
||||
peer.pause()
|
||||
const samples = []
|
||||
try {
|
||||
for (let index = 1; index <= 128; index++) {
|
||||
writer.send({ method: 'Network.dataReceived', params: { data: 'x'.repeat(1024 * 1024) } })
|
||||
if (index % 16 === 0) {
|
||||
await nextTurn()
|
||||
samples.push({
|
||||
producedMiB: index,
|
||||
bufferedBytes: socket.bufferedAmount,
|
||||
readyState: socket.readyState,
|
||||
rss: process.memoryUsage().rss
|
||||
})
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
node: process.version,
|
||||
platform: process.platform,
|
||||
bundleSha256: createHash('sha256').update(bundle).digest('hex'),
|
||||
samples
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
writer.forgetClient(socket)
|
||||
socket.terminate()
|
||||
peer.terminate()
|
||||
await new Promise((done) => wss.close(done))
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
# Claude transcript branch-proof allocation
|
||||
|
||||
Orca verifies the complete conversation ancestry before preserving or resuming a Claude session. This change reads the transcript incrementally instead of keeping the whole file and its split lines in memory. It still checks every record in the observed byte range, including old ancestors and disconnected conflicts. It does not just parse the first line.
|
||||
|
||||
## Reproduce
|
||||
|
||||
After the repository's normal dependency installation:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node --expose-gc docs/audits/claude-branch-proof-streaming/reproduce.cjs /tmp/claude-branch-streaming-results.json
|
||||
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/claude-branch-proof-streaming/regressions.mjs /tmp/claude-branch-streaming-regressions.json
|
||||
```
|
||||
|
||||
`diff` is a pinned direct development dependency. The harness reverses `fix.patch` against the checked-out reader in memory and verifies the original reader's SHA-256 before building the comparison. It does not modify production files. esbuild writes temporary modules, which are removed along with their require-cache entries on exit. No window or network access is needed.
|
||||
|
||||
The variants in the JSON are:
|
||||
|
||||
- `baseline`: the original full-file reader, reconstructed and hash-checked.
|
||||
- `candidate`: an open-ended streaming experiment retained as a control. It can admit newly appended records and is not the implementation being merged.
|
||||
- `windowCandidate`: the exact checked-out production reader, including its one internal refresh after descriptor growth.
|
||||
|
||||
`results.json` and `electron-results.json` were regenerated against production revision `2e623a227474101160de4c977bfae69da4b052ee`. They record the runtime, tool versions, harness inputs, actual bundled dependencies, production source variants, and bundle hashes. Allocation and duration values vary between runs. `provenance.json` now describes the shipped source; reproduction fails if its source hashes or quoted call-site lines drift.
|
||||
|
||||
The obsolete initial-window and open-ended result snapshots were removed because they described a different splitter and reader. Their experimental results remain available in git history. `baseline-initial-results.json` is explicitly historical: its original runtime and complete dependency graph were not captured, so it is not current reproducibility evidence.
|
||||
|
||||
## Production behavior
|
||||
|
||||
The reader opens the file once, stats that descriptor, and streams a finite range through the existing line splitter. Each message body can be released after parsing; the UUID ancestry graph remains until validation finishes. The existing 10,000-ancestor limit stays unchanged. No transcript quota, record-size cap, eviction, or tail-only proof is introduced.
|
||||
|
||||
A valid first range returns immediately, even if a newer marker appears later. When the first range is missing a marker/cursor or ends in an incomplete record, the reader checks the same descriptor's size. If the descriptor size increased, it retries the entire proof once over the enlarged finite range. Already-completed repairs therefore succeed without a caller retry. An unfinished repair can still require the existing caller retry. Conflicting ancestry, invalid sessions, malformed middle records, and append-order errors remain errors.
|
||||
|
||||
An increased size does not establish append-only writes or prove that the original prefix stayed unchanged. This is not an atomic filesystem snapshot: in-place overwrites and truncation can affect bytes inside the range, as with the old reader. Failed restat, shrink, and pathname replacement do not prove growth. The original opened descriptor is always closed before the production reader returns or throws.
|
||||
|
||||
## Reachability
|
||||
|
||||
`provenance.json` records exact source hashes and call sites for the current implementation and reported release v1.4.198:
|
||||
|
||||
- The local structured Claude runtime adapter resolves the account's transcript and delegates to the full branch proof through `session-file-resolver.ts`.
|
||||
- Closing a structured session reads the transcript after connection close, before persisting the handle. A proof failure preserves the previously observed leaf. Unexpected-exit persistence uses the same reader.
|
||||
- The resumed-PTY verifier calls the full reader and retries incomplete-tail errors on its existing 100 ms cadence, within a 15-second deadline. Static absence and invalid history remain fatal there.
|
||||
|
||||
These paths already used unrestricted full-file reads in v1.4.198. Rewind handling is newer than that release. The audit establishes source reachability and synthetic allocation reduction; it does not establish a user's transcript size, the number of concurrent readers, or the cause of a reported production OOM.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
Both recorded runtime audits pass 51 static real-file cases across all file/string APIs, 24 growth and cleanup controls, and 10 actual-verifier cases. Growth controls inject append, replace, delete, shrink, stat-error, and read-error events against real files. The three completed-repair verifier cases now succeed on the first caller attempt. Static absence, conflicts, session mismatches, and append-order failures retain their error behavior.
|
||||
|
||||
The harness checks the original `FileHandle` for closure. Historical readers without an exposed handle also check descriptor identity, avoiding false leak reports when the OS reuses a closed descriptor number. The fixture uses a private Node filesystem binding only for deterministic timing; production uses public APIs.
|
||||
|
||||
`regression-results.json` records 68 resolver, rewind, recovery, and history-window tests passing on each of the three variants (204 tests). The focused production run also passed all 26 streaming tests: 94 tests across the five relevant files in total. Tests were run on macOS arm64; this does not claim a Windows/Linux or live SSH integration run.
|
||||
|
||||
The 32 MiB allocation fixture uses 64 message bodies of 512 KiB with realistic identifiers. The current Electron audit measures about **34.6 MB** additional sampled live heap for the original reader and **1.1 MB** for the production reader. Both release almost all of that allocation after returning. Sampling forces GC at actual `JSON.parse` boundaries; it is not RSS, a natural peak, or evidence of a retained leak. GC noise can make retained deltas negative.
|
||||
|
||||
A single 8 MiB record still needs about **16.8 MB** under every variant. The identifier graph still grows with record count. This removes whole-transcript payload retention; it is not a total memory cap.
|
||||
|
||||
The separate duration control reads one stable 2.79 MB file with 8,193 small records, using two warm rounds and five measured rounds in alternating order without forced GC during timing. Electron medians were **4.9 ms before** and **7.37 ms after**. These are synthetic per-run observations, not a throughput guarantee or a timing gate.
|
||||
|
||||
`electron-results.json` uses installed Electron 43.7.0 / Node 24.21.0 in Node-only mode, with no app/window launch:
|
||||
|
||||
```sh
|
||||
ELECTRON_RUN_AS_NODE=1 ORCA_BACKGROUND_LAUNCH=1 node_modules/electron/dist/Electron.app/Contents/MacOS/Electron --expose-gc docs/audits/claude-branch-proof-streaming/reproduce.cjs /tmp/claude-branch-streaming-electron.json
|
||||
```
|
||||
|
||||
Other platforms use their installed Electron executable with the same environment and arguments. This is compatibility evidence for the installed runtime, not the historical Electron binary.
|
||||
@@ -1,106 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const sessionId = '11111111-1111-4111-8111-111111111111'
|
||||
const nodeId = (index) => `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`
|
||||
|
||||
function collect() {
|
||||
for (let index = 0; index < 5; index++) {
|
||||
global.gc()
|
||||
}
|
||||
return process.memoryUsage()
|
||||
}
|
||||
|
||||
function writeFixture(file, count, payloadBytes) {
|
||||
const descriptor = fs.openSync(file, 'w')
|
||||
try {
|
||||
for (let index = 0; index < count; index++) {
|
||||
fs.writeSync(
|
||||
descriptor,
|
||||
`${JSON.stringify({
|
||||
type: 'user',
|
||||
uuid: nodeId(index),
|
||||
parentUuid: index === 0 ? null : nodeId(index - 1),
|
||||
sessionId,
|
||||
message: { role: 'user', content: 'x'.repeat(payloadBytes) }
|
||||
})}\n`
|
||||
)
|
||||
}
|
||||
fs.writeSync(
|
||||
descriptor,
|
||||
`${JSON.stringify({ type: 'last-prompt', sessionId, leafUuid: nodeId(count - 1) })}\n`
|
||||
)
|
||||
} finally {
|
||||
fs.closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
async function sample(prove, file, count) {
|
||||
const before = collect()
|
||||
let peakHeap = before.heapUsed
|
||||
let peakExternal = before.external
|
||||
let records = 0
|
||||
const originalParse = JSON.parse
|
||||
JSON.parse = function (...args) {
|
||||
const row = originalParse(...args)
|
||||
records++
|
||||
const memory = collect()
|
||||
peakHeap = Math.max(peakHeap, memory.heapUsed)
|
||||
peakExternal = Math.max(peakExternal, memory.external)
|
||||
return row
|
||||
}
|
||||
let result
|
||||
try {
|
||||
result = await prove({
|
||||
transcriptPath: file,
|
||||
providerSessionId: sessionId,
|
||||
previousLeafUuid: nodeId(0)
|
||||
})
|
||||
} finally {
|
||||
JSON.parse = originalParse
|
||||
}
|
||||
assert.deepEqual(result, {
|
||||
leafUuid: nodeId(count - 1),
|
||||
relation: count === 1 ? 'same' : 'descendant'
|
||||
})
|
||||
const after = collect()
|
||||
return {
|
||||
records,
|
||||
peakHeapIncrease: peakHeap - before.heapUsed,
|
||||
peakExternalIncrease: peakExternal - before.external,
|
||||
retainedHeapIncrease: after.heapUsed - before.heapUsed,
|
||||
retainedExternalIncrease: after.external - before.external
|
||||
}
|
||||
}
|
||||
|
||||
async function compareAllocation(scratch, modules) {
|
||||
const reports = []
|
||||
for (const [count, payloadBytes] of [
|
||||
[16, 512 * 1024],
|
||||
[64, 512 * 1024],
|
||||
[64, 0],
|
||||
[1, 8 * 1024 * 1024]
|
||||
]) {
|
||||
const file = path.join(scratch, `allocation-${count}-${payloadBytes}.jsonl`)
|
||||
writeFixture(file, count, payloadBytes)
|
||||
const row = { count, payloadBytes, sourceBytes: fs.statSync(file).size }
|
||||
for (const [phase, methods] of Object.entries(modules)) {
|
||||
row[phase] = await sample(methods.proveClaudeTranscriptBranch, file, count)
|
||||
}
|
||||
if (count === 64 && payloadBytes > 0) {
|
||||
assert(
|
||||
row.candidate.peakHeapIncrease < row.baseline.peakHeapIncrease / 2,
|
||||
'Streaming must release prior payload rows'
|
||||
)
|
||||
assert(
|
||||
row.windowCandidate.peakHeapIncrease < row.baseline.peakHeapIncrease / 2,
|
||||
'Initial-window streaming must release prior payload rows'
|
||||
)
|
||||
}
|
||||
reports.push(row)
|
||||
fs.unlinkSync(file)
|
||||
}
|
||||
return reports
|
||||
}
|
||||
|
||||
module.exports = { compareAllocation }
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"status": "Historical initial observation only; use results.json for current reproducible measurements. The original runtime and full dependency hashes were not captured.",
|
||||
"sourceSha256": "3224594befe1edae514724caf5eff6a4cda85212e2985be4920b6164b088b82c",
|
||||
"bundleSha256": "e2d5ee6e5a7e2a15be217eab8066f9596cc9279f1e81709c1d228a575b40acc5",
|
||||
"measurement": "forced GC at each actual JSON.parse boundary; transient live bytes, not process RSS or leak",
|
||||
"rows": [
|
||||
{
|
||||
"records": 17,
|
||||
"sourceBytes": 8390708,
|
||||
"peakHeapIncrease": 9554312,
|
||||
"peakExternalIncrease": 39165,
|
||||
"retainedHeapIncrease": 155896,
|
||||
"retainedExternalIncrease": 69573,
|
||||
"elapsedMs": 76
|
||||
},
|
||||
{
|
||||
"records": 65,
|
||||
"sourceBytes": 33562676,
|
||||
"peakHeapIncrease": 34626848,
|
||||
"peakExternalIncrease": 0,
|
||||
"retainedHeapIncrease": 2416,
|
||||
"retainedExternalIncrease": 0,
|
||||
"elapsedMs": 284
|
||||
},
|
||||
{
|
||||
"records": 65,
|
||||
"sourceBytes": 8244,
|
||||
"peakHeapIncrease": 27160,
|
||||
"peakExternalIncrease": 0,
|
||||
"retainedHeapIncrease": 4752,
|
||||
"retainedExternalIncrease": 0,
|
||||
"elapsedMs": 269
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,65 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
async function compareDuration(scratch, modules) {
|
||||
const count = 8192
|
||||
const sessionId = '11111111-1111-4111-8111-111111111111'
|
||||
const id = (index) => `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`
|
||||
const file = path.join(scratch, 'small-record-duration.jsonl')
|
||||
const fd = fs.openSync(file, 'w')
|
||||
try {
|
||||
for (let index = 0; index < count; index++) {
|
||||
fs.writeSync(
|
||||
fd,
|
||||
`${JSON.stringify({
|
||||
type: index % 2 === 0 ? 'user' : 'assistant',
|
||||
uuid: id(index),
|
||||
parentUuid: index === 0 ? null : id(index - 1),
|
||||
sessionId,
|
||||
message: {
|
||||
role: index % 2 === 0 ? 'user' : 'assistant',
|
||||
content: 'Synthetic short transcript body. '.repeat(4)
|
||||
}
|
||||
})}\n`
|
||||
)
|
||||
}
|
||||
fs.writeSync(
|
||||
fd,
|
||||
`${JSON.stringify({ type: 'last-prompt', leafUuid: id(count - 1), sessionId })}\n`
|
||||
)
|
||||
} finally {
|
||||
fs.closeSync(fd)
|
||||
}
|
||||
const durations = { baseline: [], windowCandidate: [] }
|
||||
const input = { transcriptPath: file, providerSessionId: sessionId, previousLeafUuid: id(0) }
|
||||
for (let round = 0; round < 7; round++) {
|
||||
const phases =
|
||||
round % 2 === 0 ? ['baseline', 'windowCandidate'] : ['windowCandidate', 'baseline']
|
||||
for (const phase of phases) {
|
||||
const start = performance.now()
|
||||
const result = await modules[phase].proveClaudeTranscriptBranch(input)
|
||||
const elapsed = performance.now() - start
|
||||
assert.deepEqual(result, { leafUuid: id(count - 1), relation: 'descendant' })
|
||||
if (round >= 2) {
|
||||
durations[phase].push(Math.round(elapsed * 100) / 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
const medians = Object.fromEntries(
|
||||
Object.entries(durations).map(([phase, values]) => [phase, values.toSorted((a, b) => a - b)[2]])
|
||||
)
|
||||
const report = {
|
||||
node: process.version,
|
||||
records: count + 1,
|
||||
sourceBytes: fs.statSync(file).size,
|
||||
method:
|
||||
'One stable real file, two warm rounds and five measured rounds, alternating phase order; no GC forced during timing. Whole proof, not an isolated parser benchmark.',
|
||||
durationsMs: durations,
|
||||
medianMs: medians
|
||||
}
|
||||
fs.unlinkSync(file)
|
||||
return report
|
||||
}
|
||||
|
||||
module.exports = { compareDuration }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,250 +0,0 @@
|
||||
===================================================================
|
||||
--- a/src/main/claude/claude-transcript-branch-proof.ts
|
||||
+++ b/src/main/claude/claude-transcript-branch-proof.ts
|
||||
@@ -1,1 +1,2 @@
|
||||
-import { readFile } from 'node:fs/promises'
|
||||
+import { open } from 'node:fs/promises'
|
||||
+import { splitTranscriptStreamLines } from '../native-chat/transcript-stream-lines'
|
||||
@@ -4,0 +6,6 @@
|
||||
+class ClaudeTranscriptMarkerMissingError extends Error {
|
||||
+ constructor() {
|
||||
+ super('Claude transcript branch proof failed: missing last-prompt marker')
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
@@ -82,2 +89,1 @@
|
||||
-export function proveClaudeTranscriptBranchFromJsonl(input: {
|
||||
- contents: string
|
||||
+type BranchProofInput = {
|
||||
@@ -87,1 +93,3 @@
|
||||
-}): ClaudeTranscriptBranchProof {
|
||||
+}
|
||||
+
|
||||
+function createBranchProof(input: BranchProofInput) {
|
||||
@@ -91,2 +99,3 @@
|
||||
- const lines = input.contents.split('\n')
|
||||
- for (const [index, line] of lines.entries()) {
|
||||
+ return { add, finish }
|
||||
+
|
||||
+ function add(line: string, index: number, terminated: boolean): void {
|
||||
@@ -94,1 +103,1 @@
|
||||
- continue
|
||||
+ return
|
||||
@@ -100,1 +109,1 @@
|
||||
- if (index === lines.length - 1 && !input.contents.endsWith('\n')) {
|
||||
+ if (!terminated) {
|
||||
@@ -107,0 +117,1 @@
|
||||
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The parsed value is a non-array object checked above.
|
||||
@@ -120,1 +130,1 @@
|
||||
- continue
|
||||
+ return
|
||||
@@ -149,14 +159,4 @@
|
||||
- if (!leafUuid) {
|
||||
- throw transcriptError('missing last-prompt marker')
|
||||
- }
|
||||
- const leaf = nodes.get(leafUuid)
|
||||
- if (!leaf || leaf.sessionId !== input.providerSessionId || leaf.disallowedLeaf) {
|
||||
- throw transcriptError('marker leaf is missing from the session graph')
|
||||
- }
|
||||
- if (leaf.lineIndex > leafMarkerLineIndex) {
|
||||
- throw transcriptError('marker precedes its leaf record')
|
||||
- }
|
||||
- const previousLeafUuid = input.previousLeafUuid
|
||||
- if (input.intentionalRewindUuid !== undefined) {
|
||||
- if (leafUuid !== input.intentionalRewindUuid || !input.previousLeafUuid) {
|
||||
- throw transcriptError('rewind target does not match the observed leaf')
|
||||
+
|
||||
+ function finish(): ClaudeTranscriptBranchProof {
|
||||
+ if (!leafUuid) {
|
||||
+ throw new ClaudeTranscriptMarkerMissingError()
|
||||
@@ -164,6 +164,11 @@
|
||||
- proveMainLineAncestry(nodes, input.previousLeafUuid, input.providerSessionId)
|
||||
- proveAppendOrder(nodes)
|
||||
- let ancestor = nodes.get(input.previousLeafUuid)?.parentUuid ?? null
|
||||
- for (let depth = 0; ancestor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) {
|
||||
- if (ancestor === leafUuid) {
|
||||
- return { leafUuid, relation: 'intentional-rewind' }
|
||||
+ const leaf = nodes.get(leafUuid)
|
||||
+ if (!leaf || leaf.sessionId !== input.providerSessionId || leaf.disallowedLeaf) {
|
||||
+ throw transcriptError('marker leaf is missing from the session graph')
|
||||
+ }
|
||||
+ if (leaf.lineIndex > leafMarkerLineIndex) {
|
||||
+ throw transcriptError('marker precedes its leaf record')
|
||||
+ }
|
||||
+ const previousLeafUuid = input.previousLeafUuid
|
||||
+ if (input.intentionalRewindUuid !== undefined) {
|
||||
+ if (leafUuid !== input.intentionalRewindUuid || !input.previousLeafUuid) {
|
||||
+ throw transcriptError('rewind target does not match the observed leaf')
|
||||
@@ -171,1 +176,10 @@
|
||||
- ancestor = nodes.get(ancestor)?.parentUuid ?? null
|
||||
+ proveMainLineAncestry(nodes, input.previousLeafUuid, input.providerSessionId)
|
||||
+ proveAppendOrder(nodes)
|
||||
+ let ancestor = nodes.get(input.previousLeafUuid)?.parentUuid ?? null
|
||||
+ for (let depth = 0; ancestor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) {
|
||||
+ if (ancestor === leafUuid) {
|
||||
+ return { leafUuid, relation: 'intentional-rewind' }
|
||||
+ }
|
||||
+ ancestor = nodes.get(ancestor)?.parentUuid ?? null
|
||||
+ }
|
||||
+ throw transcriptError('rewind target is not an ancestor of the previous cursor')
|
||||
@@ -173,31 +187,7 @@
|
||||
- throw transcriptError('rewind target is not an ancestor of the previous cursor')
|
||||
- }
|
||||
- if (!previousLeafUuid) {
|
||||
- proveMainLineAncestry(nodes, leafUuid, input.providerSessionId)
|
||||
- // A branch proof is based on an append-only snapshot. A child that appears
|
||||
- // before its claimed parent is not a post-snapshot descendant observation;
|
||||
- // accepting that graph would turn reordered/torn rows into durable ancestry.
|
||||
- proveAppendOrder(nodes)
|
||||
- return { leafUuid, relation: 'initial' }
|
||||
- }
|
||||
- const previous = nodes.get(previousLeafUuid)
|
||||
- if (!previous) {
|
||||
- throw new ClaudeTranscriptPreviousCursorMissingError()
|
||||
- }
|
||||
- if (previous.sessionId !== input.providerSessionId || previous.disallowedLeaf) {
|
||||
- throw transcriptError('previous cursor is not on the main transcript')
|
||||
- }
|
||||
- // The latest marker can be equal to, or descend from, a sampled cursor. In
|
||||
- // either case prove the sampled cursor's own ancestry before accepting it;
|
||||
- // otherwise a cursor that descended through a parent-tool-use sidechain
|
||||
- // could be persisted and resumed as if it were on the main transcript.
|
||||
- proveMainLineAncestry(nodes, previousLeafUuid, input.providerSessionId)
|
||||
- if (leafUuid === previousLeafUuid) {
|
||||
- proveAppendOrder(nodes)
|
||||
- return { leafUuid, relation: 'same' }
|
||||
- }
|
||||
- const visited = new Set<string>()
|
||||
- let cursor: string | null = leafUuid
|
||||
- for (let depth = 0; cursor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) {
|
||||
- if (visited.has(cursor)) {
|
||||
- throw transcriptError('cycle in parentUuid ancestry')
|
||||
+ if (!previousLeafUuid) {
|
||||
+ proveMainLineAncestry(nodes, leafUuid, input.providerSessionId)
|
||||
+ // A branch proof is based on an append-only snapshot. A child that appears
|
||||
+ // before its claimed parent is not a post-snapshot descendant observation;
|
||||
+ // accepting that graph would turn reordered/torn rows into durable ancestry.
|
||||
+ proveAppendOrder(nodes)
|
||||
+ return { leafUuid, relation: 'initial' }
|
||||
@@ -205,4 +195,3 @@
|
||||
- visited.add(cursor)
|
||||
- const node = nodes.get(cursor)
|
||||
- if (!node || node.sessionId !== input.providerSessionId) {
|
||||
- throw transcriptError(`missing ancestor ${cursor}`)
|
||||
+ const previous = nodes.get(previousLeafUuid)
|
||||
+ if (!previous) {
|
||||
+ throw new ClaudeTranscriptPreviousCursorMissingError()
|
||||
@@ -210,2 +199,2 @@
|
||||
- if (node.disallowedLeaf) {
|
||||
- throw transcriptError(`ancestor ${cursor} is not on the main transcript`)
|
||||
+ if (previous.sessionId !== input.providerSessionId || previous.disallowedLeaf) {
|
||||
+ throw transcriptError('previous cursor is not on the main transcript')
|
||||
@@ -213,2 +202,6 @@
|
||||
- cursor = node.parentUuid
|
||||
- if (cursor === previousLeafUuid) {
|
||||
+ // The latest marker can be equal to, or descend from, a sampled cursor. In
|
||||
+ // either case prove the sampled cursor's own ancestry before accepting it;
|
||||
+ // otherwise a cursor that descended through a parent-tool-use sidechain
|
||||
+ // could be persisted and resumed as if it were on the main transcript.
|
||||
+ proveMainLineAncestry(nodes, previousLeafUuid, input.providerSessionId)
|
||||
+ if (leafUuid === previousLeafUuid) {
|
||||
@@ -216,1 +209,1 @@
|
||||
- return { leafUuid, relation: 'descendant' }
|
||||
+ return { leafUuid, relation: 'same' }
|
||||
@@ -217,0 +211,24 @@
|
||||
+ const visited = new Set<string>()
|
||||
+ let cursor: string | null = leafUuid
|
||||
+ for (let depth = 0; cursor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) {
|
||||
+ if (visited.has(cursor)) {
|
||||
+ throw transcriptError('cycle in parentUuid ancestry')
|
||||
+ }
|
||||
+ visited.add(cursor)
|
||||
+ const node = nodes.get(cursor)
|
||||
+ if (!node || node.sessionId !== input.providerSessionId) {
|
||||
+ throw transcriptError(`missing ancestor ${cursor}`)
|
||||
+ }
|
||||
+ if (node.disallowedLeaf) {
|
||||
+ throw transcriptError(`ancestor ${cursor} is not on the main transcript`)
|
||||
+ }
|
||||
+ cursor = node.parentUuid
|
||||
+ if (cursor === previousLeafUuid) {
|
||||
+ proveAppendOrder(nodes)
|
||||
+ return { leafUuid, relation: 'descendant' }
|
||||
+ }
|
||||
+ }
|
||||
+ if (cursor !== null) {
|
||||
+ throw transcriptError('ancestry exceeds the bounded proof limit')
|
||||
+ }
|
||||
+ throw transcriptError('latest marker is on a sibling branch')
|
||||
@@ -219,2 +236,9 @@
|
||||
- if (cursor !== null) {
|
||||
- throw transcriptError('ancestry exceeds the bounded proof limit')
|
||||
+}
|
||||
+
|
||||
+export function proveClaudeTranscriptBranchFromJsonl(
|
||||
+ input: BranchProofInput & { contents: string }
|
||||
+): ClaudeTranscriptBranchProof {
|
||||
+ const proof = createBranchProof(input)
|
||||
+ const lines = input.contents.split('\n')
|
||||
+ for (const [index, line] of lines.entries()) {
|
||||
+ proof.add(line, index, index < lines.length - 1)
|
||||
@@ -222,1 +246,1 @@
|
||||
- throw transcriptError('latest marker is on a sibling branch')
|
||||
+ return proof.finish()
|
||||
@@ -225,12 +249,44 @@
|
||||
-export async function proveClaudeTranscriptBranch(input: {
|
||||
- transcriptPath: string
|
||||
- providerSessionId: string
|
||||
- previousLeafUuid: string | null
|
||||
- intentionalRewindUuid?: string
|
||||
-}): Promise<ClaudeTranscriptBranchProof> {
|
||||
- return proveClaudeTranscriptBranchFromJsonl({
|
||||
- contents: await readFile(input.transcriptPath, 'utf8'),
|
||||
- providerSessionId: input.providerSessionId,
|
||||
- previousLeafUuid: input.previousLeafUuid,
|
||||
- intentionalRewindUuid: input.intentionalRewindUuid
|
||||
- })
|
||||
+export async function proveClaudeTranscriptBranch(
|
||||
+ input: BranchProofInput & { transcriptPath: string }
|
||||
+): Promise<ClaudeTranscriptBranchProof> {
|
||||
+ const handle = await open(input.transcriptPath, 'r')
|
||||
+ try {
|
||||
+ let size = (await handle.stat()).size
|
||||
+ let refreshed = false
|
||||
+ while (true) {
|
||||
+ const proof = createBranchProof(input)
|
||||
+ let index = 0
|
||||
+ try {
|
||||
+ if (size > 0) {
|
||||
+ const stream = handle.createReadStream({ start: 0, end: size - 1, autoClose: false })
|
||||
+ for await (const record of splitTranscriptStreamLines(stream)) {
|
||||
+ proof.add(record.line, index++, record.terminated)
|
||||
+ }
|
||||
+ }
|
||||
+ return proof.finish()
|
||||
+ } catch (error) {
|
||||
+ if (
|
||||
+ !(error instanceof ClaudeTranscriptMarkerMissingError) &&
|
||||
+ !(error instanceof ClaudeTranscriptPreviousCursorMissingError) &&
|
||||
+ !(error instanceof ClaudeTranscriptTailIncompleteError)
|
||||
+ ) {
|
||||
+ throw error
|
||||
+ }
|
||||
+ if (refreshed) {
|
||||
+ throw new ClaudeTranscriptTailIncompleteError()
|
||||
+ }
|
||||
+ const nextSize = await handle.stat().then(
|
||||
+ (current) => current.size,
|
||||
+ () => size
|
||||
+ )
|
||||
+ if (nextSize <= size) {
|
||||
+ throw error
|
||||
+ }
|
||||
+ // Finish an already-appended repair without making the caller retry.
|
||||
+ size = nextSize
|
||||
+ refreshed = true
|
||||
+ }
|
||||
+ }
|
||||
+ } finally {
|
||||
+ await handle.close()
|
||||
+ }
|
||||
@@ -1,293 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const fsPromises = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const { sessionId } = require('./parity-cases.cjs')
|
||||
|
||||
const rootRow = `${JSON.stringify({ type: 'user', uuid: 'root', parentUuid: null, sessionId })}\n`
|
||||
const rootMarker = `${JSON.stringify({ type: 'last-prompt', leafUuid: 'root', sessionId })}\n`
|
||||
const append = `${JSON.stringify({
|
||||
type: 'user',
|
||||
uuid: 'child',
|
||||
parentUuid: 'root',
|
||||
sessionId
|
||||
})}\n${JSON.stringify({ type: 'last-prompt', leafUuid: 'child', sessionId })}\n`
|
||||
const makeRow = (uuid, parentUuid) =>
|
||||
`${JSON.stringify({ type: 'user', uuid, parentUuid, sessionId })}\n`
|
||||
const makeMarker = (leafUuid) => `${JSON.stringify({ type: 'last-prompt', leafUuid, sessionId })}\n`
|
||||
function paddedSource(bytes) {
|
||||
const emptyComment = '{"comment":""}\n'
|
||||
return `${rootRow}{"comment":"${'x'.repeat(
|
||||
bytes - Buffer.byteLength(rootRow + rootMarker + emptyComment)
|
||||
)}"}\n${rootMarker}`
|
||||
}
|
||||
|
||||
async function compareGrowth(scratch, modules) {
|
||||
const scenarios = [
|
||||
{ name: 'small-regular-file', contents: rootRow + rootMarker, append },
|
||||
{ name: 'exact-512k-regular-file', contents: paddedSource(512 * 1024), append },
|
||||
{ name: 'nonaligned-large-regular-file', contents: paddedSource(512 * 1024 + 100), append },
|
||||
{ name: 'initially-empty-file', contents: '', append: rootRow + rootMarker },
|
||||
{ name: 'new-malformed-tail', contents: rootRow + rootMarker, append: '{"incomplete":' },
|
||||
{ name: 'parse-error-closes-reader', contents: `{"broken":\n${rootRow}${rootMarker}` },
|
||||
{ name: 'empty-file-closes-reader', contents: '' },
|
||||
{ name: 'read-error-closes-reader', contents: rootRow + rootMarker, readError: true },
|
||||
{ name: 'stat-error-closes-reader', contents: rootRow + rootMarker, statError: true },
|
||||
{
|
||||
name: 'restat-failure-keeps-missing-marker',
|
||||
contents: rootRow,
|
||||
append: rootMarker,
|
||||
restatError: true
|
||||
},
|
||||
{ name: 'replacement-cannot-prove-original-growth', contents: rootRow, replace: true },
|
||||
{ name: 'shrink-cannot-prove-growth', contents: `${rootRow} \n`, truncate: 0 },
|
||||
{
|
||||
name: 'growth-preserves-conflict',
|
||||
contents: rootRow + makeRow('root', 'other') + rootMarker,
|
||||
append
|
||||
},
|
||||
{
|
||||
name: 'growth-preserves-wrong-session',
|
||||
contents: `${rootRow + JSON.stringify({ type: 'last-prompt', sessionId: 'foreign', leafUuid: 'root' })}\n`,
|
||||
append
|
||||
},
|
||||
{
|
||||
name: 'growth-preserves-append-order',
|
||||
contents: makeRow('child', 'root') + rootRow + makeMarker('child'),
|
||||
append
|
||||
},
|
||||
{ name: 'path-replacement-keeps-opened-file', contents: rootRow + rootMarker, replace: true },
|
||||
{ name: 'path-deletion-keeps-opened-file', contents: rootRow + rootMarker, delete: true },
|
||||
{
|
||||
name: 'truncation-leaves-complete-prefix',
|
||||
contents: rootRow + rootMarker + append,
|
||||
truncate: Buffer.byteLength(rootRow + rootMarker)
|
||||
},
|
||||
{
|
||||
name: 'truncation-leaves-torn-tail',
|
||||
contents: paddedSource(1024),
|
||||
truncate: Buffer.byteLength(rootRow) + 8
|
||||
},
|
||||
{
|
||||
name: 'prefix-descendant-under-growth',
|
||||
contents: rootRow + append,
|
||||
append: makeRow('grandchild', 'child') + makeMarker('grandchild'),
|
||||
options: { previousLeafUuid: 'root' }
|
||||
},
|
||||
{
|
||||
name: 'prefix-rewind-under-growth',
|
||||
contents: rootRow + makeRow('kept', 'root') + makeRow('old', 'kept') + makeMarker('kept'),
|
||||
append: makeMarker('old'),
|
||||
options: { previousLeafUuid: 'old', intentionalRewindUuid: 'kept' }
|
||||
},
|
||||
{
|
||||
name: 'missing-previous-repaired-by-growth',
|
||||
contents: rootRow + rootMarker,
|
||||
append,
|
||||
options: { previousLeafUuid: 'child' }
|
||||
},
|
||||
{ name: 'missing-marker-repaired-by-growth', contents: rootRow, append: rootMarker },
|
||||
{
|
||||
name: 'torn-prefix-repaired-by-growth',
|
||||
contents: rootRow + rootMarker.slice(0, -2),
|
||||
append: '}\n'
|
||||
}
|
||||
]
|
||||
const reports = []
|
||||
for (const scenario of scenarios) {
|
||||
const phases = {}
|
||||
for (const [phase, methods] of Object.entries(modules)) {
|
||||
const file = path.join(scratch, `growth-${phase}.jsonl`)
|
||||
fs.writeFileSync(file, scenario.contents)
|
||||
const binding = process.binding('fs')
|
||||
const originalOpen = fsPromises.open
|
||||
let openedHandle
|
||||
fsPromises.open = async function (...args) {
|
||||
const handle = await originalOpen.apply(this, args)
|
||||
if (args[0] === file) {
|
||||
openedHandle = handle
|
||||
}
|
||||
return handle
|
||||
}
|
||||
const originalRead = binding.read
|
||||
const originalFstat = binding.fstat
|
||||
const originalCreateReadStream = fs.createReadStream
|
||||
let streamClosed = Promise.resolve()
|
||||
fs.createReadStream = function (...args) {
|
||||
const stream = originalCreateReadStream.apply(this, args)
|
||||
streamClosed = new Promise((resolve) => stream.once('close', resolve))
|
||||
return stream
|
||||
}
|
||||
let descriptor
|
||||
let descriptorIdentity
|
||||
let injected = false
|
||||
let statsRead = 0
|
||||
function inject(fd) {
|
||||
if (!injected) {
|
||||
injected = true
|
||||
descriptor = fd
|
||||
descriptorIdentity = fs.fstatSync(fd)
|
||||
if (scenario.append) {
|
||||
fs.appendFileSync(file, scenario.append)
|
||||
}
|
||||
if (scenario.replace) {
|
||||
fs.renameSync(file, `${file}.opened`)
|
||||
fs.writeFileSync(file, rootRow + append)
|
||||
}
|
||||
if (scenario.delete) {
|
||||
fs.unlinkSync(file)
|
||||
}
|
||||
if (scenario.truncate !== undefined) {
|
||||
fs.truncateSync(file, scenario.truncate)
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.fstat = function (...args) {
|
||||
const result = originalFstat.apply(this, args)
|
||||
if (typeof result?.then !== 'function') {
|
||||
return result
|
||||
}
|
||||
return result.then((stats) => {
|
||||
inject(args[0])
|
||||
statsRead++
|
||||
if (scenario.statError || (scenario.restatError && statsRead > 1)) {
|
||||
throw Object.assign(new Error('Synthetic stat failure after actual open'), {
|
||||
code: 'EIO'
|
||||
})
|
||||
}
|
||||
return stats
|
||||
})
|
||||
}
|
||||
binding.read = function (...args) {
|
||||
inject(args[0])
|
||||
if (scenario.readError) {
|
||||
const error = Object.assign(new Error('Synthetic read failure after actual open'), {
|
||||
code: 'EIO'
|
||||
})
|
||||
const request = args.at(-1)
|
||||
if (typeof request?.oncomplete === 'function') {
|
||||
queueMicrotask(() => request.oncomplete(error))
|
||||
return
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
return originalRead.apply(this, args)
|
||||
}
|
||||
let outcome
|
||||
try {
|
||||
outcome = {
|
||||
status: 'fulfilled',
|
||||
value: await methods.proveClaudeTranscriptBranch({
|
||||
transcriptPath: file,
|
||||
providerSessionId: sessionId,
|
||||
previousLeafUuid: null,
|
||||
...scenario.options
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
outcome = {
|
||||
status: 'rejected',
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
...(error.code ? { code: error.code } : {})
|
||||
}
|
||||
} finally {
|
||||
fsPromises.open = originalOpen
|
||||
binding.read = originalRead
|
||||
binding.fstat = originalFstat
|
||||
fs.createReadStream = originalCreateReadStream
|
||||
}
|
||||
function isOriginalDescriptorClosed() {
|
||||
if (openedHandle) {
|
||||
return openedHandle.fd === -1
|
||||
}
|
||||
try {
|
||||
const current = fs.fstatSync(descriptor)
|
||||
return current.dev !== descriptorIdentity.dev || current.ino !== descriptorIdentity.ino
|
||||
} catch (error) {
|
||||
return error.code === 'EBADF'
|
||||
}
|
||||
}
|
||||
const descriptorClosedAtReturn = isOriginalDescriptorClosed()
|
||||
await streamClosed
|
||||
const descriptorClosed = isOriginalDescriptorClosed()
|
||||
assert(injected, 'Actual file read must reach deterministic injection point')
|
||||
assert(descriptorClosed, `${scenario.name}/${phase} leaked its actual file descriptor`)
|
||||
if (phase === 'windowCandidate') {
|
||||
assert(descriptorClosedAtReturn)
|
||||
}
|
||||
phases[phase] = { outcome, descriptorClosedAtReturn, descriptorClosed }
|
||||
if (!scenario.delete) {
|
||||
fs.unlinkSync(file)
|
||||
}
|
||||
if (scenario.replace) {
|
||||
fs.unlinkSync(`${file}.opened`)
|
||||
}
|
||||
}
|
||||
if (scenario.name === 'small-regular-file' || scenario.name === 'exact-512k-regular-file') {
|
||||
assert.equal(phases.baseline.outcome.value.leafUuid, 'root')
|
||||
assert.equal(phases.candidate.outcome.value.leafUuid, 'child')
|
||||
} else if (scenario.name === 'new-malformed-tail') {
|
||||
assert.equal(phases.baseline.outcome.status, 'fulfilled')
|
||||
assert.equal(phases.candidate.outcome.name, 'ClaudeTranscriptTailIncompleteError')
|
||||
} else if (
|
||||
!scenario.options &&
|
||||
!scenario.name.includes('repaired-by-growth') &&
|
||||
!scenario.statError &&
|
||||
!scenario.restatError
|
||||
) {
|
||||
assert.deepEqual(phases.baseline.outcome, phases.candidate.outcome, scenario.name)
|
||||
}
|
||||
if (
|
||||
[
|
||||
'initially-empty-file',
|
||||
'missing-previous-repaired-by-growth',
|
||||
'missing-marker-repaired-by-growth',
|
||||
'torn-prefix-repaired-by-growth'
|
||||
].includes(scenario.name)
|
||||
) {
|
||||
assert.equal(phases.windowCandidate.outcome.status, 'fulfilled', scenario.name)
|
||||
assert.equal(
|
||||
phases.windowCandidate.outcome.value.leafUuid,
|
||||
scenario.name === 'missing-previous-repaired-by-growth' ? 'child' : 'root'
|
||||
)
|
||||
} else if (
|
||||
[
|
||||
'small-regular-file',
|
||||
'exact-512k-regular-file',
|
||||
'nonaligned-large-regular-file',
|
||||
'new-malformed-tail'
|
||||
].includes(scenario.name)
|
||||
) {
|
||||
assert.equal(phases.windowCandidate.outcome.value.leafUuid, 'root')
|
||||
} else {
|
||||
assert.deepEqual(phases.windowCandidate.outcome, phases.baseline.outcome)
|
||||
}
|
||||
if (scenario.name === 'prefix-descendant-under-growth') {
|
||||
assert.deepEqual(phases.windowCandidate.outcome.value, {
|
||||
leafUuid: 'child',
|
||||
relation: 'descendant'
|
||||
})
|
||||
}
|
||||
if (scenario.name === 'prefix-rewind-under-growth') {
|
||||
assert.deepEqual(phases.windowCandidate.outcome.value, {
|
||||
leafUuid: 'kept',
|
||||
relation: 'intentional-rewind'
|
||||
})
|
||||
}
|
||||
reports.push({
|
||||
name: scenario.name,
|
||||
initialBytes: Buffer.byteLength(scenario.contents),
|
||||
appendedBytes: Buffer.byteLength(scenario.append ?? ''),
|
||||
phases
|
||||
})
|
||||
}
|
||||
return {
|
||||
node: process.version,
|
||||
method:
|
||||
'Append after native stat captures size; open-ended stream has no stat, so append before its first native read. Real files and actual proof methods.',
|
||||
reports
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { compareGrowth }
|
||||
@@ -1,179 +0,0 @@
|
||||
const sessionId = 'synthetic-provider'
|
||||
const row = (uuid, parentUuid, extra = {}) => ({
|
||||
type: 'user',
|
||||
uuid,
|
||||
parentUuid,
|
||||
sessionId,
|
||||
...extra
|
||||
})
|
||||
const marker = (leafUuid, extra = {}) => ({ type: 'last-prompt', sessionId, leafUuid, ...extra })
|
||||
const jsonl = (rows, trailing = true) =>
|
||||
rows.map((item) => JSON.stringify(item)).join('\n') + (trailing ? '\n' : '')
|
||||
const graph = [row('root', null), row('kept', 'root'), row('old', 'kept')]
|
||||
|
||||
function parityCases() {
|
||||
const cases = []
|
||||
const add = (name, contents, options = {}, error = null) =>
|
||||
cases.push({ name, contents, options, error })
|
||||
const good = jsonl([...graph, marker('old')])
|
||||
add('initial', good)
|
||||
add('same', good, { previousLeafUuid: 'old' })
|
||||
add('descendant', good, { previousLeafUuid: 'root' })
|
||||
add('valid-unterminated-tail', good.trimEnd())
|
||||
add('blank-lines-crlf', `\r\n \r\n${good.replaceAll('\n', '\r\n')}\r\n`)
|
||||
add('torn-tail', `${good}{"type":`, {}, 'ClaudeTranscriptTailIncompleteError')
|
||||
add('terminated-malformed-tail', `${good}{"type":\n`, {}, 'Error')
|
||||
add('malformed-middle', `{"type":\n${good}`, {}, 'Error')
|
||||
add('malformed-before-blank-tail', `${good}{"type":\n `, {}, 'Error')
|
||||
add('non-object-null', `null\n${good}`, {}, 'Error')
|
||||
add('non-object-array', `[]\n${good}`, {}, 'Error')
|
||||
add('missing-marker', jsonl(graph), {}, 'Error')
|
||||
add('marker-before-leaf', jsonl([marker('old'), ...graph]), {}, 'Error')
|
||||
add(
|
||||
'wrong-marker-session',
|
||||
jsonl([...graph, marker('old', { sessionId: 'foreign' })]),
|
||||
{},
|
||||
'Error'
|
||||
)
|
||||
add('empty-marker-leaf', jsonl([...graph, marker('')]), {}, 'Error')
|
||||
add('missing-marker-leaf', jsonl([...graph, marker('absent')]), {}, 'Error')
|
||||
add('last-marker-wins', jsonl([...graph, marker('root'), marker('old')]), {
|
||||
previousLeafUuid: 'root'
|
||||
})
|
||||
add(
|
||||
'last-marker-sibling',
|
||||
jsonl([...graph, row('sibling', 'root'), marker('old'), marker('sibling')]),
|
||||
{ previousLeafUuid: 'old' },
|
||||
'Error'
|
||||
)
|
||||
add('duplicate-identical-after-marker', good + jsonl([row('old', 'kept')]))
|
||||
for (const { name, extra } of [
|
||||
{ name: 'parent', extra: { parentUuid: 'root' } },
|
||||
{ name: 'session', extra: { sessionId: 'foreign' } },
|
||||
{ name: 'sidechain', extra: { isSidechain: true } }
|
||||
]) {
|
||||
add(`duplicate-conflicting-${name}`, good + jsonl([row('old', 'kept', extra)]), {}, 'Error')
|
||||
}
|
||||
add(
|
||||
'append-order-on-proof-path',
|
||||
jsonl([graph[1], graph[0], graph[2], marker('old')]),
|
||||
{},
|
||||
'Error'
|
||||
)
|
||||
add(
|
||||
'append-order-outside-proof-path',
|
||||
good +
|
||||
jsonl([row('disconnected-child', 'disconnected-parent'), row('disconnected-parent', null)]),
|
||||
{},
|
||||
'Error'
|
||||
)
|
||||
add('missing-old-ancestor', jsonl([...graph.slice(1), marker('old')]), {}, 'Error')
|
||||
add('cycle', jsonl([row('root', 'old'), ...graph.slice(1), marker('old')]), {}, 'Error')
|
||||
add(
|
||||
'missing-previous-cursor',
|
||||
good,
|
||||
{ previousLeafUuid: 'absent' },
|
||||
'ClaudeTranscriptPreviousCursorMissingError'
|
||||
)
|
||||
add(
|
||||
'previous-foreign-session',
|
||||
jsonl([row('root', null, { sessionId: 'foreign' }), ...graph.slice(1), marker('old')]),
|
||||
{ previousLeafUuid: 'root' },
|
||||
'Error'
|
||||
)
|
||||
for (const { name, extra } of [
|
||||
{ name: 'sidechain', extra: { isSidechain: true } },
|
||||
{ name: 'parent-tool', extra: { parent_tool_use_id: 'tool' } },
|
||||
{ name: 'result', extra: { type: 'result' } },
|
||||
{ name: 'stream', extra: { type: 'stream_event' } },
|
||||
{ name: 'init', extra: { type: 'system', subtype: 'init' } }
|
||||
]) {
|
||||
add(
|
||||
`disallowed-leaf-${name}`,
|
||||
jsonl([...graph.slice(0, 2), row('old', 'kept', extra), marker('old')]),
|
||||
{},
|
||||
'Error'
|
||||
)
|
||||
add(
|
||||
`disallowed-old-ancestor-${name}`,
|
||||
jsonl([row('root', null, extra), ...graph.slice(1), marker('old')]),
|
||||
{},
|
||||
'Error'
|
||||
)
|
||||
}
|
||||
add('rewind', jsonl([...graph, marker('kept')]), {
|
||||
previousLeafUuid: 'old',
|
||||
intentionalRewindUuid: 'kept'
|
||||
})
|
||||
add(
|
||||
'rewind-without-intent',
|
||||
jsonl([...graph, marker('kept')]),
|
||||
{ previousLeafUuid: 'old' },
|
||||
'Error'
|
||||
)
|
||||
add(
|
||||
'rewind-wrong-target',
|
||||
jsonl([...graph, marker('kept')]),
|
||||
{ previousLeafUuid: 'old', intentionalRewindUuid: 'root' },
|
||||
'Error'
|
||||
)
|
||||
add(
|
||||
'rewind-no-previous',
|
||||
jsonl([...graph, marker('kept')]),
|
||||
{ intentionalRewindUuid: 'kept' },
|
||||
'Error'
|
||||
)
|
||||
add('rewind-to-same', good, { previousLeafUuid: 'old', intentionalRewindUuid: 'old' }, 'Error')
|
||||
add(
|
||||
'rewind-to-sibling',
|
||||
jsonl([...graph, row('sibling', 'root'), marker('sibling')]),
|
||||
{ previousLeafUuid: 'old', intentionalRewindUuid: 'sibling' },
|
||||
'Error'
|
||||
)
|
||||
add(
|
||||
'old-ancestor-before-large-body',
|
||||
jsonl([
|
||||
graph[0],
|
||||
row('kept', 'root', { message: 'x'.repeat(3 * 1024 * 1024) }),
|
||||
graph[2],
|
||||
marker('old')
|
||||
]),
|
||||
{ previousLeafUuid: 'root' }
|
||||
)
|
||||
const unicode = row('root', null, { message: '' })
|
||||
const messageOffset = JSON.stringify(unicode).indexOf('"message":"') + '"message":"'.length
|
||||
unicode.message = `${'x'.repeat(65535 - messageOffset)}🙂é漢字`
|
||||
add('utf8-crosses-64k-file-chunk', jsonl([unicode, ...graph.slice(1), marker('old')]))
|
||||
add('unicode-identities', jsonl([row('起🙂', null), row('終é', '起🙂'), marker('終é')]), {
|
||||
previousLeafUuid: '起🙂'
|
||||
})
|
||||
add(
|
||||
'invalid-utf8-replacement-in-message',
|
||||
Buffer.concat([
|
||||
Buffer.from(jsonl(graph)),
|
||||
Buffer.from('{"comment":"'),
|
||||
Buffer.from([0xf0, 0x80, 0x80]),
|
||||
Buffer.from(`"}\n${jsonl([marker('old')])}`)
|
||||
])
|
||||
)
|
||||
add(
|
||||
'invalid-utf8-in-torn-tail',
|
||||
Buffer.concat([Buffer.from(`${good}{"comment":"`), Buffer.from([0xf0, 0x9f])]),
|
||||
{},
|
||||
'ClaudeTranscriptTailIncompleteError'
|
||||
)
|
||||
for (const count of [10_000, 10_001]) {
|
||||
const rows = Array.from({ length: count }, (_, index) =>
|
||||
row(`depth-${index}`, index === 0 ? null : `depth-${index - 1}`)
|
||||
)
|
||||
add(
|
||||
`ancestry-depth-${count}`,
|
||||
jsonl([...rows, marker(`depth-${count - 1}`)]),
|
||||
{},
|
||||
count === 10_001 ? 'Error' : null
|
||||
)
|
||||
}
|
||||
return cases
|
||||
}
|
||||
|
||||
module.exports = { parityCases, sessionId }
|
||||
@@ -1,283 +0,0 @@
|
||||
{
|
||||
"reportedTag": "v1.4.198",
|
||||
"reportedTagCommit": "e0826956fcfc532f5a1e55b5e081f2e57e553c43",
|
||||
"currentCommitAtCapture": "2e623a227474101160de4c977bfae69da4b052ee",
|
||||
"limits": "Source reachability/version evidence only; no user transcript or incident trigger evidence. Rewind branch support is newer than this tag. Current source hashes and call sites are checked by reproduce.cjs.",
|
||||
"files": [
|
||||
{
|
||||
"path": "src/main/claude/claude-transcript-branch-proof.ts",
|
||||
"currentSha256": "ddc33ebf0c5f1b628f2fe9223b41b3dc105e08d3dd48ca36dde9e1aec39387b5",
|
||||
"reportedTagSha256": "79409696fc5bf385727d8710a5383acfe3ff350df5bdda93a79f7e97dcc54d1d",
|
||||
"identicalToTag": false,
|
||||
"currentCallSites": [
|
||||
{
|
||||
"line": 249,
|
||||
"text": "export async function proveClaudeTranscriptBranch("
|
||||
},
|
||||
{
|
||||
"line": 252,
|
||||
"text": "const handle = await open(input.transcriptPath, 'r')"
|
||||
},
|
||||
{
|
||||
"line": 261,
|
||||
"text": "const stream = handle.createReadStream({ start: 0, end: size - 1, autoClose: false })"
|
||||
},
|
||||
{
|
||||
"line": 296,
|
||||
"text": "export async function readClaudeTranscriptLeafWithReproof(input: {"
|
||||
},
|
||||
{
|
||||
"line": 297,
|
||||
"text": "readTranscriptLeaf: (input: {"
|
||||
}
|
||||
],
|
||||
"reportedTagCallSites": [
|
||||
{
|
||||
"line": 215,
|
||||
"text": "contents: await readFile(input.transcriptPath, 'utf8'),"
|
||||
},
|
||||
{
|
||||
"line": 222,
|
||||
"text": "export async function readClaudeTranscriptLeafWithReproof(input: {"
|
||||
},
|
||||
{
|
||||
"line": 223,
|
||||
"text": "readTranscriptLeaf: (input: {"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/main/runtime/structured-claude-runtime-adapter.ts",
|
||||
"currentSha256": "553c8e10458a3a5b1dcbfaef0db4033c8476889a4345a35ed542c8dd3d947428",
|
||||
"reportedTagSha256": "4b347b8222ca18eb4bd728a57c307226ba2a279e14fd5ebd4fbfac877aacc8f0",
|
||||
"identicalToTag": false,
|
||||
"currentCallSites": [
|
||||
{
|
||||
"line": 44,
|
||||
"text": "export function createStructuredClaudeRuntimeAdapter("
|
||||
},
|
||||
{
|
||||
"line": 80,
|
||||
"text": "readTranscriptLeaf: async ({"
|
||||
},
|
||||
{
|
||||
"line": 100,
|
||||
"text": "? await readClaudeTranscriptLeafUuid(transcriptPath, providerSessionId, previousLeafUuid)"
|
||||
}
|
||||
],
|
||||
"reportedTagCallSites": [
|
||||
{
|
||||
"line": 39,
|
||||
"text": "export function createStructuredClaudeRuntimeAdapter("
|
||||
},
|
||||
{
|
||||
"line": 72,
|
||||
"text": "readTranscriptLeaf: async ({ providerSessionId, previousLeafUuid, claudeConfigDir }) => {"
|
||||
},
|
||||
{
|
||||
"line": 77,
|
||||
"text": "? await readClaudeTranscriptLeafUuid(transcriptPath, providerSessionId, previousLeafUuid)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/main/native-chat/session-file-resolver.ts",
|
||||
"currentSha256": "bf332461d0401c44ab5995822f2a9be097fd24b0b92996afecb04de304a70053",
|
||||
"reportedTagSha256": "573abd5403cc58183e0b266faae09cecaeb8583855a44f0122f03f0d543efde4",
|
||||
"identicalToTag": false,
|
||||
"currentCallSites": [
|
||||
{
|
||||
"line": 168,
|
||||
"text": "export async function readClaudeTranscriptLeafUuid("
|
||||
}
|
||||
],
|
||||
"reportedTagCallSites": [
|
||||
{
|
||||
"line": 162,
|
||||
"text": "export async function readClaudeTranscriptLeafUuid("
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/main/claude/claude-structured-session-close.ts",
|
||||
"currentSha256": "40080bf4ae7b52573f797639863399edece42e254afda642dac07791996d8653",
|
||||
"reportedTagSha256": "dc6c831eb322309b8a7ba89043546c71b2edb1c00eda1521e716498201356938",
|
||||
"identicalToTag": false,
|
||||
"currentCallSites": [
|
||||
{
|
||||
"line": 100,
|
||||
"text": "if ((await session.connection.close()) !== true) {"
|
||||
},
|
||||
{
|
||||
"line": 116,
|
||||
"text": "? await readClaudeTranscriptLeafWithReproof({"
|
||||
},
|
||||
{
|
||||
"line": 117,
|
||||
"text": "readTranscriptLeaf: input.readTranscriptLeaf,"
|
||||
}
|
||||
],
|
||||
"reportedTagCallSites": [
|
||||
{
|
||||
"line": 77,
|
||||
"text": "if ((await session.connection.close()) !== true) {"
|
||||
},
|
||||
{
|
||||
"line": 85,
|
||||
"text": "? await readClaudeTranscriptLeafWithReproof({"
|
||||
},
|
||||
{
|
||||
"line": 86,
|
||||
"text": "readTranscriptLeaf: input.readTranscriptLeaf,"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/main/claude/claude-structured-session-adapter.ts",
|
||||
"currentSha256": "5227f8fd56648ba3820b307f6a0d860cffa6ac2184330a21a489bb54e2b8b445",
|
||||
"reportedTagSha256": "87a40dea24a0d742164c9ab2c28f14a05d5a44bf6e511e0b72a280dc083d93d0",
|
||||
"identicalToTag": false,
|
||||
"currentCallSites": [
|
||||
{
|
||||
"line": 97,
|
||||
"text": "const closePromise = session.connection.close().catch(() => false)"
|
||||
},
|
||||
{
|
||||
"line": 145,
|
||||
"text": "await this.persistSessionHandle(sessionId, exit.session).catch(() => undefined)"
|
||||
},
|
||||
{
|
||||
"line": 183,
|
||||
"text": "? await readClaudeTranscriptLeafWithReproof({"
|
||||
},
|
||||
{
|
||||
"line": 184,
|
||||
"text": "readTranscriptLeaf: this.deps.readTranscriptLeaf,"
|
||||
},
|
||||
{
|
||||
"line": 314,
|
||||
"text": "...(this.deps.readTranscriptLeaf ? { readTranscriptLeaf: this.deps.readTranscriptLeaf } : {}),"
|
||||
}
|
||||
],
|
||||
"reportedTagCallSites": [
|
||||
{
|
||||
"line": 92,
|
||||
"text": "const closePromise = session.connection.close().catch(() => false)"
|
||||
},
|
||||
{
|
||||
"line": 140,
|
||||
"text": "await this.persistSessionHandle(sessionId, exit.session).catch(() => undefined)"
|
||||
},
|
||||
{
|
||||
"line": 166,
|
||||
"text": "? await readClaudeTranscriptLeafWithReproof({"
|
||||
},
|
||||
{
|
||||
"line": 167,
|
||||
"text": "readTranscriptLeaf: this.deps.readTranscriptLeaf,"
|
||||
},
|
||||
{
|
||||
"line": 298,
|
||||
"text": "...(this.deps.readTranscriptLeaf ? { readTranscriptLeaf: this.deps.readTranscriptLeaf } : {}),"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/main/runtime/orca-runtime-stop-structured-session-process.ts",
|
||||
"currentSha256": "11b71f61bdad9cde89ac174a22f91937f2ba138e34bb58a86edeb568252a64b9",
|
||||
"reportedTagSha256": "11b71f61bdad9cde89ac174a22f91937f2ba138e34bb58a86edeb568252a64b9",
|
||||
"identicalToTag": true,
|
||||
"currentCallSites": [
|
||||
{
|
||||
"line": 42,
|
||||
"text": "const deadline = Date.now() + 15_000"
|
||||
},
|
||||
{
|
||||
"line": 48,
|
||||
"text": "await new Promise((resolve) => setTimeout(resolve, 100))"
|
||||
},
|
||||
{
|
||||
"line": 66,
|
||||
"text": "await new Promise((resolve) => setTimeout(resolve, 100))"
|
||||
},
|
||||
{
|
||||
"line": 150,
|
||||
"text": "const deadline = Date.now() + 15_000"
|
||||
},
|
||||
{
|
||||
"line": 172,
|
||||
"text": "await new Promise((resolve) => setTimeout(resolve, 100))"
|
||||
},
|
||||
{
|
||||
"line": 184,
|
||||
"text": "const leafUuid = await readClaudeTranscriptLeafUuid("
|
||||
},
|
||||
{
|
||||
"line": 197,
|
||||
"text": "await new Promise((resolve) => setTimeout(resolve, 100))"
|
||||
}
|
||||
],
|
||||
"reportedTagCallSites": [
|
||||
{
|
||||
"line": 42,
|
||||
"text": "const deadline = Date.now() + 15_000"
|
||||
},
|
||||
{
|
||||
"line": 48,
|
||||
"text": "await new Promise((resolve) => setTimeout(resolve, 100))"
|
||||
},
|
||||
{
|
||||
"line": 66,
|
||||
"text": "await new Promise((resolve) => setTimeout(resolve, 100))"
|
||||
},
|
||||
{
|
||||
"line": 150,
|
||||
"text": "const deadline = Date.now() + 15_000"
|
||||
},
|
||||
{
|
||||
"line": 172,
|
||||
"text": "await new Promise((resolve) => setTimeout(resolve, 100))"
|
||||
},
|
||||
{
|
||||
"line": 184,
|
||||
"text": "const leafUuid = await readClaudeTranscriptLeafUuid("
|
||||
},
|
||||
{
|
||||
"line": 197,
|
||||
"text": "await new Promise((resolve) => setTimeout(resolve, 100))"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/main/runtime/structured-agent-session-runtime.ts",
|
||||
"currentSha256": "ce6da207cd354ac372e6aa260a12463a252568909180c4ab217491f2565262aa",
|
||||
"reportedTagSha256": "65f43aaa10363e54159cac9e84e62e89be122c4372c2f41136b053b81a252bae",
|
||||
"identicalToTag": false,
|
||||
"currentCallSites": [
|
||||
{
|
||||
"line": 295,
|
||||
"text": "const claude = createStructuredClaudeRuntimeAdapter({"
|
||||
}
|
||||
],
|
||||
"reportedTagCallSites": [
|
||||
{
|
||||
"line": 238,
|
||||
"text": "const claude = createStructuredClaudeRuntimeAdapter({"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/main/native-chat/transcript-stream-lines.ts",
|
||||
"currentSha256": "f228f252f9dc3e9520c4195486df524ee0bcb258cd1c0c31046042df83675644",
|
||||
"reportedTagSha256": "4f3ed634f81521f05e39cc9c9a4e96450e891440372ddb34854d1c295af1f1bc",
|
||||
"identicalToTag": false,
|
||||
"currentCallSites": [
|
||||
{
|
||||
"line": 44,
|
||||
"text": "export async function* splitTranscriptStreamLines("
|
||||
}
|
||||
],
|
||||
"reportedTagCallSites": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"reports": [
|
||||
{
|
||||
"phase": "baseline",
|
||||
"passed": 68,
|
||||
"failed": 0,
|
||||
"files": [
|
||||
"src/main/native-chat/session-file-resolver.test.ts",
|
||||
"src/main/claude/claude-transcript-rewind-proof.test.ts",
|
||||
"src/main/claude/claude-structured-session-recovery.test.ts",
|
||||
"src/main/claude/claude-structured-history-window.test.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "candidate",
|
||||
"passed": 68,
|
||||
"failed": 0,
|
||||
"files": [
|
||||
"src/main/native-chat/session-file-resolver.test.ts",
|
||||
"src/main/claude/claude-transcript-rewind-proof.test.ts",
|
||||
"src/main/claude/claude-structured-session-recovery.test.ts",
|
||||
"src/main/claude/claude-structured-history-window.test.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "windowCandidate",
|
||||
"passed": 68,
|
||||
"failed": 0,
|
||||
"files": [
|
||||
"src/main/native-chat/session-file-resolver.test.ts",
|
||||
"src/main/claude/claude-transcript-rewind-proof.test.ts",
|
||||
"src/main/claude/claude-structured-session-recovery.test.ts",
|
||||
"src/main/claude/claude-structured-history-window.test.ts"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import loadSources from './sources.cjs'
|
||||
import { startVitest } from 'vitest/node'
|
||||
|
||||
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
|
||||
throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.')
|
||||
}
|
||||
const { root, sourceRelativePath, baseline, candidate, windowCandidate } = loadSources()
|
||||
const files = [
|
||||
'src/main/native-chat/session-file-resolver.test.ts',
|
||||
'src/main/claude/claude-transcript-rewind-proof.test.ts',
|
||||
'src/main/claude/claude-structured-session-recovery.test.ts',
|
||||
'src/main/claude/claude-structured-history-window.test.ts'
|
||||
]
|
||||
const scratch = await mkdtemp(join(tmpdir(), 'orca-branch-streaming-regressions-'))
|
||||
const reports = []
|
||||
try {
|
||||
for (const [phase, source] of Object.entries({ baseline, candidate, windowCandidate })) {
|
||||
const config = join(scratch, `${phase}.config.mjs`)
|
||||
const report = join(scratch, `${phase}.json`)
|
||||
await writeFile(
|
||||
config,
|
||||
`
|
||||
import base from ${JSON.stringify(pathToFileURL(join(root, 'config/vitest.config.ts')).href)}
|
||||
export default {
|
||||
...base,
|
||||
plugins: [{ name: 'branch-streaming-before-after', enforce: 'pre', transform(code, id) {
|
||||
if (${JSON.stringify(phase)} !== 'windowCandidate' && id.replaceAll('\\\\', '/').endsWith(${JSON.stringify(`/${sourceRelativePath}`)})) return ${JSON.stringify(source)}
|
||||
}}],
|
||||
test: { ...base.test, include: ${JSON.stringify(files.map((file) => join(root, file)))}, maxWorkers: 1, fileParallelism: false }
|
||||
}
|
||||
`
|
||||
)
|
||||
const runner = await startVitest('test', [], {
|
||||
root,
|
||||
config,
|
||||
watch: false,
|
||||
reporters: ['dot', 'json'],
|
||||
outputFile: { json: report }
|
||||
})
|
||||
assert(runner, 'Vitest did not start')
|
||||
await runner.close()
|
||||
const result = JSON.parse(await readFile(report, 'utf8'))
|
||||
assert.equal(result.success, true)
|
||||
reports.push({ phase, passed: result.numPassedTests, failed: result.numFailedTests, files })
|
||||
}
|
||||
const output = `${JSON.stringify({ reports }, null, 2)}\n`
|
||||
if (process.argv[2]) {
|
||||
await writeFile(resolve(process.argv[2]), output)
|
||||
}
|
||||
process.stdout.write(output)
|
||||
} finally {
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { createHash } = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const { createRequire } = require('node:module')
|
||||
const { tmpdir } = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { parityCases, sessionId } = require('./parity-cases.cjs')
|
||||
const { compareAllocation } = require('./allocation.cjs')
|
||||
const { compareGrowth } = require('./growth.cjs')
|
||||
const { compareDuration } = require('./duration.cjs')
|
||||
const { verifierEntry, compareVerifier } = require('./verifier.cjs')
|
||||
|
||||
const { root, sourcePath, sourceRelativePath, baseline, candidate, windowCandidate } =
|
||||
require('./sources.cjs')()
|
||||
const { buildSync } = createRequire(path.join(root, 'package.json'))('esbuild')
|
||||
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1' || !global.gc) {
|
||||
throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1 and node --expose-gc.')
|
||||
}
|
||||
const hash = (text) => createHash('sha256').update(text).digest('hex')
|
||||
const verifierPath = 'src/main/runtime/orca-runtime-stop-structured-session-process.ts'
|
||||
const verifierSource = fs.readFileSync(path.join(root, verifierPath), 'utf8')
|
||||
const scratch = fs.mkdtempSync(path.join(tmpdir(), 'orca-claude-branch-streaming-'))
|
||||
const modulePaths = []
|
||||
|
||||
async function capture(action) {
|
||||
try {
|
||||
return { status: 'fulfilled', value: await action() }
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'rejected',
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
...(error.code ? { code: error.code } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const provenance = require('./provenance.json')
|
||||
for (const file of provenance.files) {
|
||||
const source = fs.readFileSync(path.join(root, file.path), 'utf8')
|
||||
assert.equal(hash(source), file.currentSha256, `Refresh provenance for ${file.path}`)
|
||||
const lines = source.split('\n')
|
||||
for (const site of file.currentCallSites) {
|
||||
assert.equal(lines[site.line - 1].trim(), site.text, `${file.path}:${site.line}`)
|
||||
}
|
||||
}
|
||||
const auditHashes = {}
|
||||
for (const file of fs.readdirSync(__dirname)) {
|
||||
if (
|
||||
file.endsWith('.cjs') ||
|
||||
file.endsWith('.mjs') ||
|
||||
['candidate-transform.json', 'fix.patch', 'provenance.json'].includes(file)
|
||||
) {
|
||||
auditHashes[file] = hash(fs.readFileSync(path.join(__dirname, file)))
|
||||
}
|
||||
}
|
||||
const modules = {}
|
||||
const sourceHashes = {}
|
||||
const bundleHashes = {}
|
||||
for (const [phase, contents] of Object.entries({ baseline, candidate, windowCandidate })) {
|
||||
const built = buildSync({
|
||||
stdin: {
|
||||
contents: contents + verifierEntry(verifierSource),
|
||||
sourcefile: sourcePath,
|
||||
resolveDir: path.dirname(sourcePath),
|
||||
loader: 'ts'
|
||||
},
|
||||
absWorkingDir: root,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
write: false,
|
||||
metafile: true
|
||||
})
|
||||
const modulePath = path.join(scratch, `${phase}.cjs`)
|
||||
fs.writeFileSync(modulePath, built.outputFiles[0].text)
|
||||
modulePaths.push(modulePath)
|
||||
modules[phase] = require(modulePath)
|
||||
bundleHashes[phase] = hash(built.outputFiles[0].text)
|
||||
for (const input of Object.keys(built.metafile.inputs)) {
|
||||
if (path.resolve(root, input) !== sourcePath) {
|
||||
sourceHashes[input] = hash(fs.readFileSync(path.resolve(root, input)))
|
||||
}
|
||||
}
|
||||
}
|
||||
sourceHashes[sourceRelativePath] = {
|
||||
baseline: hash(baseline),
|
||||
candidate: hash(candidate),
|
||||
windowCandidate: hash(windowCandidate)
|
||||
}
|
||||
sourceHashes[verifierPath] = hash(verifierSource)
|
||||
const parity = []
|
||||
for (const scenario of parityCases()) {
|
||||
const file = path.join(scratch, 'parity.jsonl')
|
||||
fs.writeFileSync(file, scenario.contents)
|
||||
const input = { providerSessionId: sessionId, previousLeafUuid: null, ...scenario.options }
|
||||
const outcomes = {}
|
||||
for (const [phase, methods] of Object.entries(modules)) {
|
||||
outcomes[`${phase}File`] = await capture(() =>
|
||||
methods.proveClaudeTranscriptBranch({ ...input, transcriptPath: file })
|
||||
)
|
||||
outcomes[`${phase}String`] = await capture(() =>
|
||||
methods.proveClaudeTranscriptBranchFromJsonl({
|
||||
...input,
|
||||
contents: Buffer.from(scenario.contents).toString('utf8')
|
||||
})
|
||||
)
|
||||
}
|
||||
for (const outcome of Object.values(outcomes)) {
|
||||
assert.deepEqual(outcome, outcomes.baselineFile, scenario.name)
|
||||
}
|
||||
assert.equal(
|
||||
outcomes.baselineFile.status,
|
||||
scenario.error ? 'rejected' : 'fulfilled',
|
||||
scenario.name
|
||||
)
|
||||
if (scenario.error) {
|
||||
assert.equal(outcomes.baselineFile.name, scenario.error, scenario.name)
|
||||
}
|
||||
parity.push({
|
||||
name: scenario.name,
|
||||
sourceBytes: fs.statSync(file).size,
|
||||
outcome: outcomes.baselineFile
|
||||
})
|
||||
fs.unlinkSync(file)
|
||||
}
|
||||
const missing = {}
|
||||
for (const [phase, methods] of Object.entries(modules)) {
|
||||
missing[phase] = await capture(() =>
|
||||
methods.proveClaudeTranscriptBranch({
|
||||
transcriptPath: path.join(scratch, 'missing'),
|
||||
providerSessionId: sessionId,
|
||||
previousLeafUuid: null
|
||||
})
|
||||
)
|
||||
assert.equal(missing[phase].code, 'ENOENT')
|
||||
}
|
||||
const growth = await compareGrowth(scratch, modules)
|
||||
const allocations = await compareAllocation(scratch, modules)
|
||||
const duration = await compareDuration(scratch, modules)
|
||||
const verifier = await compareVerifier(scratch, modules)
|
||||
const report = {
|
||||
status:
|
||||
'Actual production source; baseline reconstructed by reverse fix.patch; open-ended experiment retained as a control',
|
||||
measurement:
|
||||
'Forced GC at actual JSON.parse boundaries; sampled transient live bytes, not RSS, natural peak, or a retained-leak proof.',
|
||||
runtime: {
|
||||
node: process.version,
|
||||
electron: process.versions.electron ?? null,
|
||||
platform: process.platform,
|
||||
arch: process.arch
|
||||
},
|
||||
toolVersions: {
|
||||
esbuild: require('esbuild/package.json').version,
|
||||
diff: require('diff/package.json').version
|
||||
},
|
||||
auditHashes,
|
||||
sourceHashes,
|
||||
bundleHashes,
|
||||
parity,
|
||||
missingFile: 'All three readers reject with ENOENT',
|
||||
growth,
|
||||
allocations,
|
||||
duration,
|
||||
verifier
|
||||
}
|
||||
const output = `${JSON.stringify(report, null, 2)}\n`
|
||||
if (process.argv[2]) {
|
||||
fs.writeFileSync(path.resolve(process.argv[2]), output)
|
||||
}
|
||||
process.stdout.write(output)
|
||||
}
|
||||
|
||||
run()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => {
|
||||
for (const modulePath of modulePaths) {
|
||||
delete require.cache[require.resolve(modulePath)]
|
||||
}
|
||||
fs.rmSync(scratch, { recursive: true, force: true })
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,36 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { createHash } = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { applyPatch, parsePatch, reversePatch } = require('diff')
|
||||
|
||||
module.exports = function loadSources() {
|
||||
const root = path.resolve(__dirname, '../../..')
|
||||
const transform = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, 'candidate-transform.json'), 'utf8')
|
||||
)
|
||||
const sourcePath = path.join(root, transform.path)
|
||||
const windowCandidate = fs.readFileSync(sourcePath, 'utf8')
|
||||
const patch = parsePatch(fs.readFileSync(path.join(__dirname, 'fix.patch'), 'utf8'))
|
||||
assert.equal(patch.length, 1)
|
||||
const baseline = applyPatch(windowCandidate, reversePatch(patch[0]))
|
||||
assert.notEqual(baseline, false, 'Source changed; review fix.patch')
|
||||
assert.equal(
|
||||
createHash('sha256').update(baseline).digest('hex'),
|
||||
transform.sha256,
|
||||
'Baseline drift'
|
||||
)
|
||||
let candidate = baseline
|
||||
for (const change of transform.changes) {
|
||||
assert.equal(candidate.split(change.before).length, 2)
|
||||
candidate = candidate.replace(change.before, change.after)
|
||||
}
|
||||
return {
|
||||
root,
|
||||
sourcePath,
|
||||
sourceRelativePath: transform.path,
|
||||
baseline,
|
||||
candidate,
|
||||
windowCandidate
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { sessionId } = require('./parity-cases.cjs')
|
||||
|
||||
function verifierEntry(source) {
|
||||
const marker = ' protected async waitForStructuredClaudeTuiProof(input: {'
|
||||
assert.equal(source.split(marker).length, 2)
|
||||
const method = source.slice(source.indexOf(marker), source.lastIndexOf('\n}'))
|
||||
return `
|
||||
export async function runActualVerifier(fixture: { transcriptPath: string; previousLeafUuid?: string | null }) {
|
||||
let attempts = 0
|
||||
const resolveSessionFilePath = async () => { attempts++; return fixture.transcriptPath }
|
||||
const readClaudeTranscriptLeafUuid = async (file: string, session: string, previous: string | null) =>
|
||||
(await proveClaudeTranscriptBranch({ transcriptPath: file, providerSessionId: session, previousLeafUuid: previous })).leafUuid
|
||||
const isPathWithinDirectory = () => true
|
||||
class ActualVerifier {
|
||||
getLivePtyForHandle() { return { pty: { connected: true, paneKey: 'tab:leaf', launchAgent: 'claude' } } }
|
||||
hasProviderSessionObservationSource() { return true }
|
||||
findAdoptedProviderSession() { return { launchToken: 'fresh-launch', receivedAt: 1 } }
|
||||
run() { return this.waitForStructuredClaudeTuiProof({ handle: 'handle', paneKey: 'tab:leaf', sessionId: ${JSON.stringify(sessionId)}, projectsDir: 'fixture-root', previousLeafUuid: fixture.previousLeafUuid ?? null, spawnToken: 'fresh-launch', minimumProviderSessionReceivedAt: 1 }) }
|
||||
${method}
|
||||
}
|
||||
try {
|
||||
const value = await new ActualVerifier().run()
|
||||
return { attempts, outcome: { status: 'fulfilled', value } }
|
||||
} catch (error) { return { attempts, outcome: { status: 'rejected', name: error.name, message: error.message } } }
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
async function compareVerifier(scratch, modules) {
|
||||
const row = (uuid, parentUuid) =>
|
||||
`${JSON.stringify({ type: 'user', uuid, parentUuid, sessionId })}\n`
|
||||
const marker = (leafUuid, provider = sessionId) =>
|
||||
`${JSON.stringify({ type: 'last-prompt', leafUuid, sessionId: provider })}\n`
|
||||
const root = row('root', null)
|
||||
const child = row('child', 'root')
|
||||
const cases = [
|
||||
{
|
||||
name: 'growing-empty-prefix-refreshed-internally',
|
||||
contents: '',
|
||||
append: root + marker('root'),
|
||||
refresh: true
|
||||
},
|
||||
{
|
||||
name: 'growing-missing-marker-refreshed-internally',
|
||||
contents: root,
|
||||
append: marker('root'),
|
||||
refresh: true
|
||||
},
|
||||
{
|
||||
name: 'growing-missing-previous-refreshed-internally',
|
||||
contents: root + marker('root'),
|
||||
append: child + marker('child'),
|
||||
previousLeafUuid: 'child',
|
||||
refresh: true
|
||||
},
|
||||
{ name: 'static-empty-fatal', contents: '', error: 'Error' },
|
||||
{ name: 'static-missing-marker-fatal', contents: root, error: 'Error' },
|
||||
{
|
||||
name: 'static-missing-previous-fatal',
|
||||
contents: root + marker('root'),
|
||||
previousLeafUuid: 'child',
|
||||
error: 'ClaudeTranscriptPreviousCursorMissingError'
|
||||
},
|
||||
{
|
||||
name: 'growing-conflict-fatal',
|
||||
contents: root + row('root', 'foreign') + marker('root'),
|
||||
append: child,
|
||||
error: 'Error'
|
||||
},
|
||||
{
|
||||
name: 'growing-session-mismatch-fatal',
|
||||
contents: root + marker('root', 'foreign'),
|
||||
append: child,
|
||||
error: 'Error'
|
||||
},
|
||||
{
|
||||
name: 'growing-append-order-fatal',
|
||||
contents: child + root + marker('child'),
|
||||
append: child,
|
||||
error: 'Error'
|
||||
},
|
||||
{
|
||||
name: 'successful-prefix-stays-prefix',
|
||||
contents: root + marker('root'),
|
||||
append: child + marker('child')
|
||||
}
|
||||
]
|
||||
const reports = []
|
||||
for (const scenario of cases) {
|
||||
const phases = {}
|
||||
for (const phase of ['baseline', 'windowCandidate']) {
|
||||
const file = path.join(scratch, `verifier-${phase}.jsonl`)
|
||||
fs.writeFileSync(file, scenario.contents)
|
||||
const binding = process.binding('fs')
|
||||
const originalFstat = binding.fstat
|
||||
let injected = false
|
||||
binding.fstat = function (...args) {
|
||||
const result = originalFstat.apply(this, args)
|
||||
if (typeof result?.then !== 'function') {
|
||||
return result
|
||||
}
|
||||
return result.then((stats) => {
|
||||
if (!injected) {
|
||||
injected = true
|
||||
if (scenario.append) {
|
||||
fs.appendFileSync(file, scenario.append)
|
||||
}
|
||||
}
|
||||
return stats
|
||||
})
|
||||
}
|
||||
try {
|
||||
phases[phase] = await modules[phase].runActualVerifier({
|
||||
transcriptPath: file,
|
||||
previousLeafUuid: scenario.previousLeafUuid
|
||||
})
|
||||
} finally {
|
||||
binding.fstat = originalFstat
|
||||
fs.unlinkSync(file)
|
||||
}
|
||||
if (scenario.error) {
|
||||
assert.equal(phases[phase].outcome.name, scenario.error, `${scenario.name}/${phase}`)
|
||||
assert.equal(phases[phase].attempts, 1)
|
||||
}
|
||||
if (phase === 'windowCandidate' && scenario.refresh) {
|
||||
assert.equal(phases[phase].outcome.status, 'fulfilled')
|
||||
assert.equal(phases[phase].attempts, 1)
|
||||
}
|
||||
if (scenario.name === 'successful-prefix-stays-prefix') {
|
||||
assert.equal(phases[phase].outcome.value.leafUuid, 'root')
|
||||
assert.equal(phases[phase].attempts, 1)
|
||||
}
|
||||
if (phases[phase].outcome.value) {
|
||||
delete phases[phase].outcome.value.transcriptPath
|
||||
}
|
||||
}
|
||||
reports.push({ name: scenario.name, phases })
|
||||
}
|
||||
return {
|
||||
method:
|
||||
'Exact current waitForStructuredClaudeTuiProof method body; live-owner/fresh-hook/path-resolution ports controlled, actual file reader and actual 100ms retry timer.',
|
||||
reports
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { verifierEntry, compareVerifier }
|
||||
@@ -1,25 +0,0 @@
|
||||
# Claude history-window read quota
|
||||
|
||||
Restart reconciliation's Claude history reader declares a 16 MiB source quota, but the original `stat` followed by unrestricted `readFile` admits later growth. Reuse `readNodeFileWithinLimit` to enforce the same quota while reading from one descriptor. Overflow preserves the existing inconsistent-history result; it cannot establish that a submitted message was absent.
|
||||
|
||||
## Reproduce
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/claude-history-window-budget/reproduce.mjs
|
||||
```
|
||||
|
||||
The seven-case fixture calls the actual history reader, branch proof and bounded reader against real temporary files. It injects an append immediately after the relevant size snapshot. The baseline reverses only `fix.patch` in a temporary Vite transform, leaving the checkout unchanged. Each child has a 45-second timeout and a 512 MiB old-space ceiling. No application window or provider process starts.
|
||||
|
||||
| Case | Before | After |
|
||||
| --------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| 305-byte valid source grows to 17 MiB after size snapshot | Reads 17,825,792 bytes and accepts the history | Stops after 16,777,217 bytes, rejects history and closes its descriptor |
|
||||
| Stable valid source | Accepted | Accepted |
|
||||
| Exactly 16 MiB | Accepted | Accepted |
|
||||
| Initially 16 MiB plus one byte | Rejected before content read | Same |
|
||||
| Growth within quota | Reads the additional bytes and accepts | Same |
|
||||
| Read error | Inconsistent history | Same; descriptor closed |
|
||||
| Missing anchor | No source read | Same |
|
||||
|
||||
Before: one failing fixed-behavior assertion and six passing controls. After: seven passing cases. `results.json` records observed read bytes, size snapshots, descriptor counts, source hashes, exit codes and timeout status. These are read-budget measurements, not RSS or retained-heap measurements; the byte quota does not describe all parser allocations.
|
||||
|
||||
This reader is **absent from v1.4.198**, so the finding cannot explain #19768 or #19831. It fixes a current-source quota race. The separately unrestricted `proveClaudeTranscriptBranch` file reader has no declared byte quota and is outside this change. The related legacy-import quota race is already addressed by #20976.
|
||||
@@ -1,30 +0,0 @@
|
||||
diff --git a/src/main/claude/claude-structured-history-window.ts b/src/main/claude/claude-structured-history-window.ts
|
||||
index 1cfd48a8a6..f7a541fa31 100644
|
||||
--- a/src/main/claude/claude-structured-history-window.ts
|
||||
+++ b/src/main/claude/claude-structured-history-window.ts
|
||||
@@ -12,8 +12,8 @@
|
||||
// of those makes absence meaningless. Failing it reports an inconsistent
|
||||
// boundary rather than an empty window, because the two decide opposite things.
|
||||
|
||||
-import { readFile, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
+import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader'
|
||||
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
|
||||
import { resolveSessionFilePath } from '../native-chat/session-file-resolver'
|
||||
import type {
|
||||
@@ -284,10 +284,11 @@ export async function readClaudeProviderHistoryWindow(input: {
|
||||
}
|
||||
let contents: string
|
||||
try {
|
||||
- if ((await stat(input.transcriptPath)).size > MAX_HISTORY_WINDOW_SOURCE_BYTES) {
|
||||
- return INCONSISTENT
|
||||
- }
|
||||
- contents = await readFile(input.transcriptPath, 'utf8')
|
||||
+ const read = await readNodeFileWithinLimit(
|
||||
+ input.transcriptPath,
|
||||
+ MAX_HISTORY_WINDOW_SOURCE_BYTES
|
||||
+ )
|
||||
+ contents = read.buffer.toString('utf8')
|
||||
} catch {
|
||||
return INCONSISTENT
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { applyPatch, parsePatch, reversePatch } from 'diff'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
|
||||
throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.')
|
||||
}
|
||||
|
||||
const root = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const patch = await readFile(new URL('./fix.patch', import.meta.url), 'utf8')
|
||||
const beforeSources = {}
|
||||
const sourceHashes = {}
|
||||
for (const parsed of parsePatch(patch)) {
|
||||
const path = parsed.newFileName.replace(/^b\//, '')
|
||||
const absolute = resolve(root, path)
|
||||
const current = await readFile(absolute, 'utf8')
|
||||
const before = applyPatch(current, reversePatch(parsed))
|
||||
if (before === false) {
|
||||
throw new Error(`Source changed; review the proof patch: ${path}`)
|
||||
}
|
||||
beforeSources[absolute.replaceAll('\\', '/')] = before
|
||||
sourceHashes[path] = {
|
||||
before: createHash('sha256').update(before).digest('hex'),
|
||||
after: createHash('sha256').update(current).digest('hex')
|
||||
}
|
||||
}
|
||||
|
||||
for (const path of [
|
||||
'src/main/claude/claude-history-window-source-budget.test.ts',
|
||||
'src/shared/node-bounded-file-reader.ts',
|
||||
'src/main/claude/claude-transcript-branch-proof.ts'
|
||||
]) {
|
||||
sourceHashes[path] = {
|
||||
current: createHash('sha256')
|
||||
.update(await readFile(resolve(root, path)))
|
||||
.digest('hex')
|
||||
}
|
||||
}
|
||||
|
||||
const scratch = await mkdtemp(join(tmpdir(), 'orca-claude-history-window-budget-'))
|
||||
const require = createRequire(import.meta.url)
|
||||
let runnerModuleId
|
||||
try {
|
||||
const runnerPath = join(scratch, 'run-process.cjs')
|
||||
await build({
|
||||
absWorkingDir: root,
|
||||
entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')],
|
||||
outfile: runnerPath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
logLevel: 'silent'
|
||||
})
|
||||
runnerModuleId = require.resolve(runnerPath)
|
||||
const { runProcess } = require(runnerModuleId)
|
||||
const baselineConfig = join(scratch, 'before.config.mjs')
|
||||
const fixedConfig = join(scratch, 'after.config.mjs')
|
||||
const includes = ['src/main/claude/claude-history-window-source-budget.test.ts']
|
||||
const configImport = JSON.stringify(pathToFileURL(resolve(root, 'config/vitest.config.ts')).href)
|
||||
await writeFile(
|
||||
baselineConfig,
|
||||
`import base from ${configImport};
|
||||
const beforeSources = ${JSON.stringify(beforeSources)};
|
||||
export default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}, plugins: [{
|
||||
name: 'claude-history-window-budget-before-fix', enforce: 'pre',
|
||||
transform(_code, id) {
|
||||
const before = beforeSources[id.replaceAll('\\\\', '/').split('?')[0]];
|
||||
return before === undefined ? null : {code: before, map: null};
|
||||
}
|
||||
}]};\n`
|
||||
)
|
||||
|
||||
await writeFile(
|
||||
fixedConfig,
|
||||
`import base from ${configImport};\nexport default {...base, test: {...base.test, include: ${JSON.stringify(includes)}}};\n`
|
||||
)
|
||||
|
||||
async function run(label, config) {
|
||||
const report = join(scratch, `${label}.json`)
|
||||
const observationPath = join(scratch, `${label}.observations.jsonl`)
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: [
|
||||
resolve(root, 'node_modules/vitest/vitest.mjs'),
|
||||
'run',
|
||||
'--config',
|
||||
config,
|
||||
'--reporter=json',
|
||||
`--outputFile=${report}`
|
||||
],
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_HISTORY_BUDGET_PROOF_OUTPUT: observationPath,
|
||||
NODE_OPTIONS: '--max-old-space-size=512'
|
||||
},
|
||||
timeoutMs: 45_000,
|
||||
maxOutputBytes: 4 * 1024 * 1024
|
||||
})
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(report, 'utf8'))
|
||||
} catch (error) {
|
||||
throw new Error(`${label} runner failed: ${result.stderr || result.stdout}`, { cause: error })
|
||||
}
|
||||
return {
|
||||
observations: (await readFile(observationPath, 'utf8'))
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line)),
|
||||
exitCode: result.code,
|
||||
timedOut: result.timedOut,
|
||||
passed: parsed.numPassedTests,
|
||||
failed: parsed.numFailedTests,
|
||||
failedCases: parsed.testResults.flatMap((suite) =>
|
||||
suite.assertionResults
|
||||
.filter((test) => test.status === 'failed')
|
||||
.map((test) => test.fullName)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const before = await run('before', baselineConfig)
|
||||
const after = await run('after', fixedConfig)
|
||||
const passed =
|
||||
before.exitCode === 1 &&
|
||||
after.exitCode === 0 &&
|
||||
!before.timedOut &&
|
||||
!after.timedOut &&
|
||||
before.failed === 1 &&
|
||||
before.passed === 6 &&
|
||||
after.passed === 7 &&
|
||||
after.failed === 0
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
comparison:
|
||||
'Actual history reader and bounded file reader with real files; growth injected after stat; before reverses only fix.patch in a temporary Vite transform',
|
||||
sourceHashes,
|
||||
before,
|
||||
after,
|
||||
passed
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
if (!passed) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
} finally {
|
||||
if (runnerModuleId) {
|
||||
delete require.cache[runnerModuleId]
|
||||
}
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
{
|
||||
"comparison": "Actual history reader and bounded file reader with real files; growth injected after stat; before reverses only fix.patch in a temporary Vite transform",
|
||||
"sourceHashes": {
|
||||
"src/main/claude/claude-structured-history-window.ts": {
|
||||
"before": "43a9868f6bb87bfda620ace31f680f36b3ecd88851ad98c24be5320a9814358b",
|
||||
"after": "77098508b3f5ab5cf4a53163a14da2fa74b1de38859c4717496f7a97e2cd50ea"
|
||||
},
|
||||
"src/main/claude/claude-history-window-source-budget.test.ts": {
|
||||
"current": "88f7a9a9ad9669c636bd7369fdd9a1263325848971f67b499278bf78eb17681e"
|
||||
},
|
||||
"src/shared/node-bounded-file-reader.ts": {
|
||||
"current": "2cffa451b72d059226e0c2d4b6d2a5203cb8ddafb358d5e828dcd3436d197ecb"
|
||||
},
|
||||
"src/main/claude/claude-transcript-branch-proof.ts": {
|
||||
"current": "3224594befe1edae514724caf5eff6a4cda85212e2985be4920b6164b088b82c"
|
||||
}
|
||||
},
|
||||
"before": {
|
||||
"observations": [
|
||||
{
|
||||
"test": "reads a stable history without changing prompt evidence",
|
||||
"bytesRead": 305,
|
||||
"observedStatBytes": 305,
|
||||
"opens": 0,
|
||||
"closes": 0
|
||||
},
|
||||
{
|
||||
"test": "accepts a complete source at the existing byte limit",
|
||||
"bytesRead": 16777216,
|
||||
"observedStatBytes": 16777216,
|
||||
"opens": 0,
|
||||
"closes": 0
|
||||
},
|
||||
{
|
||||
"test": "rejects an initially oversized source before reading its contents",
|
||||
"bytesRead": 0,
|
||||
"observedStatBytes": 16777217,
|
||||
"opens": 0,
|
||||
"closes": 0
|
||||
},
|
||||
{
|
||||
"test": "refuses concurrent growth beyond the existing source quota",
|
||||
"bytesRead": 17825792,
|
||||
"observedStatBytes": 305,
|
||||
"opens": 0,
|
||||
"closes": 0
|
||||
},
|
||||
{
|
||||
"test": "accepts concurrent growth that stays within the quota",
|
||||
"bytesRead": 369,
|
||||
"observedStatBytes": 305,
|
||||
"opens": 0,
|
||||
"closes": 0
|
||||
},
|
||||
{
|
||||
"test": "preserves the inconsistent result on a read error",
|
||||
"bytesRead": 0,
|
||||
"observedStatBytes": 305,
|
||||
"opens": 0,
|
||||
"closes": 0
|
||||
},
|
||||
{
|
||||
"test": "does not open a source without an anchor",
|
||||
"bytesRead": 0,
|
||||
"observedStatBytes": 0,
|
||||
"opens": 0,
|
||||
"closes": 0
|
||||
}
|
||||
],
|
||||
"exitCode": 1,
|
||||
"timedOut": false,
|
||||
"passed": 6,
|
||||
"failed": 1,
|
||||
"failedCases": [
|
||||
"Claude provider history source budget refuses concurrent growth beyond the existing source quota"
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"observations": [
|
||||
{
|
||||
"test": "reads a stable history without changing prompt evidence",
|
||||
"bytesRead": 305,
|
||||
"observedStatBytes": 305,
|
||||
"opens": 1,
|
||||
"closes": 1
|
||||
},
|
||||
{
|
||||
"test": "accepts a complete source at the existing byte limit",
|
||||
"bytesRead": 16777216,
|
||||
"observedStatBytes": 16777216,
|
||||
"opens": 1,
|
||||
"closes": 1
|
||||
},
|
||||
{
|
||||
"test": "rejects an initially oversized source before reading its contents",
|
||||
"bytesRead": 0,
|
||||
"observedStatBytes": 16777217,
|
||||
"opens": 1,
|
||||
"closes": 1
|
||||
},
|
||||
{
|
||||
"test": "refuses concurrent growth beyond the existing source quota",
|
||||
"bytesRead": 16777217,
|
||||
"observedStatBytes": 305,
|
||||
"opens": 1,
|
||||
"closes": 1
|
||||
},
|
||||
{
|
||||
"test": "accepts concurrent growth that stays within the quota",
|
||||
"bytesRead": 369,
|
||||
"observedStatBytes": 305,
|
||||
"opens": 1,
|
||||
"closes": 1
|
||||
},
|
||||
{
|
||||
"test": "preserves the inconsistent result on a read error",
|
||||
"bytesRead": 0,
|
||||
"observedStatBytes": 305,
|
||||
"opens": 1,
|
||||
"closes": 1
|
||||
},
|
||||
{
|
||||
"test": "does not open a source without an anchor",
|
||||
"bytesRead": 0,
|
||||
"observedStatBytes": 0,
|
||||
"opens": 0,
|
||||
"closes": 0
|
||||
}
|
||||
],
|
||||
"exitCode": 0,
|
||||
"timedOut": false,
|
||||
"passed": 7,
|
||||
"failed": 0,
|
||||
"failedCases": []
|
||||
},
|
||||
"passed": true
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
# Retained Claude background-task text
|
||||
|
||||
The actual Claude task tracker retained oversized input strings through its
|
||||
512-character description/name slices. Its live tasks, settled tasks, and
|
||||
recently removed tasks can each retain those slices. The fix uses the existing
|
||||
`ownRetainedString` at the shared text boundary; normalization, UTF-16 clipping,
|
||||
task identity, publication, and lifecycle behavior stay the same.
|
||||
|
||||
This extends [ML-018 / #20960](https://github.com/stablyai/orca/pull/20960).
|
||||
|
||||
## Reproduce
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node --expose-gc docs/audits/claude-task-retention/reproduce.cjs
|
||||
```
|
||||
|
||||
The script bundles the actual tracker and its retention classes. Its baseline
|
||||
removes only the new copy call in memory. It exercises flat strings, concatenated
|
||||
strings, and JSON-parsed SDK-style frames; each input has a distinct task owner.
|
||||
It measures after GC, then clears the tracker and yields before measuring cleanup.
|
||||
[Results and bundle hashes](./results.json) preserve the complete run.
|
||||
|
||||
| JSON-parsed case | Input per task | Tasks | Visible text | Heap before | Heap after |
|
||||
| ------------------------- | ---------------: | ----: | ----------------: | ----------: | ---------: |
|
||||
| Live | 64 Ki characters | 32 | 16,384 characters | 2,125,672 | 43,536 |
|
||||
| Settled | 64 Ki characters | 32 | 16,384 characters | 2,127,072 | 44,296 |
|
||||
| Removed, awaiting outcome | 64 Ki characters | 32 | 0 characters | 2,108,136 | 25,360 |
|
||||
| Live | 4 Mi characters | 8 | 4,096 characters | 33,562,624 | 11,048 |
|
||||
| Settled | 4 Mi characters | 8 | 4,096 characters | 33,563,960 | 11,656 |
|
||||
| Removed, awaiting outcome | 4 Mi characters | 8 | 0 characters | 33,558,584 | 7,008 |
|
||||
|
||||
Captured with Node v26.6.0 on macOS. Cleanup returned near the initial heap for
|
||||
every case. Six regression tests retain the actual tracker through these three
|
||||
lifetimes for both descriptions and names. Text behavior tests preserve whitespace
|
||||
normalization, fallback names, and a clipped surrogate pair.
|
||||
|
||||
## Reachability and limits
|
||||
|
||||
`claude-stream-json-connection.ts` forwards SDK messages to the structured adapter,
|
||||
whose `emit` calls `backgroundTasks.observe`. Installed SDK 0.3.251 uses Node
|
||||
`readline` to assemble stdout records, parses each record with `JSON.parse`, then
|
||||
yields it. The inspected path imposes no record or description length limit;
|
||||
native read-chunk size does not cap an assembled JSON field. Descriptions are
|
||||
declared as plain strings in `SDKTaskStartedMessage`.
|
||||
|
||||
The description slice and this SDK version also exist in `v1.4.198`; that tag
|
||||
keeps the reader inline in `claude-background-task-tracker.ts`. The separate
|
||||
settled/recently-removed retention and name-reader paths describe current code.
|
||||
|
||||
The current maps are count-bounded: at most 256 live, 256 settled, and 256 recently
|
||||
removed entries per tracker. Settled context clears when no visible work remains;
|
||||
recently removed context awaits an outcome, eviction, or explicit clearing.
|
||||
Session end/close clears the tracker. Copy work is at most 512 UTF-16 code units
|
||||
per retained field, and it does not reduce temporary parsing allocation.
|
||||
|
||||
These are synthetic oversized task fields, not evidence that an affected user
|
||||
received such fields. The path concerns structured Claude sessions, not ordinary
|
||||
terminal output or stderr. Neither #19831 nor #19768 establishes this trigger.
|
||||
|
||||
The separate digest-bounded subagent ID was also checked at actual consumers.
|
||||
The mobile response sanitizer can temporarily retain the original until JSON
|
||||
serialization flattens its concatenated ID. Worker transcript bounding already
|
||||
serializes for its byte budget and released that parent in the probe. No durable
|
||||
ID-owner leak was established, so that helper is unchanged.
|
||||
@@ -1,197 +0,0 @@
|
||||
const fs = require('node:fs')
|
||||
const { build } = require('esbuild')
|
||||
const assert = require('node:assert/strict')
|
||||
const path = require('node:path')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { createHash } = require('node:crypto')
|
||||
const root = path.resolve(__dirname, '../../..')
|
||||
const bundles = {}
|
||||
|
||||
assert.equal(process.env.ORCA_BACKGROUND_LAUNCH, '1')
|
||||
assert.equal(typeof global.gc, 'function')
|
||||
|
||||
async function loadTracker(fixed) {
|
||||
const result = await build({
|
||||
entryPoints: [path.join(root, 'src/main/claude/claude-background-task-tracker.ts')],
|
||||
bundle: true,
|
||||
write: false,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node22',
|
||||
plugins: fixed
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: 'baseline-without-task-text-copy',
|
||||
setup(builder) {
|
||||
builder.onLoad({ filter: /claude-background-task-frames\.ts$/ }, (args) => {
|
||||
const source = fs.readFileSync(args.path, 'utf8')
|
||||
const boundary = 'ownRetainedString(trimmed.slice(0, MAX_TASK_TEXT_LENGTH))'
|
||||
assert.ok(
|
||||
source.includes(boundary),
|
||||
'The copy boundary changed; update the baseline transform'
|
||||
)
|
||||
return {
|
||||
loader: 'ts',
|
||||
contents: source.replace(boundary, 'trimmed.slice(0, MAX_TASK_TEXT_LENGTH)')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
bundles[fixed ? 'after' : 'before'] = createHash('sha256')
|
||||
.update(result.outputFiles[0].text)
|
||||
.digest('hex')
|
||||
const scratch = fs.mkdtempSync(path.join(tmpdir(), 'orca-claude-task-proof-'))
|
||||
let moduleId
|
||||
try {
|
||||
const bundlePath = path.join(scratch, 'tracker.cjs')
|
||||
fs.writeFileSync(bundlePath, result.outputFiles[0].text)
|
||||
moduleId = require.resolve(bundlePath)
|
||||
return require(moduleId).ClaudeBackgroundTaskTracker
|
||||
} finally {
|
||||
if (moduleId) {
|
||||
delete require.cache[moduleId]
|
||||
}
|
||||
fs.rmSync(scratch, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function collect() {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
global.gc()
|
||||
}
|
||||
return process.memoryUsage().heapUsed
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
function frame(index, size, ingress, field) {
|
||||
const value = String.fromCharCode(65 + (index % 26)).repeat(size)
|
||||
const message = {
|
||||
type: 'system',
|
||||
subtype: 'task_started',
|
||||
task_id: `task-${index}`,
|
||||
task_type: 'local_bash',
|
||||
is_backgrounded: true,
|
||||
[field]: value
|
||||
}
|
||||
if (ingress === 'json') {
|
||||
return JSON.parse(JSON.stringify(message))
|
||||
}
|
||||
if (ingress === 'flat') {
|
||||
value.charCodeAt(value.length - 1)
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
function populate(Tracker, { count, size, ingress, retention, field }) {
|
||||
const owner = new Tracker()
|
||||
const keeper = {
|
||||
type: 'system',
|
||||
subtype: 'task_started',
|
||||
task_id: 'keeper',
|
||||
task_type: 'local_bash',
|
||||
is_backgrounded: true
|
||||
}
|
||||
if (retention !== 'live') {
|
||||
owner.observe(keeper)
|
||||
}
|
||||
for (let index = 0; index < count; index++) {
|
||||
owner.observe(frame(index, size, ingress, field))
|
||||
if (retention === 'settled') {
|
||||
owner.observe({
|
||||
type: 'system',
|
||||
subtype: 'task_notification',
|
||||
task_id: `task-${index}`,
|
||||
status: 'completed'
|
||||
})
|
||||
}
|
||||
}
|
||||
if (retention === 'removed') {
|
||||
owner.observe({ type: 'system', subtype: 'background_tasks_changed', tasks: [keeper] })
|
||||
}
|
||||
return owner
|
||||
}
|
||||
|
||||
function logicalChars(owner) {
|
||||
const state = owner.state
|
||||
return [...(state?.tasks ?? []), ...(state?.settledTasks ?? [])].reduce(
|
||||
(sum, task) => sum + (task.description?.length ?? 0) + (task.name?.length ?? 0),
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const Before = await loadTracker(false)
|
||||
const Fixed = await loadTracker(true)
|
||||
for (const Tracker of [Before, Fixed]) {
|
||||
const warm = populate(Tracker, {
|
||||
count: 1,
|
||||
size: 1024,
|
||||
ingress: 'json',
|
||||
retention: 'live',
|
||||
field: 'description'
|
||||
})
|
||||
warm.clear()
|
||||
}
|
||||
const results = []
|
||||
for (const [count, size] of [
|
||||
[32, 64 * 1024],
|
||||
[8, 4 * 1024 * 1024]
|
||||
]) {
|
||||
for (const ingress of ['flat', 'cons', 'json']) {
|
||||
for (const retention of ['live', 'settled', 'removed']) {
|
||||
for (const [phase, Tracker] of [
|
||||
['before', Before],
|
||||
['after', Fixed]
|
||||
]) {
|
||||
await settle()
|
||||
const baseline = collect()
|
||||
global.auditTaskOwner = populate(Tracker, {
|
||||
count,
|
||||
size,
|
||||
ingress,
|
||||
retention,
|
||||
field: 'description'
|
||||
})
|
||||
await settle()
|
||||
const retainedHeapBytes = collect() - baseline
|
||||
const visibleTextChars = logicalChars(global.auditTaskOwner)
|
||||
global.auditTaskOwner.clear()
|
||||
global.auditTaskOwner = null
|
||||
await settle()
|
||||
const afterClearHeapBytes = collect() - baseline
|
||||
if (phase === 'after') {
|
||||
assert.ok(retainedHeapBytes < 1024 * 1024, 'A bounded task retained its parent frame')
|
||||
} else {
|
||||
assert.ok(
|
||||
retainedHeapBytes > count * size * 0.75,
|
||||
'Baseline no longer reproduces retention'
|
||||
)
|
||||
}
|
||||
assert.ok(afterClearHeapBytes < 1024 * 1024, 'Tracker cleanup retained the fixture')
|
||||
results.push({
|
||||
count,
|
||||
size,
|
||||
ingress,
|
||||
retention,
|
||||
phase,
|
||||
visibleTextChars,
|
||||
retainedHeapBytes,
|
||||
afterClearHeapBytes
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({ node: process.version, platform: process.platform, bundles, results }, null, 2)
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,370 +0,0 @@
|
||||
{
|
||||
"node": "v26.6.0",
|
||||
"platform": "darwin",
|
||||
"bundles": {
|
||||
"before": "95425d0894ed107d85a671238f0229e6db3e229c0fa94286d1bea1a52db7112f",
|
||||
"after": "e1e5f58ddba3aeea88922be927509754b766247b81b6dcf7675b49a25c3a8d29"
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "flat",
|
||||
"retention": "live",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 16384,
|
||||
"retainedHeapBytes": 2159976,
|
||||
"afterClearHeapBytes": 32392
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "flat",
|
||||
"retention": "live",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 16384,
|
||||
"retainedHeapBytes": 97680,
|
||||
"afterClearHeapBytes": 51600
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "flat",
|
||||
"retention": "settled",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 16384,
|
||||
"retainedHeapBytes": 2140416,
|
||||
"afterClearHeapBytes": 15320
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "flat",
|
||||
"retention": "settled",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 16384,
|
||||
"retainedHeapBytes": 55816,
|
||||
"afterClearHeapBytes": 12976
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "flat",
|
||||
"retention": "removed",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 0,
|
||||
"retainedHeapBytes": 2112656,
|
||||
"afterClearHeapBytes": 5200
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "flat",
|
||||
"retention": "removed",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 0,
|
||||
"retainedHeapBytes": 36568,
|
||||
"afterClearHeapBytes": 11832
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "cons",
|
||||
"retention": "live",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 16384,
|
||||
"retainedHeapBytes": 2125456,
|
||||
"afterClearHeapBytes": -304
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "cons",
|
||||
"retention": "live",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 16384,
|
||||
"retainedHeapBytes": 42736,
|
||||
"afterClearHeapBytes": -304
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "cons",
|
||||
"retention": "settled",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 16384,
|
||||
"retainedHeapBytes": 2127096,
|
||||
"afterClearHeapBytes": 464
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "cons",
|
||||
"retention": "settled",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 16384,
|
||||
"retainedHeapBytes": 43896,
|
||||
"afterClearHeapBytes": 368
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "cons",
|
||||
"retention": "removed",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 0,
|
||||
"retainedHeapBytes": 2123696,
|
||||
"afterClearHeapBytes": 16216
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "cons",
|
||||
"retention": "removed",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 0,
|
||||
"retainedHeapBytes": 39672,
|
||||
"afterClearHeapBytes": 15064
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "json",
|
||||
"retention": "live",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 16384,
|
||||
"retainedHeapBytes": 2125672,
|
||||
"afterClearHeapBytes": -32
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "json",
|
||||
"retention": "live",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 16384,
|
||||
"retainedHeapBytes": 43536,
|
||||
"afterClearHeapBytes": 544
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "json",
|
||||
"retention": "settled",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 16384,
|
||||
"retainedHeapBytes": 2127072,
|
||||
"afterClearHeapBytes": 1424
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "json",
|
||||
"retention": "settled",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 16384,
|
||||
"retainedHeapBytes": 44296,
|
||||
"afterClearHeapBytes": 1248
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "json",
|
||||
"retention": "removed",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 0,
|
||||
"retainedHeapBytes": 2108136,
|
||||
"afterClearHeapBytes": 1072
|
||||
},
|
||||
{
|
||||
"count": 32,
|
||||
"size": 65536,
|
||||
"ingress": "json",
|
||||
"retention": "removed",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 0,
|
||||
"retainedHeapBytes": 25360,
|
||||
"afterClearHeapBytes": 8832
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "flat",
|
||||
"retention": "live",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 4096,
|
||||
"retainedHeapBytes": 33562624,
|
||||
"afterClearHeapBytes": -320
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "flat",
|
||||
"retention": "live",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 4096,
|
||||
"retainedHeapBytes": 11048,
|
||||
"afterClearHeapBytes": -320
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "flat",
|
||||
"retention": "settled",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 4096,
|
||||
"retainedHeapBytes": 33563960,
|
||||
"afterClearHeapBytes": 912
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "flat",
|
||||
"retention": "settled",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 4096,
|
||||
"retainedHeapBytes": 12384,
|
||||
"afterClearHeapBytes": 464
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "flat",
|
||||
"retention": "removed",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 0,
|
||||
"retainedHeapBytes": 33559744,
|
||||
"afterClearHeapBytes": 1112
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "flat",
|
||||
"retention": "removed",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 0,
|
||||
"retainedHeapBytes": 7512,
|
||||
"afterClearHeapBytes": 552
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "cons",
|
||||
"retention": "live",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 4096,
|
||||
"retainedHeapBytes": 33562624,
|
||||
"afterClearHeapBytes": -384
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "cons",
|
||||
"retention": "live",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 4096,
|
||||
"retainedHeapBytes": 11048,
|
||||
"afterClearHeapBytes": -384
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "cons",
|
||||
"retention": "settled",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 4096,
|
||||
"retainedHeapBytes": 33563960,
|
||||
"afterClearHeapBytes": 784
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "cons",
|
||||
"retention": "settled",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 4096,
|
||||
"retainedHeapBytes": 12384,
|
||||
"afterClearHeapBytes": 416
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "cons",
|
||||
"retention": "removed",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 0,
|
||||
"retainedHeapBytes": 32432128,
|
||||
"afterClearHeapBytes": -1126504
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "cons",
|
||||
"retention": "removed",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 0,
|
||||
"retainedHeapBytes": 7008,
|
||||
"afterClearHeapBytes": 48
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "json",
|
||||
"retention": "live",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 4096,
|
||||
"retainedHeapBytes": 33562624,
|
||||
"afterClearHeapBytes": -384
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "json",
|
||||
"retention": "live",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 4096,
|
||||
"retainedHeapBytes": 11048,
|
||||
"afterClearHeapBytes": -384
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "json",
|
||||
"retention": "settled",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 4096,
|
||||
"retainedHeapBytes": 33563960,
|
||||
"afterClearHeapBytes": 432
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "json",
|
||||
"retention": "settled",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 4096,
|
||||
"retainedHeapBytes": 11656,
|
||||
"afterClearHeapBytes": -392
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "json",
|
||||
"retention": "removed",
|
||||
"phase": "before",
|
||||
"visibleTextChars": 0,
|
||||
"retainedHeapBytes": 33558584,
|
||||
"afterClearHeapBytes": -48
|
||||
},
|
||||
{
|
||||
"count": 8,
|
||||
"size": 4194304,
|
||||
"ingress": "json",
|
||||
"retention": "removed",
|
||||
"phase": "after",
|
||||
"visibleTextChars": 0,
|
||||
"retainedHeapBytes": 7008,
|
||||
"afterClearHeapBytes": 48
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
# Closed editor model lifetime
|
||||
|
||||
Closing the final editor, or closing a file while every editor panel is hidden, can leave its Monaco text model registered for the renderer's remaining lifetime. The old cleanup hook lived in the conditionally mounted `EditorPanel`: it disappeared before observing the final close and missed closes while absent. `MonacoEditor` uses `keepCurrentModel`, so unmounting the widget does not provide the missing cleanup.
|
||||
|
||||
The fix subscribes from the app shell and lazily receives the model registry from the existing `monaco-setup` load. It retires only closed tab owners. It preserves live same-URI editors, replacement model objects, generated diff namespaces, attached models until detachment, and caches written by a reopened owner during a disposal callback. Existing scroll, selection, preview, and PDF caches retain their separate 20-entry LRU lifetime. No open tab, dirty draft, or live editor history is pruned. Closed-file undo follows Monaco's existing bounded policy; it is not unlimited (see below).
|
||||
|
||||
## Measured result
|
||||
|
||||
| Actual source control | Before | Fixed |
|
||||
| -------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| Eight distinct last-file closes | 8 registered closed models; 2,097,208 logical characters; 0 open files | 0 registered models; 0 open files |
|
||||
| Close with another editor still mounted | Closed model disposed; sibling preserved | Same |
|
||||
| Close while hidden, then open another editor | Old closed model remains; 2 models for 1 open file | Old model disposed; 1 model for 1 open file |
|
||||
| Normal terminal/editor switch | Original open model preserved | Same |
|
||||
|
||||
All four controls pass with their stated before/fixed expectations on both graphs and both runtimes: **32 comparative cases**, Node 24.20.0 and Electron 43.7.0 / Node 24.21.0. Reports are `worktree-*-results.json` and `main-*-results.json`. Contents use eight distinct fixture paths and 256 KiB of logical characters per model; this is a controlled amplification, not a field file-size measurement or heap-byte estimate.
|
||||
|
||||
This explains a concrete renderer retention mechanism relevant to [#12845](https://github.com/stablyai/orca/issues/12845). The same conditional panel, hook placement, and `keepCurrentModel` chain exists in reported v1.4.170; `source-versions.json` records four historical source hashes and matching line numbers. That comparison is static, not an execution of the historical application. It does not establish which allocations caused #12845's reported heap, or the process and cause of [#19831](https://github.com/stablyai/orca/issues/19831).
|
||||
|
||||
## Ownership and regression coverage
|
||||
|
||||
The **27 permanent cases across five suites** in `closed-editor-model-{lifetime,reentrancy,registry,shell}.test.*` and `closed-editor-view-state-retention.test.ts` use actual installed Monaco models and store close actions. They cover final/hidden panel closure, live siblings and shared URIs, replacement model identities, attachment/detachment, reopening, registry removal/replacement, reentrant registry changes, diff prefixes, bounded caches, and ordinary edit closes without a global registry scan. Together with the three existing disposal/cache suites, **47 tests pass**. The fixture exercises Monaco's real attachment ports without creating a native editor widget.
|
||||
|
||||
The startup routing suite now also guards the production app-shell hook placement: deleting that call fails the test. All **75 selected tests across nine suites pass**. Full Web typechecking currently reports one unrelated existing unused `NativeChatMessage` import in `NativeChatMessageList.windowing.test.tsx`; it reports no errors in the changed files.
|
||||
|
||||
Registry notifications cancel obsolete queued callbacks but preserve captured model identities and attached-model listeners. A temporary missing registry defers cleanup until registration returns; an interrupted disposal batch is retried. The identity fence prevents that retry from disposing a successor at the same URI. Controller teardown still cancels that controller's ownership and listeners; this audit does not claim to reconcile all preexisting models after an app-shell HMR remount.
|
||||
|
||||
### Closed-file undo decision
|
||||
|
||||
Keep Monaco's bounded closed-file undo store rather than retaining every edited model indefinitely, following the same design used by [VS Code's model service](https://github.com/microsoft/vscode/blob/main/src/vs/editor/common/services/modelService.ts). Installed Monaco keeps about 20 MiB of closed-file undo data. Tests confirm ordinary POSIX paths and `file:///C:/...` paths restore undo when reopened with matching contents. Large files (over 10 MiB of text in the fixture) and raw Windows `C:/...` paths do not. Those cases lose undo after close/reopen, including final/hidden tabs whose models previously leaked. Existing view-state caches remain intact within their 20-entry budget. This is a deliberate bounded-memory tradeoff, not zero user-visible risk.
|
||||
|
||||
## Source fences and independent main publication
|
||||
|
||||
`source-versions.json` fences **1,021 repository paths**: the complete loaded repository graph of the comparative proof plus selected static wiring/caller files. Vite rejects any additional repository module under `src/` or `config/`. Package code is not part of that repository-graph claim: twelve separate dependency hashes pin the installed package metadata and relevant Monaco model/registry/event and Zustand store implementations.
|
||||
|
||||
- Worktree baseline: `f142544a8653eedab2eec0237a9005001497b2f5`.
|
||||
- Publication main: `291b4ddd6f1c1af480169885e0fda7f9c78ff053`.
|
||||
- `fix.patch` contains only the eight product changes, including two new modules. The loader reverses it from the checked-out fixed sources to reconstruct baseline behavior.
|
||||
- `main-context.patch` reconstructs 22 unrelated context differences, including two paths absent on main. It is proof scaffolding; do not apply it as a product patch.
|
||||
- Main retains its unrelated `registerShellMarkdownAliases` import/call in `monaco-setup.ts`. The standalone product patch preserves both lines.
|
||||
- The loader checks each before/fixed hash, refuses source/dependency drift, normalizes CRLF to LF, and supports both the working checkout and an independently published main checkout. `loader-results.json` records synthetic CRLF and simulated-main-checkout controls for all four graph/variant combinations.
|
||||
|
||||
The comparative test executes the actual legacy surface, old/new cleanup hook, disposal functions, store close action and model registry. It supplies controlled Panel/Shell ports and a fixture store to the hook. Actual app-shell placement, first lazy registration, conditional panel callers and `keepCurrentModel` wiring are source-fenced static checks; it does not render the entire application or exercise HMR through Vite itself. It opens no Electron window, starts no native editor widget, and measures no RSS, heap bytes, or incident allocation rate.
|
||||
|
||||
## Review follow-up
|
||||
|
||||
The registry-transition regression is covered by four new cases. The uninformative cold-registry fixture assertion was removed, and this audit's product patch, source hashes, loader controls, and all eight comparative reports were regenerated from the current sources. The audit directory is now explicitly allowed in `.gitignore`.
|
||||
|
||||
## Reproduce
|
||||
|
||||
From the repository root with its installed dependencies:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config docs/audits/closed-editor-model-lifetime/vitest.config.mjs
|
||||
node docs/audits/closed-editor-model-lifetime/loader-controls.cjs
|
||||
```
|
||||
|
||||
The default is `ORCA_CLOSED_MODEL_GRAPH=worktree` and `ORCA_CLOSED_MODEL_VARIANT=fixed`. Repeat with each graph (`worktree`, `main`) and variant (`before`, `fixed`). Set these environment variables using the syntax of your shell; the runner and path handling are platform-neutral. Set `ORCA_CLOSED_MODEL_OUTPUT` or `ORCA_CLOSED_MODEL_LOADER_OUTPUT` to an output path to avoid replacing checked-in reports.
|
||||
|
||||
For Electron, run the installed Electron binary with `ELECTRON_RUN_AS_NODE=1`, `ORCA_BACKGROUND_LAUNCH=1`, `--no-experimental-webstorage --expose-gc`, followed by `node_modules/vitest/vitest.mjs run --config docs/audits/closed-editor-model-lifetime/vitest.config.mjs` and the same graph/variant environment. This uses Electron's Node runtime without creating a window.
|
||||
@@ -1,485 +0,0 @@
|
||||
===================================================================
|
||||
--- a/src/renderer/src/app-shell/use-app-shell-services.ts
|
||||
+++ b/src/renderer/src/app-shell/use-app-shell-services.ts
|
||||
@@ -1,0 +2,1 @@
|
||||
+import { useClosedEditorTabCleanup } from '../components/editor/useClosedEditorTabCleanup'
|
||||
@@ -32,0 +34,1 @@
|
||||
+ useClosedEditorTabCleanup()
|
||||
===================================================================
|
||||
--- a/src/renderer/src/components/editor/EditorPanel.tsx
|
||||
+++ b/src/renderer/src/components/editor/EditorPanel.tsx
|
||||
@@ -15,1 +14,0 @@
|
||||
-import { useClosedEditorTabCleanup } from './useClosedEditorTabCleanup'
|
||||
@@ -145,1 +143,0 @@
|
||||
- useClosedEditorTabCleanup(openFiles)
|
||||
===================================================================
|
||||
--- a/src/renderer/src/components/editor/closed-editor-tab-controller.ts
|
||||
+++ b/src/renderer/src/components/editor/closed-editor-tab-controller.ts
|
||||
@@ -0,0 +1,234 @@
|
||||
+import type { StoreApi } from 'zustand'
|
||||
+import type { editor } from 'monaco-editor'
|
||||
+import type { OpenFile } from '@/store/slices/editor'
|
||||
+import { editorModelRegistry } from '@/lib/editor-model-registry'
|
||||
+import { disposeClosedEditorModels, type ClosedEditorTab } from './closed-editor-tab-disposal'
|
||||
+import type { MonacoModelRegistry, DisposableMonacoModel } from './diff-monaco-model-disposal'
|
||||
+
|
||||
+type EditorStore = Pick<StoreApi<{ openFiles: OpenFile[] }>, 'getState' | 'subscribe'>
|
||||
+type RetainedModel = {
|
||||
+ files: Map<string, ClosedEditorTab>
|
||||
+ detach: { dispose(): void }
|
||||
+ dispose: { dispose(): void }
|
||||
+}
|
||||
+
|
||||
+function ownerKey(file: ClosedEditorTab): string {
|
||||
+ return JSON.stringify([file.id, file.mode, file.filePath])
|
||||
+}
|
||||
+
|
||||
+export function attachClosedEditorTabCleanup(
|
||||
+ store: EditorStore,
|
||||
+ bridge = editorModelRegistry
|
||||
+): () => void {
|
||||
+ let registry = bridge.get()
|
||||
+ let previousFiles = store.getState().openFiles
|
||||
+ const pendingFiles = new Map<string, ClosedEditorTab>()
|
||||
+ const candidateModels = new Set<editor.ITextModel>()
|
||||
+ const retainedModels = new Map<editor.ITextModel, RetainedModel>()
|
||||
+ let active = true
|
||||
+ let scheduled = false
|
||||
+ let generation = 0
|
||||
+
|
||||
+ const releaseRetainedModel = (model: editor.ITextModel): void => {
|
||||
+ const retained = retainedModels.get(model)
|
||||
+ if (!retained) {
|
||||
+ return
|
||||
+ }
|
||||
+ retainedModels.delete(model)
|
||||
+ retained.detach.dispose()
|
||||
+ retained.dispose.dispose()
|
||||
+ retained.files.clear()
|
||||
+ }
|
||||
+
|
||||
+ const clearPending = (): void => {
|
||||
+ generation += 1
|
||||
+ scheduled = false
|
||||
+ pendingFiles.clear()
|
||||
+ candidateModels.clear()
|
||||
+ for (const model of retainedModels.keys()) {
|
||||
+ releaseRetainedModel(model)
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ const schedule = (): void => {
|
||||
+ if (!active || scheduled) {
|
||||
+ return
|
||||
+ }
|
||||
+ scheduled = true
|
||||
+ const queuedGeneration = generation
|
||||
+ queueMicrotask(() => {
|
||||
+ if (!active || generation !== queuedGeneration) {
|
||||
+ return
|
||||
+ }
|
||||
+ scheduled = false
|
||||
+ flush()
|
||||
+ })
|
||||
+ }
|
||||
+
|
||||
+ const retainAttachedModel = (candidate: DisposableMonacoModel, file: ClosedEditorTab): void => {
|
||||
+ if (!registry) {
|
||||
+ return
|
||||
+ }
|
||||
+ const model = registry.editor.getModel(registry.Uri.parse(candidate.uri.toString()))
|
||||
+ if (!model || model !== candidate) {
|
||||
+ return
|
||||
+ }
|
||||
+ let retained = retainedModels.get(model)
|
||||
+ if (!retained) {
|
||||
+ retained = {
|
||||
+ files: new Map(),
|
||||
+ detach: model.onDidChangeAttached(schedule),
|
||||
+ dispose: model.onWillDispose(() => releaseRetainedModel(model))
|
||||
+ }
|
||||
+ retainedModels.set(model, retained)
|
||||
+ }
|
||||
+ retained.files.set(ownerKey(file), file)
|
||||
+ }
|
||||
+
|
||||
+ const flush = (): void => {
|
||||
+ const currentRegistry = registry
|
||||
+ if (!currentRegistry) {
|
||||
+ if (candidateModels.size === 0) {
|
||||
+ pendingFiles.clear()
|
||||
+ }
|
||||
+ return
|
||||
+ }
|
||||
+ const flushGeneration = generation
|
||||
+ let checkedOpenFiles: OpenFile[] | null = null
|
||||
+ let openIds = new Set<string>()
|
||||
+ let openEditUris = new Set<string>()
|
||||
+ const stillOwned = (file: ClosedEditorTab): boolean => {
|
||||
+ const openFiles = store.getState().openFiles
|
||||
+ if (openFiles !== checkedOpenFiles) {
|
||||
+ checkedOpenFiles = openFiles
|
||||
+ openIds = new Set(openFiles.map((openFile) => openFile.id))
|
||||
+ openEditUris = new Set(
|
||||
+ openFiles
|
||||
+ .filter((openFile) => openFile.mode === 'edit')
|
||||
+ .map(
|
||||
+ (openFile) =>
|
||||
+ currentRegistry?.Uri.parse(openFile.filePath).toString() ?? openFile.filePath
|
||||
+ )
|
||||
+ )
|
||||
+ }
|
||||
+ return (
|
||||
+ openIds.has(file.id) ||
|
||||
+ (file.mode === 'edit' &&
|
||||
+ openEditUris.has(currentRegistry?.Uri.parse(file.filePath).toString() ?? file.filePath))
|
||||
+ )
|
||||
+ }
|
||||
+ const disposeCaptured = (
|
||||
+ files: ClosedEditorTab[],
|
||||
+ models: ReadonlySet<editor.ITextModel>
|
||||
+ ): void => {
|
||||
+ if (!currentRegistry) {
|
||||
+ return
|
||||
+ }
|
||||
+ const fencedRegistry: MonacoModelRegistry = {
|
||||
+ Uri: currentRegistry.Uri,
|
||||
+ editor: {
|
||||
+ getModel(uri) {
|
||||
+ if (!currentRegistry.Uri.isUri(uri)) {
|
||||
+ return null
|
||||
+ }
|
||||
+ const model = currentRegistry.editor.getModel(uri)
|
||||
+ return model && models.has(model) ? model : null
|
||||
+ },
|
||||
+ getModels: () =>
|
||||
+ [...models].filter((model) => currentRegistry.editor.getModel(model.uri) === model)
|
||||
+ }
|
||||
+ }
|
||||
+ disposeClosedEditorModels(
|
||||
+ fencedRegistry,
|
||||
+ files,
|
||||
+ retainAttachedModel,
|
||||
+ (file) => active && generation === flushGeneration && !stillOwned(file)
|
||||
+ )
|
||||
+ }
|
||||
+
|
||||
+ const files = [...pendingFiles.values()].filter((file) => !stillOwned(file))
|
||||
+ const models = new Set(candidateModels)
|
||||
+ pendingFiles.clear()
|
||||
+ candidateModels.clear()
|
||||
+ disposeCaptured(files, models)
|
||||
+ if (active && generation !== flushGeneration) {
|
||||
+ for (const file of files) {
|
||||
+ pendingFiles.set(ownerKey(file), file)
|
||||
+ }
|
||||
+ for (const model of models) {
|
||||
+ if (!model.isDisposed()) {
|
||||
+ candidateModels.add(model)
|
||||
+ }
|
||||
+ }
|
||||
+ schedule()
|
||||
+ return
|
||||
+ }
|
||||
+
|
||||
+ for (const [model, retained] of retainedModels) {
|
||||
+ if (!active || generation !== flushGeneration) {
|
||||
+ return
|
||||
+ }
|
||||
+ if (currentRegistry?.editor.getModel(model.uri) !== model) {
|
||||
+ releaseRetainedModel(model)
|
||||
+ continue
|
||||
+ }
|
||||
+ const closedFiles = [...retained.files.values()].filter((file) => !stillOwned(file))
|
||||
+ if (closedFiles.length === 0) {
|
||||
+ releaseRetainedModel(model)
|
||||
+ } else if (!model.isAttachedToEditor()) {
|
||||
+ releaseRetainedModel(model)
|
||||
+ disposeCaptured(closedFiles, new Set([model]))
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ const unsubscribe = store.subscribe(() => {
|
||||
+ const openFiles = store.getState().openFiles
|
||||
+ if (openFiles === previousFiles) {
|
||||
+ return
|
||||
+ }
|
||||
+ const previous = previousFiles
|
||||
+ previousFiles = openFiles
|
||||
+ const liveIds = new Set(openFiles.map((file) => file.id))
|
||||
+ let removed = false
|
||||
+ let removedDiff = false
|
||||
+ for (const file of previous) {
|
||||
+ if (!liveIds.has(file.id)) {
|
||||
+ const descriptor = { id: file.id, mode: file.mode, filePath: file.filePath }
|
||||
+ pendingFiles.set(ownerKey(descriptor), descriptor)
|
||||
+ removed = true
|
||||
+ if (file.mode === 'edit' && registry) {
|
||||
+ const model = registry.editor.getModel(registry.Uri.parse(file.filePath))
|
||||
+ if (model) {
|
||||
+ candidateModels.add(model)
|
||||
+ }
|
||||
+ } else if (file.mode === 'diff') {
|
||||
+ removedDiff = true
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ if (removedDiff && registry) {
|
||||
+ for (const model of registry.editor.getModels()) {
|
||||
+ candidateModels.add(model)
|
||||
+ }
|
||||
+ }
|
||||
+ if (removed || retainedModels.size > 0) {
|
||||
+ schedule()
|
||||
+ }
|
||||
+ })
|
||||
+ const unsubscribeRegistry = bridge.subscribe(() => {
|
||||
+ // Registry notifications invalidate callbacks, not custody of captured models.
|
||||
+ generation += 1
|
||||
+ scheduled = false
|
||||
+ registry = bridge.get()
|
||||
+ if (pendingFiles.size > 0 || retainedModels.size > 0) {
|
||||
+ schedule()
|
||||
+ }
|
||||
+ })
|
||||
+ return () => {
|
||||
+ active = false
|
||||
+ unsubscribe()
|
||||
+ unsubscribeRegistry()
|
||||
+ clearPending()
|
||||
+ }
|
||||
+}
|
||||
===================================================================
|
||||
--- a/src/renderer/src/components/editor/closed-editor-tab-disposal.ts
|
||||
+++ b/src/renderer/src/components/editor/closed-editor-tab-disposal.ts
|
||||
@@ -11,1 +11,2 @@
|
||||
- type MonacoModelRegistry
|
||||
+ type MonacoModelRegistry,
|
||||
+ type DisposableMonacoModel
|
||||
@@ -18,8 +19,4 @@
|
||||
-/**
|
||||
- * Releases the Monaco models and view-state cache entries owned by a batch of closed tabs.
|
||||
- *
|
||||
- * Why the batch shape: every prefix sweep here is a full scan of a shared registry or cache, so
|
||||
- * doing one per closed tab makes "close all"/worktree-switch quadratic in retained models. Takes
|
||||
- * the monaco namespace as an argument so it stays testable without importing `monaco-editor`.
|
||||
- */
|
||||
-export function disposeClosedEditorTabs(
|
||||
+export type ClosedEditorTab = Pick<OpenFile, 'id' | 'mode' | 'filePath'>
|
||||
+
|
||||
+// One registry sweep avoids quadratic close-all work.
|
||||
+export function disposeClosedEditorModels(
|
||||
@@ -27,1 +24,3 @@
|
||||
- closedFiles: readonly OpenFile[]
|
||||
+ closedFiles: readonly ClosedEditorTab[],
|
||||
+ onAttachedModel?: (model: DisposableMonacoModel, file: ClosedEditorTab) => void,
|
||||
+ isStillClosed: (file: ClosedEditorTab) => boolean = () => true
|
||||
@@ -33,1 +32,50 @@
|
||||
- const diffModelPathPrefixes: string[] = []
|
||||
+ const diffFilesByPrefix = new Map<string, ClosedEditorTab>()
|
||||
+ for (const closedFile of closedFiles) {
|
||||
+ if (!isStillClosed(closedFile)) {
|
||||
+ continue
|
||||
+ }
|
||||
+ if (closedFile.mode === 'edit') {
|
||||
+ const model = monacoRegistry.editor.getModel(monacoRegistry.Uri.parse(closedFile.filePath))
|
||||
+ if (model?.isAttachedToEditor()) {
|
||||
+ onAttachedModel?.(model, closedFile)
|
||||
+ } else {
|
||||
+ model?.dispose()
|
||||
+ }
|
||||
+ } else if (closedFile.mode === 'diff') {
|
||||
+ const { originalModelPathPrefix, modifiedModelPathPrefix } =
|
||||
+ getDiffViewerMonacoModelPathPrefixes(closedFile.id)
|
||||
+ diffFilesByPrefix.set(originalModelPathPrefix, closedFile)
|
||||
+ diffFilesByPrefix.set(modifiedModelPathPrefix, closedFile)
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ disposeUnattachedMonacoModelsByPathPrefixes(
|
||||
+ monacoRegistry,
|
||||
+ [...diffFilesByPrefix.keys()],
|
||||
+ (model, prefix) => {
|
||||
+ const file = diffFilesByPrefix.get(prefix)
|
||||
+ if (file) {
|
||||
+ onAttachedModel?.(model, file)
|
||||
+ }
|
||||
+ },
|
||||
+ (prefix) => {
|
||||
+ const file = diffFilesByPrefix.get(prefix)
|
||||
+ return file !== undefined && isStillClosed(file)
|
||||
+ }
|
||||
+ )
|
||||
+}
|
||||
+
|
||||
+export function disposeClosedEditorTabs(
|
||||
+ monacoRegistry: MonacoModelRegistry,
|
||||
+ closedFiles: readonly ClosedEditorTab[],
|
||||
+ onAttachedModel?: (model: DisposableMonacoModel, file: ClosedEditorTab) => void,
|
||||
+ isStillClosed: (file: ClosedEditorTab) => boolean = () => true
|
||||
+): void {
|
||||
+ disposeClosedEditorModels(monacoRegistry, closedFiles, onAttachedModel, isStillClosed)
|
||||
+ disposeClosedEditorTabCaches(closedFiles, isStillClosed)
|
||||
+}
|
||||
+
|
||||
+export function disposeClosedEditorTabCaches(
|
||||
+ closedFiles: readonly ClosedEditorTab[],
|
||||
+ isStillClosed: (file: ClosedEditorTab) => boolean = () => true
|
||||
+): void {
|
||||
@@ -39,0 +88,3 @@
|
||||
+ if (!isStillClosed(closedFile)) {
|
||||
+ continue
|
||||
+ }
|
||||
@@ -42,3 +92,0 @@
|
||||
- // Why: the edit model URI is constructed via monaco.Uri.parse(filePath)
|
||||
- // to match @monaco-editor/react's `path` prop convention.
|
||||
- monacoRegistry.editor.getModel(monacoRegistry.Uri.parse(closedFile.filePath))?.dispose()
|
||||
@@ -46,1 +93,0 @@
|
||||
- // Why: markdown and mermaid surfaces keep mode-scoped scroll positions.
|
||||
@@ -53,1 +99,0 @@
|
||||
- // Why: only 'edit' tabs ever get a PDF scroll key (see EditorContent).
|
||||
@@ -57,2 +102,0 @@
|
||||
- // Why: preview tabs own pane-scoped preview scroll cache entries even
|
||||
- // though they do not retain Monaco models.
|
||||
@@ -62,6 +106,1 @@
|
||||
- case 'diff': {
|
||||
- // Why: kept diff models are keyed by tab id, and fallback recovery can
|
||||
- // append generation suffixes; closing the tab owns that whole namespace.
|
||||
- const { originalModelPathPrefix, modifiedModelPathPrefix } =
|
||||
- getDiffViewerMonacoModelPathPrefixes(closedFile.id)
|
||||
- diffModelPathPrefixes.push(originalModelPathPrefix, modifiedModelPathPrefix)
|
||||
+ case 'diff':
|
||||
@@ -73,1 +111,0 @@
|
||||
- }
|
||||
@@ -80,2 +117,0 @@
|
||||
-
|
||||
- disposeUnattachedMonacoModelsByPathPrefixes(monacoRegistry, diffModelPathPrefixes)
|
||||
===================================================================
|
||||
--- a/src/renderer/src/components/editor/diff-monaco-model-disposal.ts
|
||||
+++ b/src/renderer/src/components/editor/diff-monaco-model-disposal.ts
|
||||
@@ -15,1 +15,1 @@
|
||||
-type DisposableMonacoModel = Pick<editor.ITextModel, 'dispose' | 'isAttachedToEditor'> & {
|
||||
+export type DisposableMonacoModel = Pick<editor.ITextModel, 'dispose' | 'isAttachedToEditor'> & {
|
||||
@@ -91,1 +91,3 @@
|
||||
- modelPathPrefixes: readonly string[]
|
||||
+ modelPathPrefixes: readonly string[],
|
||||
+ onAttachedModel?: (model: DisposableMonacoModel, prefix: string) => void,
|
||||
+ isStillOwned: (prefix: string) => boolean = () => true
|
||||
@@ -109,5 +111,9 @@
|
||||
- if (
|
||||
- isOwnedByPathPrefix(model.uri.toString(true), ownedPrefixes, bounds) ||
|
||||
- isOwnedByPathPrefix(model.uri.toString(), ownedPrefixes, bounds)
|
||||
- ) {
|
||||
- disposeUnattachedMonacoModel(model)
|
||||
+ const prefix =
|
||||
+ findOwnedPathPrefix(model.uri.toString(true), ownedPrefixes, bounds) ??
|
||||
+ findOwnedPathPrefix(model.uri.toString(), ownedPrefixes, bounds)
|
||||
+ if (prefix !== undefined && isStillOwned(prefix)) {
|
||||
+ if (model.isAttachedToEditor()) {
|
||||
+ onAttachedModel?.(model, prefix)
|
||||
+ } else {
|
||||
+ model.dispose()
|
||||
+ }
|
||||
@@ -122,1 +128,1 @@
|
||||
-function isOwnedByPathPrefix(
|
||||
+function findOwnedPathPrefix(
|
||||
@@ -126,1 +132,1 @@
|
||||
-): boolean {
|
||||
+): string | undefined {
|
||||
@@ -128,1 +134,1 @@
|
||||
- return true
|
||||
+ return uriString
|
||||
@@ -140,1 +146,1 @@
|
||||
- return true
|
||||
+ return uriString.slice(0, boundary)
|
||||
@@ -144,1 +150,1 @@
|
||||
- return false
|
||||
+ return undefined
|
||||
===================================================================
|
||||
--- a/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts
|
||||
+++ b/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts
|
||||
@@ -1,4 +1,3 @@
|
||||
-import { useEffect, useRef } from 'react'
|
||||
-import * as monaco from 'monaco-editor'
|
||||
-import type { OpenFile } from '@/store/slices/editor'
|
||||
-import { disposeClosedEditorTabs } from './closed-editor-tab-disposal'
|
||||
+import { useEffect } from 'react'
|
||||
+import { useAppStore } from '@/store'
|
||||
+import { attachClosedEditorTabCleanup } from './closed-editor-tab-controller'
|
||||
@@ -6,16 +5,2 @@
|
||||
-export function useClosedEditorTabCleanup(openFiles: OpenFile[]): void {
|
||||
- const prevOpenFilesRef = useRef<Map<string, OpenFile>>(new Map())
|
||||
-
|
||||
- useEffect(() => {
|
||||
- const currentFilesById = new Map(openFiles.map((f) => [f.id, f]))
|
||||
- const closedFiles: OpenFile[] = []
|
||||
- for (const [prevId, prevFile] of prevOpenFilesRef.current) {
|
||||
- if (!currentFilesById.has(prevId)) {
|
||||
- closedFiles.push(prevFile)
|
||||
- }
|
||||
- }
|
||||
- // Why one call for the whole removal batch: each sweep scans a shared registry/cache, so
|
||||
- // per-tab sweeps make a "close all" quadratic in retained models.
|
||||
- disposeClosedEditorTabs(monaco, closedFiles)
|
||||
- prevOpenFilesRef.current = currentFilesById
|
||||
- }, [openFiles])
|
||||
+export function useClosedEditorTabCleanup(): void {
|
||||
+ useEffect(() => attachClosedEditorTabCleanup(useAppStore), [])
|
||||
===================================================================
|
||||
--- a/src/renderer/src/lib/editor-model-registry.ts
|
||||
+++ b/src/renderer/src/lib/editor-model-registry.ts
|
||||
@@ -0,0 +1,38 @@
|
||||
+import type * as Monaco from 'monaco-editor'
|
||||
+
|
||||
+export type EditorModelRegistryBridge = {
|
||||
+ get(): typeof Monaco | null
|
||||
+ subscribe(listener: () => void): () => void
|
||||
+ register(registry: typeof Monaco): () => void
|
||||
+}
|
||||
+
|
||||
+export function createEditorModelRegistry(): EditorModelRegistryBridge {
|
||||
+ let registration: { registry: typeof Monaco } | null = null
|
||||
+ const listeners = new Set<() => void>()
|
||||
+ const notify = (): void => {
|
||||
+ for (const listener of listeners) {
|
||||
+ listener()
|
||||
+ }
|
||||
+ }
|
||||
+ return {
|
||||
+ get: (): typeof Monaco | null => registration?.registry ?? null,
|
||||
+ subscribe(listener: () => void): () => void {
|
||||
+ listeners.add(listener)
|
||||
+ return () => listeners.delete(listener)
|
||||
+ },
|
||||
+ register(registry: typeof Monaco): () => void {
|
||||
+ const next = { registry }
|
||||
+ registration = next
|
||||
+ notify()
|
||||
+ return () => {
|
||||
+ if (registration !== next) {
|
||||
+ return
|
||||
+ }
|
||||
+ registration = null
|
||||
+ notify()
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+export const editorModelRegistry = createEditorModelRegistry()
|
||||
===================================================================
|
||||
--- a/src/renderer/src/lib/monaco-setup.ts
|
||||
+++ b/src/renderer/src/lib/monaco-setup.ts
|
||||
@@ -1,0 +2,1 @@
|
||||
+import { editorModelRegistry } from './editor-model-registry'
|
||||
@@ -12,0 +14,1 @@
|
||||
+import { registerShellMarkdownAliases } from './monaco-languages/register-shell-markdown-aliases'
|
||||
@@ -81,0 +84,1 @@
|
||||
+registerShellMarkdownAliases(monaco)
|
||||
@@ -92,0 +96,5 @@
|
||||
+
|
||||
+const unregisterEditorModelRegistry = editorModelRegistry.register(monaco)
|
||||
+if (import.meta.hot) {
|
||||
+ import.meta.hot.dispose(unregisterEditorModelRegistry)
|
||||
+}
|
||||
@@ -1,233 +0,0 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { resolve } from 'node:path'
|
||||
import React from 'react'
|
||||
import { useStore } from 'zustand'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import * as monaco from 'monaco-editor'
|
||||
import { afterAll, afterEach, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createTestStore,
|
||||
makeWorktree
|
||||
} from '../../../src/renderer/src/store/slices/store-test-helpers'
|
||||
import { createStoreSessionMockApi } from '../../../src/renderer/src/store/slices/store-session-test-harness'
|
||||
import { useClosedEditorTabCleanup } from '../../../src/renderer/src/components/editor/useClosedEditorTabCleanup'
|
||||
import { TerminalLegacyEditorSurface } from '../../../src/renderer/src/components/TerminalLegacyEditorSurface'
|
||||
|
||||
const fixture = vi.hoisted(() => ({ store: null }))
|
||||
const variant = process.env.ORCA_CLOSED_MODEL_VARIANT ?? 'fixed'
|
||||
const fixed = variant === 'fixed'
|
||||
const bridge = fixed
|
||||
? (await import('../../../src/renderer/src/lib/editor-model-registry')).editorModelRegistry
|
||||
: null
|
||||
let unregisterRegistry = null
|
||||
const observations = []
|
||||
const require = createRequire(resolve('package.json'))
|
||||
const { loadSources, readText, sha256 } = require(
|
||||
resolve('docs/audits/closed-editor-model-lifetime/sources.cjs')
|
||||
)
|
||||
const workspace = 'audit-repo::/audit-model-workspace'
|
||||
const domWindow = globalThis.window
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => fixture.store.getState(),
|
||||
subscribe: (...args) => fixture.store.subscribe(...args)
|
||||
}
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() } }))
|
||||
vi.mock('../../../src/renderer/src/components/editor/EditorPanel', async () => {
|
||||
const { useClosedEditorTabCleanup } =
|
||||
await import('../../../src/renderer/src/components/editor/useClosedEditorTabCleanup')
|
||||
return {
|
||||
default:
|
||||
(process.env.ORCA_CLOSED_MODEL_VARIANT ?? 'fixed') === 'before'
|
||||
? function PanelCleanupPort() {
|
||||
const files = useStore(fixture.store, (state) => state.openFiles)
|
||||
useClosedEditorTabCleanup(files)
|
||||
return React.createElement('div', { 'data-testid': 'cleanup-panel' })
|
||||
}
|
||||
: function PanelPort() {
|
||||
return React.createElement('div', { 'data-testid': 'cleanup-panel' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function FixedShell(props) {
|
||||
useClosedEditorTabCleanup()
|
||||
return React.createElement(Surface, props)
|
||||
}
|
||||
const Shell = fixed ? FixedShell : Surface
|
||||
|
||||
function Surface({ visible = true }) {
|
||||
const files = useStore(fixture.store, (state) => state.openFiles)
|
||||
return React.createElement(TerminalLegacyEditorSurface, {
|
||||
controller: {
|
||||
activeTabType: visible ? 'editor' : 'terminal',
|
||||
renderedActiveWorktreeId: workspace,
|
||||
worktreeFiles: files
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const api = createStoreSessionMockApi()
|
||||
globalThis.window = domWindow
|
||||
domWindow.api = api
|
||||
unregisterRegistry = bridge?.register(monaco)
|
||||
const store = createTestStore()
|
||||
fixture.store = store
|
||||
store.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'audit-repo',
|
||||
path: '/audit-model-workspace',
|
||||
displayName: 'Audit',
|
||||
badgeColor: 'gray',
|
||||
addedAt: 0,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'audit-repo': [
|
||||
makeWorktree({
|
||||
id: workspace,
|
||||
repoId: 'audit-repo',
|
||||
path: '/audit-model-workspace',
|
||||
hostId: 'local'
|
||||
})
|
||||
]
|
||||
},
|
||||
activeWorktreeId: workspace,
|
||||
openFiles: [],
|
||||
activeFileId: null
|
||||
})
|
||||
return store
|
||||
}
|
||||
|
||||
function addFile(store, id) {
|
||||
const file = {
|
||||
id,
|
||||
worktreeId: workspace,
|
||||
filePath: `/audit-model-workspace/${id}.txt`,
|
||||
relativePath: `${id}.txt`,
|
||||
mode: 'edit',
|
||||
language: 'plaintext',
|
||||
isDirty: false,
|
||||
runtimeEnvironmentId: null
|
||||
}
|
||||
const model = monaco.editor.createModel(
|
||||
`${id}\n${'x'.repeat(256 * 1024)}`,
|
||||
'plaintext',
|
||||
monaco.Uri.parse(file.filePath)
|
||||
)
|
||||
act(() => store.setState({ openFiles: [...store.getState().openFiles, file], activeFileId: id }))
|
||||
return model
|
||||
}
|
||||
|
||||
async function mounted(view) {
|
||||
await waitFor(() => expect(view.queryByTestId('cleanup-panel')).not.toBeNull())
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
unregisterRegistry?.()
|
||||
unregisterRegistry = null
|
||||
for (const model of monaco.editor.getModels()) {
|
||||
model.dispose()
|
||||
}
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('measures distinct last-file closes when the final editor removes the panel', async () => {
|
||||
const store = setup()
|
||||
const view = render(React.createElement(Shell))
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
addFile(store, `last-${index}`)
|
||||
await mounted(view)
|
||||
act(() => store.getState().closeFile(`last-${index}`))
|
||||
expect(view.queryByTestId('cleanup-panel')).toBeNull()
|
||||
expect(store.getState().openFiles).toHaveLength(0)
|
||||
await Promise.resolve()
|
||||
}
|
||||
const models = monaco.editor.getModels()
|
||||
expect(models).toHaveLength(fixed ? 0 : 8)
|
||||
observations.push({
|
||||
control: 'eight-last-file-closes',
|
||||
openFiles: store.getState().openFiles.length,
|
||||
retainedModels: models.length,
|
||||
logicalCharacters: models.reduce((sum, model) => sum + model.getValueLength(), 0)
|
||||
})
|
||||
})
|
||||
|
||||
it('disposes a closed model while another file keeps the panel mounted', async () => {
|
||||
const store = setup()
|
||||
const closedModel = addFile(store, 'closed-with-sibling')
|
||||
const liveModel = addFile(store, 'live-sibling')
|
||||
const view = render(React.createElement(Shell))
|
||||
await mounted(view)
|
||||
act(() => store.getState().closeFile('closed-with-sibling'))
|
||||
await Promise.resolve()
|
||||
expect(closedModel.isDisposed()).toBe(true)
|
||||
expect(liveModel.isDisposed()).toBe(false)
|
||||
observations.push({
|
||||
control: 'panel-stays-mounted',
|
||||
closedDisposed: closedModel.isDisposed(),
|
||||
liveDisposed: liveModel.isDisposed()
|
||||
})
|
||||
})
|
||||
|
||||
it('measures a close while hidden followed by another editor remount', async () => {
|
||||
const store = setup()
|
||||
const oldModel = addFile(store, 'hidden-close')
|
||||
const view = render(React.createElement(Shell))
|
||||
await mounted(view)
|
||||
view.rerender(React.createElement(Shell, { visible: false }))
|
||||
expect(oldModel.isDisposed()).toBe(false)
|
||||
act(() => store.getState().closeFile('hidden-close'))
|
||||
await Promise.resolve()
|
||||
addFile(store, 'later-editor')
|
||||
view.rerender(React.createElement(Shell))
|
||||
await mounted(view)
|
||||
expect(oldModel.isDisposed()).toBe(fixed)
|
||||
expect(monaco.editor.getModels()).toHaveLength(fixed ? 1 : 2)
|
||||
observations.push({
|
||||
control: 'hidden-close-and-remount',
|
||||
retainedClosedModel: !oldModel.isDisposed(),
|
||||
openFiles: store.getState().openFiles.length,
|
||||
models: monaco.editor.getModels().length
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a still-open model across a normal panel unmount and remount', async () => {
|
||||
const store = setup()
|
||||
const model = addFile(store, 'normal-switch')
|
||||
const view = render(React.createElement(Shell))
|
||||
await mounted(view)
|
||||
view.rerender(React.createElement(Shell, { visible: false }))
|
||||
expect(model.isDisposed()).toBe(false)
|
||||
view.rerender(React.createElement(Shell))
|
||||
await mounted(view)
|
||||
expect(monaco.editor.getModel(model.uri)).toBe(model)
|
||||
observations.push({ control: 'normal-switch', originalModelPreserved: true })
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
const loaded = loadSources()
|
||||
const sourceFence = {
|
||||
sources: loaded.sources.size,
|
||||
graphSha256: sha256(JSON.stringify(loaded.hashes)),
|
||||
manifestSha256: sha256(
|
||||
readText(resolve('docs/audits/closed-editor-model-lifetime/source-versions.json'))
|
||||
),
|
||||
scenarioSha256: sha256(
|
||||
readText(resolve('docs/audits/closed-editor-model-lifetime/lifecycle.test.mjs'))
|
||||
),
|
||||
loaderSha256: sha256(readText(resolve('docs/audits/closed-editor-model-lifetime/sources.cjs')))
|
||||
}
|
||||
writeFileSync(
|
||||
process.env.ORCA_CLOSED_MODEL_OUTPUT ??
|
||||
`docs/audits/closed-editor-model-lifetime/${process.env.ORCA_CLOSED_MODEL_GRAPH ?? 'worktree'}-${variant}-${process.versions.electron ? 'electron' : 'node'}-results.json`,
|
||||
`${JSON.stringify({ runtime: process.versions, variant, graph: process.env.ORCA_CLOSED_MODEL_GRAPH ?? 'worktree', sourceFence, observations, scope: 'Actual legacy surface, cleanup hook, disposal, Store close action and installed Monaco models. Shell/Panel bodies are controlled ports; files/models are seeded. Eight distinct fixture paths, 256 KiB characters each. No native editor widget, app window, field file sizes, heap bytes or incident allocation rate.' }, null, 2)}\n`
|
||||
)
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { existsSync, writeFileSync } = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { loadSources, readText, sha256, versions } = require('./sources.cjs')
|
||||
|
||||
const observations = []
|
||||
const published = loadSources({ graph: 'main', variant: 'fixed' })
|
||||
const virtualRead = (filename) => published.sources.get(filename) ?? readText(filename)
|
||||
const virtualExists = (filename) => {
|
||||
const relative = path.relative(published.root, filename).split(path.sep).join('/')
|
||||
return Object.hasOwn(versions.sources, relative)
|
||||
? published.sources.has(filename)
|
||||
: existsSync(filename)
|
||||
}
|
||||
for (const graph of ['worktree', 'main']) {
|
||||
for (const variant of ['before', 'fixed']) {
|
||||
const normal = loadSources({ graph, variant })
|
||||
const crlf = loadSources({
|
||||
graph,
|
||||
variant,
|
||||
read: (filename) => readText(filename).replaceAll('\n', '\r\n')
|
||||
})
|
||||
const fromPublication = loadSources({
|
||||
graph,
|
||||
variant,
|
||||
read: virtualRead,
|
||||
exists: virtualExists
|
||||
})
|
||||
assert.deepEqual(crlf.hashes, normal.hashes)
|
||||
assert.deepEqual(fromPublication.hashes, normal.hashes)
|
||||
observations.push({
|
||||
graph,
|
||||
variant,
|
||||
sources: normal.sources.size,
|
||||
canonicalLfMatches: true,
|
||||
simulatedMainCheckoutMatches: true,
|
||||
graphSha256: sha256(JSON.stringify(normal.hashes))
|
||||
})
|
||||
}
|
||||
}
|
||||
const setup = path.join(published.root, 'src/renderer/src/lib/monaco-setup.ts')
|
||||
assert.ok(published.sources.get(setup).includes('registerShellMarkdownAliases(monaco)'))
|
||||
assert.throws(
|
||||
() =>
|
||||
loadSources({
|
||||
read: (filename) =>
|
||||
filename === setup ? `${readText(filename)}\n// drift\n` : readText(filename)
|
||||
}),
|
||||
/Fixed source drift/
|
||||
)
|
||||
writeFileSync(
|
||||
process.env.ORCA_CLOSED_MODEL_LOADER_OUTPUT ?? path.join(__dirname, 'loader-results.json'),
|
||||
`${JSON.stringify({ observations, mainMarkdownAliasesPreserved: true, driftRejected: true }, null, 2)}\n`
|
||||
)
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"observations": [
|
||||
{
|
||||
"graph": "worktree",
|
||||
"variant": "before",
|
||||
"sources": 1019,
|
||||
"canonicalLfMatches": true,
|
||||
"simulatedMainCheckoutMatches": true,
|
||||
"graphSha256": "135b131bc7a3023e0e88c572802c4b837eb968af74e49a71ea93a97ba3454bd9"
|
||||
},
|
||||
{
|
||||
"graph": "worktree",
|
||||
"variant": "fixed",
|
||||
"sources": 1021,
|
||||
"canonicalLfMatches": true,
|
||||
"simulatedMainCheckoutMatches": true,
|
||||
"graphSha256": "389b3a60b0ddc080a86e9569c371ac1d51db1e6845c8f6d65a1987d92e11cec9"
|
||||
},
|
||||
{
|
||||
"graph": "main",
|
||||
"variant": "before",
|
||||
"sources": 1017,
|
||||
"canonicalLfMatches": true,
|
||||
"simulatedMainCheckoutMatches": true,
|
||||
"graphSha256": "9b092ce86caf62164bc106a6ac8a382b9b487c5abbaadcfaedd371c3755a1a50"
|
||||
},
|
||||
{
|
||||
"graph": "main",
|
||||
"variant": "fixed",
|
||||
"sources": 1019,
|
||||
"canonicalLfMatches": true,
|
||||
"simulatedMainCheckoutMatches": true,
|
||||
"graphSha256": "efafe6f45ec73681fe43ff9df05200a041ccadfbb3ab39ced5f5ba096d829274"
|
||||
}
|
||||
],
|
||||
"mainMarkdownAliasesPreserved": true,
|
||||
"driftRejected": true
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"runtime": {
|
||||
"node": "24.21.0",
|
||||
"acorn": "8.18.0",
|
||||
"ada": "4.0.0",
|
||||
"amaro": "1.1.11",
|
||||
"ares": "1.34.8",
|
||||
"brotli": "1.2.0",
|
||||
"cldr": "48.0",
|
||||
"icu": "78.2",
|
||||
"llhttp": "9.4.3",
|
||||
"merve": "1.2.2",
|
||||
"modules": "148",
|
||||
"napi": "10",
|
||||
"nbytes": "0.1.4",
|
||||
"ncrypto": "0.0.1",
|
||||
"nghttp2": "1.70.0",
|
||||
"nghttp3": "",
|
||||
"ngtcp2": "",
|
||||
"openssl": "0.0.0",
|
||||
"simdjson": "4.6.7",
|
||||
"simdutf": "7.7.0",
|
||||
"sqlite": "3.53.4",
|
||||
"tz": "2025c",
|
||||
"undici": "7.29.1",
|
||||
"unicode": "17.0",
|
||||
"uv": "1.52.1",
|
||||
"uvwasi": "0.0.23",
|
||||
"v8": "15.0.245.31-electron.0",
|
||||
"zlib": "1.3.2.1-motley",
|
||||
"zstd": "1.6.0",
|
||||
"electron": "43.7.0",
|
||||
"chrome": "150.0.7871.250"
|
||||
},
|
||||
"variant": "before",
|
||||
"graph": "main",
|
||||
"sourceFence": {
|
||||
"sources": 1017,
|
||||
"graphSha256": "9b092ce86caf62164bc106a6ac8a382b9b487c5abbaadcfaedd371c3755a1a50",
|
||||
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
|
||||
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
|
||||
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
|
||||
},
|
||||
"observations": [
|
||||
{
|
||||
"control": "eight-last-file-closes",
|
||||
"openFiles": 0,
|
||||
"retainedModels": 8,
|
||||
"logicalCharacters": 2097208
|
||||
},
|
||||
{
|
||||
"control": "panel-stays-mounted",
|
||||
"closedDisposed": true,
|
||||
"liveDisposed": false
|
||||
},
|
||||
{
|
||||
"control": "hidden-close-and-remount",
|
||||
"retainedClosedModel": true,
|
||||
"openFiles": 1,
|
||||
"models": 2
|
||||
},
|
||||
{
|
||||
"control": "normal-switch",
|
||||
"originalModelPreserved": true
|
||||
}
|
||||
],
|
||||
"scope": "Actual legacy surface, cleanup hook, disposal, Store close action and installed Monaco models. Shell/Panel bodies are controlled ports; files/models are seeded. Eight distinct fixture paths, 256 KiB characters each. No native editor widget, app window, field file sizes, heap bytes or incident allocation rate."
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"runtime": {
|
||||
"node": "24.20.0",
|
||||
"acorn": "8.18.0",
|
||||
"ada": "4.0.0",
|
||||
"amaro": "1.1.11",
|
||||
"ares": "1.34.8",
|
||||
"brotli": "1.2.0",
|
||||
"cldr": "48.0",
|
||||
"icu": "78.3",
|
||||
"llhttp": "9.4.3",
|
||||
"merve": "1.2.2",
|
||||
"modules": "137",
|
||||
"napi": "10",
|
||||
"nbytes": "0.1.4",
|
||||
"ncrypto": "0.0.1",
|
||||
"nghttp2": "1.70.0",
|
||||
"nghttp3": "",
|
||||
"ngtcp2": "",
|
||||
"openssl": "3.5.7",
|
||||
"simdjson": "4.6.6",
|
||||
"simdutf": "6.4.0",
|
||||
"sqlite": "3.53.4",
|
||||
"tz": "2026c",
|
||||
"undici": "7.29.0",
|
||||
"unicode": "17.0",
|
||||
"uv": "1.52.1",
|
||||
"uvwasi": "0.0.23",
|
||||
"v8": "13.6.233.17-node.53",
|
||||
"zlib": "1.3.2.1-motley-42c2f19",
|
||||
"zstd": "1.5.7"
|
||||
},
|
||||
"variant": "before",
|
||||
"graph": "main",
|
||||
"sourceFence": {
|
||||
"sources": 1017,
|
||||
"graphSha256": "9b092ce86caf62164bc106a6ac8a382b9b487c5abbaadcfaedd371c3755a1a50",
|
||||
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
|
||||
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
|
||||
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
|
||||
},
|
||||
"observations": [
|
||||
{
|
||||
"control": "eight-last-file-closes",
|
||||
"openFiles": 0,
|
||||
"retainedModels": 8,
|
||||
"logicalCharacters": 2097208
|
||||
},
|
||||
{
|
||||
"control": "panel-stays-mounted",
|
||||
"closedDisposed": true,
|
||||
"liveDisposed": false
|
||||
},
|
||||
{
|
||||
"control": "hidden-close-and-remount",
|
||||
"retainedClosedModel": true,
|
||||
"openFiles": 1,
|
||||
"models": 2
|
||||
},
|
||||
{
|
||||
"control": "normal-switch",
|
||||
"originalModelPreserved": true
|
||||
}
|
||||
],
|
||||
"scope": "Actual legacy surface, cleanup hook, disposal, Store close action and installed Monaco models. Shell/Panel bodies are controlled ports; files/models are seeded. Eight distinct fixture paths, 256 KiB characters each. No native editor widget, app window, field file sizes, heap bytes or incident allocation rate."
|
||||
}
|
||||
@@ -1,662 +0,0 @@
|
||||
--- a/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.ts
|
||||
+++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-viewport.ts
|
||||
@@ -139,0 +140,6 @@
|
||||
+/** Divides the preset's window-DIP size by the live UI zoom factor (see `main.css`). */
|
||||
+export const BROWSER_PAGE_PRESET_VIEWPORT_CLASS_NAME = 'browser-page-preset-viewport'
|
||||
+
|
||||
+// Why the DIP conversion: CDP emulates the guest viewport in window DIP, but UI zoom
|
||||
+// redefines this renderer's CSS px, so an unconverted `${width}px` host box outgrows the
|
||||
+// emulated page and leaves an unpainted strip beside it (STA-7568).
|
||||
@@ -144,2 +150,14 @@
|
||||
- viewport.content.style.width = size ? `${size.width}px` : '100%'
|
||||
- viewport.content.style.height = size ? `${size.height}px` : '100%'
|
||||
+ if (size) {
|
||||
+ viewport.content.style.setProperty('--browser-page-viewport-width', `${size.width}px`)
|
||||
+ viewport.content.style.setProperty('--browser-page-viewport-height', `${size.height}px`)
|
||||
+ // Why: an inline width/height would outrank the class rule's zoom division.
|
||||
+ viewport.content.style.removeProperty('width')
|
||||
+ viewport.content.style.removeProperty('height')
|
||||
+ viewport.content.classList.add(BROWSER_PAGE_PRESET_VIEWPORT_CLASS_NAME)
|
||||
+ } else {
|
||||
+ viewport.content.classList.remove(BROWSER_PAGE_PRESET_VIEWPORT_CLASS_NAME)
|
||||
+ viewport.content.style.removeProperty('--browser-page-viewport-width')
|
||||
+ viewport.content.style.removeProperty('--browser-page-viewport-height')
|
||||
+ viewport.content.style.width = '100%'
|
||||
+ viewport.content.style.height = '100%'
|
||||
+ }
|
||||
--- a/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts
|
||||
+++ b/src/renderer/src/components/terminal-pane/ipc-pty-connect.ts
|
||||
@@ -30 +29,0 @@
|
||||
- handleExplicitlyClosedConnect?: (id: string) => boolean
|
||||
@@ -93,3 +91,0 @@
|
||||
- if (context.handleExplicitlyClosedConnect?.(spawnResult.id)) {
|
||||
- return
|
||||
- }
|
||||
--- a/src/renderer/src/components/terminal-pane/pty-eager-buffer-clamp.ts
|
||||
+++ b/src/renderer/src/components/terminal-pane/pty-eager-buffer-clamp.ts
|
||||
@@ -2 +1,0 @@
|
||||
-import { flattenRetainedSlice } from '../../lib/flatten-retained-slice'
|
||||
@@ -11,4 +10 @@
|
||||
- return {
|
||||
- data: tail.text.length < data.length ? flattenRetainedSlice(tail.text) : tail.text,
|
||||
- bytes: tail.bytes
|
||||
- }
|
||||
+ return { data: tail.text, bytes: tail.bytes }
|
||||
--- a/src/renderer/src/components/terminal-pane/pty-transport.ts
|
||||
+++ b/src/renderer/src/components/terminal-pane/pty-transport.ts
|
||||
@@ -47 +46,0 @@
|
||||
- let onAbandonedConnect: ((ptyId: string) => boolean) | undefined
|
||||
@@ -141 +139,0 @@
|
||||
- handleExplicitlyClosedConnect: (id) => destroyed && (onAbandonedConnect?.(id) ?? false),
|
||||
@@ -273,2 +271 @@
|
||||
- destroy(options) {
|
||||
- onAbandonedConnect ??= options?.onAbandonedConnect
|
||||
+ destroy() {
|
||||
--- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts
|
||||
+++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts
|
||||
@@ -13 +12,0 @@
|
||||
-import { releaseTerminalScrollIntentKey } from '../../lib/pane-manager/terminal-scroll-intent-key-store'
|
||||
@@ -144,14 +143 @@
|
||||
- const capture = capturedPanesByTabId.get(tabId)
|
||||
- if (capture) {
|
||||
- // Parked panes never run PaneManager's close teardown. Release their
|
||||
- // strong scroll-intent keys here or every closed parked tab leaks one per
|
||||
- // leaf for the renderer lifetime.
|
||||
- for (const pane of capture.panes) {
|
||||
- releaseCapturedPaneScrollIntent(pane)
|
||||
- }
|
||||
- capturedPanesByTabId.delete(tabId)
|
||||
- }
|
||||
-}
|
||||
-
|
||||
-function releaseCapturedPaneScrollIntent(pane: ParkedTerminalPaneCapture): void {
|
||||
- releaseTerminalScrollIntentKey(pane.leafId)
|
||||
+ capturedPanesByTabId.delete(tabId)
|
||||
@@ -232,5 +217,0 @@
|
||||
- for (const pane of capture.panes) {
|
||||
- // Worktree removal can bypass closeTab while panes are parked; release
|
||||
- // the same strong scroll-intent keys as explicit tab retirement.
|
||||
- releaseCapturedPaneScrollIntent(pane)
|
||||
- }
|
||||
--- a/src/renderer/src/components/terminal-pane/terminal-render-desync-trigger.ts
|
||||
+++ b/src/renderer/src/components/terminal-pane/terminal-render-desync-trigger.ts
|
||||
@@ -121,9 +120,0 @@
|
||||
-
|
||||
-if (import.meta !== undefined && import.meta.hot) {
|
||||
- // Vite can replace this module without a full renderer reload. Remove the
|
||||
- // opt-in gesture hook so dev sessions do not retain stale pane closures.
|
||||
- import.meta.hot.dispose(() => {
|
||||
- removeClickListener()
|
||||
- stopRenderDesyncSampleBurst()
|
||||
- })
|
||||
-}
|
||||
--- a/src/renderer/src/i18n/en-runtime-required.json
|
||||
+++ b/src/renderer/src/i18n/en-runtime-required.json
|
||||
@@ -2735,14 +2734,0 @@
|
||||
- "map": {
|
||||
- "agentCount": "{{count}} agents",
|
||||
- "agentCount_one": "{{count}} agent",
|
||||
- "agentCount_other": "{{count}} agents",
|
||||
- "host": {
|
||||
- "local": "Local",
|
||||
- "remote": "Remote",
|
||||
- "ssh": "SSH",
|
||||
- "wsl": "WSL"
|
||||
- },
|
||||
- "liveContainmentMap": "Live containment map",
|
||||
- "worktreeSummary_one": "{{total}} agent · {{active}} active · {{done}} done",
|
||||
- "worktreeSummary_other": "{{total}} agents · {{active}} active · {{done}} done"
|
||||
- },
|
||||
@@ -2752,5 +2737,0 @@
|
||||
- },
|
||||
- "view": {
|
||||
- "board": "Dashboard",
|
||||
- "label": "Dashboard view",
|
||||
- "map": "Agent Map"
|
||||
--- a/src/renderer/src/lib/input-quiet-scheduler.ts
|
||||
+++ b/src/renderer/src/lib/input-quiet-scheduler.ts
|
||||
@@ -18 +17,0 @@
|
||||
-let listenersWindow: Window | null = null
|
||||
@@ -53,19 +51,0 @@
|
||||
- listenersWindow = targetWindow
|
||||
-}
|
||||
-
|
||||
-function disposeInputQuietListeners(): void {
|
||||
- if (!listenersWindow) {
|
||||
- return
|
||||
- }
|
||||
- const options: AddEventListenerOptions = { capture: true }
|
||||
- for (const eventName of INPUT_QUIET_EVENTS) {
|
||||
- listenersWindow.removeEventListener(eventName, recordInput, options)
|
||||
- }
|
||||
- listenersWindow = null
|
||||
- listenersInstalled = false
|
||||
-}
|
||||
-
|
||||
-if (import.meta !== undefined && import.meta.hot) {
|
||||
- // Vite can replace this module without a full renderer reload. Remove the
|
||||
- // global input hooks so dev sessions do not retain stale scheduler closures.
|
||||
- import.meta.hot.dispose(disposeInputQuietListeners)
|
||||
--- a/src/renderer/src/lib/monaco-setup.ts
|
||||
+++ b/src/renderer/src/lib/monaco-setup.ts
|
||||
@@ -12,0 +13 @@
|
||||
+import { registerShellMarkdownAliases } from './monaco-languages/register-shell-markdown-aliases'
|
||||
@@ -81,0 +83 @@
|
||||
+registerShellMarkdownAliases(monaco)
|
||||
--- a/src/renderer/src/lib/worktree-operation-route.ts
|
||||
+++ b/src/renderer/src/lib/worktree-operation-route.ts
|
||||
@@ -2 +2,5 @@
|
||||
-import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
|
||||
+import {
|
||||
+ LOCAL_EXECUTION_HOST_ID,
|
||||
+ parseExecutionHostId,
|
||||
+ type ExecutionHostId
|
||||
+} from '../../../shared/execution-host'
|
||||
@@ -45,0 +50,5 @@
|
||||
+/**
|
||||
+ * Owner rows for this id on one host, read from the repo catalog AND the detected-worktree index
|
||||
+ * because owner provenance is split across both stores — a HUB-projected owner may appear in
|
||||
+ * either one, and missing it would drop the transport the caller needs.
|
||||
+ */
|
||||
@@ -103,0 +113,5 @@
|
||||
+/**
|
||||
+ * `null`-returning adapter for host-qualified callers with no branch for `ambiguous` vs
|
||||
+ * `missing`. The fail-closed decision stays in the `*Result` resolver so the two entry
|
||||
+ * points can never disagree about what counts as an owner.
|
||||
+ */
|
||||
@@ -112,0 +127,5 @@
|
||||
+/**
|
||||
+ * An authoritative host selection already names the target, so only the transport has to be
|
||||
+ * recovered — and only for `ssh:`, which a paired HUB can proxy. Rival HUBs projecting the same
|
||||
+ * host stay unresolved rather than guessing one.
|
||||
+ */
|
||||
@@ -166,0 +186,4 @@
|
||||
+/**
|
||||
+ * `null`-returning adapter over the owner-routed resolver for call sites that cannot act on
|
||||
+ * `ambiguous` — collapsing both refusals to `null` keeps them fail-closed at the call site.
|
||||
+ */
|
||||
@@ -174,0 +198,5 @@
|
||||
+/**
|
||||
+ * Owner precedence for owner-routed operations: stamped identity first, the legacy
|
||||
+ * pre-owner-projection branches strictly below it, and an id no row can place fails closed —
|
||||
+ * defaulting an unplaceable id to `local` would aim the operation at the wrong machine.
|
||||
+ */
|
||||
@@ -222,0 +251,14 @@
|
||||
+ // Why: a found repo/worktree record is positive identity evidence, so keep terminal-owner
|
||||
+ // parity with the folder branch below. Every stamped row already routed above, so an unstamped
|
||||
+ // repo row here is a legacy pre-owner-projection row — local by construction, as
|
||||
+ // getRepoExecutionHostId, main's resolveRepoOwnershipEvidence and Repo.executionHostId's own
|
||||
+ // contract all agree. Without this, the legacy hydration gates fail a genuinely local git
|
||||
+ // worktree closed whenever any unrelated runtime is saved — the #10251 symptom, for git
|
||||
+ // worktrees (#16733). A repo row on its own is repo identity, not worktree identity (#16841),
|
||||
+ // so a known worktree row — listed or currently detected — must back it.
|
||||
+ const localOwnerRoute = hasKnownWorktree
|
||||
+ ? resolveUnstampedLocalWorktreeRoute(state, repoId)
|
||||
+ : null
|
||||
+ if (localOwnerRoute) {
|
||||
+ return { kind: 'resolved', route: localOwnerRoute }
|
||||
+ }
|
||||
@@ -229 +271,4 @@
|
||||
- ? { kind: 'resolved', route: { executionHostId: 'local', runtimeEnvironmentId: null } }
|
||||
+ ? {
|
||||
+ kind: 'resolved',
|
||||
+ route: { executionHostId: LOCAL_EXECUTION_HOST_ID, runtimeEnvironmentId: null }
|
||||
+ }
|
||||
@@ -232,0 +278,21 @@
|
||||
+/**
|
||||
+ * A local route for a worktree whose only repo rows predate owner projection — the exact
|
||||
+ * condition `resolveExplicitWorktreeOperationRouteResult` already routed above if it applied to
|
||||
+ * any row. Reaching this function means every row for `repoId` is unstamped, so
|
||||
+ * `getRepoExecutionHostId`'s own fallback resolves each of them to `local`; a row only has to
|
||||
+ * exist.
|
||||
+ */
|
||||
+function resolveUnstampedLocalWorktreeRoute(
|
||||
+ state: WorktreeOperationRouteState,
|
||||
+ repoId: string
|
||||
+): WorktreeOperationRoute | null {
|
||||
+ const hasUnstampedRepoRow = state.repos?.some((repo) => repo.id === repoId) ?? false
|
||||
+ return hasUnstampedRepoRow
|
||||
+ ? { executionHostId: LOCAL_EXECUTION_HOST_ID, runtimeEnvironmentId: null }
|
||||
+ : null
|
||||
+}
|
||||
+
|
||||
+/**
|
||||
+ * Folder workspaces have no repo or worktree rows, so they route off their own owner record
|
||||
+ * instead of the legacy hydration gates above.
|
||||
+ */
|
||||
@@ -254,0 +321,5 @@
|
||||
+/**
|
||||
+ * Projects the route's runtime environment onto settings so a routed operation runs against the
|
||||
+ * owner's environment rather than whichever one the UI has active; settings can still be absent
|
||||
+ * during early hydration, hence the synthesized fallback.
|
||||
+ */
|
||||
--- a/src/renderer/src/lib/worktree-runtime-owner-index.ts
|
||||
+++ b/src/renderer/src/lib/worktree-runtime-owner-index.ts
|
||||
@@ -282,2 +282,2 @@
|
||||
-export function findIndexedFolderWorkspaceOwner(
|
||||
- folderWorkspaces: readonly FolderWorkspaceOwnerRecord[] | undefined,
|
||||
+export function findIndexedFolderWorkspaceOwner<T extends FolderWorkspaceOwnerRecord>(
|
||||
+ folderWorkspaces: readonly T[] | undefined,
|
||||
@@ -286 +286 @@
|
||||
-): FolderWorkspaceOwnerRecord | null {
|
||||
+): T | null {
|
||||
@@ -298,5 +298,6 @@
|
||||
- return resolution?.kind === 'resolved' ? resolution.owner : null
|
||||
-}
|
||||
-
|
||||
-export function findIndexedProjectGroupOwner(
|
||||
- projectGroups: readonly ProjectGroupOwnerRecord[] | undefined,
|
||||
+ // The cache is keyed by this exact array, so its owner retains the caller's row type.
|
||||
+ return resolution?.kind === 'resolved' ? (resolution.owner as T) : null
|
||||
+}
|
||||
+
|
||||
+export function findIndexedProjectGroupOwner<T extends ProjectGroupOwnerRecord>(
|
||||
+ projectGroups: readonly T[] | undefined,
|
||||
@@ -305 +306 @@
|
||||
-): ProjectGroupOwnerRecord | null {
|
||||
+): T | null {
|
||||
@@ -317,2 +318,3 @@
|
||||
- return resolution?.kind === 'resolved' ? resolution.owner : null
|
||||
-}
|
||||
+ // The cache is keyed by this exact array, so its owner retains the caller's row type.
|
||||
+ return resolution?.kind === 'resolved' ? (resolution.owner as T) : null
|
||||
+}
|
||||
--- a/src/renderer/src/runtime/runtime-rpc-client.ts
|
||||
+++ b/src/renderer/src/runtime/runtime-rpc-client.ts
|
||||
@@ -98 +98 @@
|
||||
-export async function ensureRuntimeEnvironmentCompatible(
|
||||
+async function ensureRuntimeEnvironmentCompatible(
|
||||
--- a/src/renderer/src/store/slices/terminal-tab-retirement.ts
|
||||
+++ b/src/renderer/src/store/slices/terminal-tab-retirement.ts
|
||||
@@ -136,24 +135,0 @@
|
||||
-}
|
||||
-
|
||||
-export function hasTerminalPtyOwnerOutsidePane(
|
||||
- state: TerminalTabRetirementState,
|
||||
- identity: string,
|
||||
- tabId: string,
|
||||
- excludedLeafId?: string
|
||||
-): boolean {
|
||||
- for (const [ownerTabId, owner] of collectLiveTerminalTabs(state)) {
|
||||
- const ids =
|
||||
- ownerTabId === tabId
|
||||
- ? Object.entries(state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {})
|
||||
- .filter(([leafId]) => leafId !== excludedLeafId)
|
||||
- .map(([, ptyId]) => ptyId)
|
||||
- : collectPtyIdsForTab(state, ownerTabId, owner.rowPtyId)
|
||||
- if (
|
||||
- ids.some(
|
||||
- (ptyId) => getTerminalPtyOwnershipIdentity(state, ptyId, owner.worktreeId) === identity
|
||||
- )
|
||||
- ) {
|
||||
- return true
|
||||
- }
|
||||
- }
|
||||
- return false
|
||||
--- a/src/renderer/src/store/terminals/terminal-tab-close-empty.ts
|
||||
+++ b/src/renderer/src/store/terminals/terminal-tab-close-empty.ts
|
||||
@@ -1,42 +0,0 @@
|
||||
-import { captureEmptyTerminalTabRetirement } from '../../../../shared/empty-terminal-tab-retirement'
|
||||
-import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
-import { getConnectionIdFromState } from '@/lib/connection-owner-resolution'
|
||||
-import type { AppState } from '../types'
|
||||
-import type { TerminalTabRetirementPlan } from '../slices/terminal-tab-retirement'
|
||||
-
|
||||
-export function requestEmptyTerminalTabRetirement(
|
||||
- state: AppState,
|
||||
- tabId: string,
|
||||
- plan: TerminalTabRetirementPlan,
|
||||
- reason: string
|
||||
-): void {
|
||||
- if (
|
||||
- typeof window === 'undefined' ||
|
||||
- reason !== 'user' ||
|
||||
- plan.ptyIds.length > 0 ||
|
||||
- !plan.worktreeId ||
|
||||
- getConnectionIdFromState(state, plan.worktreeId) !== null ||
|
||||
- getExecutionHostIdForWorktree(state, plan.worktreeId) !== 'local'
|
||||
- ) {
|
||||
- return
|
||||
- }
|
||||
- if (
|
||||
- Object.values(state.unifiedTabsByWorktree).some((tabs) =>
|
||||
- tabs.some(
|
||||
- (row) =>
|
||||
- (row.id === tabId || row.entityId === tabId) &&
|
||||
- (row.structuredSessionId || row.viewMode === 'chat')
|
||||
- )
|
||||
- )
|
||||
- ) {
|
||||
- return
|
||||
- }
|
||||
- const request = captureEmptyTerminalTabRetirement(state, plan.worktreeId, tabId)
|
||||
- const retire = window.api.session?.retireEmptyTerminalTab
|
||||
- if (!request || !retire) {
|
||||
- return
|
||||
- }
|
||||
- void retire(request).catch((error: unknown) =>
|
||||
- console.warn('Empty terminal tab retirement failed', error)
|
||||
- )
|
||||
-}
|
||||
--- a/src/renderer/src/store/terminals/terminal-tab-close-providers.ts
|
||||
+++ b/src/renderer/src/store/terminals/terminal-tab-close-providers.ts
|
||||
@@ -1,4 +0,0 @@
|
||||
-import {
|
||||
- captureRuntimeEnvironmentRequestRevision,
|
||||
- getRuntimeEnvironmentRevision
|
||||
-} from '@/runtime/runtime-environment-revision'
|
||||
@@ -6 +2 @@
|
||||
-import { callRuntimeRpc, ensureRuntimeEnvironmentCompatible } from '@/runtime/runtime-rpc-client'
|
||||
+import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
@@ -18,2 +14 @@
|
||||
- tabId,
|
||||
- canRetireRuntimeTerminal
|
||||
+ tabId
|
||||
@@ -26 +20,0 @@
|
||||
- canRetireRuntimeTerminal?: () => boolean
|
||||
@@ -42 +36,5 @@
|
||||
- retireRuntimeTerminal(environmentId, terminal.handle, canRetireRuntimeTerminal)
|
||||
+ callRuntimeRpc(
|
||||
+ environmentId ? { kind: 'environment', environmentId } : { kind: 'local' },
|
||||
+ 'terminal.close',
|
||||
+ { terminal: terminal.handle }
|
||||
+ )
|
||||
@@ -71,37 +68,0 @@
|
||||
-
|
||||
-async function retireRuntimeTerminal(
|
||||
- environmentId: string | null | undefined,
|
||||
- handle: string,
|
||||
- canRetire?: () => boolean
|
||||
-): Promise<unknown> {
|
||||
- const target = environmentId
|
||||
- ? { kind: 'environment' as const, environmentId }
|
||||
- : { kind: 'local' as const }
|
||||
- if (!canRetire) {
|
||||
- return callRuntimeRpc(target, 'terminal.close', { terminal: handle })
|
||||
- }
|
||||
- const revision = environmentId
|
||||
- ? captureRuntimeEnvironmentRequestRevision(environmentId)
|
||||
- : undefined
|
||||
- if (environmentId) {
|
||||
- await ensureRuntimeEnvironmentCompatible(environmentId, {
|
||||
- expectedEnvironmentPairingRevision: revision
|
||||
- })
|
||||
- }
|
||||
- if (
|
||||
- (environmentId && getRuntimeEnvironmentRevision(environmentId) !== revision) ||
|
||||
- !canRetire()
|
||||
- ) {
|
||||
- return
|
||||
- }
|
||||
- // Compatibility was checked above; recheck pane ownership at the actual dispatch boundary.
|
||||
- return callRuntimeRpc(
|
||||
- target,
|
||||
- 'terminal.close',
|
||||
- { terminal: handle },
|
||||
- {
|
||||
- skipCompatibilityCheck: true,
|
||||
- expectedEnvironmentPairingRevision: revision
|
||||
- }
|
||||
- )
|
||||
-}
|
||||
--- a/src/renderer/src/store/terminals/terminal-tab-close.ts
|
||||
+++ b/src/renderer/src/store/terminals/terminal-tab-close.ts
|
||||
@@ -1 +0,0 @@
|
||||
-import { requestEmptyTerminalTabRetirement } from './terminal-tab-close-empty'
|
||||
@@ -35 +33,0 @@
|
||||
- requestEmptyTerminalTabRetirement(get(), tabId, retirementPlan, closeReason)
|
||||
--- a/src/shared/check-job-log-tail-slice.ts
|
||||
+++ b/src/shared/check-job-log-tail-slice.ts
|
||||
@@ -6 +5,0 @@
|
||||
-import { ownRetainedString } from './own-retained-string'
|
||||
@@ -61 +60 @@
|
||||
-function buildCheckLogTail(logText: string): string {
|
||||
+export function sliceCheckLogTail(logText: string): string {
|
||||
@@ -84,5 +82,0 @@
|
||||
-
|
||||
-export function sliceCheckLogTail(logText: string): string {
|
||||
- // Cached excerpts must not pin the downloaded log behind a small V8 slice.
|
||||
- return ownRetainedString(buildCheckLogTail(logText))
|
||||
-}
|
||||
--- a/src/shared/empty-terminal-tab-retirement.ts
|
||||
+++ b/src/shared/empty-terminal-tab-retirement.ts
|
||||
@@ -1,96 +0,0 @@
|
||||
-import type { WorkspaceSessionState } from './workspace-session-state-types'
|
||||
-
|
||||
-export type EmptyTerminalTabRetirementRequest = {
|
||||
- worktreeId: string
|
||||
- tabId: string
|
||||
- createdAt: number
|
||||
- generation: number
|
||||
-}
|
||||
-export type EmptyTerminalTabRetirementResult =
|
||||
- | { closed: true }
|
||||
- | {
|
||||
- closed: false
|
||||
- reason:
|
||||
- | 'invalid-identity'
|
||||
- | 'not-local'
|
||||
- | 'renderer-owned-membership'
|
||||
- | 'structured-owner'
|
||||
- | 'different-owner'
|
||||
- | 'stale-terminal'
|
||||
- | 'unrepresented-owner'
|
||||
- | 'runtime-owner'
|
||||
- | 'runtime-unavailable'
|
||||
- }
|
||||
-
|
||||
-type EmptyTerminalTabState = Pick<
|
||||
- WorkspaceSessionState,
|
||||
- | 'tabsByWorktree'
|
||||
- | 'terminalLayoutsByTabId'
|
||||
- | 'remoteSessionIdsByTabId'
|
||||
- | 'terminalPtyIncarnationsByPaneKey'
|
||||
- | 'sleepingAgentSessionsByPaneKey'
|
||||
->
|
||||
-
|
||||
-export function hasTerminalTabBindingOrHistory(
|
||||
- session: EmptyTerminalTabState,
|
||||
- tabId: string
|
||||
-): boolean {
|
||||
- const layout = session.terminalLayoutsByTabId[tabId]
|
||||
- return Boolean(
|
||||
- layout?.root ||
|
||||
- layout?.activeLeafId ||
|
||||
- layout?.expandedLeafId ||
|
||||
- session.remoteSessionIdsByTabId?.[tabId] ||
|
||||
- [layout?.ptyIdsByLeafId, layout?.buffersByLeafId, layout?.scrollbackRefsByLeafId].some(
|
||||
- (map) => Object.keys(map ?? {}).length > 0
|
||||
- ) ||
|
||||
- Object.keys(session.terminalPtyIncarnationsByPaneKey ?? {}).some((key) =>
|
||||
- key.startsWith(`${tabId}:`)
|
||||
- ) ||
|
||||
- Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}).some(
|
||||
- ([key, value]) => key.startsWith(`${tabId}:`) || value.tabId === tabId
|
||||
- )
|
||||
- )
|
||||
-}
|
||||
-
|
||||
-export function captureEmptyTerminalTabRetirement(
|
||||
- session: EmptyTerminalTabState,
|
||||
- worktreeId: string,
|
||||
- tabId: string
|
||||
-): EmptyTerminalTabRetirementRequest | undefined {
|
||||
- const tabs = Object.values(session.tabsByWorktree)
|
||||
- .flat()
|
||||
- .filter((tab) => tab.id === tabId)
|
||||
- const row = session.tabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)
|
||||
- if (
|
||||
- !row ||
|
||||
- tabs.length !== 1 ||
|
||||
- row.worktreeId !== worktreeId ||
|
||||
- row.ptyId ||
|
||||
- row.isPinned ||
|
||||
- row.viewMode === 'chat' ||
|
||||
- (row.generation ?? 0) !== 0 ||
|
||||
- hasTerminalTabBindingOrHistory(session, tabId)
|
||||
- ) {
|
||||
- return undefined
|
||||
- }
|
||||
- return { worktreeId, tabId, createdAt: row.createdAt, generation: 0 }
|
||||
-}
|
||||
-
|
||||
-export function isEmptyTerminalTabRetirementRequest(
|
||||
- value: unknown
|
||||
-): value is EmptyTerminalTabRetirementRequest {
|
||||
- return (
|
||||
- typeof value === 'object' &&
|
||||
- value !== null &&
|
||||
- 'worktreeId' in value &&
|
||||
- typeof value.worktreeId === 'string' &&
|
||||
- 'tabId' in value &&
|
||||
- typeof value.tabId === 'string' &&
|
||||
- 'createdAt' in value &&
|
||||
- typeof value.createdAt === 'number' &&
|
||||
- Number.isFinite(value.createdAt) &&
|
||||
- 'generation' in value &&
|
||||
- value.generation === 0
|
||||
- )
|
||||
-}
|
||||
--- a/src/shared/protocol-version.ts
|
||||
+++ b/src/shared/protocol-version.ts
|
||||
@@ -132,0 +133,5 @@
|
||||
+// Why: an older host strips terminal.create's unknown `shell` and answers with a terminal running
|
||||
+// the host default shell. That reply is indistinguishable from success, so a client asking for a
|
||||
+// shell must refuse rather than create the wrong one.
|
||||
+export const TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY =
|
||||
+ 'terminal.create-shell-selection.v1' as const
|
||||
@@ -232,0 +238,16 @@
|
||||
+/**
|
||||
+ * `agent.launch` exists: one host-side method that decides structured-vs-terminal and creates the
|
||||
+ * surface, instead of each client routing for itself.
|
||||
+ *
|
||||
+ * Negotiated rather than assumed because a client that cannot see it must keep using
|
||||
+ * `worktree.create` + `startupAgent`, which stays supported verbatim. The reverse skew is the
|
||||
+ * dangerous one: `worktree.create` returns `agentTerminalHandle` only when a startup agent was
|
||||
+ * requested, so a host that quietly routed that call to a structured session would hand an old
|
||||
+ * client a response with no handle and no error.
|
||||
+ *
|
||||
+ * Advertising it is a statement that the client understands EITHER outcome, since the host is what
|
||||
+ * picks: a structured session it can open, or a terminal agent. A client that renders only one of
|
||||
+ * the two keeps using the surface-specific methods.
|
||||
+ */
|
||||
+export const AGENT_LAUNCH_RUNTIME_CAPABILITY = 'agent.launch.v1' as const
|
||||
+
|
||||
@@ -242 +263,2 @@
|
||||
- AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
|
||||
+ AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
|
||||
+ AGENT_LAUNCH_RUNTIME_CAPABILITY
|
||||
@@ -298,0 +321 @@
|
||||
+ TERMINAL_CREATE_SHELL_SELECTION_RUNTIME_CAPABILITY,
|
||||
@@ -336 +359,2 @@
|
||||
- NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY
|
||||
+ NOTIFICATIONS_REMOTE_PUSH_RUNTIME_CAPABILITY,
|
||||
+ AGENT_LAUNCH_RUNTIME_CAPABILITY
|
||||
--- a/src/shared/structured-agent-session-live-turn.ts
|
||||
+++ b/src/shared/structured-agent-session-live-turn.ts
|
||||
@@ -8 +8,2 @@
|
||||
- AgentJournalToolCallItem
|
||||
+ AgentJournalToolCallItem,
|
||||
+ AgentJournalTurnLifecycle
|
||||
@@ -21,0 +23,21 @@
|
||||
+}
|
||||
+
|
||||
+/** The same verdict for reduced items a caller holds unordered, so a reader that already has them
|
||||
+ * need not render and sort a whole snapshot to ask. Sequence is the ordering key the render pass
|
||||
+ * sorts on, and ties resolve to the later-reduced item exactly as that stable sort would. */
|
||||
+export function activeStructuredAgentSessionTurnIdBySequence(
|
||||
+ items: Iterable<AgentJournalRenderItem>
|
||||
+): string | null {
|
||||
+ let newestSequence = 0
|
||||
+ let newest: AgentJournalTurnLifecycle | null = null
|
||||
+ for (const item of items) {
|
||||
+ if (item.sequence < newestSequence) {
|
||||
+ continue
|
||||
+ }
|
||||
+ const turn = readAgentJournalTurn(item.body)
|
||||
+ if (turn) {
|
||||
+ newestSequence = item.sequence
|
||||
+ newest = turn
|
||||
+ }
|
||||
+ }
|
||||
+ return newest?.state === 'running' ? newest.turnId : null
|
||||
--- a/src/shared/tui-agent-launch-defaults.ts
|
||||
+++ b/src/shared/tui-agent-launch-defaults.ts
|
||||
@@ -23,0 +24,15 @@
|
||||
+}
|
||||
+
|
||||
+/**
|
||||
+ * Whether the configured arguments carry this agent's permission-bypass flag.
|
||||
+ *
|
||||
+ * The Agent Permissions toggle has no storage of its own — it writes and reads this flag inside
|
||||
+ * the arguments string — so presence at a token boundary, not whole-string equality, is what
|
||||
+ * "Yolo" means. A terminal launch applies the flag wherever else the user has written in the field.
|
||||
+ */
|
||||
+export function tuiAgentArgsBypassPermissions(
|
||||
+ agent: TuiAgent,
|
||||
+ value: string | null | undefined
|
||||
+): boolean {
|
||||
+ const bypassArg = YOLO_TUI_AGENT_ARGS[agent]
|
||||
+ return typeof value === 'string' && bypassArg !== undefined && argPattern(bypassArg).test(value)
|
||||
@@ -95,0 +111,14 @@
|
||||
+/**
|
||||
+ * Whether this agent's *resolved* launch arguments ask for a permission bypass.
|
||||
+ *
|
||||
+ * Resolved, not configured: an untouched Arguments field falls back to the default Orca ships,
|
||||
+ * which is the bypass flag, so bypass is the posture a user gets until they choose otherwise.
|
||||
+ * Choosing Manual stores an empty string, which owns the key and so beats that default.
|
||||
+ */
|
||||
+export function resolvedTuiAgentArgsBypassPermissions(
|
||||
+ agent: TuiAgent,
|
||||
+ configuredArgs: Partial<Record<TuiAgent, string>> | null | undefined
|
||||
+): boolean {
|
||||
+ return tuiAgentArgsBypassPermissions(agent, resolveTuiAgentLaunchArgs(agent, configuredArgs))
|
||||
+}
|
||||
+
|
||||
--- a/src/shared/windows-terminal-shell.ts
|
||||
+++ b/src/shared/windows-terminal-shell.ts
|
||||
@@ -56,0 +57,44 @@
|
||||
+
|
||||
+/**
|
||||
+ * Shell names a caller may request for a single Windows terminal, keyed by accepted spelling and
|
||||
+ * mapped to the one canonical spelling every downstream consumer keys on.
|
||||
+ *
|
||||
+ * The relay owns the spawn and has always refused anything outside this set, but the set lived
|
||||
+ * only there — so a bad value from `terminal create --shell` surfaced as a spawn-time throw with
|
||||
+ * no way for the CLI to answer before the round trip. Shared so the RPC boundary and the relay
|
||||
+ * agree on the same names.
|
||||
+ *
|
||||
+ * Why canonicalize: `resolveWindowsShellStartupFamily`, the launch-arg builders, and the Git Bash
|
||||
+ * path resolver all exact-match the `.exe` spelling, so a bare `cmd` accepted here would spawn cmd
|
||||
+ * yet quote its startup command for PowerShell. `pwsh` and `powershell` are different binaries and
|
||||
+ * are never collapsed into each other.
|
||||
+ */
|
||||
+const WINDOWS_SHELL_OVERRIDE_CANONICAL_NAMES: ReadonlyMap<string, string> = new Map([
|
||||
+ ['powershell.exe', 'powershell.exe'],
|
||||
+ ['powershell', 'powershell.exe'],
|
||||
+ ['pwsh.exe', 'pwsh.exe'],
|
||||
+ ['pwsh', 'pwsh.exe'],
|
||||
+ ['cmd.exe', 'cmd.exe'],
|
||||
+ ['cmd', 'cmd.exe'],
|
||||
+ ['wsl.exe', 'wsl.exe'],
|
||||
+ ['wsl', 'wsl.exe'],
|
||||
+ // Why: both spellings classify as a POSIX startup family, so rejecting them here made the relay
|
||||
+ // the one host that hard-failed a setting the local and daemon PTYs accept.
|
||||
+ ['bash.exe', 'bash.exe'],
|
||||
+ ['bash', 'bash.exe'],
|
||||
+ [WINDOWS_GIT_BASH_SHELL, WINDOWS_GIT_BASH_SHELL]
|
||||
+])
|
||||
+
|
||||
+/** Canonical spelling for an accepted override (case-insensitive), or undefined when refused. */
|
||||
+export function canonicalizeWindowsShellOverride(shell: string): string | undefined {
|
||||
+ return WINDOWS_SHELL_OVERRIDE_CANONICAL_NAMES.get(shell.toLowerCase())
|
||||
+}
|
||||
+
|
||||
+export function isSupportedWindowsShellOverride(shell: string): boolean {
|
||||
+ return canonicalizeWindowsShellOverride(shell) !== undefined
|
||||
+}
|
||||
+
|
||||
+/** Sorted for a stable error message; callers list these when refusing a value. */
|
||||
+export function listSupportedWindowsShellOverrides(): string[] {
|
||||
+ return [...WINDOWS_SHELL_OVERRIDE_CANONICAL_NAMES.keys()].sort()
|
||||
+}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"runtime": {
|
||||
"node": "24.21.0",
|
||||
"acorn": "8.18.0",
|
||||
"ada": "4.0.0",
|
||||
"amaro": "1.1.11",
|
||||
"ares": "1.34.8",
|
||||
"brotli": "1.2.0",
|
||||
"cldr": "48.0",
|
||||
"icu": "78.2",
|
||||
"llhttp": "9.4.3",
|
||||
"merve": "1.2.2",
|
||||
"modules": "148",
|
||||
"napi": "10",
|
||||
"nbytes": "0.1.4",
|
||||
"ncrypto": "0.0.1",
|
||||
"nghttp2": "1.70.0",
|
||||
"nghttp3": "",
|
||||
"ngtcp2": "",
|
||||
"openssl": "0.0.0",
|
||||
"simdjson": "4.6.7",
|
||||
"simdutf": "7.7.0",
|
||||
"sqlite": "3.53.4",
|
||||
"tz": "2025c",
|
||||
"undici": "7.29.1",
|
||||
"unicode": "17.0",
|
||||
"uv": "1.52.1",
|
||||
"uvwasi": "0.0.23",
|
||||
"v8": "15.0.245.31-electron.0",
|
||||
"zlib": "1.3.2.1-motley",
|
||||
"zstd": "1.6.0",
|
||||
"electron": "43.7.0",
|
||||
"chrome": "150.0.7871.250"
|
||||
},
|
||||
"variant": "fixed",
|
||||
"graph": "main",
|
||||
"sourceFence": {
|
||||
"sources": 1019,
|
||||
"graphSha256": "efafe6f45ec73681fe43ff9df05200a041ccadfbb3ab39ced5f5ba096d829274",
|
||||
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
|
||||
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
|
||||
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
|
||||
},
|
||||
"observations": [
|
||||
{
|
||||
"control": "eight-last-file-closes",
|
||||
"openFiles": 0,
|
||||
"retainedModels": 0,
|
||||
"logicalCharacters": 0
|
||||
},
|
||||
{
|
||||
"control": "panel-stays-mounted",
|
||||
"closedDisposed": true,
|
||||
"liveDisposed": false
|
||||
},
|
||||
{
|
||||
"control": "hidden-close-and-remount",
|
||||
"retainedClosedModel": false,
|
||||
"openFiles": 1,
|
||||
"models": 1
|
||||
},
|
||||
{
|
||||
"control": "normal-switch",
|
||||
"originalModelPreserved": true
|
||||
}
|
||||
],
|
||||
"scope": "Actual legacy surface, cleanup hook, disposal, Store close action and installed Monaco models. Shell/Panel bodies are controlled ports; files/models are seeded. Eight distinct fixture paths, 256 KiB characters each. No native editor widget, app window, field file sizes, heap bytes or incident allocation rate."
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"runtime": {
|
||||
"node": "24.20.0",
|
||||
"acorn": "8.18.0",
|
||||
"ada": "4.0.0",
|
||||
"amaro": "1.1.11",
|
||||
"ares": "1.34.8",
|
||||
"brotli": "1.2.0",
|
||||
"cldr": "48.0",
|
||||
"icu": "78.3",
|
||||
"llhttp": "9.4.3",
|
||||
"merve": "1.2.2",
|
||||
"modules": "137",
|
||||
"napi": "10",
|
||||
"nbytes": "0.1.4",
|
||||
"ncrypto": "0.0.1",
|
||||
"nghttp2": "1.70.0",
|
||||
"nghttp3": "",
|
||||
"ngtcp2": "",
|
||||
"openssl": "3.5.7",
|
||||
"simdjson": "4.6.6",
|
||||
"simdutf": "6.4.0",
|
||||
"sqlite": "3.53.4",
|
||||
"tz": "2026c",
|
||||
"undici": "7.29.0",
|
||||
"unicode": "17.0",
|
||||
"uv": "1.52.1",
|
||||
"uvwasi": "0.0.23",
|
||||
"v8": "13.6.233.17-node.53",
|
||||
"zlib": "1.3.2.1-motley-42c2f19",
|
||||
"zstd": "1.5.7"
|
||||
},
|
||||
"variant": "fixed",
|
||||
"graph": "main",
|
||||
"sourceFence": {
|
||||
"sources": 1019,
|
||||
"graphSha256": "efafe6f45ec73681fe43ff9df05200a041ccadfbb3ab39ced5f5ba096d829274",
|
||||
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
|
||||
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
|
||||
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
|
||||
},
|
||||
"observations": [
|
||||
{
|
||||
"control": "eight-last-file-closes",
|
||||
"openFiles": 0,
|
||||
"retainedModels": 0,
|
||||
"logicalCharacters": 0
|
||||
},
|
||||
{
|
||||
"control": "panel-stays-mounted",
|
||||
"closedDisposed": true,
|
||||
"liveDisposed": false
|
||||
},
|
||||
{
|
||||
"control": "hidden-close-and-remount",
|
||||
"retainedClosedModel": false,
|
||||
"openFiles": 1,
|
||||
"models": 1
|
||||
},
|
||||
{
|
||||
"control": "normal-switch",
|
||||
"originalModelPreserved": true
|
||||
}
|
||||
],
|
||||
"scope": "Actual legacy surface, cleanup hook, disposal, Store close action and installed Monaco models. Shell/Panel bodies are controlled ports; files/models are seeded. Eight distinct fixture paths, 256 KiB characters each. No native editor widget, app window, field file sizes, heap bytes or incident allocation rate."
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,93 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { readFileSync, existsSync } = require('node:fs')
|
||||
const { createHash } = require('node:crypto')
|
||||
const path = require('node:path')
|
||||
const { applyPatch, parsePatch, reversePatch } = require('diff')
|
||||
const root = path.resolve(__dirname, '../../..')
|
||||
const canonicalLf = (text) => text.replaceAll('\r\n', '\n')
|
||||
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
|
||||
const readText = (filename) => canonicalLf(readFileSync(filename, 'utf8'))
|
||||
const versions = JSON.parse(readText(path.join(__dirname, 'source-versions.json')))
|
||||
|
||||
function patchMap(filename, read) {
|
||||
return new Map(
|
||||
parsePatch(canonicalLf(read(path.join(__dirname, filename)))).map((patch) => {
|
||||
assert.ok(patch.oldFileName.startsWith('a/'))
|
||||
assert.ok(patch.newFileName.startsWith('b/'))
|
||||
assert.equal(patch.oldFileName.slice(2), patch.newFileName.slice(2))
|
||||
return [patch.newFileName.slice(2), patch]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function apply(source, patch, reverse = false) {
|
||||
const result = applyPatch(source ?? '', reverse ? reversePatch(patch) : patch)
|
||||
assert.notEqual(result, false, 'Source patch no longer applies')
|
||||
return result
|
||||
}
|
||||
|
||||
function loadSources({
|
||||
graph = process.env.ORCA_CLOSED_MODEL_GRAPH ?? 'worktree',
|
||||
variant = process.env.ORCA_CLOSED_MODEL_VARIANT ?? 'fixed',
|
||||
read = readText,
|
||||
exists = existsSync
|
||||
} = {}) {
|
||||
assert.ok(graph === 'worktree' || graph === 'main')
|
||||
assert.ok(variant === 'before' || variant === 'fixed')
|
||||
const fixes = patchMap('fix.patch', read)
|
||||
const contexts = patchMap('main-context.patch', read)
|
||||
for (const [relative, expected] of Object.entries(versions.dependencies)) {
|
||||
assert.equal(
|
||||
sha256(canonicalLf(read(path.join(root, relative)))),
|
||||
expected,
|
||||
`Dependency drift: ${relative}`
|
||||
)
|
||||
}
|
||||
const sources = new Map()
|
||||
const hashes = {}
|
||||
for (const [relative, expected] of Object.entries(versions.sources)) {
|
||||
const filename = path.join(root, relative)
|
||||
let source = exists(filename) ? canonicalLf(read(filename)) : null
|
||||
const actual = source === null ? null : sha256(source)
|
||||
const fix = fixes.get(relative)
|
||||
const context = contexts.get(relative)
|
||||
if (fix && source !== null) {
|
||||
assert.ok(
|
||||
actual === expected.worktreeFixed || actual === expected.mainFixed,
|
||||
`Fixed source drift: ${relative}`
|
||||
)
|
||||
source = apply(source, fix, true)
|
||||
if (expected.worktreeBefore === null && expected.mainBefore === null) {
|
||||
assert.equal(source, '')
|
||||
source = null
|
||||
}
|
||||
}
|
||||
let baselineHash = source === null ? null : sha256(source)
|
||||
assert.ok(
|
||||
baselineHash === expected.worktreeBefore || baselineHash === expected.mainBefore,
|
||||
`Source graph drift: ${relative}`
|
||||
)
|
||||
const desired = expected[`${graph}Before`]
|
||||
if (baselineHash !== desired) {
|
||||
assert.ok(context, `Missing graph context: ${relative}`)
|
||||
source = apply(source, context, graph === 'worktree')
|
||||
if (desired === null) {
|
||||
assert.equal(source, '')
|
||||
source = null
|
||||
}
|
||||
baselineHash = source === null ? null : sha256(source)
|
||||
}
|
||||
assert.equal(baselineHash, desired, `Named graph drift: ${relative}`)
|
||||
if (variant === 'fixed' && fix) {
|
||||
source = apply(source, fix)
|
||||
assert.equal(sha256(source), expected[`${graph}Fixed`], `Fixed graph drift: ${relative}`)
|
||||
}
|
||||
if (source !== null) {
|
||||
sources.set(filename, source)
|
||||
hashes[relative] = sha256(source)
|
||||
}
|
||||
}
|
||||
return { root, graph, variant, sources, hashes }
|
||||
}
|
||||
|
||||
module.exports = { loadSources, canonicalLf, readText, sha256, versions }
|
||||
@@ -1,96 +0,0 @@
|
||||
{
|
||||
"permanentTests": {
|
||||
"cases": 27,
|
||||
"files": 5,
|
||||
"exitCode": 0
|
||||
},
|
||||
"permanentAndExistingCompatibilityTests": {
|
||||
"cases": 47,
|
||||
"files": 8,
|
||||
"exitCode": 0
|
||||
},
|
||||
"selectedTestsIncludingStartupRouting": {
|
||||
"cases": 75,
|
||||
"files": 9,
|
||||
"exitCode": 0
|
||||
},
|
||||
"actualFullWebTypecheck": {
|
||||
"command": "ORCA_BACKGROUND_LAUNCH=1 node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.tc.web.json",
|
||||
"exitCode": 1,
|
||||
"diagnostics": [
|
||||
"src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx(12,15): error TS6196: 'NativeChatMessage' is declared but never used."
|
||||
],
|
||||
"scope": "The single diagnostic is in an unchanged file. No changed-file diagnostics."
|
||||
},
|
||||
"comparativeProof": {
|
||||
"graphs": ["worktree", "main"],
|
||||
"variants": ["before", "fixed"],
|
||||
"runtimes": {
|
||||
"node": "24.20.0",
|
||||
"electron": "43.7.0 / Node 24.21.0"
|
||||
},
|
||||
"casesPerRun": 4,
|
||||
"runs": 8,
|
||||
"cases": 32,
|
||||
"exitCodes": [0, 0, 0, 0, 0, 0, 0, 0]
|
||||
},
|
||||
"sourceFence": {
|
||||
"repositoryPaths": 1021,
|
||||
"contextDifferences": 22,
|
||||
"dependencyHashes": 12,
|
||||
"allNamedWorktreeBaselineHashesVerified": true,
|
||||
"historicalComparison": "Four static v1.4.170 paths only",
|
||||
"mainMarkdownAliasRegistrationPreserved": true
|
||||
},
|
||||
"loaderControls": {
|
||||
"graphVariantCombinations": 4,
|
||||
"canonicalLfAndSyntheticCrlfEquivalent": true,
|
||||
"simulatedPublicationCheckoutEquivalent": true,
|
||||
"driftRejected": true
|
||||
},
|
||||
"quality": {
|
||||
"scope": "Three changed source/test files",
|
||||
"ordinary": {
|
||||
"exitCode": 0
|
||||
},
|
||||
"casting": {
|
||||
"exitCode": 0
|
||||
},
|
||||
"typeAware": {
|
||||
"exitCode": 0
|
||||
},
|
||||
"react": {
|
||||
"exitCode": 0
|
||||
},
|
||||
"design": {
|
||||
"exitCode": 0
|
||||
},
|
||||
"antiSlop": {
|
||||
"exitCode": 0
|
||||
}
|
||||
},
|
||||
"remainingTradeoffs": {
|
||||
"undo": "Existing Monaco bounded closed-file undo policy; oversized models and raw Windows drive-path URIs can lose closed-file undo.",
|
||||
"controllerTeardown": "Cancels ownership and listeners; no claim to reconcile every preexisting model after app-shell HMR remount.",
|
||||
"viewCaches": "Preserved within existing 20-entry LRU."
|
||||
},
|
||||
"artifactHashes": {
|
||||
"README.md": "c279ab97bf4fe580becc63e337207caf558a0f176dbbc280ee0cd4d4fa8ce673",
|
||||
"fix.patch": "30027498dfbb6dbea9397be08a79711884f24f3b11268fdc0a641c174da6be6a",
|
||||
"lifecycle.test.mjs": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
|
||||
"loader-controls.cjs": "4cdcf0309ff0acce00cff5f5b17ce91236f5eaa27245de65c651dd4c6535a352",
|
||||
"loader-results.json": "6434cd0a1da483a1ea48fe7ce15d3e64a25bb97d998c34ba95983282276edc85",
|
||||
"main-before-electron-results.json": "459135cb152bf4431de7df071417de0305722b562906f7fb36784911dc58d515",
|
||||
"main-before-node-results.json": "8ab5239a631772cad4480e9b3cd5a192fe4cc8317498357cfa727693872e1436",
|
||||
"main-context.patch": "faab10d72b52b184fdb212da1b5af53550d4937208b04266afa3d0b3d3c446dc",
|
||||
"main-fixed-electron-results.json": "c048e499841cf0a7dbebc0078e641f19edee936c8338d588e91315dd5e604d61",
|
||||
"main-fixed-node-results.json": "652b21071405779c2ce7583c1481eb47f4b8ab48b71b144434262f735194e390",
|
||||
"source-versions.json": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
|
||||
"sources.cjs": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be",
|
||||
"vitest.config.mjs": "bfe98cdc66445e31801a9b64f698f158110dc7a8b1ec8f92ef7f99a5fb296444",
|
||||
"worktree-before-electron-results.json": "e6ac502f2089535f010e728ee412d0e6b3e82121c908253e37f83577092e6254",
|
||||
"worktree-before-node-results.json": "922b64c2fb6b8c0fc5c87d859762a0eddc0cc1cbebfd83eaabbc751c99b4d382",
|
||||
"worktree-fixed-electron-results.json": "4cb43d9e8289672ab5523b30c92742c9af41b8f21b842c62515f916500e2bbbe",
|
||||
"worktree-fixed-node-results.json": "9f857aec5fbb3bd98e6015d1be8a1a72d648134d5b47319fd398120761f9b68f"
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, relative, resolve, sep } from 'node:path'
|
||||
import base from '../../../config/vitest.config.ts'
|
||||
const require = createRequire(import.meta.url)
|
||||
const { loadSources } = require(resolve('docs/audits/closed-editor-model-lifetime/sources.cjs'))
|
||||
|
||||
const loaded = loadSources()
|
||||
export default {
|
||||
...base,
|
||||
plugins: [
|
||||
{
|
||||
name: 'closed-model-source-fence',
|
||||
enforce: 'pre',
|
||||
resolveId(source, importer) {
|
||||
const target = source.startsWith('@/')
|
||||
? resolve(loaded.root, 'src/renderer/src', source.slice(2))
|
||||
: source.startsWith('.') && importer
|
||||
? resolve(dirname(importer), source)
|
||||
: source.startsWith(loaded.root)
|
||||
? source
|
||||
: null
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
for (const extension of ['', '.ts', '.tsx', '.json']) {
|
||||
const candidate = `${target}${extension}`
|
||||
if (loaded.sources.has(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
load(id) {
|
||||
const name = resolve(id.split('?')[0])
|
||||
const local = relative(loaded.root, name)
|
||||
if (local.startsWith(`src${sep}`) || local.startsWith(`config${sep}`)) {
|
||||
if (!loaded.sources.has(name)) {
|
||||
throw new Error(`Unfenced repository source: ${name}`)
|
||||
}
|
||||
return loaded.sources.get(name)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
],
|
||||
test: {
|
||||
...base.test,
|
||||
environment: 'happy-dom',
|
||||
maxWorkers: 1,
|
||||
include: ['docs/audits/closed-editor-model-lifetime/lifecycle.test.mjs']
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"runtime": {
|
||||
"node": "24.21.0",
|
||||
"acorn": "8.18.0",
|
||||
"ada": "4.0.0",
|
||||
"amaro": "1.1.11",
|
||||
"ares": "1.34.8",
|
||||
"brotli": "1.2.0",
|
||||
"cldr": "48.0",
|
||||
"icu": "78.2",
|
||||
"llhttp": "9.4.3",
|
||||
"merve": "1.2.2",
|
||||
"modules": "148",
|
||||
"napi": "10",
|
||||
"nbytes": "0.1.4",
|
||||
"ncrypto": "0.0.1",
|
||||
"nghttp2": "1.70.0",
|
||||
"nghttp3": "",
|
||||
"ngtcp2": "",
|
||||
"openssl": "0.0.0",
|
||||
"simdjson": "4.6.7",
|
||||
"simdutf": "7.7.0",
|
||||
"sqlite": "3.53.4",
|
||||
"tz": "2025c",
|
||||
"undici": "7.29.1",
|
||||
"unicode": "17.0",
|
||||
"uv": "1.52.1",
|
||||
"uvwasi": "0.0.23",
|
||||
"v8": "15.0.245.31-electron.0",
|
||||
"zlib": "1.3.2.1-motley",
|
||||
"zstd": "1.6.0",
|
||||
"electron": "43.7.0",
|
||||
"chrome": "150.0.7871.250"
|
||||
},
|
||||
"variant": "before",
|
||||
"graph": "worktree",
|
||||
"sourceFence": {
|
||||
"sources": 1019,
|
||||
"graphSha256": "135b131bc7a3023e0e88c572802c4b837eb968af74e49a71ea93a97ba3454bd9",
|
||||
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
|
||||
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
|
||||
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
|
||||
},
|
||||
"observations": [
|
||||
{
|
||||
"control": "eight-last-file-closes",
|
||||
"openFiles": 0,
|
||||
"retainedModels": 8,
|
||||
"logicalCharacters": 2097208
|
||||
},
|
||||
{
|
||||
"control": "panel-stays-mounted",
|
||||
"closedDisposed": true,
|
||||
"liveDisposed": false
|
||||
},
|
||||
{
|
||||
"control": "hidden-close-and-remount",
|
||||
"retainedClosedModel": true,
|
||||
"openFiles": 1,
|
||||
"models": 2
|
||||
},
|
||||
{
|
||||
"control": "normal-switch",
|
||||
"originalModelPreserved": true
|
||||
}
|
||||
],
|
||||
"scope": "Actual legacy surface, cleanup hook, disposal, Store close action and installed Monaco models. Shell/Panel bodies are controlled ports; files/models are seeded. Eight distinct fixture paths, 256 KiB characters each. No native editor widget, app window, field file sizes, heap bytes or incident allocation rate."
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"runtime": {
|
||||
"node": "24.20.0",
|
||||
"acorn": "8.18.0",
|
||||
"ada": "4.0.0",
|
||||
"amaro": "1.1.11",
|
||||
"ares": "1.34.8",
|
||||
"brotli": "1.2.0",
|
||||
"cldr": "48.0",
|
||||
"icu": "78.3",
|
||||
"llhttp": "9.4.3",
|
||||
"merve": "1.2.2",
|
||||
"modules": "137",
|
||||
"napi": "10",
|
||||
"nbytes": "0.1.4",
|
||||
"ncrypto": "0.0.1",
|
||||
"nghttp2": "1.70.0",
|
||||
"nghttp3": "",
|
||||
"ngtcp2": "",
|
||||
"openssl": "3.5.7",
|
||||
"simdjson": "4.6.6",
|
||||
"simdutf": "6.4.0",
|
||||
"sqlite": "3.53.4",
|
||||
"tz": "2026c",
|
||||
"undici": "7.29.0",
|
||||
"unicode": "17.0",
|
||||
"uv": "1.52.1",
|
||||
"uvwasi": "0.0.23",
|
||||
"v8": "13.6.233.17-node.53",
|
||||
"zlib": "1.3.2.1-motley-42c2f19",
|
||||
"zstd": "1.5.7"
|
||||
},
|
||||
"variant": "before",
|
||||
"graph": "worktree",
|
||||
"sourceFence": {
|
||||
"sources": 1019,
|
||||
"graphSha256": "135b131bc7a3023e0e88c572802c4b837eb968af74e49a71ea93a97ba3454bd9",
|
||||
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
|
||||
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
|
||||
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
|
||||
},
|
||||
"observations": [
|
||||
{
|
||||
"control": "eight-last-file-closes",
|
||||
"openFiles": 0,
|
||||
"retainedModels": 8,
|
||||
"logicalCharacters": 2097208
|
||||
},
|
||||
{
|
||||
"control": "panel-stays-mounted",
|
||||
"closedDisposed": true,
|
||||
"liveDisposed": false
|
||||
},
|
||||
{
|
||||
"control": "hidden-close-and-remount",
|
||||
"retainedClosedModel": true,
|
||||
"openFiles": 1,
|
||||
"models": 2
|
||||
},
|
||||
{
|
||||
"control": "normal-switch",
|
||||
"originalModelPreserved": true
|
||||
}
|
||||
],
|
||||
"scope": "Actual legacy surface, cleanup hook, disposal, Store close action and installed Monaco models. Shell/Panel bodies are controlled ports; files/models are seeded. Eight distinct fixture paths, 256 KiB characters each. No native editor widget, app window, field file sizes, heap bytes or incident allocation rate."
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"runtime": {
|
||||
"node": "24.21.0",
|
||||
"acorn": "8.18.0",
|
||||
"ada": "4.0.0",
|
||||
"amaro": "1.1.11",
|
||||
"ares": "1.34.8",
|
||||
"brotli": "1.2.0",
|
||||
"cldr": "48.0",
|
||||
"icu": "78.2",
|
||||
"llhttp": "9.4.3",
|
||||
"merve": "1.2.2",
|
||||
"modules": "148",
|
||||
"napi": "10",
|
||||
"nbytes": "0.1.4",
|
||||
"ncrypto": "0.0.1",
|
||||
"nghttp2": "1.70.0",
|
||||
"nghttp3": "",
|
||||
"ngtcp2": "",
|
||||
"openssl": "0.0.0",
|
||||
"simdjson": "4.6.7",
|
||||
"simdutf": "7.7.0",
|
||||
"sqlite": "3.53.4",
|
||||
"tz": "2025c",
|
||||
"undici": "7.29.1",
|
||||
"unicode": "17.0",
|
||||
"uv": "1.52.1",
|
||||
"uvwasi": "0.0.23",
|
||||
"v8": "15.0.245.31-electron.0",
|
||||
"zlib": "1.3.2.1-motley",
|
||||
"zstd": "1.6.0",
|
||||
"electron": "43.7.0",
|
||||
"chrome": "150.0.7871.250"
|
||||
},
|
||||
"variant": "fixed",
|
||||
"graph": "worktree",
|
||||
"sourceFence": {
|
||||
"sources": 1021,
|
||||
"graphSha256": "389b3a60b0ddc080a86e9569c371ac1d51db1e6845c8f6d65a1987d92e11cec9",
|
||||
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
|
||||
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
|
||||
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
|
||||
},
|
||||
"observations": [
|
||||
{
|
||||
"control": "eight-last-file-closes",
|
||||
"openFiles": 0,
|
||||
"retainedModels": 0,
|
||||
"logicalCharacters": 0
|
||||
},
|
||||
{
|
||||
"control": "panel-stays-mounted",
|
||||
"closedDisposed": true,
|
||||
"liveDisposed": false
|
||||
},
|
||||
{
|
||||
"control": "hidden-close-and-remount",
|
||||
"retainedClosedModel": false,
|
||||
"openFiles": 1,
|
||||
"models": 1
|
||||
},
|
||||
{
|
||||
"control": "normal-switch",
|
||||
"originalModelPreserved": true
|
||||
}
|
||||
],
|
||||
"scope": "Actual legacy surface, cleanup hook, disposal, Store close action and installed Monaco models. Shell/Panel bodies are controlled ports; files/models are seeded. Eight distinct fixture paths, 256 KiB characters each. No native editor widget, app window, field file sizes, heap bytes or incident allocation rate."
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"runtime": {
|
||||
"node": "24.20.0",
|
||||
"acorn": "8.18.0",
|
||||
"ada": "4.0.0",
|
||||
"amaro": "1.1.11",
|
||||
"ares": "1.34.8",
|
||||
"brotli": "1.2.0",
|
||||
"cldr": "48.0",
|
||||
"icu": "78.3",
|
||||
"llhttp": "9.4.3",
|
||||
"merve": "1.2.2",
|
||||
"modules": "137",
|
||||
"napi": "10",
|
||||
"nbytes": "0.1.4",
|
||||
"ncrypto": "0.0.1",
|
||||
"nghttp2": "1.70.0",
|
||||
"nghttp3": "",
|
||||
"ngtcp2": "",
|
||||
"openssl": "3.5.7",
|
||||
"simdjson": "4.6.6",
|
||||
"simdutf": "6.4.0",
|
||||
"sqlite": "3.53.4",
|
||||
"tz": "2026c",
|
||||
"undici": "7.29.0",
|
||||
"unicode": "17.0",
|
||||
"uv": "1.52.1",
|
||||
"uvwasi": "0.0.23",
|
||||
"v8": "13.6.233.17-node.53",
|
||||
"zlib": "1.3.2.1-motley-42c2f19",
|
||||
"zstd": "1.5.7"
|
||||
},
|
||||
"variant": "fixed",
|
||||
"graph": "worktree",
|
||||
"sourceFence": {
|
||||
"sources": 1021,
|
||||
"graphSha256": "389b3a60b0ddc080a86e9569c371ac1d51db1e6845c8f6d65a1987d92e11cec9",
|
||||
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
|
||||
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
|
||||
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
|
||||
},
|
||||
"observations": [
|
||||
{
|
||||
"control": "eight-last-file-closes",
|
||||
"openFiles": 0,
|
||||
"retainedModels": 0,
|
||||
"logicalCharacters": 0
|
||||
},
|
||||
{
|
||||
"control": "panel-stays-mounted",
|
||||
"closedDisposed": true,
|
||||
"liveDisposed": false
|
||||
},
|
||||
{
|
||||
"control": "hidden-close-and-remount",
|
||||
"retainedClosedModel": false,
|
||||
"openFiles": 1,
|
||||
"models": 1
|
||||
},
|
||||
{
|
||||
"control": "normal-switch",
|
||||
"originalModelPreserved": true
|
||||
}
|
||||
],
|
||||
"scope": "Actual legacy surface, cleanup hook, disposal, Store close action and installed Monaco models. Shell/Panel bodies are controlled ports; files/models are seeded. Eight distinct fixture paths, 256 KiB characters each. No native editor widget, app window, field file sizes, heap bytes or incident allocation rate."
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
# Codex prompt claims retained after turn completion
|
||||
|
||||
Confirmed cancellation keeps a prompt claim until its turn completes. If the prompt's lookup entries are evicted or replaced first, the old `clearTurn()` cannot find it. The separate claims map then retains the prompt until the whole session is cleared.
|
||||
|
||||
The fix includes claimed prompts in the existing exact-thread/turn cleanup. Existing turn matching and `forget()` object-identity checks preserve a replacement prompt's authority. Registry limits and cancellation timing are unchanged.
|
||||
|
||||
## Source ownership and reachability
|
||||
|
||||
1. `codex-structured-provider-events.ts:57` registers incoming prompt requests and publishes them through the translator. `codex-structured-session-acquire.ts:95` binds the translator's turn cleanup to the session registry.
|
||||
2. `codex-structured-prompt-ownership.ts:33` acquires the claim. Confirmed cancellation deliberately leaves it owned; unsuccessful/unconfirmed cancellation releases it. The actual `CodexStructuredTurnCancellation` invokes the confirmation callback after the injected interrupt transport acknowledges success.
|
||||
3. `codex-prompt-registry.ts:258` trims the address and journal-binding maps independently. Neither trim removes claims. Replacing the same journal address can similarly leave the old claim without a lookup entry.
|
||||
4. A later `turn/completed` goes through `translateCodexNotification`, the journal translator and `settleCodexJournalTurn`. Accepted lifecycle settlement invokes `clearPromptTurn` at `codex-structured-journal-settlement.ts:170`.
|
||||
5. The old cleanup enumerates only address/binding values. The fix also enumerates `claims.keys()`, still filtering by the exact thread/turn. `forget()` deletes replacement lookup entries only when they contain that same prompt object.
|
||||
|
||||
The safely expired owner is the claim for the terminal turn whose cleanup has been admitted. Live claims survive unrelated turn cleanup and registry eviction. A refused lifecycle settlement does not clear them.
|
||||
|
||||
## Reproduce
|
||||
|
||||
From the worktree root, using installed dependencies:
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node --expose-gc --max-old-space-size=192 docs/audits/codex-prompt-claim-retention/reproduce.cjs
|
||||
```
|
||||
|
||||
For Electron, use its installed executable with `ELECTRON_RUN_AS_NODE=1`, the same flags and script. The final optional argument selects the report path; the default is `node-results.json` beside the script. No Electron window is created.
|
||||
|
||||
`sources.cjs` reverses `fix.patch` against current source and rejects a baseline hash mismatch. It neither reads a previous commit to reconstruct the implementation nor changes product files. The proof bundles actual source in memory. Each report records effective source and bundle hashes, dependency hashes and runtime versions. Only the requested report is written.
|
||||
|
||||
The fixture uses the actual registry, server-request delivery, cancellation ownership function, cancellation class, journal translator and delayed notification delivery. It injects an accepting journal sink, interrupt transport and primary-turn lookup. Prompts belong to child threads, so the production child-turn cancellation branch does not enumerate or terminate processes. Every injected process helper throws if unexpectedly called.
|
||||
|
||||
The sequence creates 32 ordinary small prompt objects, confirms their cancellations, admits 256 unrelated prompts to evict lookup entries, then completes the original exact turns. WeakRefs count prompt liveness after forced collections. No large payload is attached. The 20-second deadline and 192 MiB heap limit bound the proof.
|
||||
|
||||
## Results
|
||||
|
||||
Both [Node 26.6.0](./node-results.json) and [Electron 43.7.0 / Node 24.21.0](./electron-results.json) produced:
|
||||
|
||||
| Observation | Before | After |
|
||||
| -------------------------------------------------------------------------- | -----: | ----: |
|
||||
| Retained cancelled prompts after lookup eviction, before completion | 32 | 32 |
|
||||
| Retained after exact turn completion | 32 | 0 |
|
||||
| Retained after all lookup maps become empty | 32 | 0 |
|
||||
| Retained after session clear | 0 | 0 |
|
||||
| Old prompt retained after same-address replacement and old-turn completion | 1 | 0 |
|
||||
|
||||
Ordinary completion releases its prompt on both versions. Wrong-thread, wrong-turn and refused-completion controls preserve claims. The replacement prompt and its active claim remain valid after old-turn cleanup on both versions.
|
||||
|
||||
The four regression tests cover 32 evicted claims, replacement authority, a compatibility turn digest and session cleanup. Applying the reversed source produces three expected failures; the session-clear control passes. Existing prompt ownership/reply tests also pass on the reversed source. Current source passes 71 tests across six files plus Node, CLI and Web typechecks; [validation.json](./validation.json) records commands and other checks.
|
||||
|
||||
## Limits
|
||||
|
||||
This is a code-level lifetime defect. The request/completion ordering is deliberately injected; this is not a capture of Codex emitting that sequence or an affected host. Counts do not measure ordinary prompt bytes or establish a growth rate. It does not identify the cause of #19831 or any other incident.
|
||||
|
||||
[source-versions.json](./source-versions.json) records matching baseline source at the named main revision. No historical application runtime was reproduced. The fix uses existing turn ownership and identity checks; it adds no arbitrary eviction policy.
|
||||
@@ -1,24 +0,0 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { defineConfig, mergeConfig } from 'vitest/config'
|
||||
import baseConfig from '../../../config/vitest.config.ts'
|
||||
|
||||
const loadSources = createRequire(import.meta.url)(
|
||||
resolve('docs/audits/codex-prompt-claim-retention/sources.cjs')
|
||||
)
|
||||
const { before } = loadSources()
|
||||
export default mergeConfig(
|
||||
baseConfig,
|
||||
defineConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: 'codex-claim-before-fix',
|
||||
enforce: 'pre',
|
||||
transform(_code, id) {
|
||||
const source = before.get(resolve(id.split('?')[0]))
|
||||
return source === undefined ? undefined : { code: source, map: null }
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
||||
--- a/src/main/codex/codex-prompt-registry.ts
|
||||
+++ b/src/main/codex/codex-prompt-registry.ts
|
||||
@@ -226,7 +226,7 @@
|
||||
|
||||
clearTurn(threadId: string, turnId: string): void {
|
||||
const prompts = new Set(
|
||||
- [...this.byAddress.values(), ...this.boundPrompts.values()].filter(
|
||||
+ [...this.byAddress.values(), ...this.boundPrompts.values(), ...this.claims.keys()].filter(
|
||||
(prompt) => prompt.threadId === threadId && codexPromptMatchesTurn(prompt, turnId)
|
||||
)
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync, writeFileSync } = require('node:fs')
|
||||
const { resolve, relative } = require('node:path')
|
||||
const esbuild = require('esbuild')
|
||||
const Module = require('node:module')
|
||||
|
||||
assert.equal(process.env.ORCA_BACKGROUND_LAUNCH, '1')
|
||||
assert.equal(typeof global.gc, 'function')
|
||||
const { root, before, after, hashes } = require('./sources.cjs')()
|
||||
const sourcePath = 'src/main/codex/codex-prompt-registry.ts'
|
||||
const source = before.get(resolve(root, sourcePath))
|
||||
const candidate = after.get(resolve(root, sourcePath))
|
||||
const hash = (value) => createHash('sha256').update(value).digest('hex')
|
||||
const entry = `
|
||||
export { CodexPromptRegistry } from './src/main/codex/codex-prompt-registry';
|
||||
export { cancelCodexStructuredTurn } from './src/main/codex/codex-structured-prompt-ownership';
|
||||
export { CodexStructuredTurnCancellation } from './src/main/codex/codex-structured-turn-cancellation';
|
||||
export { createCodexJournalTranslator } from './src/main/codex/codex-structured-journal-translation';
|
||||
export { deliverCodexServerRequest, translateCodexNotification } from './src/main/codex/codex-structured-provider-events';
|
||||
`
|
||||
|
||||
async function build(mode) {
|
||||
const result = await esbuild.build({
|
||||
stdin: {
|
||||
contents: entry,
|
||||
resolveDir: root,
|
||||
loader: 'ts',
|
||||
sourcefile: 'codex-claim-proof-entry.ts'
|
||||
},
|
||||
absWorkingDir: root,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
packages: 'external',
|
||||
write: false,
|
||||
metafile: true,
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
{
|
||||
name: 'candidate-only-in-memory',
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /\/codex-prompt-registry\.ts$/ }, (args) => {
|
||||
assert.equal(args.path, resolve(root, sourcePath))
|
||||
return { contents: mode === 'candidate' ? candidate : source, loader: 'ts' }
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
const bundlePath = resolve(root, `codex-claim-${mode}-proof.cjs`)
|
||||
const loaded = new Module(bundlePath, module)
|
||||
loaded.filename = bundlePath
|
||||
loaded.paths = Module._nodeModulePaths(root)
|
||||
loaded._compile(result.outputFiles[0].text, bundlePath)
|
||||
const dependencies = Object.keys(result.metafile.inputs)
|
||||
.filter((path) => path.startsWith('src/'))
|
||||
.map((path) => ({
|
||||
path,
|
||||
sha256: hash(
|
||||
path === sourcePath
|
||||
? mode === 'original'
|
||||
? source
|
||||
: candidate
|
||||
: readFileSync(resolve(root, path))
|
||||
)
|
||||
}))
|
||||
return { api: loaded.exports, bundleSha256: hash(result.outputFiles[0].contents), dependencies }
|
||||
}
|
||||
|
||||
const run = require('./scenario.cjs')
|
||||
|
||||
async function main() {
|
||||
const deadline = setTimeout(() => {
|
||||
process.stderr.write('proof deadline\n')
|
||||
process.exit(2)
|
||||
}, 20_000)
|
||||
const results = {}
|
||||
const versions = {}
|
||||
for (const mode of ['original', 'candidate']) {
|
||||
const built = await build(mode)
|
||||
results[mode] = await run(built.api, mode)
|
||||
versions[mode] = { bundleSha256: built.bundleSha256, dependencies: built.dependencies }
|
||||
}
|
||||
clearTimeout(deadline)
|
||||
const report = {
|
||||
capturedAt: new Date().toISOString(),
|
||||
runtime: process.versions,
|
||||
scope:
|
||||
'Actual registry, server-request translation, cancellation ownership/cancellation class, journal translator, delayed turn completion. Injected accepted sink, interrupt transport, and compaction lookup. No provider, process enumeration/termination, or host data. Bundles load in memory; only the requested report is written.',
|
||||
sourceHashes: hashes,
|
||||
countsOnly: true,
|
||||
noPayloadAmplification: true,
|
||||
results,
|
||||
versions
|
||||
}
|
||||
const output = process.argv[2] ?? resolve(__dirname, 'node-results.json')
|
||||
writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`)
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{ output: relative(root, output), sourceHashes: report.sourceHashes, results },
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.stack}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,215 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const admitted = () => ({ accepted: true })
|
||||
|
||||
function fixture(api) {
|
||||
const prompts = new api.CodexPromptRegistry()
|
||||
const state = { prompts, requestCount: 0, lastBinding: null, blockCompletion: false }
|
||||
const sink = {
|
||||
appendItem() {},
|
||||
appendTombstone() {},
|
||||
publish() {},
|
||||
tryAppendItem: admitted,
|
||||
tryAppendTombstone: admitted,
|
||||
tryAppendLifecycleBatch: (id) =>
|
||||
state.blockCompletion && id.startsWith('turn-completed:')
|
||||
? { accepted: false, reason: 'backpressure' }
|
||||
: admitted(),
|
||||
tryPublish: admitted
|
||||
}
|
||||
const translator = api.createCodexJournalTranslator({
|
||||
sink,
|
||||
sessionId: 'session',
|
||||
primaryThreadId: () => 'primary',
|
||||
bindPromptItemId: (id, thread, promptKey, turn) => {
|
||||
prompts.bindJournalItemId(id, thread, promptKey, turn)
|
||||
state.lastBinding = id
|
||||
},
|
||||
clearPromptTurn: (thread, turn) => prompts.clearTurn(thread, turn)
|
||||
})
|
||||
const session = {
|
||||
threadId: 'primary',
|
||||
prompts,
|
||||
translator,
|
||||
fence: 7,
|
||||
acquisitionGeneration: 'generation',
|
||||
ended: false,
|
||||
connection: {
|
||||
request: async (method) => {
|
||||
assert.equal(method, 'turn/interrupt')
|
||||
state.requestCount++
|
||||
return {}
|
||||
},
|
||||
respondWithError() {
|
||||
throw new Error('unexpected server refusal')
|
||||
},
|
||||
respond() {
|
||||
throw new Error('unexpected prompt response')
|
||||
}
|
||||
}
|
||||
}
|
||||
const emit = (_session, event) => translator.handle(event)
|
||||
const cancellation = new api.CodexStructuredTurnCancellation({
|
||||
emit,
|
||||
captureTurnProcesses: async () => {
|
||||
throw new Error('no process enumeration allowed')
|
||||
},
|
||||
terminateTurnProcesses: async () => {
|
||||
throw new Error('no process termination allowed')
|
||||
}
|
||||
})
|
||||
cancellation.register(session)
|
||||
return Object.assign(state, {
|
||||
api,
|
||||
session,
|
||||
translator,
|
||||
cancellation,
|
||||
emit,
|
||||
sessions: new Map([['session', session]]),
|
||||
compactions: { providerTurnId: () => 'primary-turn' }
|
||||
})
|
||||
}
|
||||
|
||||
function register(
|
||||
state,
|
||||
serial,
|
||||
thread = `child-${serial}`,
|
||||
turn = `turn-${serial}`,
|
||||
item = `item-${serial}`
|
||||
) {
|
||||
state.lastBinding = null
|
||||
const admission = state.api.deliverCodexServerRequest(
|
||||
'session',
|
||||
state.session,
|
||||
{
|
||||
id: serial,
|
||||
method: 'item/commandExecution/requestApproval',
|
||||
params: { threadId: thread, turnId: turn, itemId: item, command: 'echo bounded-proof' }
|
||||
},
|
||||
state.emit
|
||||
)
|
||||
assert.equal(admission.accepted, true)
|
||||
assert.equal(typeof state.lastBinding, 'string')
|
||||
const prompt = state.prompts.find(state.lastBinding)
|
||||
assert.ok(prompt)
|
||||
return { ref: new WeakRef(prompt), id: state.lastBinding, thread, turn }
|
||||
}
|
||||
|
||||
async function cancel(state, record) {
|
||||
const result = await state.api.cancelCodexStructuredTurn({
|
||||
sessions: state.sessions,
|
||||
compactions: state.compactions,
|
||||
cancellation: state.cancellation,
|
||||
request: {
|
||||
sessionId: 'session',
|
||||
turnId: 'primary-turn',
|
||||
fence: 7,
|
||||
prompt: { itemId: record.id, kind: 'approval' }
|
||||
}
|
||||
})
|
||||
assert.equal(result.cancelled, true)
|
||||
}
|
||||
|
||||
function complete(state, thread, turn, expectedAccepted = true) {
|
||||
const admission = state.api.translateCodexNotification({
|
||||
sessionId: 'session',
|
||||
session: state.session,
|
||||
method: 'turn/completed',
|
||||
params: { threadId: thread, turn: { id: turn, status: 'interrupted' } },
|
||||
turnCancellation: state.cancellation,
|
||||
emit: state.emit
|
||||
})
|
||||
assert.equal(admission.accepted, expectedAccepted)
|
||||
}
|
||||
|
||||
async function alive(records) {
|
||||
for (let round = 0; round < 8; round++) {
|
||||
await new Promise(setImmediate)
|
||||
global.gc()
|
||||
}
|
||||
return records.filter((record) => record.ref.deref() !== undefined).length
|
||||
}
|
||||
|
||||
async function run(api, mode) {
|
||||
const state = fixture(api)
|
||||
const ordinary = register(state, 1)
|
||||
await cancel(state, ordinary)
|
||||
assert.equal(await alive([ordinary]), 1)
|
||||
complete(state, ordinary.thread, ordinary.turn)
|
||||
const ordinaryAfterCompletion = await alive([ordinary])
|
||||
assert.equal(ordinaryAfterCompletion, 0)
|
||||
|
||||
const records = []
|
||||
for (let index = 0; index < 32; index++) {
|
||||
const record = register(state, index + 10)
|
||||
await cancel(state, record)
|
||||
records.push(record)
|
||||
}
|
||||
assert.equal(await alive(records), 32)
|
||||
// Unrelated child traffic evicts old binding/address entries without ending their turns.
|
||||
for (let index = 0; index < 256; index++) {
|
||||
register(state, index + 1000, 'other-child', 'other-turn')
|
||||
}
|
||||
const sizesAfterEviction = state.prompts.sizes
|
||||
for (const record of records) {
|
||||
assert.equal(state.prompts.find(record.id), null)
|
||||
}
|
||||
const afterEvictionBeforeCompletion = await alive(records)
|
||||
assert.equal(afterEvictionBeforeCompletion, 32)
|
||||
complete(state, 'wrong-child', records[0].turn)
|
||||
complete(state, records[0].thread, 'wrong-turn')
|
||||
assert.equal(await alive(records), 32)
|
||||
register(state, 5000, records[0].thread, records[0].turn)
|
||||
state.blockCompletion = true
|
||||
complete(state, records[0].thread, records[0].turn, false)
|
||||
assert.equal(await alive(records), 32)
|
||||
state.blockCompletion = false
|
||||
for (const record of records) {
|
||||
complete(state, record.thread, record.turn)
|
||||
}
|
||||
const afterExactTurnCompletion = await alive(records)
|
||||
assert.equal(afterExactTurnCompletion, mode === 'original' ? 32 : 0)
|
||||
complete(state, 'other-child', 'other-turn')
|
||||
assert.deepEqual(state.prompts.sizes, { prompts: 0, journalBindings: 0 })
|
||||
const afterAllLookupMapsEmpty = await alive(records)
|
||||
assert.equal(afterAllLookupMapsEmpty, mode === 'original' ? 32 : 0)
|
||||
state.prompts.clear()
|
||||
const afterSessionClear = await alive(records)
|
||||
assert.equal(afterSessionClear, 0)
|
||||
|
||||
// Replacing a journal address must not let old-turn completion clear the new prompt/claim.
|
||||
const old = register(state, 2000, 'reuse-child', 'old-turn', 'reused-item')
|
||||
await cancel(state, old)
|
||||
const newer = register(state, 2001, 'reuse-child', 'new-turn', 'reused-item')
|
||||
assert.equal(newer.id, old.id)
|
||||
const replacementClaim = state.prompts.claimBound(newer.id)
|
||||
assert.ok(replacementClaim)
|
||||
complete(state, old.thread, old.turn)
|
||||
assert.equal(
|
||||
state.prompts.ownsBoundClaim(replacementClaim, newer.id, newer.thread, newer.turn),
|
||||
true
|
||||
)
|
||||
const oldAfterReplacementCompletion = await alive([old])
|
||||
assert.equal(oldAfterReplacementCompletion, mode === 'original' ? 1 : 0)
|
||||
state.prompts.releaseClaim(replacementClaim)
|
||||
complete(state, newer.thread, newer.turn)
|
||||
state.prompts.clear()
|
||||
state.translator.dispose()
|
||||
return {
|
||||
ordinaryAfterCompletion,
|
||||
cancelledPrompts: 32,
|
||||
sizesAfterEviction,
|
||||
afterEvictionBeforeCompletion,
|
||||
afterExactTurnCompletion,
|
||||
afterAllLookupMapsEmpty,
|
||||
afterSessionClear,
|
||||
oldAfterReplacementCompletion,
|
||||
replacementClaimPreserved: true,
|
||||
wrongThreadPreserved: true,
|
||||
wrongTurnPreserved: true,
|
||||
rejectedCompletionPreserved: true,
|
||||
successfulInterruptRequests: state.requestCount
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = run
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"baselineHashes": {
|
||||
"src/main/codex/codex-prompt-registry.ts": "254c530216f47abfc3572bb1689161ad3c10775af2cdceda7e94f443e52c0691"
|
||||
},
|
||||
"namedRefs": [
|
||||
{
|
||||
"ref": "HEAD",
|
||||
"revision": "9e2c137548bf99f91255ab4862c01145e42a0883",
|
||||
"sha256": "254c530216f47abfc3572bb1689161ad3c10775af2cdceda7e94f443e52c0691",
|
||||
"matchesBaseline": true
|
||||
},
|
||||
{
|
||||
"ref": "origin/main",
|
||||
"revision": "77cd61df396f25ec91ee2d5ddcbd1f55aa94f818",
|
||||
"sha256": "254c530216f47abfc3572bb1689161ad3c10775af2cdceda7e94f443e52c0691",
|
||||
"matchesBaseline": true
|
||||
}
|
||||
],
|
||||
"historicalRuntimeReproduced": false,
|
||||
"callbackProvenance": [
|
||||
{
|
||||
"path": "src/main/codex/codex-structured-session-acquire.ts",
|
||||
"sha256": "71cd2bae18944c2aaa3ea2a1d00958890e4c8219d5aae9a4de48dd692562ace0"
|
||||
},
|
||||
{
|
||||
"path": "src/main/codex/codex-structured-session-adapter.ts",
|
||||
"sha256": "eab8820250ebdb1b3f6b3ab9287e16686bd6766f5af5dd29e081d7e47f083b20"
|
||||
},
|
||||
{
|
||||
"path": "src/main/codex/codex-structured-provider-events.ts",
|
||||
"sha256": "a411dc378e521ea8fc915e2a2a21476341a30f440e8233ae5649205d2a1e2e44"
|
||||
},
|
||||
{
|
||||
"path": "src/main/codex/codex-structured-prompt-ownership.ts",
|
||||
"sha256": "8d86f3784b79a1470e4002527bdfa505efe2762cba865de7716442f7d9730013"
|
||||
},
|
||||
{
|
||||
"path": "src/main/codex/codex-structured-turn-cancellation.ts",
|
||||
"sha256": "518b8e5a2f6dc4f8e46e01366c0a61830d6c805b68101484726bd62bbfc831a8"
|
||||
},
|
||||
{
|
||||
"path": "src/main/codex/codex-structured-journal-translation.ts",
|
||||
"sha256": "56e17b9649471e804d29d1d54a3e14b4298ca8af2e25c4759dc37fce000b06a1"
|
||||
},
|
||||
{
|
||||
"path": "src/main/codex/codex-structured-journal-settlement.ts",
|
||||
"sha256": "761616c292e15894b337fdd171acd7c2c1272fcabdbad3ea460f8831a923c797"
|
||||
},
|
||||
{
|
||||
"path": "src/main/codex/codex-structured-session-close.ts",
|
||||
"sha256": "f85aa2cdcfd2ddf34ae0be8397a3896128f04a989eaff750f8e72eee3398b361"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { resolve } = require('node:path')
|
||||
const { applyPatch, parsePatch, reversePatch } = require('diff')
|
||||
|
||||
module.exports = function loadSources() {
|
||||
const root = resolve(__dirname, '../../..')
|
||||
const expected = JSON.parse(readFileSync(resolve(__dirname, 'source-versions.json'), 'utf8'))
|
||||
const parsed = parsePatch(readFileSync(resolve(__dirname, 'fix.patch'), 'utf8'))
|
||||
const before = new Map()
|
||||
const after = new Map()
|
||||
const hashes = {}
|
||||
assert.equal(parsed.length, 1)
|
||||
for (const patch of parsed) {
|
||||
const path = patch.newFileName.replace(/^b\//, '')
|
||||
assert.ok(Object.hasOwn(expected.baselineHashes, path), `Unexpected patch path: ${path}`)
|
||||
const absolute = resolve(root, path)
|
||||
const current = readFileSync(absolute, 'utf8')
|
||||
const baseline = applyPatch(current, reversePatch(patch))
|
||||
assert.notEqual(baseline, false, `Source changed; review fix.patch: ${path}`)
|
||||
const hash = (source) => createHash('sha256').update(source).digest('hex')
|
||||
assert.equal(hash(baseline), expected.baselineHashes[path], `Baseline drift: ${path}`)
|
||||
before.set(absolute, baseline)
|
||||
after.set(absolute, current)
|
||||
hashes[path] = { before: hash(baseline), after: hash(current) }
|
||||
}
|
||||
return { root, before, after, hashes }
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
{
|
||||
"backgroundLaunch": true,
|
||||
"newRegressionCases": 4,
|
||||
"before": {
|
||||
"config": "docs/audits/codex-prompt-claim-retention/before.config.mjs",
|
||||
"passed": 31,
|
||||
"failed": 3,
|
||||
"exitCode": 1,
|
||||
"paths": [
|
||||
"src/main/codex/codex-prompt-registry-retention.test.ts",
|
||||
"src/main/codex/codex-structured-prompt-ownership.test.ts",
|
||||
"src/main/codex/codex-structured-prompt-replies.test.ts"
|
||||
],
|
||||
"expectedFailures": [
|
||||
"releases 32 evicted claims only when their exact turn completes",
|
||||
"preserves a replacement prompt and its active claim when the old turn completes",
|
||||
"finds an evicted claim through its bounded turn digest"
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"config": "config/vitest.config.ts",
|
||||
"passed": 71,
|
||||
"failed": 0,
|
||||
"exitCode": 0,
|
||||
"paths": [
|
||||
"src/main/codex/codex-prompt-registry-retention.test.ts",
|
||||
"src/main/codex/codex-structured-prompt-ownership.test.ts",
|
||||
"src/main/codex/codex-structured-prompt-replies.test.ts",
|
||||
"src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts",
|
||||
"src/main/codex/codex-structured-journal-translation-settlement.test.ts",
|
||||
"src/main/codex/codex-structured-session-close.test.ts"
|
||||
]
|
||||
},
|
||||
"typechecks": {
|
||||
"command": "node config/scripts/run-typecheck-projects-in-parallel.mjs",
|
||||
"projects": [
|
||||
"config/tsconfig.node.json",
|
||||
"config/tsconfig.tc.cli.json",
|
||||
"config/tsconfig.tc.web.json"
|
||||
],
|
||||
"exitCode": 0
|
||||
},
|
||||
"focusedOxlint": {
|
||||
"ordinaryExitCode": 0,
|
||||
"typeAwareExitCode": 0,
|
||||
"noIgnore": true,
|
||||
"files": 6
|
||||
},
|
||||
"changedCodeQuality": {
|
||||
"command": "node config/scripts/check-changed-code-quality.mjs",
|
||||
"base": "2fccacadbe23",
|
||||
"changedFiles": 310,
|
||||
"newFindings": 0,
|
||||
"exitCode": 0
|
||||
},
|
||||
"proof": {
|
||||
"command": "node --expose-gc --max-old-space-size=192 docs/audits/codex-prompt-claim-retention/reproduce.cjs",
|
||||
"node": "26.6.0",
|
||||
"electron": "43.7.0",
|
||||
"electronNode": "24.21.0",
|
||||
"bothExitCode": 0,
|
||||
"beforeRetainedAfterExactCompletion": 32,
|
||||
"afterRetainedAfterExactCompletion": 0,
|
||||
"ordinaryPromptBytes": "not measured",
|
||||
"payloadAmplification": false,
|
||||
"ordering": "injected delayed child-turn completion, not an affected-host capture"
|
||||
},
|
||||
"formatCheckExitCode": 0
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
# Crashpad bounded reads
|
||||
|
||||
After a crash, Orca extracts a short diagnostic signature from a potentially large dump.
|
||||
The old reader loaded the complete dump. The current parser reads bounded ranges through
|
||||
four retained 64 KiB pages and scans embedded diagnostic text in roughly 1 MiB windows.
|
||||
It can capture a report that grows beyond the existing 64 MiB directory-discovery limit
|
||||
without keeping that report in one large buffer. Reports already beyond that limit when
|
||||
discovered retain the existing exclusion policy.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/crashpad-read-limit/reproduce.mjs
|
||||
```
|
||||
|
||||
Use the repository's installed dependencies and supported Node version. The runner checks
|
||||
committed SHA-256 values in `source-hashes.json` before executing any tests. This includes
|
||||
the capture/parser, test fixtures, runner and its local imports, plus package and lockfile
|
||||
versions. It refuses changed sources instead of silently recording new hashes. Updating
|
||||
the manifest requires reviewing the changed evidence and rerunning the proof.
|
||||
|
||||
The runner executes the actual capture and file-source tests with synthetic files, then
|
||||
repeats them with only the zero-size extent deadline disabled through an in-memory Vite
|
||||
transform. The mutation changes no files. This is a regression control for the deadline,
|
||||
not a simulation of every behavior in the original implementation.
|
||||
|
||||
| Phase | Pass | Fail | Skip | Exit |
|
||||
| ---------------------- | ---: | ---: | ---: | ---: |
|
||||
| Deadline removed | 24 | 2 | 0 | 1 |
|
||||
| Current implementation | 26 | 0 | 0 | 0 |
|
||||
|
||||
The two expected failures verify that a file reported as size zero stops being read at
|
||||
the deadline, after either one or two pages, even though more bytes remain available.
|
||||
The controls fail both on the observed extent and the number of actual file reads.
|
||||
The runner checks their exact names and exits unsuccessfully for unexpected results.
|
||||
`results.json` records the verified hashes, counts, failed cases, exit codes and timeouts.
|
||||
|
||||
Other controls cover 80 MiB sparse dumps, metadata beyond 64 MiB, marker/check-message
|
||||
boundaries, growth and replacement during capture, zero-size growth, truncation, descriptor
|
||||
cleanup and partial-header retry behavior. A symlink swap, deleted candidate, or directory replacement now skips only that candidate; another valid dump can still supply the signature. The symlink-swap test is skipped on platforms without `O_NOFOLLOW`. The largest requested parser read is 1 MiB +
|
||||
4,096 bytes, plus four retained 64 KiB metadata pages. A 1,042-module fixture requires no
|
||||
more than eight reads, preventing repeated reads between module and name pages.
|
||||
|
||||
## Parser compatibility
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/crashpad-read-limit/stream-signature-parity.cjs
|
||||
```
|
||||
|
||||
The separate parity script compares 47 fixtures with the published parser at
|
||||
`09dbe227547fadaec8d9163f35fd127b0dc1c3ed`, using both in-memory buffers and real file handles.
|
||||
It covers annotations, modules, exceptions, chunk boundaries and marker exhaustion. It
|
||||
also prints five warm timings for 8/64 MiB sparse dumps; these local synthetic timings
|
||||
are not a platform-wide performance guarantee. This older parity script does not enforce
|
||||
the source manifest; use the manifest-checking regression runner first.
|
||||
|
||||
## Limits and user impact
|
||||
|
||||
This removes a potentially large transient allocation after a crash; it does not establish
|
||||
the cause of normal-session OOMs or an aggregate process-memory cap. Independent captures
|
||||
can overlap. No evidence ties this file-growth race to #19831 or #19768.
|
||||
|
||||
The opened file supplies the parsed ranges. In-place rewrites are not an atomic snapshot.
|
||||
Nonempty files retain their opened extent. A file opened at size zero receives at least one
|
||||
read, then observes growth only until the capture deadline; later bytes may therefore be
|
||||
omitted from that diagnostic signature. This keeps a continuously growing report from
|
||||
holding capture open indefinitely. Dump files themselves are not truncated by this reader.
|
||||
|
||||
Crashpad normally writes an invalid header first and promotes a completed file later:
|
||||
[database implementation](https://chromium.googlesource.com/crashpad/crashpad/+/refs/heads/main/client/crash_report_database_mac.mm)
|
||||
and [minidump writer](https://chromium.googlesource.com/crashpad/crashpad/+/HEAD/minidump/minidump_file_writer.cc).
|
||||
The fixtures represent that ordering; they do not reproduce a field incident or a native
|
||||
Crashpad process. All checks run in background Node processes without app windows.
|
||||
@@ -1,155 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { constants as fsConstants } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
|
||||
throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.')
|
||||
}
|
||||
|
||||
const root = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const expectedHashes = JSON.parse(
|
||||
await readFile(new URL('./source-hashes.json', import.meta.url), 'utf8')
|
||||
)
|
||||
const sourceHashes = {}
|
||||
for (const [path, expected] of Object.entries(expectedHashes)) {
|
||||
const actual = createHash('sha256')
|
||||
.update(await readFile(resolve(root, path)))
|
||||
.digest('hex')
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Source hash changed; review this evidence: ${path}`)
|
||||
}
|
||||
sourceHashes[path] = actual
|
||||
}
|
||||
|
||||
const scratch = await mkdtemp(join(tmpdir(), 'orca-crashpad-read-limit-'))
|
||||
const require = createRequire(import.meta.url)
|
||||
let runnerModuleId
|
||||
try {
|
||||
const runnerPath = join(scratch, 'run-process.cjs')
|
||||
await build({
|
||||
absWorkingDir: root,
|
||||
entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')],
|
||||
outfile: runnerPath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
logLevel: 'silent'
|
||||
})
|
||||
runnerModuleId = require.resolve(runnerPath)
|
||||
const { runProcess } = require(runnerModuleId)
|
||||
const includes = [
|
||||
'src/main/crash-reporting/crashpad-capture-read-limit.test.ts',
|
||||
'src/main/crash-reporting/minidump-file-source.test.ts'
|
||||
]
|
||||
const deadlinePath = resolve(root, 'src/main/crash-reporting/minidump-file-source.ts')
|
||||
const current = await readFile(deadlinePath, 'utf8')
|
||||
const deadlineCondition =
|
||||
'size > 0 && options.deadlineMs !== undefined && now() >= options.deadlineMs'
|
||||
if (current.split(deadlineCondition).length !== 2) {
|
||||
throw new Error('Deadline mutation no longer identifies exactly one branch.')
|
||||
}
|
||||
const withoutDeadline = current.replace(deadlineCondition, 'false')
|
||||
const deadlineConfig = join(scratch, 'without-deadline.config.mjs')
|
||||
const fixedConfig = join(scratch, 'fixed.config.mjs')
|
||||
const config = {
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: includes,
|
||||
testTimeout: 30_000,
|
||||
execArgv: ['--no-experimental-webstorage']
|
||||
}
|
||||
}
|
||||
await writeFile(
|
||||
deadlineConfig,
|
||||
`export default {...${JSON.stringify(config)}, plugins: [{
|
||||
name: 'disable-extent-deadline', enforce: 'pre',
|
||||
transform(_code, id) {
|
||||
return id.split('?')[0].replaceAll('\\\\', '/') === ${JSON.stringify(deadlinePath.replaceAll('\\', '/'))}
|
||||
? {code: ${JSON.stringify(withoutDeadline)}, map: null} : null;
|
||||
}
|
||||
}]};\n`
|
||||
)
|
||||
await writeFile(fixedConfig, `export default ${JSON.stringify(config)};\n`)
|
||||
|
||||
async function run(label, configPath) {
|
||||
const report = join(scratch, `${label}.json`)
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: [
|
||||
resolve(root, 'node_modules/vitest/vitest.mjs'),
|
||||
'run',
|
||||
'--config',
|
||||
configPath,
|
||||
'--reporter=json',
|
||||
`--outputFile=${report}`
|
||||
],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
timeoutMs: 90_000,
|
||||
maxOutputBytes: 4 * 1024 * 1024
|
||||
})
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(report, 'utf8'))
|
||||
} catch (error) {
|
||||
throw new Error(`${label} runner failed: ${result.stderr || result.stdout}`, { cause: error })
|
||||
}
|
||||
return {
|
||||
exitCode: result.code,
|
||||
timedOut: result.timedOut,
|
||||
passed: parsed.numPassedTests,
|
||||
failed: parsed.numFailedTests,
|
||||
skipped: parsed.numPendingTests,
|
||||
failedCases: parsed.testResults.flatMap((suite) =>
|
||||
suite.assertionResults
|
||||
.filter((test) => test.status === 'failed')
|
||||
.map((test) => test.fullName)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const withoutDeadlineResult = await run('without-deadline', deadlineConfig)
|
||||
const fixed = await run('fixed', fixedConfig)
|
||||
const expectedFailures = [1, 2].map(
|
||||
(pages) => `stops observing a growing size-zero dump after the deadline at page ${pages}`
|
||||
)
|
||||
const skippedSymlink = fsConstants.O_NOFOLLOW ? 0 : 1
|
||||
const passed =
|
||||
withoutDeadlineResult.exitCode === 1 &&
|
||||
!withoutDeadlineResult.timedOut &&
|
||||
withoutDeadlineResult.failed === 2 &&
|
||||
withoutDeadlineResult.passed === 24 - skippedSymlink &&
|
||||
withoutDeadlineResult.skipped === skippedSymlink &&
|
||||
JSON.stringify(withoutDeadlineResult.failedCases) === JSON.stringify(expectedFailures) &&
|
||||
fixed.exitCode === 0 &&
|
||||
!fixed.timedOut &&
|
||||
fixed.passed === 26 - skippedSymlink &&
|
||||
fixed.failed === 0 &&
|
||||
fixed.skipped === skippedSymlink
|
||||
const result = {
|
||||
comparison:
|
||||
'Current capture and file-source regressions; negative control removes only the extent deadline in memory.',
|
||||
sourceHashes,
|
||||
withoutDeadline: withoutDeadlineResult,
|
||||
fixed,
|
||||
passed
|
||||
}
|
||||
await writeFile(
|
||||
new URL('./results.json', import.meta.url),
|
||||
`${JSON.stringify(result, null, 2)}\n`
|
||||
)
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
if (!passed) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
} finally {
|
||||
if (runnerModuleId) {
|
||||
delete require.cache[runnerModuleId]
|
||||
}
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"comparison": "Current capture and file-source regressions; negative control removes only the extent deadline in memory.",
|
||||
"sourceHashes": {
|
||||
"docs/audits/crashpad-read-limit/reproduce.mjs": "f933664d7956dc935dc54873f43f1e9628830e4eb30f85759ba7dc2590b3c276",
|
||||
"docs/audits/crashpad-read-limit/stream-signature-parity.cjs": "7ed52f7c94ff888e48b0dcdcdce0ca3b5135dd9dd8b237309321b2a87b64426d",
|
||||
"package.json": "039a5464a296a1ad4cd0456154da71843e1bd3c63337e24c3767119f44a573ec",
|
||||
"pnpm-lock.yaml": "759c01a2687e953a9adc5c09332bafa193471ba6ec4875468565abb4ddf0d498",
|
||||
"src/main/crash-reporting/crashpad-capture-read-limit.test.ts": "259050b4b65d68cccf2597aed5f3776237028dd9b5653b0963a391ba4171a1f3",
|
||||
"src/main/crash-reporting/crashpad-capture.ts": "43ebef6ef55c0639df7c69fbfdb8a9f4d899db67dd4de7d56adae7e47bef8c77",
|
||||
"src/main/crash-reporting/minidump-crash-signature.test.ts": "2a393e98c5cf5c70e371e17baf7c689871180db0e84585f547d094a2ae05e6b9",
|
||||
"src/main/crash-reporting/minidump-crash-signature.ts": "e82d9739727ec04cbae845495d68ae08d1dc5c5045445832882b8a0547396fca",
|
||||
"src/main/crash-reporting/minidump-crashpad-annotations.ts": "34b136b27eb95b713c857df4f40eb4e82e63889883426c606c0f94210c69050e",
|
||||
"src/main/crash-reporting/minidump-embedded-check.ts": "3f6bbe5532d5d2ef1d719ceacec3d83f10544424cbbc36254785e8819e2c0de4",
|
||||
"src/main/crash-reporting/minidump-file-source.test.ts": "083b0b74fed8ebb103c800256479799348144b38dfa4d46e20d746652bc877ad",
|
||||
"src/main/crash-reporting/minidump-file-source.ts": "4b869262332187ed822fc3df9b2c268ed0b06dc7b2ebe5f7bb635059076ce66d",
|
||||
"src/main/crash-reporting/minidump-stream-reader.ts": "11361dce22b1ec60ac6b7d100df8f33999ecf124811956cdba365c8e892f2701",
|
||||
"src/shared/child-process/bounded-output-sink.ts": "7dbab9fc70dac8e5d19dd03d743b55609092b45ca13423e2dc80a69cf7fc4865",
|
||||
"src/shared/child-process/child-termination-reporter.ts": "d91ff09b26c9a23e26c2a2f92b129879cd6d0bddea14cd3bcac5d4489fecfca0",
|
||||
"src/shared/child-process/process-spec.ts": "97e52b5e320da1eed4cfb2c0b73a88696b62f355eb4b5fe0df5389764afb8289",
|
||||
"src/shared/child-process/process-tree-kill-gate.ts": "62848f063785e15cb0231deb622b043e81484ed3040c612afdc4f98fa2db06ab",
|
||||
"src/shared/child-process/process-tree-termination.ts": "49f001659c872e23040fe5bab2dd387f2589178f5bc3ce9361373303d23ff120",
|
||||
"src/shared/child-process/run-process.ts": "ae4dad239273351ce61269cd7f7abb8325f3f620be600fa9c0ae760c1dd7cbad",
|
||||
"src/shared/child-process/spawn-resolution.ts": "8c62c3c14d6f110f25bab23e3f4b2633a68e2f36252bd3aac68cffd7b2e274dc",
|
||||
"src/shared/child-process/windows-cmd-shim-resolution.ts": "5044e3a9140a19c8bb713044de655b611244659a754f32e5a5df7f519df40eb3",
|
||||
"src/shared/child-process/windows-command-line.ts": "4bf94aa90ee63d965bc6e1198ddafa4a715c05d78be979025d17dc59d90557ca",
|
||||
"tsconfig.json": "d96e07b4a56a47f45f62be9dd1d0d1150d3be42b4c91d918d5b182b6a78cf16c"
|
||||
},
|
||||
"withoutDeadline": {
|
||||
"exitCode": 1,
|
||||
"timedOut": false,
|
||||
"passed": 24,
|
||||
"failed": 2,
|
||||
"skipped": 0,
|
||||
"failedCases": [
|
||||
"stops observing a growing size-zero dump after the deadline at page 1",
|
||||
"stops observing a growing size-zero dump after the deadline at page 2"
|
||||
]
|
||||
},
|
||||
"fixed": {
|
||||
"exitCode": 0,
|
||||
"timedOut": false,
|
||||
"passed": 26,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"failedCases": []
|
||||
},
|
||||
"passed": true
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user