Skip to content

Commit 6396958

Browse files
committed
fix(restore): close cross-host gatekeeper races
Recover stale cross-host ownership deterministically when Electron and Tauri start together, and fence Tauri renderer mutations against authority rotation through the final atomic replacement. Preserve restored tabs and unsent drafts after non-authoritative workspace stops, reject stale HTTP message hydration after newer SSE updates, and serialize Electron CLI lifecycle operations so restart cannot race shutdown or spawn an untracked server. Flush Tauri client state before stopping the CLI, align shared window geometry on outer position plus content size, and discard incompatible legacy native bounds during migration. Validated with 92 Electron native tests, 71 focused UI tests, 38 Tauri client-state tests, 7 Tauri shutdown tests, all affected typechecks, cargo check, focused rustfmt, and the Electron production build.
1 parent cb2d0ca commit 6396958

29 files changed

Lines changed: 488 additions & 99 deletions

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,25 @@ test("stale recovery is identity guarded and blocked by live or uncertain partic
122122
assert.equal(blocked.isPrimary, false)
123123
})
124124

125+
test("simultaneous claimants deterministically recover a stale owner", (t) => {
126+
const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY)
127+
const stale = owner(601, "stale"), first = owner(602, "a"), second = owner(603, "b")
128+
mkdirSync(staleDirectory)
129+
const observed = JSON.stringify(stale)
130+
writeFileSync(join(staleDirectory, "owner.json"), observed)
131+
writeFileSync(join(directory, "participant.603.b.json"), JSON.stringify(second))
132+
writeFileSync(join(directory, "recovery.603.b.claim"), observed)
133+
const identities = new Map([[602, first.processStartIdentity], [603, second.processStartIdentity]])
134+
const deps = {
135+
pidAlive: (pid: number) => pid !== stale.pid,
136+
processStartIdentity: (pid: number) => identities.get(pid),
137+
}
138+
const winner = CrossHostRegistration.register(directory, first, true, deps)!
139+
const loser = CrossHostRegistration.register(directory, second, true, deps)!
140+
assert.equal(winner.isPrimary, true)
141+
assert.equal(loser.isPrimary, false)
142+
})
143+
125144
test("ordinary release removes only its participant", (t) => {
126145
const directory = temp(t)
127146
const registration = CrossHostRegistration.register(directory, owner(401, "primary"), true, dependencies(true, "primary-start"))!

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

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export const CROSS_HOST_OWNER_DIRECTORY = "primary.owner.json"
99
const OWNER_FILENAME = "owner.json"
1010
const PARTICIPANT_PREFIX = "participant."
1111
const PARTICIPANT_SUFFIX = ".json"
12+
const RECOVERY_PREFIX = "recovery."
13+
const RECOVERY_SUFFIX = ".claim"
1214
const RETIRED_PREFIX = "retired."
1315
const ACQUIRE_ATTEMPTS = 10
1416

@@ -130,6 +132,10 @@ function participantPath(directory: string, owner: ProcessOwner): string {
130132
return join(directory, `${PARTICIPANT_PREFIX}${owner.pid}.${owner.runToken}${PARTICIPANT_SUFFIX}`)
131133
}
132134

135+
function recoveryPath(directory: string, owner: ProcessOwner): string {
136+
return join(directory, `${RECOVERY_PREFIX}${owner.pid}.${owner.runToken}${RECOVERY_SUFFIX}`)
137+
}
138+
133139
function publishParticipant(path: string, owner: ProcessOwner): void {
134140
const value = serializeOwner(owner)
135141
try { publishFile(path, value) } catch (error) {
@@ -156,22 +162,37 @@ function removeParticipantIfOwned(path: string, owner: ProcessOwner): void {
156162
}
157163
}
158164

159-
function hasLiveParticipants(directory: string, current: ProcessOwner, dependencies: CrossHostLeaseDependencies): boolean {
165+
function recoveryClaimants(
166+
directory: string,
167+
current: ProcessOwner,
168+
observedOwner: string,
169+
dependencies: CrossHostLeaseDependencies,
170+
): ProcessOwner[] | undefined {
171+
const claimants = [current]
160172
for (const name of readdirSync(directory)) {
161173
if (!name.startsWith(PARTICIPANT_PREFIX) || !name.endsWith(PARTICIPANT_SUFFIX)) continue
162174
const path = join(directory, name)
163175
const participant = parseOwner(readIfExists(path) ?? "")
164-
if (!participant) return true
176+
if (!participant) return undefined
165177
if (sameOwner(participant, current)) continue
166178
const stale = ownerIsStale(participant, dependencies)
167-
if (stale !== true) return true
168-
removeParticipantIfOwned(path, participant)
179+
if (stale === true) {
180+
removeParticipantIfOwned(path, participant)
181+
try { unlinkSync(recoveryPath(directory, participant)) } catch {}
182+
continue
183+
}
184+
if (readIfExists(recoveryPath(directory, participant)) !== observedOwner) return undefined
185+
claimants.push(participant)
169186
}
170-
return false
187+
return claimants
171188
}
172189

173190
function retireOwner(directory: string, observed: string, owner: ProcessOwner, claimant: ProcessOwner, dependencies: CrossHostLeaseDependencies): boolean {
174-
if (ownerIsStale(owner, dependencies) !== true || hasLiveParticipants(directory, claimant, dependencies)) return false
191+
if (ownerIsStale(owner, dependencies) !== true) return false
192+
const claimants = recoveryClaimants(directory, claimant, observed, dependencies)
193+
if (!claimants) return false
194+
claimants.sort((left, right) => serializeOwner(left) < serializeOwner(right) ? -1 : 1)
195+
if (!sameOwner(claimants[0]!, claimant)) return false
175196
if (readIfExists(ownerPath(directory)) !== observed) return false
176197
const retired = join(directory, `${RETIRED_PREFIX}${owner.pid}.${owner.runToken}`)
177198
try {
@@ -220,6 +241,7 @@ export class CrossHostRegistration {
220241
private readonly directory: string,
221242
readonly owner: ProcessOwner,
222243
private readonly participant: string,
244+
private readonly recoveryClaim: string | undefined,
223245
private primary: boolean,
224246
) {}
225247

@@ -237,6 +259,7 @@ export class CrossHostRegistration {
237259
publishParticipant(participant, owner)
238260
dependencies.onParticipantPublished?.()
239261
let primary = false
262+
let recoveryClaim: string | undefined
240263
try {
241264
if (typeof primaryCandidate === "function" ? primaryCandidate() : primaryCandidate) {
242265
for (let attempt = 0; attempt < ACQUIRE_ATTEMPTS; attempt += 1) {
@@ -246,12 +269,19 @@ export class CrossHostRegistration {
246269
const existing = parseOwner(observed)
247270
if (!existing) break
248271
if (sameOwner(existing, owner)) { primary = true; break }
272+
if (ownerIsStale(existing, dependencies) === true) {
273+
recoveryClaim ??= recoveryPath(directory, owner)
274+
try { publishFile(recoveryClaim, observed) } catch (error) {
275+
if (!hasErrorCode(error, "EEXIST") || readIfExists(recoveryClaim) !== observed) throw error
276+
}
277+
}
249278
if (!retireOwner(directory, observed, existing, owner, dependencies)) break
250279
}
251280
}
252-
return new CrossHostRegistration(directory, owner, participant, primary)
281+
return new CrossHostRegistration(directory, owner, participant, recoveryClaim, primary)
253282
} catch (error) {
254283
removeParticipantIfOwned(participant, owner)
284+
if (recoveryClaim) try { unlinkSync(recoveryClaim) } catch {}
255285
throw error
256286
}
257287
}
@@ -265,6 +295,7 @@ export class CrossHostRegistration {
265295
release(): boolean {
266296
if (this.released) return false
267297
removeParticipantIfOwned(this.participant, this.owner)
298+
if (this.recoveryClaim) try { unlinkSync(this.recoveryClaim) } catch {}
268299
this.primary = false
269300
this.released = true
270301
return true

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ function harness(options: {
2727
const other = { isDestroyed: () => false, hide: () => { calls.push("hide-other") } } as unknown as BrowserWindow
2828
const app = { on: (name: string, handler: never) => appEvents.set(name, handler), quit: () => calls.push("quit"), exit: () => { exits++ } } as unknown as App
2929
const manager = { isPrimary: true, flush: async () => {}, drainAndReleasePrimary: async () => { calls.push("release") } } as ClientStateManager
30-
const cli = { stop: async () => { calls.push("stop"); await options.stop?.() } } as unknown as CliProcessManager
30+
const cli = { shutdown: async () => { calls.push("stop"); await options.stop?.() } } as unknown as CliProcessManager
3131
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, isWindows: true })
3232
lifecycle.attachMainWindow(window, { flush: async () => { calls.push("native"); await options.nativeFlush?.() } } as unknown as WindowStateTracker)
3333
lifecycle.registerAppEvents()

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export class ClientStateLifecycle {
9494
if (this.shutdown) return this.shutdown
9595
const stages = (async () => {
9696
const rendererFlush = this.runStage("renderer shutdown flush", () => this.flushRenderer(window))
97-
const cliStop = this.dependencies.cliManager.stop().then(() => null, (error: unknown) => error)
97+
const cliStop = this.dependencies.cliManager.shutdown().then(() => null, (error: unknown) => error)
9898
await rendererFlush
9999
await this.runStage("native shutdown flush", () => this.flushNative())
100100
const cliError = await cliStop

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,15 @@ test("first shared primary deterministically migrates legacy host envelopes", as
8585
mkdirSync(electron, { recursive: true }); mkdirSync(tauri, { recursive: true })
8686
t.after(() => rmSync(root, { recursive: true, force: true }))
8787
writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { revision: 999, savedAt: 10, host: "electron" } }))
88-
writeFileSync(join(tauri, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { savedAt: 20, host: "tauri" } }))
88+
writeFileSync(join(tauri, "client-state.json"), JSON.stringify({
89+
version: 1,
90+
restoreEnabled: true,
91+
snapshot: { savedAt: 20, host: "tauri" },
92+
window: { bounds: { x: 10, y: 10, width: 1000, height: 700 }, maximized: false, fullscreen: false, zoomFactor: 1 },
93+
}))
8994
const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri })
9095
assert.deepEqual(manager.loadClientState().snapshot, { savedAt: 20, host: "tauri" })
96+
assert.equal(manager.getWindowState(), undefined)
9197
assert.equal(existsSync(join(electron, "client-state.json")), true)
9298
assert.equal(existsSync(join(tauri, "client-state.json")), true)
9399
await manager.drainAndReleasePrimary()

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ function legacyCandidate(path: string, host: "electron" | "tauri"): { host: stri
118118
const candidate = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>
119119
if (!candidate || candidate.version !== CLIENT_STATE_VERSION) return undefined
120120
const parsed = parseClientState(JSON.stringify(candidate)).state
121+
delete parsed.window
121122
const snapshot = candidate.snapshot as Record<string, unknown> | undefined
122123
const savedAt = typeof snapshot?.savedAt === "number" && Number.isFinite(snapshot.savedAt) ? snapshot.savedAt : -1
123124
return { host, state: parsed, savedAt, hasSnapshot: snapshot !== undefined }

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,7 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
4141

4242
ipcMain.handle("cli:restart", async () => {
4343
const devMode = process.env.NODE_ENV === "development"
44-
await cliManager.stop()
45-
return cliManager.start({ dev: devMode })
44+
return cliManager.restart({ dev: devMode })
4645
})
4746

4847
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ function createWindow() {
384384
mainWindow = new BrowserWindow({
385385
width: restoredBounds?.width ?? DEFAULT_WINDOW_WIDTH,
386386
height: restoredBounds?.height ?? DEFAULT_WINDOW_HEIGHT,
387+
useContentSize: true,
387388
...(restoredBounds ? { x: restoredBounds.x, y: restoredBounds.y } : {}),
388389
minWidth: 800,
389390
minHeight: 600,

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

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
forceCapturedProcessTree,
1515
stopManagedChild,
1616
} from "./process-stop"
17+
import { SerializedLifecycle } from "./serialized-lifecycle"
1718
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
1819

1920
const nodeRequire = createRequire(import.meta.url)
@@ -139,10 +140,32 @@ export class CliProcessManager extends EventEmitter {
139140
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
140141
private requestedStop = false
141142
private shutdownStatus: "complete" | "incomplete" | null = null
143+
private lifecycle = new SerializedLifecycle()
142144

143-
async start(options: StartOptions): Promise<CliStatus> {
145+
start(options: StartOptions): Promise<CliStatus> {
146+
return this.lifecycle.enqueue(() => this.startNow(options))
147+
}
148+
149+
restart(options: StartOptions): Promise<CliStatus> {
150+
return this.lifecycle.enqueue(async () => {
151+
await this.stopNow()
152+
if (this.lifecycle.stopped) throw new Error("CLI process manager is shutting down")
153+
return this.startNow(options)
154+
})
155+
}
156+
157+
stop(): Promise<void> {
158+
return this.lifecycle.enqueue(() => this.stopNow())
159+
}
160+
161+
shutdown(): Promise<void> {
162+
return this.lifecycle.stop(() => this.stopNow())
163+
}
164+
165+
private async startNow(options: StartOptions): Promise<CliStatus> {
166+
if (this.lifecycle.stopped) throw new Error("CLI process manager is shutting down")
144167
if (this.child) {
145-
await this.stop()
168+
await this.stopNow()
146169
if (this.child) throw new Error("CLI process did not exit before restart")
147170
}
148171

@@ -234,7 +257,7 @@ export class CliProcessManager extends EventEmitter {
234257
})
235258
}
236259

237-
async stop(): Promise<void> {
260+
private async stopNow(): Promise<void> {
238261
const child = this.child
239262
if (!child) {
240263
this.updateStatus({ state: "stopped" })
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import { SerializedLifecycle } from "./serialized-lifecycle"
4+
5+
test("serializes operations and exposes shutdown before queued work resumes", async () => {
6+
const lifecycle = new SerializedLifecycle()
7+
let release!: () => void
8+
const gate = new Promise<void>((resolve) => { release = resolve })
9+
let active = 0, maximum = 0
10+
const first = lifecycle.enqueue(async () => {
11+
active += 1; maximum = Math.max(maximum, active)
12+
await gate
13+
active -= 1
14+
if (lifecycle.stopped) throw new Error("stopped")
15+
})
16+
const second = lifecycle.enqueue(async () => {
17+
active += 1; maximum = Math.max(maximum, active); active -= 1
18+
})
19+
const shutdown = lifecycle.stop(async () => {})
20+
release()
21+
await assert.rejects(first, /stopped/)
22+
await second
23+
await shutdown
24+
assert.equal(maximum, 1)
25+
})

0 commit comments

Comments
 (0)