fix(runtime): keep timed-out graph generations fenced

This commit is contained in:
Brennan Benson
2026-08-27 02:11:32 -07:00
parent 3788ac8a52
commit d6adeb775b
2 changed files with 67 additions and 7 deletions
+14 -6
View File
@@ -3143,6 +3143,8 @@ export class OrcaRuntimeService {
private headlessGraphFallbackAvailable = false
private pendingHeadlessPromotionWindowId: number | null = null
private rendererGeneration: string | null = null
// Only a live renderer whose notification frame failed may heal with the last accepted generation.
private sameGenerationGraphRecoveryAllowed = false
/** Window whose graph was retired when the window closed; only a fresh attachWindow() revives it. */
private retiredGraphWindowId: number | null = null
private readonly graphReloadLifecycle = new RuntimeGraphReloadLifecycle({
@@ -7141,17 +7143,17 @@ export class OrcaRuntimeService {
if (
typeof rendererGeneration === 'string' &&
rendererGeneration === this.rendererGeneration &&
this.graphStatus === 'reloading'
this.graphStatus !== 'ready' &&
!this.sameGenerationGraphRecoveryAllowed
) {
// Why only while reloading: a replacement document is on its way, so the
// pre-reload one must not settle the fence. Any other non-ready state has
// no successor coming — rejecting the live renderer there stranded the
// graph `unavailable` for the rest of the process (STA-5523).
// Only an explicit frame-unavailable failure proves the last document may heal;
// a reload timeout or process exit must keep its superseded generation fenced.
throw new Error('Runtime graph publisher belongs to a superseded renderer generation')
}
// Why after the fences: a rejected publication used to keep the authority it
// took on the way in, pinning the graph to a window that could never publish.
this.authoritativeWindowId = windowId
this.sameGenerationGraphRecoveryAllowed = false
if (windowId === HEADLESS_RUNTIME_WINDOW_ID) {
this.headlessGraphFallbackAvailable = true
this.rendererGeneration = null
@@ -31663,6 +31665,7 @@ export class OrcaRuntimeService {
// Why: the rebuilt graph decides whether an incarnation survived; do not stale proven process identities before that comparison.
this.rendererGraphEpoch += 1
this.graphStatus = 'reloading'
this.sameGenerationGraphRecoveryAllowed = false
const revision = this.graphReloadLifecycle.begin(windowId)
this.setTerminalSideEffectConsumerAvailable(false)
this.rememberDetachedPreAllocatedLeaves()
@@ -31717,13 +31720,14 @@ export class OrcaRuntimeService {
this.pendingHeadlessPromotionWindowId = null
}
this.graphStatus = 'ready'
this.sameGenerationGraphRecoveryAllowed = false
this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID)
this.refreshWritableFlags()
}
markGraphReloadFailed(
windowId: number,
_reason: 'renderer-frame-unavailable' | 'renderer-process-gone'
reason: 'renderer-frame-unavailable' | 'renderer-process-gone'
): void {
if (windowId !== this.authoritativeWindowId) {
return
@@ -31732,6 +31736,7 @@ export class OrcaRuntimeService {
this.beginGraphReload(windowId)
}
this.graphReloadLifecycle.settleActive('failure')
this.sameGenerationGraphRecoveryAllowed = reason === 'renderer-frame-unavailable'
this.transitionGraphReloadToTerminalState(windowId)
}
@@ -31750,6 +31755,7 @@ export class OrcaRuntimeService {
// Why: this runs when the window itself closed, so its renderer is finished
// publishing — record that before any branch drops the authority binding.
this.retiredGraphWindowId = windowId
this.sameGenerationGraphRecoveryAllowed = false
this.graphReloadLifecycle.settleActive('cancelled')
if (this.shouldRestoreHeadlessGraph(windowId)) {
this.pendingHeadlessPromotionWindowId = null
@@ -31778,6 +31784,7 @@ export class OrcaRuntimeService {
if (windowId !== this.authoritativeWindowId || this.graphStatus !== 'reloading') {
return
}
this.sameGenerationGraphRecoveryAllowed = false
this.transitionGraphReloadToTerminalState(windowId)
}
@@ -31808,6 +31815,7 @@ export class OrcaRuntimeService {
this.authoritativeWindowId = HEADLESS_RUNTIME_WINDOW_ID
this.graphStatus = 'ready'
this.rendererGeneration = null
this.sameGenerationGraphRecoveryAllowed = false
this.setTerminalSideEffectConsumerAvailable(false)
this.tabs.clear()
this.leaves.clear()
@@ -5,8 +5,10 @@
* heal a terminally-failed graph — leaving paired mobile clients on an empty
* tab inventory for the rest of the desktop process's life.
*/
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import { RUNTIME_GRAPH_RELOAD_TIMEOUT_MS } from './runtime-graph-reload-lifecycle'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types'
const WINDOW_ID = 7
const OTHER_WINDOW_ID = 9
@@ -41,6 +43,10 @@ function desktopGraph(generation: string): Parameters<OrcaRuntimeService['syncWi
}
describe('runtime graph publication recovery', () => {
afterEach(() => {
vi.useRealTimers()
})
it('lets the still-live renderer republish after a terminal reload failure', () => {
const runtime = createRuntime()
runtime.attachWindow(WINDOW_ID)
@@ -76,6 +82,37 @@ describe('runtime graph publication recovery', () => {
expect(runtime.getStatus()).toMatchObject({ graphStatus: 'reloading' })
})
it('still rejects the pre-reload document after its replacement times out', async () => {
vi.useFakeTimers()
const runtime = createRuntime()
runtime.attachWindow(WINDOW_ID)
runtime.syncWindowGraph(WINDOW_ID, desktopGraph('renderer-a'))
runtime.markRendererReloading(WINDOW_ID)
await vi.advanceTimersByTimeAsync(RUNTIME_GRAPH_RELOAD_TIMEOUT_MS)
expect(runtime.getStatus()).toMatchObject({ graphStatus: 'unavailable' })
expect(() => runtime.syncWindowGraph(WINDOW_ID, desktopGraph('renderer-a'))).toThrow(
'superseded renderer generation'
)
runtime.syncWindowGraph(WINDOW_ID, desktopGraph('renderer-b'))
expect(runtime.getStatus()).toMatchObject({ graphStatus: 'ready' })
})
it('still rejects the pre-reload document after its renderer exits', () => {
const runtime = createRuntime()
runtime.attachWindow(WINDOW_ID)
runtime.syncWindowGraph(WINDOW_ID, desktopGraph('renderer-a'))
runtime.markRendererReloading(WINDOW_ID)
runtime.markGraphReloadFailed(WINDOW_ID, 'renderer-process-gone')
expect(() => runtime.syncWindowGraph(WINDOW_ID, desktopGraph('renderer-a'))).toThrow(
'superseded renderer generation'
)
runtime.syncWindowGraph(WINDOW_ID, desktopGraph('renderer-b'))
expect(runtime.getStatus()).toMatchObject({ graphStatus: 'ready' })
})
it('refuses a late publication from a window whose graph was retired', () => {
const runtime = createRuntime()
runtime.attachWindow(WINDOW_ID)
@@ -95,4 +132,19 @@ describe('runtime graph publication recovery', () => {
graphStatus: 'ready'
})
})
it('refuses a late publication from a retired headless-promotion window', () => {
const runtime = createRuntime()
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
runtime.attachWindow(WINDOW_ID)
runtime.markGraphUnavailable(WINDOW_ID)
expect(() => runtime.syncWindowGraph(WINDOW_ID, desktopGraph('renderer-a'))).toThrow(
'pending desktop promotion'
)
expect(runtime.getStatus()).toMatchObject({
authoritativeWindowId: HEADLESS_RUNTIME_WINDOW_ID,
graphStatus: 'ready'
})
})
})