fix(editor): preserve cleanup work across registry replacement

This commit is contained in:
Neil
2026-09-18 21:01:27 -07:00
parent 44fe1887bb
commit e9ee5f595b
17 changed files with 317 additions and 193 deletions
+3
View File
@@ -98,6 +98,9 @@ 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/assets/
!docs/assets/**
!docs/readme/
@@ -2,7 +2,7 @@
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. Cold rich/preview/PDF caches are cleaned without loading Monaco. No tab, dirty draft, or retained live editor history is pruned.
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
@@ -13,17 +13,21 @@ The fix subscribes from the app shell and lazily receives the model registry fro
| 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 26.6.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.
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 17 permanent cases in `closed-editor-model-{lifetime,reentrancy,registry,shell}.test.*` use actual installed Monaco text models and full test-store close actions. They cover final/hidden panel closure, live siblings and canonical shared URIs, model replacement, real attachment events and post-stack disposal, URI reopening, HMR successor registration, generated diff prefixes, reentrant model/cache ownership, cold caches, terminal subtree remount, and zero global registry enumeration for ordinary edit closes. Together with the three existing disposal/cache suites, **37 tests pass**. The fixture invokes Monaco's real model attachment/detachment ports; it does not create a native editor widget.
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.
Independent review additionally exercised controller teardown, registry replacement, reentrant teardown/replacement, a 24-diff `closeAllFiles` batch, and live metadata updates: six cases in each runtime. Both retained event listeners were disposed once, old callback authority was cancelled, the 48-model diff batch enumerated the registry once, and 32 live metadata changes performed no global scan. These are recorded review results; the self-contained comparative runner here contains the four tabled controls.
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.
The controller remembers minimal closed-owner descriptors and exact model identities for the current microtask. Attached closed models retain those descriptors until detachment/disposal or owner teardown. This retained custody is intentional. It does not reconcile unrelated preexisting Monaco models or erase separate cache lifetimes without a closed-tab transition.
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
@@ -38,9 +42,9 @@ The controller remembers minimal closed-owner descriptors and exact model identi
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.
## CI fixture correction
## Review follow-up
The first published head failed the separate anti-slop gate because the attachment fixture used `Reflect.get` and `Reflect.apply`. The fixture now checks Monaco's attachment port and calls it directly with the model as receiver. Product behavior and portable source graphs are unchanged. The correction passes 46 tests across seven selected lifetime/disposal/cache suites, the actual Web typecheck (`config/tsconfig.tc.web.json`), and anti-slop over all 13 published source files. The five general quality configurations also pass on the changed fixture. The portable MJS proof's existing boundary mocks are outside the CI anti-slop command's `src config tests mobile` scope.
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
@@ -1,27 +1,26 @@
===================================================================
--- 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,0 +2,1 @@
+import { useClosedEditorTabCleanup } from '../components/editor/useClosedEditorTabCleanup'
@@ -32,0 +34 @@
@@ -32,0 +34,1 @@
+ useClosedEditorTabCleanup()
===================================================================
--- a/src/renderer/src/components/editor/EditorPanel.tsx
+++ b/src/renderer/src/components/editor/EditorPanel.tsx
@@ -15 +14,0 @@
@@ -15,1 +14,0 @@
-import { useClosedEditorTabCleanup } from './useClosedEditorTabCleanup'
@@ -145 +143,0 @@
@@ -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,224 @@
@@ -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 {
+ disposeClosedEditorTabs,
+ disposeClosedEditorTabCaches,
+ type ClosedEditorTab
+} from './closed-editor-tab-disposal'
+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'>
@@ -106,6 +105,12 @@
+
+ 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>()
@@ -135,10 +140,6 @@
+ models: ReadonlySet<editor.ITextModel>
+ ): void => {
+ if (!currentRegistry) {
+ disposeClosedEditorTabCaches(
+ files,
+ (file) => active && generation === flushGeneration && !stillOwned(file)
+ )
+ return
+ }
+ const fencedRegistry: MonacoModelRegistry = {
@@ -155,7 +156,7 @@
+ [...models].filter((model) => currentRegistry.editor.getModel(model.uri) === model)
+ }
+ }
+ disposeClosedEditorTabs(
+ disposeClosedEditorModels(
+ fencedRegistry,
+ files,
+ retainAttachedModel,
@@ -168,8 +169,23 @@
+ 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
@@ -219,14 +235,11 @@
+ }
+ })
+ const unsubscribeRegistry = bridge.subscribe(() => {
+ const pendingCaches = [...pendingFiles.values()]
+ clearPending()
+ // Registry notifications invalidate callbacks, not custody of captured models.
+ generation += 1
+ scheduled = false
+ registry = bridge.get()
+ previousFiles = store.getState().openFiles
+ for (const file of pendingCaches) {
+ pendingFiles.set(ownerKey(file), file)
+ }
+ if (pendingCaches.length > 0) {
+ if (pendingFiles.size > 0 || retainedModels.size > 0) {
+ schedule()
+ }
+ })
@@ -237,21 +250,32 @@
+ clearPending()
+ }
+}
===================================================================
--- a/src/renderer/src/components/editor/closed-editor-tab-disposal.ts
+++ b/src/renderer/src/components/editor/closed-editor-tab-disposal.ts
@@ -11 +11,2 @@
@@ -11,1 +11,2 @@
- type MonacoModelRegistry
+ type MonacoModelRegistry,
+ type DisposableMonacoModel
@@ -16,0 +18,2 @@
+
@@ -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'>
@@ -27 +30,3 @@
+
+// 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 +38,41 @@
@@ -33,1 +32,50 @@
- const diffModelPathPrefixes: string[] = []
+ const diffFilesByPrefix = new Map<string, ClosedEditorTab>()
+ for (const closedFile of closedFiles) {
@@ -287,6 +311,15 @@
+ 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)
+}
+
@@ -294,22 +327,22 @@
+ closedFiles: readonly ClosedEditorTab[],
+ isStillClosed: (file: ClosedEditorTab) => boolean = () => true
+): void {
@@ -39,0 +85,3 @@
@@ -39,0 +88,3 @@
+ if (!isStillClosed(closedFile)) {
+ continue
+ }
@@ -42,3 +89,0 @@
@@ -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 +90,0 @@
@@ -46,1 +93,0 @@
- // Why: markdown and mermaid surfaces keep mode-scoped scroll positions.
@@ -53 +96,0 @@
@@ -53,1 +99,0 @@
- // Why: only 'edit' tabs ever get a PDF scroll key (see EditorContent).
@@ -57,2 +99,0 @@
@@ -57,2 +102,0 @@
- // Why: preview tabs own pane-scoped preview scroll cache entries even
- // though they do not retain Monaco models.
@@ -62,6 +103 @@
@@ -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.
@@ -317,17 +350,18 @@
- getDiffViewerMonacoModelPathPrefixes(closedFile.id)
- diffModelPathPrefixes.push(originalModelPathPrefix, modifiedModelPathPrefix)
+ case 'diff':
@@ -73 +108,0 @@
@@ -73,1 +111,0 @@
- }
@@ -80,2 +114,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 +15 @@
@@ -15,1 +15,1 @@
-type DisposableMonacoModel = Pick<editor.ITextModel, 'dispose' | 'isAttachedToEditor'> & {
+export type DisposableMonacoModel = Pick<editor.ITextModel, 'dispose' | 'isAttachedToEditor'> & {
@@ -91 +91,3 @@
@@ -91,1 +91,3 @@
- modelPathPrefixes: readonly string[]
+ modelPathPrefixes: readonly string[],
+ onAttachedModel?: (model: DisposableMonacoModel, prefix: string) => void,
@@ -347,21 +381,22 @@
+ } else {
+ model.dispose()
+ }
@@ -122 +128 @@
@@ -122,1 +128,1 @@
-function isOwnedByPathPrefix(
+function findOwnedPathPrefix(
@@ -126 +132 @@
@@ -126,1 +132,1 @@
-): boolean {
+): string | undefined {
@@ -128 +134 @@
@@ -128,1 +134,1 @@
- return true
+ return uriString
@@ -140 +146 @@
@@ -140,1 +146,1 @@
- return true
+ return uriString.slice(0, boundary)
@@ -144 +150 @@
@@ -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 @@
@@ -391,6 +426,7 @@
- }, [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 @@
@@ -432,11 +468,16 @@
+}
+
+export const editorModelRegistry = createEditorModelRegistry()
===================================================================
--- a/src/renderer/src/lib/monaco-setup.ts
+++ b/src/renderer/src/lib/monaco-setup.ts
@@ -1,0 +2 @@
@@ -1,0 +2,1 @@
+import { editorModelRegistry } from './editor-model-registry'
@@ -94,0 +96,5 @@
@@ -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) {
@@ -14,7 +14,7 @@
"sources": 1021,
"canonicalLfMatches": true,
"simulatedMainCheckoutMatches": true,
"graphSha256": "e89fae0a61015446e8de9252fa47f1e8aeeb0044eec5da03fab083e437fa41bd"
"graphSha256": "389b3a60b0ddc080a86e9569c371ac1d51db1e6845c8f6d65a1987d92e11cec9"
},
{
"graph": "main",
@@ -30,7 +30,7 @@
"sources": 1019,
"canonicalLfMatches": true,
"simulatedMainCheckoutMatches": true,
"graphSha256": "53be6c5fb9cb4b60386a7ab30b960f078f24b887c036c68836d4c5c7b17a767e"
"graphSha256": "efafe6f45ec73681fe43ff9df05200a041ccadfbb3ab39ced5f5ba096d829274"
}
],
"mainMarkdownAliasesPreserved": true,
@@ -37,7 +37,7 @@
"sourceFence": {
"sources": 1017,
"graphSha256": "9b092ce86caf62164bc106a6ac8a382b9b487c5abbaadcfaedd371c3755a1a50",
"manifestSha256": "4ee4ae0901343e818ec4a396cc7bcaf11289b1ab7b58018e48ed5ae84778f884",
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
},
@@ -1,34 +1,33 @@
{
"runtime": {
"node": "26.6.0",
"acorn": "8.17.0",
"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",
"libffi": "3.7.1",
"llhttp": "9.4.3",
"merve": "1.2.2",
"modules": "147",
"modules": "137",
"napi": "10",
"nbytes": "0.1.4",
"ncrypto": "0.0.1",
"nghttp2": "1.70.0",
"nghttp3": "",
"ngtcp2": "",
"openssl": "3.6.3",
"openssl": "3.5.7",
"simdjson": "4.6.6",
"simdutf": "7.7.0",
"simdutf": "6.4.0",
"sqlite": "3.53.4",
"tz": "2026a",
"undici": "8.9.0",
"tz": "2026c",
"undici": "7.29.0",
"unicode": "17.0",
"uv": "1.52.1",
"uvwasi": "0.0.23",
"v8": "14.6.202.34-node.26",
"zlib": "1.2.12",
"v8": "13.6.233.17-node.53",
"zlib": "1.3.2.1-motley-42c2f19",
"zstd": "1.5.7"
},
"variant": "before",
@@ -36,7 +35,7 @@
"sourceFence": {
"sources": 1017,
"graphSha256": "9b092ce86caf62164bc106a6ac8a382b9b487c5abbaadcfaedd371c3755a1a50",
"manifestSha256": "4ee4ae0901343e818ec4a396cc7bcaf11289b1ab7b58018e48ed5ae84778f884",
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
},
@@ -36,8 +36,8 @@
"graph": "main",
"sourceFence": {
"sources": 1019,
"graphSha256": "53be6c5fb9cb4b60386a7ab30b960f078f24b887c036c68836d4c5c7b17a767e",
"manifestSha256": "4ee4ae0901343e818ec4a396cc7bcaf11289b1ab7b58018e48ed5ae84778f884",
"graphSha256": "efafe6f45ec73681fe43ff9df05200a041ccadfbb3ab39ced5f5ba096d829274",
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
},
@@ -1,42 +1,41 @@
{
"runtime": {
"node": "26.6.0",
"acorn": "8.17.0",
"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",
"libffi": "3.7.1",
"llhttp": "9.4.3",
"merve": "1.2.2",
"modules": "147",
"modules": "137",
"napi": "10",
"nbytes": "0.1.4",
"ncrypto": "0.0.1",
"nghttp2": "1.70.0",
"nghttp3": "",
"ngtcp2": "",
"openssl": "3.6.3",
"openssl": "3.5.7",
"simdjson": "4.6.6",
"simdutf": "7.7.0",
"simdutf": "6.4.0",
"sqlite": "3.53.4",
"tz": "2026a",
"undici": "8.9.0",
"tz": "2026c",
"undici": "7.29.0",
"unicode": "17.0",
"uv": "1.52.1",
"uvwasi": "0.0.23",
"v8": "14.6.202.34-node.26",
"zlib": "1.2.12",
"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": "53be6c5fb9cb4b60386a7ab30b960f078f24b887c036c68836d4c5c7b17a767e",
"manifestSha256": "4ee4ae0901343e818ec4a396cc7bcaf11289b1ab7b58018e48ed5ae84778f884",
"graphSha256": "efafe6f45ec73681fe43ff9df05200a041ccadfbb3ab39ced5f5ba096d829274",
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
},
@@ -135,14 +135,14 @@
"src/renderer/src/components/editor/closed-editor-tab-controller.ts": {
"worktreeBefore": null,
"mainBefore": null,
"worktreeFixed": "dc72bdb2f483224cfa638e0893c1089c54544331e20cc8cd499f71a4dc5156e7",
"mainFixed": "dc72bdb2f483224cfa638e0893c1089c54544331e20cc8cd499f71a4dc5156e7"
"worktreeFixed": "39e47f958db77e9eeb93b58a9106953ee0b06bf615acaa945ec5990014838ffe",
"mainFixed": "39e47f958db77e9eeb93b58a9106953ee0b06bf615acaa945ec5990014838ffe"
},
"src/renderer/src/components/editor/closed-editor-tab-disposal.ts": {
"worktreeBefore": "acb9143556f0defa281febb021d7348fa5a576e085dd89187b46636842ec14f3",
"mainBefore": "acb9143556f0defa281febb021d7348fa5a576e085dd89187b46636842ec14f3",
"worktreeFixed": "5629ea319bdbc5f49551999fcace4c3d6550f40462832a5dff8def5fd9184cde",
"mainFixed": "5629ea319bdbc5f49551999fcace4c3d6550f40462832a5dff8def5fd9184cde"
"worktreeFixed": "297743a55b2b1f13ffd61984d0331befa41d51a15f70da2b8063543d0b30edee",
"mainFixed": "297743a55b2b1f13ffd61984d0331befa41d51a15f70da2b8063543d0b30edee"
},
"src/renderer/src/components/editor/diff-monaco-model-disposal.ts": {
"worktreeBefore": "62f639d07c2053d966c1677f5f6f88acaf75e62cbd5d46c63996e52f48f572b3",
@@ -897,8 +897,8 @@
"src/renderer/src/lib/monaco-setup.ts": {
"worktreeBefore": "9150a96585fb426af4b7b978a8987be5b029bdf03d9d2e79f2429c6d47b441fa",
"mainBefore": "1366b0e8e5ba13dcb2842a9a24024bf73aa8bcf654abdde17dff676caa6572d4",
"worktreeFixed": "2071dcbb3c870219921e0f9430a704a32596d4b24be04fec8df726350b7fe154",
"mainFixed": "c451980faf7956225e372b4a15dd2b4936eef1fc4a30cdb5de49799dfbd28ca9"
"worktreeFixed": "c451980faf7956225e372b4a15dd2b4936eef1fc4a30cdb5de49799dfbd28ca9",
"mainFixed": "1d7244836da3540bf0cf151c54b7f40a4e508a4e97e5f5dbc0bf501542918a58"
},
"src/renderer/src/lib/native-chat-telemetry.ts": {
"worktreeBefore": "8e22e59b47ddef7b12628913fb02d16a57cd5c3fb8cea82ffa873ddd2fa12dd9",
@@ -1,23 +1,32 @@
{
"permanentTests": {
"cases": 17,
"files": 4,
"cases": 27,
"files": 5,
"exitCode": 0
},
"permanentAndExistingCompatibilityTests": {
"cases": 37,
"files": 7,
"cases": 47,
"files": 8,
"exitCode": 0
},
"selectedTestsIncludingStartupRouting": {
"cases": 75,
"files": 9,
"exitCode": 0
},
"actualFullWebTypecheck": {
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm tc:web",
"exitCode": 0
"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": "26.6.0",
"node": "24.20.0",
"electron": "43.7.0 / Node 24.21.0"
},
"casesPerRun": 4,
@@ -25,13 +34,6 @@
"cases": 32,
"exitCodes": [0, 0, 0, 0, 0, 0, 0, 0]
},
"independentReview": {
"casesPerRuntime": 6,
"runtimes": 2,
"totalCases": 12,
"exitCodes": [0, 0],
"scope": "Additional ignored review proof; not the four-control durable runner."
},
"sourceFence": {
"repositoryPaths": 1021,
"contextDifferences": 22,
@@ -47,72 +49,48 @@
"driftRejected": true
},
"quality": {
"scope": "Three changed source/test files",
"ordinary": {
"exitCode": 0,
"files": 17
},
"casting": {
"exitCode": 0,
"files": 17
},
"type-aware": {
"exitCode": 0,
"files": 17
},
"react": {
"exitCode": 0,
"files": 17
},
"design": {
"exitCode": 0,
"files": 17
}
},
"artifactHashes": {
"README.md": "f1a1b5fd46003314bb75bc241b5bb91cf5f30a3abbd9365093322734c8d87983",
"fix.patch": "c725f2a6c8ff66ea2a59a3fdc4bba24773d1b42eb371ae54e3c135ae9d6593cd",
"lifecycle.test.mjs": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
"loader-controls.cjs": "4cdcf0309ff0acce00cff5f5b17ce91236f5eaa27245de65c651dd4c6535a352",
"loader-results.json": "c8c93567b6035a8057b103ae9a1c46dccbe562a04b51bc38a00bf930d4b51a5c",
"main-before-electron-results.json": "3d0c320dd69b031b1c57f9ba80a1be28cb512b671e550440c8d67a762b0b1721",
"main-before-node-results.json": "b5ba920f1f1e58b9a4a793516c7e2e3b5d1c4e631d323739a8b2224375648bf9",
"main-context.patch": "faab10d72b52b184fdb212da1b5af53550d4937208b04266afa3d0b3d3c446dc",
"main-fixed-electron-results.json": "b53e0f8a7f3ddddfcbda7f947993d0ecaafd5ac874cab04910149686cfdc58e0",
"main-fixed-node-results.json": "309cd9b24fd4cbb274c2d372c96581d9ecd13c8de800e3923289f19e4a4c4dbc",
"source-versions.json": "4ee4ae0901343e818ec4a396cc7bcaf11289b1ab7b58018e48ed5ae84778f884",
"sources.cjs": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be",
"vitest.config.mjs": "bfe98cdc66445e31801a9b64f698f158110dc7a8b1ec8f92ef7f99a5fb296444",
"worktree-before-electron-results.json": "3d7ca347967a4e2eee5428b3492de7d5053fd422b6f5033252be82cb765e9bfb",
"worktree-before-node-results.json": "e538c14953cd9b3310fac11a690a34927f300b0e75f49380fb1bee3055ae11b8",
"worktree-fixed-electron-results.json": "6fc3f80bb1682071d3e973c62985e11d8a9571c3330809ed7a20e5724f3a9a39",
"worktree-fixed-node-results.json": "957859be38734c836e102a357d694c2989fd888a333e0a754e074b4735dfac0b"
},
"fullFileWhitespace": {
"files": 31,
"diagnostics": 0
},
"sourceFormatting": {
"files": 13,
"exitCode": 0
},
"ciFixtureCorrection": {
"failedPublishedHead": "082e8659238d59ae4d12ad78607408264226e06a",
"failedJob": "https://github.com/stablyai/orca/actions/runs/35191331126/job/105104807447",
"cause": "Reflect.get/Reflect.apply in actual-model attachment fixture violate separate CI anti-slop gate",
"fix": "Checked attachment port and direct receiver-preserving calls; no product or portable proof changes",
"tests": {
"files": 7,
"cases": 46,
"exitCode": 0
},
"webTypecheck": {
"command": "ORCA_BACKGROUND_LAUNCH=1 node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.tc.web.json",
"casting": {
"exitCode": 0
},
"typeAware": {
"exitCode": 0
},
"react": {
"exitCode": 0
},
"design": {
"exitCode": 0
},
"antiSlop": {
"files": 13,
"scope": "All published src paths; CI scans src config tests mobile, not portable docs MJS boundary mocks",
"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"
}
}
@@ -37,7 +37,7 @@
"sourceFence": {
"sources": 1019,
"graphSha256": "135b131bc7a3023e0e88c572802c4b837eb968af74e49a71ea93a97ba3454bd9",
"manifestSha256": "4ee4ae0901343e818ec4a396cc7bcaf11289b1ab7b58018e48ed5ae84778f884",
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
},
@@ -1,34 +1,33 @@
{
"runtime": {
"node": "26.6.0",
"acorn": "8.17.0",
"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",
"libffi": "3.7.1",
"llhttp": "9.4.3",
"merve": "1.2.2",
"modules": "147",
"modules": "137",
"napi": "10",
"nbytes": "0.1.4",
"ncrypto": "0.0.1",
"nghttp2": "1.70.0",
"nghttp3": "",
"ngtcp2": "",
"openssl": "3.6.3",
"openssl": "3.5.7",
"simdjson": "4.6.6",
"simdutf": "7.7.0",
"simdutf": "6.4.0",
"sqlite": "3.53.4",
"tz": "2026a",
"undici": "8.9.0",
"tz": "2026c",
"undici": "7.29.0",
"unicode": "17.0",
"uv": "1.52.1",
"uvwasi": "0.0.23",
"v8": "14.6.202.34-node.26",
"zlib": "1.2.12",
"v8": "13.6.233.17-node.53",
"zlib": "1.3.2.1-motley-42c2f19",
"zstd": "1.5.7"
},
"variant": "before",
@@ -36,7 +35,7 @@
"sourceFence": {
"sources": 1019,
"graphSha256": "135b131bc7a3023e0e88c572802c4b837eb968af74e49a71ea93a97ba3454bd9",
"manifestSha256": "4ee4ae0901343e818ec4a396cc7bcaf11289b1ab7b58018e48ed5ae84778f884",
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
},
@@ -36,8 +36,8 @@
"graph": "worktree",
"sourceFence": {
"sources": 1021,
"graphSha256": "e89fae0a61015446e8de9252fa47f1e8aeeb0044eec5da03fab083e437fa41bd",
"manifestSha256": "4ee4ae0901343e818ec4a396cc7bcaf11289b1ab7b58018e48ed5ae84778f884",
"graphSha256": "389b3a60b0ddc080a86e9569c371ac1d51db1e6845c8f6d65a1987d92e11cec9",
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
},
@@ -1,42 +1,41 @@
{
"runtime": {
"node": "26.6.0",
"acorn": "8.17.0",
"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",
"libffi": "3.7.1",
"llhttp": "9.4.3",
"merve": "1.2.2",
"modules": "147",
"modules": "137",
"napi": "10",
"nbytes": "0.1.4",
"ncrypto": "0.0.1",
"nghttp2": "1.70.0",
"nghttp3": "",
"ngtcp2": "",
"openssl": "3.6.3",
"openssl": "3.5.7",
"simdjson": "4.6.6",
"simdutf": "7.7.0",
"simdutf": "6.4.0",
"sqlite": "3.53.4",
"tz": "2026a",
"undici": "8.9.0",
"tz": "2026c",
"undici": "7.29.0",
"unicode": "17.0",
"uv": "1.52.1",
"uvwasi": "0.0.23",
"v8": "14.6.202.34-node.26",
"zlib": "1.2.12",
"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": "e89fae0a61015446e8de9252fa47f1e8aeeb0044eec5da03fab083e437fa41bd",
"manifestSha256": "4ee4ae0901343e818ec4a396cc7bcaf11289b1ab7b58018e48ed5ae84778f884",
"graphSha256": "389b3a60b0ddc080a86e9569c371ac1d51db1e6845c8f6d65a1987d92e11cec9",
"manifestSha256": "c3f9b3b2e3c669a5565df1c73adb4a0b6468835f7ef5674e822032d08bf6459b",
"scenarioSha256": "afc0b7e45e056d1f730ac58f266094471b58187646c91b4436b909544a3a4e29",
"loaderSha256": "d5914770eb9db3c75b153b55d7f2f94415d3ab1850bc5b021be1165d104828be"
},
@@ -19,6 +19,13 @@ const SESSION_PERSISTENCE_PATH = 'src/renderer/src/app-shell/use-app-session-per
const PERSISTED_UI_WRITER_PATH = 'src/renderer/src/app-shell/use-persisted-ui-writer.ts'
describe('renderer startup runtime routing', () => {
it('owns closed editor cleanup in the persistent app shell', () => {
expect(readSource(SHELL_SERVICES_PATH)).toContain('useClosedEditorTabCleanup()')
expect(readSource('src/renderer/src/components/editor/EditorPanel.tsx')).not.toContain(
'useClosedEditorTabCleanup'
)
})
it('routes packaged terminal restore through the daemon adoption gate', () => {
const source = readFileSync(
join(process.cwd(), 'src/renderer/src/components/use-terminal-watcher-effects.ts'),
@@ -82,7 +82,7 @@ it('releases generated closed diff namespaces after detachment and preserves sib
})
it('preserves bounded cold-registry rich, preview and PDF caches without loading Monaco', async () => {
const { store, bridge, attach } = createModelLifetimeFixture(false)
const { store, attach } = createModelLifetimeFixture(false)
const file = modelLifetimeFile('cold-rich')
const preview = modelLifetimeFile('cold-preview', 'markdown-preview')
const sibling = modelLifetimeFile('cold-live')
@@ -100,7 +100,6 @@ it('preserves bounded cold-registry rich, preview and PDF caches without loading
store.getState().closeFile(file.id)
store.getState().closeFile(preview.id)
await Promise.resolve()
expect(bridge.get()).toBeNull()
expect([...scrollTopCache]).toEqual([
[`${file.filePath}:rich`, 10],
[`${file.filePath}::pane`, 11],
@@ -114,3 +113,81 @@ it('preserves bounded cold-registry rich, preview and PDF caches without loading
[`${sibling.filePath}:pdf`, position]
])
})
it('preserves queued model custody across registry removal and replacement', async () => {
const { store, bridge, attach, add } = createModelLifetimeFixture(false)
const unregister = bridge.register(monaco)
const { file, model } = add('queued-replacement')
attach()
store.getState().closeFile(file.id)
unregister()
await Promise.resolve()
expect(model.isDisposed()).toBe(false)
const unregisterNext = bridge.register(monaco)
try {
await Promise.resolve()
expect(model.isDisposed()).toBe(true)
} finally {
unregisterNext()
}
})
it('preserves retained attachment custody across registry replacement', async () => {
const { store, bridge, attach, add } = createModelLifetimeFixture(false)
const unregister = bridge.register(monaco)
const { file, model } = add('attached-replacement')
const detachView = attachModelLifetimeView(model)
attach()
store.getState().closeFile(file.id)
await Promise.resolve()
unregister()
detachView()
await Promise.resolve()
expect(model.isDisposed()).toBe(false)
const unregisterNext = bridge.register(monaco)
try {
await Promise.resolve()
expect(model.isDisposed()).toBe(true)
} finally {
unregisterNext()
}
})
it('preserves successor models at a captured URI across registry replacement', async () => {
const { store, bridge, attach, add } = createModelLifetimeFixture(false)
const unregister = bridge.register(monaco)
const { file, model } = add('successor-replacement')
attach()
store.getState().closeFile(file.id)
unregister()
model.dispose()
const successor = modelLifetimeTextModel(file.filePath)
const unregisterNext = bridge.register(monaco)
try {
await Promise.resolve()
expect(successor.isDisposed()).toBe(false)
} finally {
unregisterNext()
}
})
it('retries the remaining captured batch after reentrant registry replacement', async () => {
const { store, bridge, attach, add } = createModelLifetimeFixture()
const first = add('reentrant-registry-first')
const second = add('reentrant-registry-second')
attach()
let unregisterNext: (() => void) | undefined
const replaceRegistry = first.model.onWillDispose(() => {
unregisterNext = bridge.register(monaco)
})
try {
store.getState().closeAllFiles()
await Promise.resolve()
await Promise.resolve()
expect(first.model.isDisposed()).toBe(true)
expect(second.model.isDisposed()).toBe(true)
} finally {
replaceRegistry.dispose()
unregisterNext?.()
}
})
@@ -87,6 +87,12 @@ export function attachClosedEditorTabCleanup(
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>()
@@ -145,8 +151,23 @@ export function attachClosedEditorTabCleanup(
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
@@ -196,14 +217,11 @@ export function attachClosedEditorTabCleanup(
}
})
const unsubscribeRegistry = bridge.subscribe(() => {
const pendingCaches = [...pendingFiles.values()]
clearPending()
// Registry notifications invalidate callbacks, not custody of captured models.
generation += 1
scheduled = false
registry = bridge.get()
previousFiles = store.getState().openFiles
for (const file of pendingCaches) {
pendingFiles.set(ownerKey(file), file)
}
if (pendingCaches.length > 0) {
if (pendingFiles.size > 0 || retainedModels.size > 0) {
schedule()
}
})