diff --git a/docs/agents/telemetry-change.md b/docs/agents/telemetry-change.md index beda0159..e916c794 100644 --- a/docs/agents/telemetry-change.md +++ b/docs/agents/telemetry-change.md @@ -116,12 +116,16 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网 `page` 仅记录有限数值页码;Webhook 等命令使用的不透明字符串游标不得进入本地日志或远端 AEM,即使字符串内容看起来是数字。 -事件同时写入两处: +事件写入两处: -1. 本地 `~/.bailian/telemetry.jsonl`:权限 `0600`,超过 5 MB 后重建 +1. 本地 `~/.bailian/telemetry.jsonl`:权限 `0600`,超过 5 MB 后重建;每行附带 `uid`(只读已有文件,不创建) 2. AEM:`pid=bailian-cli-node`,源码运行自动使用 `env=dev`,npm 安装或编译二进制使用 `env=prod` -底层 Node tracker 还会附加公共设备字段:OS 类型/版本、Node 应用名与版本、平台,以及由本机网络标识计算的 MD5 `device_id`。 +旁路状态:`~/.bailian/telemetry-uid`(遵循 `BAILIAN_CONFIG_DIR`)由 `ensureUid` 在首次远端上报时生成,不是事件落盘目标。 + +底层 Node tracker 还会附加公共设备字段:OS 类型/版本、Node 应用名与版本、平台,以及内置的 `device_id`(本机网卡 MAC 的 MD5,保持不变)。稳定匿名 install id 写入 AEM 的 `uid`:来自 `~/.bailian/telemetry-uid`,首次远端上报时生成随机 UUID v4,以 0600 权限原子发布;生成失败时不上报 `uid`。遥测关闭时不生成该文件。本地 `telemetry.jsonl` 每行附带同名 `uid`。注意:这不是账号用户 ID,只是上报到 AEM `uid` 字段的匿名标识;与 Tracker 内置 `device_id` 无关。 + +`ensureUid` 无锁(对齐 qwen-code `InstallationManager`):稳态只读;缺失或损坏时同目录 temp 写满后 `rename` 发布(内容原子替换;并发首次创建时后写覆盖先写,磁盘最终为一个 UUID)。失败则省略 `uid`,不阻塞业务退出。 从 2.8.2 起,每条命令事件通过 `commandPrefix` 显式记录调用入口,三种入口仍共用 `pid=bailian-cli-node`: @@ -180,6 +184,8 @@ AEM 映射: - [ ] 成功与失败均覆盖;遥测异常必须静默且不改变业务退出码 - [ ] runtime 本地语义错误应记录 `exitCode`;新增字段默认随 AEM `ext` 上报,无需占用新的 `c1`—`c5` - [ ] 检查 `DO_NOT_TRACK=1` 与 `telemetry: false` 两个关闭入口 +- [ ] `remoteSink` 从调用到 `remoteSendEvent` 入队之间不得出现 `await`(flush 只排空已入队事件) +- [ ] 测试中调用 `remoteSink` 的用例必须使用 `enterIsolatedHome()`,避免写入真实配置目录 - [ ] 遥测发送的 DNS/HTTP/响应体必须受可取消预算控制;flush 超时必须取消在途操作,不能只停止等待,也不能修改全局 DNS/dispatcher 或强制退出宿主 - [ ] 退出生命周期变更保留真实子进程回归:挂起响应头/响应体、DNS 预查询和关闭遥测;检查实际退出及进程仍存活时的连接释放,不以 flush Promise 返回代替验收 - [ ] 错误字段不得额外拼接 token、请求体、prompt 或本地路径 @@ -190,7 +196,7 @@ AEM 映射: rg -n "trackingHeaders|x-dashscope-source-config|User-Agent" packages --glob '*.ts' rg -n "trackCommandExecution|PARAM_ALLOWLIST|buildRemoteAemOptions|readCaller" packages/core packages/runtime --glob '*.ts' vp check -vp test packages/core/tests packages/runtime/tests/telemetry-stage.test.ts packages/commands/tests/e2e/auth.e2e.test.ts +vp test packages/core/tests/telemetry-uid.test.ts packages/core/tests/telemetry-sink.test.ts packages/core/tests/telemetry-tracker.test.ts packages/core/tests/telemetry-send.test.ts packages/runtime/tests/telemetry-stage.test.ts packages/commands/tests/e2e/auth.e2e.test.ts ``` ## 常见漏点 diff --git a/packages/core/src/telemetry/sink.ts b/packages/core/src/telemetry/sink.ts index 41c3bf81..de823700 100644 --- a/packages/core/src/telemetry/sink.ts +++ b/packages/core/src/telemetry/sink.ts @@ -3,6 +3,7 @@ import { join } from "path"; import { getConfigDir, ensureConfigDir } from "../config/paths.ts"; import { buildRemoteAemOptions, type TrackingEvent } from "./event.ts"; import { detectEnv } from "./env.ts"; +import { ensureUid, loadUid } from "./uid.ts"; import Tracker from "../../lib/remote-telemetry/tracker.cjs"; import EventPlugin from "../../lib/remote-telemetry/event-plugin.cjs"; @@ -12,11 +13,11 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB let remoteSendEvent: ((name: string, opts: Record) => void) | undefined; -// 追踪在途(in-flight)的远端发送请求,以便在进程退出前等待它们完成。 -// 底层 tracker 是 fire-and-forget 模式(发起 fetch 后不再持有 promise); -// 如果不追踪,CLI 在短命令或 SIGINT 场景下会在请求真正落网络前就退出。 -// 配合 tracker.js 中的 `keepalive: true`,await 这些 promise 可以在不阻塞 -// 主流程的前提下,尽最大努力把埋点送达。 +// Track in-flight remote sends so we can wait for them before process exit. +// The underlying tracker is fire-and-forget (fetch starts without holding the promise); +// without tracking, short commands or SIGINT can exit before the request hits the network. +// With `keepalive: true` in tracker.js, awaiting these promises best-effort delivers +// telemetry without blocking the main command path. const SEND_TIMEOUT_MS = 1000; interface PendingSend { controller: AbortController; @@ -24,62 +25,78 @@ interface PendingSend { } const inflightSends = new Set(); let remoteClient: any = undefined; +let remoteInitialized = false; -try { - // env 传给 Tracker 顶层 config 后会进 URL 查询参数, AEM 后台 "环境" 列 - // (原始字段 env) 直接读它, 值域 prod/pre/daily/dev。我们目前只用 prod/dev。 - const client = new Tracker({ pid: "bailian-cli-node", env: detectEnv() }); - // 仅对当前实例的 `send` 做猴补丁,把每次 fetch 的 promise 收集起来。 - // 不修改 prototype,避免污染其他可能存在的 Tracker 使用方。 - const originalSend = client.send.bind(client); - client.send = function (payload: string) { - const controller = new AbortController(); - const deadline = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS); - deadline.unref?.(); - const pending: PendingSend = { - controller, - promise: Promise.resolve() - .then(() => originalSend(payload, { signal: controller.signal })) - .catch(() => {}) - .finally(() => { - // Bun may resolve body.cancel() before stopping its underlying fetch. - // Finish this send's private scope before removing its deadline. - controller.abort(); - clearTimeout(deadline); - inflightSends.delete(pending); - }), +/** + * Construct the tracker on first remote report so disabled telemetry neither + * constructs the tracker nor creates the uid file. Fully synchronous; see ensureUid. + */ +function initRemote(): void { + if (remoteInitialized) return; + remoteInitialized = true; + try { + const uid = ensureUid(); + // `env` on the Tracker top-level config becomes a URL query param; AEM's + // "environment" column (raw field `env`) reads it. Allowed values: + // prod/pre/daily/dev. We currently use only prod/dev. + // anonymous install id as `uid`, which AEM exposes as a dashboard field. + const client = new Tracker({ + pid: "bailian-cli-node", + env: detectEnv(), + ...(uid ? { uid } : {}), + }); + // Monkey-patch only this instance's `send` to collect each fetch promise. + // Do not touch the prototype so other Tracker consumers stay unaffected. + const originalSend = client.send.bind(client); + client.send = function (payload: string) { + const controller = new AbortController(); + const deadline = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS); + deadline.unref?.(); + const pending: PendingSend = { + controller, + promise: Promise.resolve() + .then(() => originalSend(payload, { signal: controller.signal })) + .catch(() => {}) + .finally(() => { + // Bun may resolve body.cancel() before stopping its underlying fetch. + // Finish this send's private scope before removing its deadline. + controller.abort(); + clearTimeout(deadline); + inflightSends.delete(pending); + }), + }; + inflightSends.add(pending); + return pending.promise; }; - inflightSends.add(pending); - return pending.promise; - }; - remoteClient = client; - remoteSendEvent = client.use(EventPlugin); -} catch { - // 埋点逻辑任何异常都不能影响 CLI 主流程 + remoteClient = client; + remoteSendEvent = client.use(EventPlugin); + } catch { + // Telemetry must never affect the CLI main flow + } } /** - * 尽力等待所有在途的埋点发送完成(best-effort)。 + * Best-effort wait for all in-flight telemetry sends. * - * 1. 先调用 `_sendAll` 排空 tracker 内部的去抖队列,把还卡在 500ms 合并窗口里 - * 的事件立刻推上网络。 - * 2. 在预算内等待发送,预算耗尽时真正取消 DNS/HTTP/body 工作。 + * 1. Call `_sendAll` to drain the tracker's debounce queue so events still in + * the 500ms merge window are pushed to the network immediately. + * 2. Wait for sends within the budget; on timeout, cancel DNS/HTTP/body work. * - * 埋点永远不应阻塞 CLI:调用方应传入较短的超时(例如 1000ms),并始终与超时 - * race。错误与超时一律静默吞掉。 + * Telemetry must never block the CLI: callers should pass a short timeout + * (e.g. 1000ms) and always race against it. Errors and timeouts are swallowed. */ export async function flushTelemetry(timeoutMs = 1000): Promise { let deadline: ReturnType | undefined; try { if (remoteClient) { try { - // 排空去抖队列:把卡在 500ms 合并窗口里的事件立刻发出去 - // (每次发出都会被追加到 inflightSends 里)。 + // Drain the debounce queue so events stuck in the 500ms merge window + // are sent immediately (each send is appended to inflightSends). if (typeof remoteClient._sendAll === "function") { remoteClient._sendAll(); } } catch { - // flush 自身绝不抛出 + // flush itself must never throw } } if (inflightSends.size === 0) return; @@ -94,7 +111,7 @@ export async function flushTelemetry(timeoutMs = 1000): Promise { }), ]); } catch { - // 埋点逻辑任何异常都不能影响 CLI 主流程 + // Telemetry must never affect the CLI main flow } finally { clearTimeout(deadline); } @@ -111,20 +128,25 @@ export async function localSink(event: TrackingEvent): Promise { unlinkSync(path); } } catch { - // 文件还不存在,忽略 + // File does not exist yet; ignore } - appendFileSync(path, JSON.stringify(event) + "\n", { mode: 0o600 }); + // Read-only; remoteSink creates the file on first report. + const uid = loadUid(); + appendFileSync(path, JSON.stringify(uid ? { ...event, uid } : event) + "\n", { + mode: 0o600, + }); } catch { - // 埋点逻辑任何异常都不能影响 CLI 主流程 + // Telemetry must never affect the CLI main flow } } export async function remoteSink(event: TrackingEvent): Promise { try { + initRemote(); if (!remoteSendEvent) return; remoteSendEvent(event.command, buildRemoteAemOptions(event)); } catch { - // 埋点逻辑任何异常都不能影响 CLI 主流程 + // Telemetry must never affect the CLI main flow } } diff --git a/packages/core/src/telemetry/uid.ts b/packages/core/src/telemetry/uid.ts new file mode 100644 index 00000000..1122c057 --- /dev/null +++ b/packages/core/src/telemetry/uid.ts @@ -0,0 +1,96 @@ +import { randomBytes, randomUUID } from "crypto"; +import { + closeSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, +} from "fs"; +import { join } from "path"; +import { getConfigDir } from "../config/paths.ts"; + +/** On-disk name for the anonymous install id reported to AEM as `uid`. */ +export const UID_FILE_NAME = "telemetry-uid"; + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +type ReadResult = + | { kind: "ok"; id: string } + | { kind: "missing" } + | { kind: "invalid" } + | { kind: "unreadable" }; + +export function getUidPath(): string { + return join(getConfigDir(), UID_FILE_NAME); +} + +function readUidFile(path: string): ReadResult { + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" + ? { kind: "missing" } + : { kind: "unreadable" }; + } + const id = raw.trim().toLowerCase(); + return UUID_V4.test(id) ? { kind: "ok", id } : { kind: "invalid" }; +} + +/** Read-only: returns undefined when missing or invalid; never creates the file. */ +export function loadUid(): string | undefined { + const result = readUidFile(getUidPath()); + return result.kind === "ok" ? result.id : undefined; +} + +/** Same-directory temp + rename publish (atomic content swap; may overwrite). */ +function publishUid(path: string, id: string): void { + const tmp = `${path}.${randomBytes(6).toString("hex")}.tmp`; + try { + const fd = openSync(tmp, "wx", 0o600); + try { + // writeFileSync(fd) loops until the full buffer is written; writeSync can short-write. + writeFileSync(fd, `${id}\n`); + } finally { + closeSync(fd); + } + renameSync(tmp, path); + } finally { + try { + unlinkSync(tmp); + } catch { + /* rename removed tmp; ignore ENOENT */ + } + } +} + +/** + * Read or create the persistent anonymous install id (AEM `uid`). + * + * Must stay synchronous: remoteSink relies on no await between the call and + * event enqueue, otherwise flushTelemetry may return before the event is queued. + * + * Lock-free like qwen-code InstallationManager: steady-state is a plain read; + * missing/invalid content is published with temp+rename (last writer wins under + * concurrent first runs). Any failure returns undefined so remoteSink omits + * `uid`; built-in Tracker `device_id` is unchanged. + */ +export function ensureUid(): string | undefined { + const path = getUidPath(); + const current = readUidFile(path); + if (current.kind === "ok") return current.id; + // Do not overwrite unreadable paths (permission errors, a directory in the way). + if (current.kind === "unreadable") return undefined; + + try { + mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); + const id = randomUUID(); + publishUid(path, id); + return id; + } catch { + // Read-only FS, disk full, etc.: stay silent and try reading once more. + return loadUid(); + } +} diff --git a/packages/core/tests/telemetry-send.test.ts b/packages/core/tests/telemetry-send.test.ts index c1d202fd..9fce1ef2 100644 --- a/packages/core/tests/telemetry-send.test.ts +++ b/packages/core/tests/telemetry-send.test.ts @@ -18,6 +18,18 @@ beforeEach(() => { }); afterEach(() => vi.restoreAllMocks()); +test("constructor uid reaches send config without replacing built-in device_id", () => { + const id = "0f8fad5b-d9cb-469f-a165-70867728950e"; + const tracker = new Tracker({ pid: "fixture-pid", uid: id }) as unknown as { + getConfig(key: string): unknown; + _getSendConfig(): Record; + }; + expect(tracker.getConfig("uid")).toBe(id); + expect(tracker._getSendConfig().uid).toBe(id); + expect(tracker.getConfig("device_id")).toMatch(/^[0-9a-f]{32}$/); + expect(tracker._getSendConfig().device_id).toBe(tracker.getConfig("device_id")); +}); + test("telemetry uses the bounded transport without a separate DNS preflight", async () => { const controller = new AbortController(); const tracker = new Tracker({ pid: "fixture-pid", env: "dev" }); diff --git a/packages/core/tests/telemetry-sink.test.ts b/packages/core/tests/telemetry-sink.test.ts index 60ff96f5..77dbc8ed 100644 --- a/packages/core/tests/telemetry-sink.test.ts +++ b/packages/core/tests/telemetry-sink.test.ts @@ -1,12 +1,18 @@ -import { afterEach, expect, test, vi } from "vite-plus/test"; +import { existsSync } from "node:fs"; +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; import type { TrackingEvent } from "../src/telemetry/event.ts"; +import { enterIsolatedHome, type IsolatedHomeHandle } from "./helpers/isolation.ts"; const fixture = vi.hoisted(() => ({ signals: [] as Array, complete: false, + configs: [] as Array>, })); vi.mock("../lib/remote-telemetry/tracker.cjs", () => ({ default: class { + constructor(config: Record) { + fixture.configs.push(config); + } send(_payload: string, options: { signal?: AbortSignal } = {}) { fixture.signals.push(options.signal); if (fixture.complete) return Promise.resolve(); @@ -23,12 +29,20 @@ vi.mock("../lib/remote-telemetry/tracker.cjs", () => ({ }, })); +let iso: IsolatedHomeHandle; + +beforeEach(() => { + iso = enterIsolatedHome(); +}); + afterEach(() => { + iso.restore(); vi.clearAllTimers(); vi.useRealTimers(); vi.resetModules(); fixture.signals = []; fixture.complete = false; + fixture.configs = []; }); test("flush cancels in-flight work when its shorter shutdown budget expires", async () => { @@ -66,3 +80,23 @@ test("successful sending and repeated flushes leave no deadlines behind", async expect(fixture.signals[0]?.aborted).toBe(true); expect(vi.getTimerCount()).toBe(0); }); + +test("first remoteSink constructs the tracker once with persisted uid, not device_id", async () => { + const { remoteSink } = await import("../src/telemetry/sink.ts"); + expect(fixture.configs).toHaveLength(0); + await remoteSink({ command: "fixture" } as TrackingEvent); + await remoteSink({ command: "fixture" } as TrackingEvent); + expect(fixture.configs).toHaveLength(1); + const { loadUid } = await import("../src/telemetry/uid.ts"); + expect(fixture.configs[0]).toMatchObject({ + pid: "bailian-cli-node", + uid: loadUid(), + }); + expect(fixture.configs[0]).not.toHaveProperty("device_id"); +}); + +test("importing sink without reporting does not create the uid file", async () => { + await import("../src/telemetry/sink.ts"); + const { getUidPath } = await import("../src/telemetry/uid.ts"); + expect(existsSync(getUidPath())).toBe(false); +}); diff --git a/packages/core/tests/telemetry-uid.test.ts b/packages/core/tests/telemetry-uid.test.ts new file mode 100644 index 00000000..4898a082 --- /dev/null +++ b/packages/core/tests/telemetry-uid.test.ts @@ -0,0 +1,174 @@ +import { spawn } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { afterEach, beforeEach, expect, test } from "vite-plus/test"; +import type { Identity, Settings } from "../src/config/schema.ts"; +import { UID_FILE_NAME, ensureUid, getUidPath, loadUid } from "../src/telemetry/uid.ts"; +import { trackCommandExecution } from "../src/telemetry/tracker.ts"; +import { enterIsolatedHome, type IsolatedHomeHandle } from "./helpers/isolation.ts"; + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +let iso: IsolatedHomeHandle; + +beforeEach(() => { + iso = enterIsolatedHome(); +}); +afterEach(() => iso.restore()); + +function uidModuleUrl(): string { + return pathToFileURL(fileURLToPath(new URL("../src/telemetry/uid.ts", import.meta.url))).href; +} + +async function runEnsureUidChildren(count: number): Promise { + const script = ` + import { ensureUid } from ${JSON.stringify(uidModuleUrl())}; + process.stdout.write(String(ensureUid() ?? "")); + `; + const tsxLoader = import.meta.resolve("tsx"); + const run = () => + new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ["--import", tsxLoader, "--input-type=module", "-e", script], + { + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let out = ""; + let err = ""; + child.stdout.on("data", (chunk: Buffer) => { + out += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + err += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve(out); + else reject(new Error(`child exited ${code}: ${err || out}`)); + }); + }); + return Promise.all(Array.from({ length: count }, () => run())); +} + +test("first call writes a UUID v4 and later calls return the same value", () => { + expect(loadUid()).toBeUndefined(); + const id = ensureUid(); + expect(id).toMatch(UUID_V4); + expect(readFileSync(getUidPath(), "utf8")).toBe(`${id}\n`); + expect(ensureUid()).toBe(id); + expect(loadUid()).toBe(id); +}); + +test("uid path follows BAILIAN_CONFIG_DIR", () => { + ensureUid(); + expect(getUidPath()).toBe(join(iso.configDir, UID_FILE_NAME)); +}); + +test.skipIf(process.platform === "win32")("file mode is 0600", () => { + ensureUid(); + expect(statSync(getUidPath()).mode & 0o777).toBe(0o600); +}); + +test("creates the config directory when missing", () => { + process.env.BAILIAN_CONFIG_DIR = join(iso.home, "nested", "cfg"); + expect(ensureUid()).toMatch(UUID_V4); +}); + +test("rebuilds when content is invalid", () => { + writeFileSync(getUidPath(), "not-a-uuid\n"); + const id = ensureUid(); + expect(id).toMatch(UUID_V4); + expect(readFileSync(getUidPath(), "utf8").trim()).toBe(id); +}); + +test("accepts upper-case or padded UUID without rewriting the file", () => { + const upper = " 0F8FAD5B-D9CB-469F-A165-70867728950E \n"; + writeFileSync(getUidPath(), upper); + expect(ensureUid()).toBe("0f8fad5b-d9cb-469f-a165-70867728950e"); + expect(readFileSync(getUidPath(), "utf8")).toBe(upper); +}); + +test("keeps an ID already published by another process", () => { + const existing = "0f8fad5b-d9cb-469f-a165-70867728950e"; + writeFileSync(getUidPath(), `${existing}\n`); + expect(ensureUid()).toBe(existing); +}); + +test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "returns undefined without throwing when the directory is not writable", + () => { + chmodSync(iso.configDir, 0o500); + try { + expect(ensureUid()).toBeUndefined(); + } finally { + chmodSync(iso.configDir, 0o700); + } + }, +); + +test.skipIf(process.platform === "win32")( + "does not overwrite a path occupied by a directory; returns undefined", + () => { + mkdirSync(getUidPath()); + expect(ensureUid()).toBeUndefined(); + expect(statSync(getUidPath()).isDirectory()).toBe(true); + }, +); + +test("leaves no temporary files behind", () => { + ensureUid(); + const names = readdirSync(iso.configDir); + expect(names.filter((name) => name.endsWith(".tmp"))).toEqual([]); + expect(names).toContain(UID_FILE_NAME); +}); + +test("telemetry disabled does not create the uid file", async () => { + const identity: Identity = { + binName: "bl", + version: "0.0.0-test", + clientName: "bailian-cli-test", + npmPackage: "bailian-cli", + }; + await trackCommandExecution( + { + identity, + settings: { telemetry: false } as Settings, + caller: { callerType: "unknown" }, + }, + ["config", "show"], + {}, + async () => {}, + ); + expect(existsSync(getUidPath())).toBe(false); +}); + +test("eight concurrent first runs leave one valid on-disk ID", async () => { + const ids = await runEnsureUidChildren(8); + const finalId = readFileSync(getUidPath(), "utf8").trim(); + expect(finalId).toMatch(UUID_V4); + // Lock-free temp+rename: each process returns its own id; disk keeps the last rename. + expect(ids.every((id) => UUID_V4.test(id))).toBe(true); + expect(ids).toContain(finalId); + expect(readdirSync(iso.configDir).filter((name) => name.endsWith(".tmp"))).toEqual([]); +}); + +test("eight concurrent repairs of an invalid file leave one valid on-disk ID", async () => { + writeFileSync(getUidPath(), "not-a-uuid\n"); + const ids = await runEnsureUidChildren(8); + const finalId = readFileSync(getUidPath(), "utf8").trim(); + expect(finalId).toMatch(UUID_V4); + expect(ids.every((id) => UUID_V4.test(id))).toBe(true); + expect(ids).toContain(finalId); + expect(readdirSync(iso.configDir).filter((name) => name.endsWith(".tmp"))).toEqual([]); +});