Skip to content

Commit 92044a5

Browse files
committed
fix(restore): harden Electron state recovery
Restore workspace tabs without treating transient startup stops as user removals, hydrate saved session ancestry and metadata across worktree workspaces, and preserve scroll snapshots through cache eviction and late native seeding. Keep Electron zoom consistent for native menu, keyboard, and wheel input across navigation and restart. Bound desktop shutdown, start CLI termination alongside state flushing, and use verified Windows process-tree escalation to avoid minute-long exits. Treat Electron as a secondary client when a live Tauri marker already owns restoration, while retaining an independent CLI and manual workspace creation. Cancellation ownership is request-scoped so reused workspace launches cannot remove or retain the wrong instance. Validated with UI and Electron typechecks, server typecheck, 67 Electron native tests, 54 focused restore tests, independent targeted reviews, and successful server/UI/Electron builds.
1 parent 698908d commit 92044a5

23 files changed

Lines changed: 627 additions & 101 deletions

packages/electron-app/electron/main/client-state-lifecycle.test.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@ import type { CliProcessManager } from "./process-manager"
77
import type { WindowStateTracker } from "./window-state"
88

99
const tick = () => new Promise((resolve) => setImmediate(resolve))
10-
function harness(options: { flush?: () => Promise<unknown>; timeout?: number; otherWindow?: boolean } = {}) {
10+
function harness(options: {
11+
flush?: () => Promise<unknown>
12+
stop?: () => Promise<void>
13+
nativeFlush?: () => Promise<void>
14+
timeout?: number
15+
shutdownTimeout?: number
16+
otherWindow?: boolean
17+
} = {}) {
1118
const windows = new Map<string, (event?: { preventDefault(): void }) => void>()
1219
const appEvents = new Map<string, (event?: { preventDefault(): void }) => void>()
1320
const calls: string[] = []
@@ -21,9 +28,9 @@ function harness(options: { flush?: () => Promise<unknown>; timeout?: number; ot
2128
const other = { isDestroyed: () => false } as BrowserWindow
2229
const app = { on: (name: string, handler: never) => appEvents.set(name, handler), quit: () => calls.push("quit"), exit: () => { exits++ } } as unknown as App
2330
const manager = { isPrimary: true, flush: async () => {}, drainAndReleasePrimary: async () => { calls.push("release") } } as ClientStateManager
24-
const cli = { stop: async () => { calls.push("stop") } } as unknown as CliProcessManager
25-
const lifecycle = new ClientStateLifecycle({ app, clientStateManager: manager, cliManager: cli, getMainWindow: () => window, getAllWindows: () => options.otherWindow ? [window, other] : [window], getAllowedRendererOrigins: () => ["http://127.0.0.1:43123"], isTrustedRendererOrigin: () => true, windowsSessionEndFlushTimeoutMs: options.timeout, rendererFlushTimeoutMs: options.timeout && options.timeout * 2, isWindows: true })
26-
lifecycle.attachMainWindow(window, { flush: async () => { calls.push("native") } } as unknown as WindowStateTracker)
31+
const cli = { stop: async () => { calls.push("stop"); await options.stop?.() } } as unknown as CliProcessManager
32+
const lifecycle = new ClientStateLifecycle({ app, clientStateManager: manager, cliManager: cli, getMainWindow: () => window, getAllWindows: () => options.otherWindow ? [window, other] : [window], getAllowedRendererOrigins: () => ["http://127.0.0.1:43123"], isTrustedRendererOrigin: () => true, windowsSessionEndFlushTimeoutMs: options.timeout, rendererFlushTimeoutMs: options.timeout && options.timeout * 2, shutdownTimeoutMs: options.shutdownTimeout, isWindows: true })
33+
lifecycle.attachMainWindow(window, { flush: async () => { calls.push("native"); await options.nativeFlush?.() } } as unknown as WindowStateTracker)
2734
lifecycle.registerAppEvents()
2835
const close = () => { let prevented = false; windows.get("close")?.({ preventDefault: () => { prevented = true } }); return prevented }
2936
return { appEvents, calls, close, exits: () => exits, lifecycle, window, windows }
@@ -58,7 +65,7 @@ test("late old-window detach preserves replacement tracker during shutdown", asy
5865
h.lifecycle.detachMainWindow(h.window)
5966
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
6067
await (h.lifecycle as any).shutdown
61-
assert.deepEqual(h.calls, ["renderer", "replacement-native", "release", "stop"])
68+
assert.deepEqual(h.calls, ["renderer", "stop", "replacement-native", "release"])
6269
})
6370

6471
test("Windows session end flushes once and exits", async () => {
@@ -68,7 +75,7 @@ test("Windows session end flushes once and exits", async () => {
6875
h.windows.get("session-end")?.()
6976
await (h.lifecycle as any).sessionEnd; await tick()
7077
assert.equal(prevented, true)
71-
assert.deepEqual(h.calls, ["renderer", "native", "release", "stop"])
78+
assert.deepEqual(h.calls, ["renderer", "stop", "native", "release"])
7279
assert.equal(h.exits(), 1)
7380
})
7481

@@ -79,6 +86,24 @@ test("session end promotes and bounds an already-hung ordinary shutdown", async
7986
h.windows.get("query-session-end")?.({ preventDefault: () => {} })
8087
await (h.lifecycle as any).sessionEnd; await tick()
8188
assert.ok(Date.now() - started < 500)
82-
assert.deepEqual(h.calls, ["renderer"])
89+
assert.deepEqual(h.calls, ["renderer", "stop"])
90+
assert.equal(h.exits(), 1)
91+
})
92+
93+
test("ordinary quit has an absolute deadline when CLI stop never settles", async () => {
94+
const h = harness({ stop: () => new Promise(() => {}), shutdownTimeout: 20 })
95+
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
96+
await (h.lifecycle as any).shutdown
97+
await tick()
98+
assert.deepEqual(h.calls, ["renderer", "stop", "native", "release"])
99+
assert.equal(h.exits(), 1)
100+
})
101+
102+
test("CLI termination starts before a hung native flush reaches the absolute deadline", async () => {
103+
const h = harness({ nativeFlush: () => new Promise(() => {}), shutdownTimeout: 20 })
104+
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
105+
await (h.lifecycle as any).shutdown
106+
await tick()
107+
assert.deepEqual(h.calls, ["renderer", "stop", "native"])
83108
assert.equal(h.exits(), 1)
84109
})

packages/electron-app/electron/main/client-state-lifecycle.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { flushRendererClientStateBeforeShutdown } from "./renderer-client-state-
55
import type { WindowStateTracker } from "./window-state"
66

77
const WINDOWS_SESSION_END_FLUSH_TIMEOUT_MS = 1_500
8+
const SHUTDOWN_TIMEOUT_MS = 10_000
89

910
interface ClientStateLifecycleDependencies {
1011
app: App
@@ -16,6 +17,7 @@ interface ClientStateLifecycleDependencies {
1617
isTrustedRendererOrigin(url: string, allowedOrigins: string[]): boolean
1718
windowsSessionEndFlushTimeoutMs?: number
1819
rendererFlushTimeoutMs?: number
20+
shutdownTimeoutMs?: number
1921
isWindows?: boolean
2022
}
2123

@@ -92,12 +94,25 @@ export class ClientStateLifecycle {
9294

9395
private startShutdown(window: BrowserWindow | null): Promise<void> {
9496
if (this.shutdown) return this.shutdown
95-
this.shutdown = (async () => {
96-
await this.runStage("renderer shutdown flush", () => this.flushRenderer(window))
97+
const stages = (async () => {
98+
const rendererFlush = this.runStage("renderer shutdown flush", () => this.flushRenderer(window))
99+
const cliStop = this.runStage("CLI stop", () => this.dependencies.cliManager.stop())
100+
await rendererFlush
97101
await this.runStage("native shutdown flush", () => this.flushNative())
98102
await this.runStage("primary release", () => this.dependencies.clientStateManager.drainAndReleasePrimary())
99-
await this.runStage("CLI stop", () => this.dependencies.cliManager.stop())
103+
await cliStop
100104
})()
105+
const timeoutMs = this.dependencies.shutdownTimeoutMs ?? SHUTDOWN_TIMEOUT_MS
106+
let timeout: ReturnType<typeof setTimeout> | undefined
107+
this.shutdown = Promise.race([
108+
stages,
109+
new Promise<void>((resolve) => { timeout = setTimeout(() => {
110+
console.warn(`[client-state] desktop shutdown timed out after ${timeoutMs}ms; forcing exit`)
111+
resolve()
112+
}, timeoutMs) }),
113+
]).finally(() => {
114+
if (timeout) clearTimeout(timeout)
115+
})
101116
return this.shutdown
102117
}
103118

packages/electron-app/electron/main/client-state-process.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,21 @@ import { tmpdir } from "node:os"
88
import { join } from "node:path"
99
import { fileURLToPath } from "node:url"
1010
import test from "node:test"
11-
import { cleanStaleRunningMarkers, createRunningMarker, electClientStateProcess, getRunningMarkerPath, REGISTRATION_LOCK_WAIT_MS, removeProcessOwnerLockIfOwned, removeRunningMarkerIfOwned, type ProcessOwner } from "./client-state-process"
11+
import { cleanStaleRunningMarkers, createRunningMarker, electClientStateProcess, getRunningMarkerPath, hasLiveTauriClient, REGISTRATION_LOCK_WAIT_MS, removeProcessOwnerLockIfOwned, removeRunningMarkerIfOwned, type ProcessOwner } from "./client-state-process"
1212
import { getProcessStartIdentity } from "./client-state-process-identity"
1313

1414
function temp(t: test.TestContext) { const directory = mkdtempSync(join(tmpdir(), "codenomad-election-")); t.after(() => rmSync(directory, { recursive: true, force: true })); return directory }
1515

16+
test("detects only live Tauri client-state markers", (t) => {
17+
const directory = temp(t)
18+
writeFileSync(join(directory, "client-state.running.123.1.lock"), "")
19+
writeFileSync(join(directory, "client-state.running.456.2.lock"), "")
20+
writeFileSync(join(directory, "unrelated.lock"), "")
21+
assert.equal(hasLiveTauriClient(directory, (pid) => pid === 456), true)
22+
assert.equal(hasLiveTauriClient(directory, () => false), false)
23+
assert.equal(hasLiveTauriClient(join(directory, "missing"), () => true), false)
24+
})
25+
1626
interface Child { process: ChildProcessWithoutNullStreams; result: Promise<{ isPrimary: boolean }> }
1727
function child(directory: string, start: string, wait = "", paused = "", release = ""): Child {
1828
const process = spawn(globalThis.process.execPath, ["--import", "tsx", fileURLToPath(new URL("./client-state-election-child.ts", import.meta.url)), directory, randomUUID(), start, wait, paused, release])

packages/electron-app/electron/main/client-state-process.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,25 @@ export function isPidAlive(pid: number): boolean {
9191
}
9292
}
9393

94+
export function hasLiveTauriClient(
95+
tauriDataPath: string,
96+
pidAlive: (pid: number) => boolean = isPidAlive,
97+
): boolean {
98+
let entries: string[]
99+
try {
100+
entries = readdirSync(tauriDataPath)
101+
} catch (error) {
102+
if (hasErrorCode(error, "ENOENT")) return false
103+
throw error
104+
}
105+
return entries.some((name) => {
106+
const match = /^client-state\.running\.(\d+)\..+\.lock$/.exec(name)
107+
if (!match) return false
108+
const pid = Number(match[1])
109+
return Number.isInteger(pid) && pid > 0 && pid !== process.pid && pidAlive(pid)
110+
})
111+
}
112+
94113
export function classifyRunningMarker(
95114
markerOwner: ProcessOwner,
96115
currentOwner: ProcessOwner,

packages/electron-app/electron/main/client-state.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,23 @@ test("renderer access is exclusive per document and resettable", async (t) => {
4343
assert.equal(manager.claimClientStateAccess("document-2"), true)
4444
})
4545

46+
test("a live Tauri owner blocks Electron snapshot restoration", async (t) => {
47+
const directory = mkdtempSync(join(tmpdir(), "codenomad-cross-host-state-"))
48+
t.after(() => rmSync(directory, { recursive: true, force: true }))
49+
writeFileSync(join(directory, "client-state.json"), JSON.stringify({
50+
version: 1,
51+
restoreEnabled: true,
52+
snapshot: { tabs: ["must-not-restore"] },
53+
}))
54+
const secondary = new ClientStateManager(directory, undefined, { primaryBlocked: true })
55+
assert.deepEqual(secondary.loadClientState(), { isPrimary: false, restoreEnabled: true, snapshot: null })
56+
await secondary.drainAndReleasePrimary()
57+
58+
const successor = new ClientStateManager(directory)
59+
assert.equal(successor.isPrimary, true)
60+
await successor.drainAndReleasePrimary()
61+
})
62+
4663
test("failed preference and clear writes roll memory and suppression back", async (t) => {
4764
const h = harness(t)
4865
const manager = h.create()

packages/electron-app/electron/main/client-state.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,11 @@ export class ClientStateManager {
109109
private frozen = false
110110
private rendererAccessToken: string | undefined
111111

112-
constructor(userDataPath: string, private readonly writeState: ClientStateWriter = writeClientStateTemporary) {
112+
constructor(
113+
userDataPath: string,
114+
private readonly writeState: ClientStateWriter = writeClientStateTemporary,
115+
options?: { primaryBlocked?: boolean },
116+
) {
113117
mkdirSync(userDataPath, { recursive: true })
114118
this.userDataPath = userDataPath
115119
this.statePath = join(userDataPath, CLIENT_STATE_FILENAME)
@@ -123,6 +127,10 @@ export class ClientStateManager {
123127
(message, error) => console.warn(`[client-state] ${message}`, error),
124128
)
125129
this.primary = election
130+
if (this.primary && options?.primaryBlocked) {
131+
removeProcessOwnerLockIfOwned(this.lockPath, this.owner)
132+
this.primary = false
133+
}
126134
if (this.primary) {
127135
const persisted = this.readState()
128136
this.state = persisted.state

packages/electron-app/electron/main/main.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { app, BrowserView, BrowserWindow, ipcMain, nativeImage, screen, session,
22
import http from "node:http"
33
import https from "node:https"
44
import { existsSync, mkdirSync, rmSync } from "fs"
5-
import { dirname, join } from "path"
5+
import { dirname, isAbsolute, join } from "path"
66
import { fileURLToPath } from "url"
77
import { createApplicationMenu } from "./menu"
88
import { ClientStateManager } from "./client-state"
@@ -13,10 +13,12 @@ import { setupCliIPC } from "./ipc"
1313
import { configureMediaPermissionHandlers, isAllowedRendererOrigin } from "./permissions"
1414
import { resolveConfiguredRendererOrigins } from "./renderer-origin"
1515
import { CliProcessManager } from "./process-manager"
16+
import { hasLiveTauriClient } from "./client-state-process"
1617
import {
1718
clampWindowBounds,
1819
DEFAULT_WINDOW_HEIGHT,
1920
DEFAULT_WINDOW_WIDTH,
21+
installWindowZoomInput,
2022
restoreWindowState,
2123
WindowStateTracker,
2224
} from "./window-state"
@@ -94,7 +96,22 @@ function cleanupPackagedChromiumStorage() {
9496

9597
cleanupPackagedChromiumStorage()
9698

97-
const clientStateManager = new ClientStateManager(app.getPath("userData"))
99+
const linuxDataHome = process.env.XDG_DATA_HOME && isAbsolute(process.env.XDG_DATA_HOME)
100+
? process.env.XDG_DATA_HOME
101+
: join(app.getPath("home"), ".local", "share")
102+
const tauriClientStatePath = process.platform === "linux"
103+
? join(linuxDataHome, "ai.neuralnomads.codenomad.client")
104+
: join(app.getPath("appData"), "ai.neuralnomads.codenomad.client")
105+
let tauriClientIsRunning = false
106+
try {
107+
tauriClientIsRunning = hasLiveTauriClient(tauriClientStatePath)
108+
} catch (error) {
109+
console.warn("[client-state] failed to inspect Tauri process markers; continuing conservatively as secondary", error)
110+
tauriClientIsRunning = true
111+
}
112+
const clientStateManager = new ClientStateManager(app.getPath("userData"), undefined, {
113+
primaryBlocked: tauriClientIsRunning,
114+
})
98115
const cliManager = new CliProcessManager()
99116
let mainWindow: BrowserWindow | null = null
100117
let currentCliUrl: string | null = null
@@ -392,6 +409,7 @@ function createWindow() {
392409
preload: getPreloadPath(),
393410
contextIsolation: true,
394411
nodeIntegration: false,
412+
...(savedWindowState ? { zoomFactor: savedWindowState.zoomFactor } : {}),
395413
spellcheck: !isMac,
396414
additionalArguments: ["--codenomad-window-context=local"],
397415
},
@@ -415,6 +433,10 @@ function createWindow() {
415433
restoreWindowState(window, savedWindowState, restoredBounds)
416434
windowStateTracker = new WindowStateTracker(window, clientStateManager, savedWindowState)
417435
}
436+
installWindowZoomInput(window, (level) => {
437+
if (windowStateTracker) windowStateTracker.setZoomLevel(level)
438+
else window.webContents.setZoomLevel(level)
439+
})
418440

419441
setupNavigationGuards(window, navigationController)
420442

0 commit comments

Comments
 (0)