Skip to content

Commit ccdc61e

Browse files
committed
fix(restore): close lifecycle gatekeeper gaps
Prevent late restore completion and cleanup races from resurrecting explicitly closed workspaces. Preserve message scroll while invalidating evicted session hydration, retain scoped request correlation for shared launches, and retry failed cancellation cleanup safely. Add confirmed graceful desktop shutdown with concurrent workspace cleanup and final process-tree enforcement. Introduce a shared Electron/Tauri cross-host election with atomic publication, identity-guarded stale recovery, cohort participants, legacy marker checks, and Node/Rust interoperability coverage. Validated with focused UI, server, Electron, and Tauri suites; UI/Electron/server typechecks; cargo check and focused rustfmt; and UI, Electron, and server production builds.
1 parent 92044a5 commit ccdc61e

37 files changed

Lines changed: 2698 additions & 329 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { existsSync, writeFileSync } from "node:fs"
2+
import { CrossHostRegistration, createCrossHostOwner } from "./client-state-cross-host"
3+
4+
const [directory, startPath, readyPath] = process.argv.slice(2)
5+
if (!directory || !startPath) throw new Error("Expected election directory and start path")
6+
if (readyPath) writeFileSync(readyPath, "")
7+
while (!existsSync(startPath)) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5)
8+
9+
const owner = createCrossHostOwner()
10+
const registration = owner && CrossHostRegistration.register(directory, owner, true)
11+
process.stdout.write(`${JSON.stringify({ acquired: Boolean(registration?.isPrimary) })}\n`)
12+
process.stdin.resume()
13+
process.stdin.once("end", () => registration?.release())
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import assert from "node:assert/strict"
2+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"
3+
import { once } from "node:events"
4+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
5+
import { tmpdir } from "node:os"
6+
import { join, posix, win32 } from "node:path"
7+
import { fileURLToPath } from "node:url"
8+
import test from "node:test"
9+
import {
10+
CrossHostRegistration,
11+
CROSS_HOST_OWNER_FILENAME,
12+
resolveCrossHostElectionDirectory,
13+
type CrossHostLeaseDependencies,
14+
} from "./client-state-cross-host"
15+
import type { ProcessOwner } from "./client-state-process"
16+
17+
function temp(t: test.TestContext): string {
18+
const path = mkdtempSync(join(tmpdir(), "codenomad-cross-host-"))
19+
t.after(() => rmSync(path, { recursive: true, force: true }))
20+
return path
21+
}
22+
23+
function owner(pid: number, host: string, identity = `${host}-start`): ProcessOwner {
24+
return { pid, runToken: `${host}-run`, processStartIdentity: identity }
25+
}
26+
27+
function dependencies(alive: boolean, identity?: string): CrossHostLeaseDependencies {
28+
return { pidAlive: () => alive, processStartIdentity: () => identity }
29+
}
30+
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+
})
45+
46+
test("simultaneous acquisition across processes yields one owner", async (t) => {
47+
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)
51+
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+
}))
60+
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+
}
67+
})
68+
69+
test("stale recovery is identity guarded and immutable claims protect successors", (t) => {
70+
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 },
75+
]) {
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()
81+
}
82+
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")
91+
})
92+
93+
test("a surviving opposite-host secondary preserves the cohort barrier", (t) => {
94+
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()
105+
})
106+
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))!
111+
assert.equal(blocked.isPrimary, false)
112+
assert.equal(readFileSync(path, "utf8"), "incomplete")
113+
blocked.release()
114+
})
115+
116+
test("Windows home fallback exactly matches the Rust contract", () => {
117+
assert.equal(resolveCrossHostElectionDirectory({ HOME: "/Users/dev" }, "darwin", "/fallback"), posix.join("/Users/dev", ".codenomad", "client-state", "election"))
118+
assert.equal(resolveCrossHostElectionDirectory({ HOME: "/home/dev" }, "linux", "/fallback"), posix.join("/home/dev", ".codenomad", "client-state", "election"))
119+
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"))
121+
})

0 commit comments

Comments
 (0)