Skip to content

Commit 975e700

Browse files
committed
fix(restore): simplify ownership recovery
Replace the cross-host mutex and claim protocol with atomically published owner directories and participants. This removes crash-wedged intermediate states, preserves live cohorts, handles upgraded and legacy markers, and drops the sha2 dependency. Keep workspace cancellations until their matching creation settles and retry pre-response restore cancellation without relying on SSE delivery. Inline one-use cache eviction logic and remove unused aliases. Delete the unreachable Electron utility-process supervisor path. The existing identity-guarded shutdown remains; Windows crash-only descendant containment is intentionally left as a documented residual rather than adding a PowerShell Job Object supervisor. Validated with Electron native tests and build, focused UI/server suites, Tauri client-state tests, all affected typechecks, cargo check, and focused Rust formatting.
1 parent 4fdf9ab commit 975e700

26 files changed

Lines changed: 1003 additions & 1516 deletions

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ import { getProcessStartIdentity } from "./client-state-process-identity"
44
import { isPidAlive } from "./client-state-process"
55
import { ClientStateManager } from "./client-state"
66

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

1212
const manager = mode === "full" && userDataPath
1313
? new ClientStateManager(userDataPath, undefined, {
1414
crossHostElectionDirectory: directory,
15-
legacyTauriDataPath: null,
15+
legacyTauriDataPath: legacyTauriDataPath || null,
1616
crossHostDependencies: {
1717
pidAlive: isPidAlive,
1818
processStartIdentity: getProcessStartIdentity,
@@ -26,7 +26,12 @@ const manager = mode === "full" && userDataPath
2626
})
2727
: undefined
2828
const owner = manager ? undefined : createCrossHostOwner()
29-
const registration = owner && CrossHostRegistration.register(directory, owner, true)
29+
const registration = owner && CrossHostRegistration.register(directory, owner, true, {
30+
pidAlive: mode === "retire-crash" ? () => false : isPidAlive,
31+
processStartIdentity: getProcessStartIdentity,
32+
onOwnerPrepared: mode === "owner-crash" ? () => process.exit(91) : undefined,
33+
onOwnerRetired: mode === "retire-crash" ? () => process.exit(91) : undefined,
34+
})
3035
process.stdout.write(`${JSON.stringify({ acquired: manager?.isPrimary ?? Boolean(registration?.isPrimary) })}\n`)
3136
process.stdin.resume()
3237
process.stdin.once("end", () => {
Lines changed: 103 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import assert from "node:assert/strict"
22
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"
33
import { once } from "node:events"
4-
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
4+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"
55
import { tmpdir } from "node:os"
66
import { join, posix, win32 } from "node:path"
77
import { fileURLToPath } from "node:url"
88
import test from "node:test"
99
import {
1010
CrossHostRegistration,
11-
CROSS_HOST_OWNER_FILENAME,
11+
CROSS_HOST_OWNER_DIRECTORY,
1212
resolveCrossHostElectionDirectory,
1313
type CrossHostLeaseDependencies,
1414
} from "./client-state-cross-host"
@@ -20,102 +20,134 @@ function temp(t: test.TestContext): string {
2020
return path
2121
}
2222

23-
function owner(pid: number, host: string, identity = `${host}-start`): ProcessOwner {
24-
return { pid, runToken: `${host}-run`, processStartIdentity: identity }
23+
function owner(pid: number, token: string, identity = `${token}-start`): ProcessOwner {
24+
return { pid, runToken: token, processStartIdentity: identity }
2525
}
2626

2727
function dependencies(alive: boolean, identity?: string): CrossHostLeaseDependencies {
2828
return { pidAlive: () => alive, processStartIdentity: () => identity }
2929
}
3030

31-
test("both host orders elect only the first registration", (t) => {
32-
for (const order of [["electron", "tauri"], ["tauri", "electron"]] as const) {
33-
const directory = temp(t)
34-
const firstOwner = owner(101, order[0]), secondOwner = owner(102, order[1])
35-
const identities = new Map([[101, firstOwner.processStartIdentity!], [102, secondOwner.processStartIdentity!]])
36-
const deps = { pidAlive: () => true, processStartIdentity: (pid: number) => identities.get(pid) }
37-
const first = CrossHostRegistration.register(directory, firstOwner, true, deps)!
38-
const second = CrossHostRegistration.register(directory, secondOwner, true, deps)!
39-
assert.equal(first.isPrimary, true)
40-
assert.equal(second.isPrimary, false)
41-
second.release()
42-
assert.equal(first.release(), true)
43-
}
44-
})
31+
function ownerFile(directory: string): string {
32+
return join(directory, CROSS_HOST_OWNER_DIRECTORY, "owner.json")
33+
}
34+
35+
interface Child {
36+
process: ChildProcessWithoutNullStreams
37+
result: Promise<boolean>
38+
}
39+
40+
function child(directory: string, start: string, mode = ""): Child {
41+
const process = spawn(globalThis.process.execPath, [
42+
"--import", "tsx", fileURLToPath(new URL("./client-state-cross-host-child.ts", import.meta.url)), directory, start, "", mode,
43+
]) as ChildProcessWithoutNullStreams
44+
process.stdout.setEncoding("utf8"); process.stderr.setEncoding("utf8")
45+
const result = new Promise<boolean>((resolve, reject) => {
46+
let output = "", errors = ""
47+
process.stdout.on("data", (chunk: string) => {
48+
output += chunk
49+
if (output.includes("\n")) resolve(JSON.parse(output).acquired)
50+
})
51+
process.stderr.on("data", (chunk: string) => { errors += chunk })
52+
process.once("error", reject)
53+
process.once("exit", (code) => { if (!output && code !== 91) reject(new Error(`child ${code}: ${errors}`)) })
54+
})
55+
return { process, result }
56+
}
57+
58+
async function stop(...children: Child[]): Promise<void> {
59+
const running = children.filter(({ process }) => process.exitCode === null && process.signalCode === null)
60+
const exits = running.map(({ process }) => once(process, "exit"))
61+
running.forEach(({ process }) => process.stdin.end())
62+
await Promise.all(exits)
63+
}
64+
65+
async function waitForExit(child: Child): Promise<void> {
66+
if (child.process.exitCode === null && child.process.signalCode === null) await once(child.process, "exit")
67+
}
4568

4669
test("simultaneous acquisition across processes yields one owner", async (t) => {
4770
const directory = temp(t), start = join(directory, "start")
48-
const children = Array.from({ length: 4 }, () => spawn(process.execPath, [
49-
"--import", "tsx", fileURLToPath(new URL("./client-state-cross-host-child.ts", import.meta.url)), directory, start,
50-
]) as ChildProcessWithoutNullStreams)
71+
const children = Array.from({ length: 4 }, () => child(directory, start))
5172
try {
52-
const results = children.map((child) => new Promise<boolean>((resolve, reject) => {
53-
let output = "", errors = ""
54-
child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8")
55-
child.stdout.on("data", (chunk: string) => { output += chunk; if (output.includes("\n")) resolve(JSON.parse(output).acquired) })
56-
child.stderr.on("data", (chunk: string) => { errors += chunk })
57-
child.once("error", reject)
58-
child.once("exit", (code) => { if (!output) reject(new Error(`child ${code}: ${errors}`)) })
59-
}))
6073
writeFileSync(start, "")
61-
assert.equal((await Promise.all(results)).filter(Boolean).length, 1)
62-
} finally {
63-
const exits = children.map((child) => once(child, "exit"))
64-
children.forEach((child) => child.stdin.end())
65-
await Promise.all(exits)
66-
}
74+
assert.equal((await Promise.all(children.map(({ result }) => result))).filter(Boolean).length, 1)
75+
} finally { await stop(...children) }
6776
})
6877

69-
test("stale recovery is identity guarded and immutable claims protect successors", (t) => {
78+
test("owner publication crash leaves no visible owner", async (t) => {
79+
const directory = temp(t), start = join(directory, "start")
80+
const crashed = child(directory, start, "owner-crash")
81+
writeFileSync(start, "")
82+
await waitForExit(crashed)
83+
assert.equal(existsSync(join(directory, CROSS_HOST_OWNER_DIRECTORY)), false)
84+
const winner = CrossHostRegistration.register(directory, owner(101, "winner"), true, dependencies(true, "winner-start"))!
85+
assert.equal(winner.isPrimary, true)
86+
})
87+
88+
test("stale retirement crash cannot retire a successor", async (t) => {
89+
const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY)
90+
mkdirSync(staleDirectory)
91+
writeFileSync(join(staleDirectory, "owner.json"), JSON.stringify(owner(4_000_000_000, "stale")))
92+
const start = join(directory, "start"), crashed = child(directory, start, "retire-crash")
93+
writeFileSync(start, "")
94+
await waitForExit(crashed)
95+
const winner = CrossHostRegistration.register(directory, owner(102, "successor"), true, dependencies(true, "successor-start"))!
96+
assert.equal(winner.isPrimary, true)
97+
assert.equal(JSON.parse(readFileSync(ownerFile(directory), "utf8")).runToken, "successor")
98+
})
99+
100+
test("stale recovery is identity guarded and blocked by live or uncertain participants", (t) => {
70101
for (const value of [
71-
{ name: "dead PID", deps: dependencies(false), recover: true },
72-
{ name: "PID reuse", deps: dependencies(true, "new-start"), recover: true },
73-
{ name: "live owner", deps: dependencies(true, "old-start"), recover: false },
74-
{ name: "uncertain identity", deps: dependencies(true), recover: false },
102+
{ alive: false, identity: undefined, recover: true },
103+
{ alive: true, identity: "reused", recover: true },
104+
{ alive: true, identity: "old-start", recover: false },
105+
{ alive: true, identity: undefined, recover: false },
75106
]) {
76-
const directory = temp(t), path = join(directory, CROSS_HOST_OWNER_FILENAME)
77-
writeFileSync(path, JSON.stringify(owner(201, "old", "old-start")))
78-
const registration = CrossHostRegistration.register(directory, owner(202, "new"), true, value.deps)!
79-
assert.equal(registration.isPrimary, value.recover, value.name)
80-
registration.release()
107+
const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY)
108+
mkdirSync(staleDirectory); writeFileSync(join(staleDirectory, "owner.json"), JSON.stringify(owner(201, "old", "old-start")))
109+
const registration = CrossHostRegistration.register(directory, owner(202, "new"), true, dependencies(value.alive, value.identity))!
110+
assert.equal(registration.isPrimary, value.recover)
81111
}
82112

83-
const directory = temp(t), path = join(directory, CROSS_HOST_OWNER_FILENAME)
84-
const stale = JSON.stringify(owner(301, "stale"))
85-
writeFileSync(path, stale)
86-
const first = CrossHostRegistration.register(directory, owner(302, "first"), true, dependencies(false))!
87-
assert.equal(first.isPrimary, true)
88-
writeFileSync(path, JSON.stringify(owner(303, "replacement")))
89-
assert.equal(first.release(), false)
90-
assert.equal(JSON.parse(readFileSync(path, "utf8")).runToken, "replacement-run")
113+
const directory = temp(t), staleDirectory = join(directory, CROSS_HOST_OWNER_DIRECTORY)
114+
mkdirSync(staleDirectory); writeFileSync(join(staleDirectory, "owner.json"), JSON.stringify(owner(301, "dead")))
115+
writeFileSync(join(directory, "participant.302.secondary.json"), JSON.stringify(owner(302, "secondary")))
116+
const identities = new Map([[302, "secondary-start"]])
117+
const blocked = CrossHostRegistration.register(directory, owner(303, "next"), true, {
118+
pidAlive: (pid) => pid === 302,
119+
processStartIdentity: (pid) => identities.get(pid),
120+
})!
121+
assert.equal(blocked.isPrimary, false)
91122
})
92123

93-
test("a surviving opposite-host secondary preserves the cohort barrier", (t) => {
124+
test("ordinary release removes only its participant", (t) => {
94125
const directory = temp(t)
95-
const identities = new Map([[401, "primary-start"], [402, "secondary-start"]])
96-
const deps = { pidAlive: () => true, processStartIdentity: (pid: number) => identities.get(pid) }
97-
const primary = CrossHostRegistration.register(directory, owner(401, "primary", "primary-start"), true, deps)!
98-
const secondary = CrossHostRegistration.register(directory, owner(402, "secondary", "secondary-start"), true, deps)!
99-
assert.equal(secondary.isPrimary, false)
100-
assert.equal(primary.release(), false)
101-
assert.equal(existsSync(join(directory, CROSS_HOST_OWNER_FILENAME)), true)
102-
const successor = CrossHostRegistration.register(directory, owner(403, "successor", "successor-start"), true, deps)!
103-
assert.equal(successor.isPrimary, false)
104-
successor.release(); secondary.release()
126+
const registration = CrossHostRegistration.register(directory, owner(401, "primary"), true, dependencies(true, "primary-start"))!
127+
assert.equal(registration.release(), true)
128+
assert.equal(existsSync(join(directory, CROSS_HOST_OWNER_DIRECTORY)), true)
129+
assert.deepEqual(readdirSync(directory).filter((name) => name.startsWith("participant.") || name.startsWith("retired.")), [])
105130
})
106131

107-
test("malformed owners fail closed and release never removes another owner", (t) => {
108-
const directory = temp(t), path = join(directory, CROSS_HOST_OWNER_FILENAME)
109-
writeFileSync(path, "incomplete")
110-
const blocked = CrossHostRegistration.register(directory, owner(501, "new"), true, dependencies(false))!
132+
test("primary crash remains fenced by its live secondary cohort", async (t) => {
133+
const directory = temp(t), firstStart = join(directory, "first-start")
134+
const primary = child(directory, firstStart), secondary = child(directory, firstStart)
135+
writeFileSync(firstStart, "")
136+
const roles = await Promise.all([primary.result, secondary.result])
137+
const winner = roles[0] ? primary : secondary, survivor = roles[0] ? secondary : primary
138+
winner.process.kill()
139+
await waitForExit(winner)
140+
const blocked = CrossHostRegistration.register(directory, owner(501, "blocked"), true)!
111141
assert.equal(blocked.isPrimary, false)
112-
assert.equal(readFileSync(path, "utf8"), "incomplete")
113142
blocked.release()
143+
survivor.process.kill()
144+
await waitForExit(survivor)
145+
const successor = CrossHostRegistration.register(directory, owner(502, "successor"), true)!
146+
assert.equal(successor.isPrimary, true)
114147
})
115148

116-
test("Windows home fallback exactly matches the Rust contract", () => {
149+
test("platform paths match the Rust contract", () => {
117150
assert.equal(resolveCrossHostElectionDirectory({ HOME: "/Users/dev" }, "darwin", "/fallback"), posix.join("/Users/dev", ".codenomad", "client-state", "election"))
118151
assert.equal(resolveCrossHostElectionDirectory({ HOME: "/home/dev" }, "linux", "/fallback"), posix.join("/home/dev", ".codenomad", "client-state", "election"))
119152
assert.equal(resolveCrossHostElectionDirectory({ USERPROFILE: "", HOME: "D:\\Home" }, "win32", "C:\\Fallback"), win32.join("D:\\Home", ".codenomad", "client-state", "election"))
120-
assert.equal(resolveCrossHostElectionDirectory({ USERPROFILE: "\\Users\\Dev" }, "win32", "C:\\Fallback"), win32.join("C:\\Fallback", ".codenomad", "client-state", "election"))
121153
})

0 commit comments

Comments
 (0)