Skip to content

Commit 4fdf9ab

Browse files
committed
fix(restore): resolve gatekeeper concurrency blockers
Guard asynchronous session search, parent hydration, and message loading with deletion authority and per-session request epochs so stale responses cannot resurrect deleted or evicted state. Make Electron shutdown identity-safe and bounded: do not veto or force-exit Windows session end, require the graceful completion handshake, capture immutable descendant identities, reject PID reuse, and confirm process disappearance after enforcement. Serialize Electron/Tauri cross-host owner and participant transitions with a shared atomic lock so simultaneous startup elects one primary and participant publication cannot cross cohort release. Validated with Electron native tests, focused UI and server suites, Tauri client-state tests, UI/Electron/server typechecks, cargo check, and focused Rust formatting.
1 parent ccdc61e commit 4fdf9ab

15 files changed

Lines changed: 973 additions & 355 deletions
Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,35 @@
11
import { existsSync, writeFileSync } from "node:fs"
22
import { CrossHostRegistration, createCrossHostOwner } from "./client-state-cross-host"
3+
import { getProcessStartIdentity } from "./client-state-process-identity"
4+
import { isPidAlive } from "./client-state-process"
5+
import { ClientStateManager } from "./client-state"
36

4-
const [directory, startPath, readyPath] = process.argv.slice(2)
7+
const [directory, startPath, readyPath, mode, userDataPath, participantReadyPath, participantContinuePath] = process.argv.slice(2)
58
if (!directory || !startPath) throw new Error("Expected election directory and start path")
69
if (readyPath) writeFileSync(readyPath, "")
710
while (!existsSync(startPath)) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5)
811

9-
const owner = createCrossHostOwner()
12+
const manager = mode === "full" && userDataPath
13+
? new ClientStateManager(userDataPath, undefined, {
14+
crossHostElectionDirectory: directory,
15+
legacyTauriDataPath: null,
16+
crossHostDependencies: {
17+
pidAlive: isPidAlive,
18+
processStartIdentity: getProcessStartIdentity,
19+
onParticipantPublished: participantReadyPath && participantContinuePath
20+
? () => {
21+
writeFileSync(participantReadyPath, "")
22+
while (!existsSync(participantContinuePath)) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5)
23+
}
24+
: undefined,
25+
},
26+
})
27+
: undefined
28+
const owner = manager ? undefined : createCrossHostOwner()
1029
const registration = owner && CrossHostRegistration.register(directory, owner, true)
11-
process.stdout.write(`${JSON.stringify({ acquired: Boolean(registration?.isPrimary) })}\n`)
30+
process.stdout.write(`${JSON.stringify({ acquired: manager?.isPrimary ?? Boolean(registration?.isPrimary) })}\n`)
1231
process.stdin.resume()
13-
process.stdin.once("end", () => registration?.release())
32+
process.stdin.once("end", () => {
33+
if (manager) void manager.drainAndReleasePrimary().finally(() => process.exit())
34+
else registration?.release()
35+
})

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

Lines changed: 149 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
openSync,
88
readdirSync,
99
readFileSync,
10+
renameSync,
11+
rmdirSync,
1012
unlinkSync,
1113
writeFileSync,
1214
} from "node:fs"
@@ -21,10 +23,17 @@ const PARTICIPANT_SUFFIX = ".json"
2123
const REMOVAL_CLAIM_PREFIX = "removed."
2224
const REMOVAL_CLAIM_SUFFIX = ".claim"
2325
const ACQUIRE_ATTEMPTS = 10
26+
const PROTOCOL_LOCK_DIRECTORY = "protocol.lock"
27+
const PROTOCOL_LOCK_OWNER_FILENAME = "owner.json"
28+
const RETIRED_LOCK_PREFIX = "retired."
29+
const RETIRED_LOCK_SUFFIX = ".lock"
30+
const PROTOCOL_LOCK_ATTEMPTS = 500
31+
const PROTOCOL_LOCK_RETRY_MS = 10
2432

2533
export interface CrossHostLeaseDependencies {
2634
pidAlive(pid: number): boolean
2735
processStartIdentity: ProcessStartIdentityLookup
36+
onParticipantPublished?(): void
2837
}
2938

3039
const defaultDependencies: CrossHostLeaseDependencies = {
@@ -168,6 +177,90 @@ function ownerIsStale(owner: ProcessOwner, dependencies: CrossHostLeaseDependenc
168177
return identity ? identity !== owner.processStartIdentity : undefined
169178
}
170179

180+
function waitForProtocolLock(): void {
181+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, PROTOCOL_LOCK_RETRY_MS)
182+
}
183+
184+
function protocolLockOwnerPath(lockDirectory: string): string {
185+
return join(lockDirectory, PROTOCOL_LOCK_OWNER_FILENAME)
186+
}
187+
188+
function retireProtocolLock(electionDirectory: string, observed: string): boolean {
189+
const lockDirectory = join(electionDirectory, PROTOCOL_LOCK_DIRECTORY)
190+
if (readIfExists(protocolLockOwnerPath(lockDirectory)) !== observed) return false
191+
const retired = join(electionDirectory, `${RETIRED_LOCK_PREFIX}${digest(observed)}${RETIRED_LOCK_SUFFIX}`)
192+
try {
193+
// ponytail: immutable retirement directories accumulate; clean them only if directory growth becomes material.
194+
renameSync(lockDirectory, retired)
195+
return true
196+
} catch (error) {
197+
if (hasErrorCode(error, "ENOENT") || hasErrorCode(error, "EEXIST") || hasErrorCode(error, "ENOTEMPTY")) return false
198+
throw error
199+
}
200+
}
201+
202+
function acquireProtocolLock(
203+
electionDirectory: string,
204+
owner: ProcessOwner,
205+
dependencies: CrossHostLeaseDependencies,
206+
): ProcessOwner | undefined {
207+
const lockDirectory = join(electionDirectory, PROTOCOL_LOCK_DIRECTORY)
208+
const lockOwner = { ...owner, runToken: `${owner.runToken}.${randomUUID()}` }
209+
const serialized = serializeOwner(lockOwner)
210+
for (let attempt = 0; attempt < PROTOCOL_LOCK_ATTEMPTS; attempt += 1) {
211+
try {
212+
mkdirSync(lockDirectory, { mode: 0o700 })
213+
try {
214+
publishProcessLockOwner(protocolLockOwnerPath(lockDirectory), serialized)
215+
return lockOwner
216+
} catch (error) {
217+
try { rmdirSync(lockDirectory) } catch {}
218+
throw error
219+
}
220+
} catch (error) {
221+
if (!hasErrorCode(error, "EEXIST")) throw error
222+
}
223+
224+
const observed = readIfExists(protocolLockOwnerPath(lockDirectory))
225+
const existing = observed === undefined ? undefined : parseOwner(observed)
226+
if (!existing) {
227+
if (observed !== undefined && attempt >= ACQUIRE_ATTEMPTS - 1) return undefined
228+
waitForProtocolLock()
229+
continue
230+
}
231+
const stale = ownerIsStale(existing, dependencies)
232+
if (stale === undefined || !stale) {
233+
waitForProtocolLock()
234+
continue
235+
}
236+
retireProtocolLock(electionDirectory, observed!)
237+
}
238+
return undefined
239+
}
240+
241+
function publishProcessLockOwner(path: string, value: string): void {
242+
let descriptor: number | undefined
243+
try {
244+
descriptor = openSync(path, "wx", 0o600)
245+
writeFileSync(descriptor, value, "utf8")
246+
try { fsyncSync(descriptor) } catch (error) {
247+
if (!hasErrorCode(error, "EINVAL") && !hasErrorCode(error, "ENOTSUP") && !hasErrorCode(error, "ENOSYS")) throw error
248+
}
249+
closeSync(descriptor)
250+
descriptor = undefined
251+
} catch (error) {
252+
if (descriptor !== undefined) try { closeSync(descriptor) } catch {}
253+
try { unlinkSync(path) } catch {}
254+
throw error
255+
}
256+
}
257+
258+
function releaseProtocolLock(electionDirectory: string, owner: ProcessOwner): void {
259+
const observed = readIfExists(protocolLockOwnerPath(join(electionDirectory, PROTOCOL_LOCK_DIRECTORY)))
260+
const current = observed === undefined ? undefined : parseOwner(observed)
261+
if (observed !== undefined && current && sameOwner(current, owner)) retireProtocolLock(electionDirectory, observed)
262+
}
263+
171264
function removeObservedOwner(
172265
electionDirectory: string,
173266
path: string,
@@ -245,59 +338,64 @@ export class CrossHostRegistration {
245338
): CrossHostRegistration | undefined {
246339
if (!owner.processStartIdentity) return undefined
247340
mkdirSync(electionDirectory, { recursive: true, mode: 0o700 })
341+
const protocolLockOwner = acquireProtocolLock(electionDirectory, owner, dependencies)
342+
if (!protocolLockOwner) return undefined
248343
const path = join(electionDirectory, CROSS_HOST_OWNER_FILENAME)
249344
const participant = participantPath(electionDirectory, owner)
250345
let primary = false
251346

252-
if (primaryCandidate) {
253-
try {
254-
publish(path, serializeOwner(owner))
255-
primary = true
256-
} catch (error) {
257-
if (!hasErrorCode(error, "EEXIST")) throw error
258-
}
259-
}
260-
261-
if (primaryCandidate && !primary) {
262-
for (let attempt = 0; attempt < ACQUIRE_ATTEMPTS; attempt += 1) {
347+
try {
348+
if (primaryCandidate) {
263349
try {
264350
publish(path, serializeOwner(owner))
265351
primary = true
266-
break
267352
} catch (error) {
268353
if (!hasErrorCode(error, "EEXIST")) throw error
269354
}
355+
}
270356

271-
const observed = readIfExists(path)
272-
if (observed === undefined) continue
273-
const existing = parseOwner(observed)
274-
if (!existing) break
275-
if (sameOwner(existing, owner)) {
276-
primary = true
277-
break
357+
if (primaryCandidate && !primary) {
358+
for (let attempt = 0; attempt < ACQUIRE_ATTEMPTS; attempt += 1) {
359+
try {
360+
publish(path, serializeOwner(owner))
361+
primary = true
362+
break
363+
} catch (error) {
364+
if (!hasErrorCode(error, "EEXIST")) throw error
365+
}
366+
367+
const observed = readIfExists(path)
368+
if (observed === undefined) continue
369+
const existing = parseOwner(observed)
370+
if (!existing) break
371+
if (sameOwner(existing, owner)) {
372+
primary = true
373+
break
374+
}
375+
const stale = ownerIsStale(existing, dependencies)
376+
if (stale === undefined || !stale || hasOtherLiveParticipants(electionDirectory, owner, dependencies)) break
377+
if (!removeObservedOwner(electionDirectory, path, observed, existing, owner)) continue
278378
}
279-
const stale = existing.pid === owner.pid && existing.runToken !== owner.runToken
280-
? true
281-
: ownerIsStale(existing, dependencies)
282-
if (stale === undefined || !stale || hasOtherLiveParticipants(electionDirectory, owner, dependencies)) break
283-
if (!removeObservedOwner(electionDirectory, path, observed, existing, owner)) continue
284379
}
285-
}
286380

287-
try {
288-
publishParticipant(participant, owner)
289-
} catch (error) {
290-
if (primary) {
291-
const observed = readIfExists(path)
292-
const current = observed === undefined ? undefined : parseOwner(observed)
293-
if (observed !== undefined && current && sameOwner(current, owner)) {
294-
removeObservedOwner(electionDirectory, path, observed, owner, owner)
381+
try {
382+
publishParticipant(participant, owner)
383+
dependencies.onParticipantPublished?.()
384+
} catch (error) {
385+
if (primary) {
386+
const observed = readIfExists(path)
387+
const current = observed === undefined ? undefined : parseOwner(observed)
388+
if (observed !== undefined && current && sameOwner(current, owner)) {
389+
removeObservedOwner(electionDirectory, path, observed, owner, owner)
390+
}
295391
}
392+
throw error
296393
}
297-
throw error
298-
}
299394

300-
return new CrossHostRegistration(path, owner, electionDirectory, participant, dependencies, primary)
395+
return new CrossHostRegistration(path, owner, electionDirectory, participant, dependencies, primary)
396+
} finally {
397+
releaseProtocolLock(electionDirectory, protocolLockOwner)
398+
}
301399
}
302400

303401
get isPrimary(): boolean {
@@ -309,17 +407,23 @@ export class CrossHostRegistration {
309407

310408
release(): boolean {
311409
if (this.released) return false
410+
const protocolLockOwner = acquireProtocolLock(this.electionDirectory, this.owner, this.dependencies)
411+
if (!protocolLockOwner) return false
312412
let removed = false
313-
if (this.isPrimary && !hasOtherLiveParticipants(this.electionDirectory, this.owner, this.dependencies)) {
314-
const observed = readIfExists(this.path)
315-
const current = observed === undefined ? undefined : parseOwner(observed)
316-
if (observed !== undefined && current && sameOwner(current, this.owner)) {
317-
removed = removeObservedOwner(this.electionDirectory, this.path, observed, this.owner, this.owner)
413+
try {
414+
if (this.isPrimary && !hasOtherLiveParticipants(this.electionDirectory, this.owner, this.dependencies)) {
415+
const observed = readIfExists(this.path)
416+
const current = observed === undefined ? undefined : parseOwner(observed)
417+
if (observed !== undefined && current && sameOwner(current, this.owner)) {
418+
removed = removeObservedOwner(this.electionDirectory, this.path, observed, this.owner, this.owner)
419+
}
318420
}
421+
removeParticipantIfOwned(this.participant, this.owner)
422+
this.primary = false
423+
this.released = true
424+
return removed
425+
} finally {
426+
releaseProtocolLock(this.electionDirectory, protocolLockOwner)
319427
}
320-
removeParticipantIfOwned(this.participant, this.owner)
321-
this.primary = false
322-
this.released = true
323-
return removed
324428
}
325429
}

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

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ function harness(options: {
1111
flush?: () => Promise<unknown>
1212
stop?: () => Promise<void>
1313
nativeFlush?: () => Promise<void>
14-
timeout?: number
1514
otherWindow?: boolean
1615
} = {}) {
1716
const windows = new Map<string, (event?: { preventDefault(): void }) => void>()
@@ -29,7 +28,7 @@ function harness(options: {
2928
const app = { on: (name: string, handler: never) => appEvents.set(name, handler), quit: () => calls.push("quit"), exit: () => { exits++ } } as unknown as App
3029
const manager = { isPrimary: true, flush: async () => {}, drainAndReleasePrimary: async () => { calls.push("release") } } as ClientStateManager
3130
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, isWindows: true })
31+
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 })
3332
lifecycle.attachMainWindow(window, { flush: async () => { calls.push("native"); await options.nativeFlush?.() } } as unknown as WindowStateTracker)
3433
lifecycle.registerAppEvents()
3534
const close = () => { let prevented = false; windows.get("close")?.({ preventDefault: () => { prevented = true } }); return prevented }
@@ -68,26 +67,26 @@ test("late old-window detach preserves replacement tracker during shutdown", asy
6867
assert.deepEqual(h.calls, ["hide", "renderer", "stop", "replacement-native", "release"])
6968
})
7069

71-
test("Windows session end flushes once and exits", async () => {
70+
test("Windows session end starts cleanup without vetoing or explicitly exiting", async () => {
7271
const h = harness()
7372
let prevented = false
7473
h.windows.get("query-session-end")?.({ preventDefault: () => { prevented = true } })
7574
h.windows.get("session-end")?.()
7675
await (h.lifecycle as any).sessionEnd; await tick()
77-
assert.equal(prevented, true)
76+
assert.equal(prevented, false)
7877
assert.deepEqual(h.calls, ["renderer", "stop", "native", "release"])
79-
assert.equal(h.exits(), 1)
78+
assert.equal(h.exits(), 0)
8079
})
8180

82-
test("session end promotes and bounds an already-hung ordinary shutdown", async () => {
83-
const h = harness({ flush: () => new Promise(() => {}), timeout: 20 })
84-
const started = Date.now()
81+
test("session end does not force-exit an already-hung ordinary shutdown", async () => {
82+
const h = harness({ flush: () => new Promise(() => {}) })
83+
let prevented = false
8584
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
86-
h.windows.get("query-session-end")?.({ preventDefault: () => {} })
87-
await (h.lifecycle as any).sessionEnd; await tick()
88-
assert.ok(Date.now() - started < 500)
85+
h.windows.get("query-session-end")?.({ preventDefault: () => { prevented = true } })
86+
await tick()
87+
assert.equal(prevented, false)
8988
assert.deepEqual(h.calls, ["hide", "renderer", "stop"])
90-
assert.equal(h.exits(), 1)
89+
assert.equal(h.exits(), 0)
9190
})
9291

9392
test("ordinary quit hides promptly and waits for CLI stop confirmation", async () => {
@@ -103,6 +102,15 @@ test("ordinary quit hides promptly and waits for CLI stop confirmation", async (
103102
assert.equal(h.exits(), 1)
104103
})
105104

105+
test("ordinary quit does not exit when CLI cleanup is unconfirmed", async () => {
106+
const h = harness({ stop: async () => { throw new Error("unconfirmed") } })
107+
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })
108+
await assert.rejects((h.lifecycle as any).shutdown, /unconfirmed/)
109+
await tick()
110+
assert.equal(h.exits(), 0)
111+
assert.deepEqual(h.calls, ["hide", "renderer", "stop", "native"])
112+
})
113+
106114
test("CLI termination starts before a hung native flush", async () => {
107115
const h = harness({ nativeFlush: () => new Promise(() => {}) })
108116
h.appEvents.get("before-quit")?.({ preventDefault: () => {} })

0 commit comments

Comments
 (0)