|
| 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