From 259905dbd5b5a7129cc616aa198439686ff6f07a Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Wed, 11 Nov 2026 11:03:12 +0800 Subject: [PATCH 1/6] feat(telemetry): persist anonymous device_id as UUID in config dir - Replace MAC-based device_id with a stable UUID stored in ~/.bailian/telemetry-device-id, created lazily on first remote report. --- docs/agents/telemetry-change.md | 9 +- packages/core/src/telemetry/device-id.ts | 105 ++++++++++++ packages/core/src/telemetry/sink.ts | 121 +++++++------ .../core/tests/telemetry-device-id.test.ts | 159 ++++++++++++++++++ packages/core/tests/telemetry-send.test.ts | 10 ++ packages/core/tests/telemetry-sink.test.ts | 35 +++- 6 files changed, 386 insertions(+), 53 deletions(-) create mode 100644 packages/core/src/telemetry/device-id.ts create mode 100644 packages/core/tests/telemetry-device-id.test.ts diff --git a/docs/agents/telemetry-change.md b/docs/agents/telemetry-change.md index beda0159..7fe90d2a 100644 --- a/docs/agents/telemetry-change.md +++ b/docs/agents/telemetry-change.md @@ -118,10 +118,11 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网 事件同时写入两处: -1. 本地 `~/.bailian/telemetry.jsonl`:权限 `0600`,超过 5 MB 后重建 +1. 本地 `~/.bailian/telemetry.jsonl`:权限 `0600`,超过 5 MB 后重建;每行附带 `deviceId`(只读已有文件,不创建) 2. AEM:`pid=bailian-cli-node`,源码运行自动使用 `env=dev`,npm 安装或编译二进制使用 `env=prod` +3. 设备 ID 文件 `~/.bailian/telemetry-device-id`(遵循 `BAILIAN_CONFIG_DIR`):仅在首次远端上报时由 `ensureDeviceId` 生成 -底层 Node tracker 还会附加公共设备字段:OS 类型/版本、Node 应用名与版本、平台,以及由本机网络标识计算的 MD5 `device_id`。 +底层 Node tracker 还会附加公共设备字段:OS 类型/版本、Node 应用名与版本、平台,以及 `device_id`。`device_id` 来自 `~/.bailian/telemetry-device-id`(遵循 `BAILIAN_CONFIG_DIR`):首次远端上报时生成随机 UUID v4,以 0600 权限原子发布;生成失败时退回 tracker 内置的 MAC MD5。遥测关闭时不生成该文件。本地 `telemetry.jsonl` 每行附带 `deviceId`。 从 2.8.2 起,每条命令事件通过 `commandPrefix` 显式记录调用入口,三种入口仍共用 `pid=bailian-cli-node`: @@ -180,6 +181,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 +193,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-device-id.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/device-id.ts b/packages/core/src/telemetry/device-id.ts new file mode 100644 index 00000000..7ea401a3 --- /dev/null +++ b/packages/core/src/telemetry/device-id.ts @@ -0,0 +1,105 @@ +import { randomBytes, randomUUID } from "crypto"; +import { + closeSync, + linkSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + unlinkSync, + writeSync, +} from "fs"; +import { join } from "path"; +import { getConfigDir } from "../config/paths.ts"; + +export const DEVICE_ID_FILE_NAME = "telemetry-device-id"; + +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}$/; + +/** Errnos where hard links are unavailable; fall back to rename. */ +const NO_HARDLINK = new Set(["EPERM", "ENOTSUP", "ENOSYS", "EXDEV", "EOPNOTSUPP"]); + +type ReadResult = + | { kind: "ok"; id: string } + | { kind: "missing" } + | { kind: "invalid" } + | { kind: "unreadable" }; + +export function getDeviceIdPath(): string { + return join(getConfigDir(), DEVICE_ID_FILE_NAME); +} + +function readDeviceIdFile(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 loadDeviceId(): string | undefined { + const result = readDeviceIdFile(getDeviceIdPath()); + return result.kind === "ok" ? result.id : undefined; +} + +/** + * Read or create the persistent telemetry device ID. + * + * Must stay synchronous: remoteSink relies on no await between the call and + * event enqueue, otherwise flushTelemetry may return before the event is queued. + * Any failure returns undefined so the tracker falls back to its built-in MAC hash. + */ +export function ensureDeviceId(): string | undefined { + const path = getDeviceIdPath(); + const current = readDeviceIdFile(path); + if (current.kind === "ok") return current.id; + // Do not overwrite unreadable paths (e.g. permission errors); that could damage the environment. + if (current.kind === "unreadable") return undefined; + + const tmp = `${path}.${randomBytes(6).toString("hex")}.tmp`; + try { + mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); + const fd = openSync(tmp, "wx", 0o600); + try { + writeSync(fd, `${randomUUID()}\n`); + } finally { + closeSync(fd); + } + if (current.kind === "missing") { + publishIfAbsent(tmp, path); + } else { + renameSync(tmp, path); // invalid: atomic replace + } + } catch { + // Read-only FS, disk full, etc.: stay silent and try reading once more below. + } finally { + try { + unlinkSync(tmp); // tmp remains after a successful link; rename removes it (ENOENT ok) + } catch { + /* ignore */ + } + } + + return loadDeviceId(); +} + +/** Atomic create-if-absent: first writer wins on concurrent first runs. */ +function publishIfAbsent(tmp: string, path: string): void { + try { + linkSync(tmp, path); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? ""; + if (code === "EEXIST") return; // another process published first; keep theirs + if (NO_HARDLINK.has(code)) { + renameSync(tmp, path); // no hard links: last writer wins + return; + } + throw error; + } +} diff --git a/packages/core/src/telemetry/sink.ts b/packages/core/src/telemetry/sink.ts index 41c3bf81..06dd52ad 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 { ensureDeviceId, loadDeviceId } from "./device-id.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,79 @@ 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 + * scans NICs nor creates a device ID file. Fully synchronous; see ensureDeviceId. + */ +function initRemote(): void { + if (remoteInitialized) return; + remoteInitialized = true; + try { + const deviceId = ensureDeviceId(); + // `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. + // `device_id` overrides the tracker's built-in md5(MAC); when missing, keep the built-in value. + const client = new Tracker({ + pid: "bailian-cli-node", + env: detectEnv(), + // ...(deviceId ? { device_id: deviceId } : {}), + ...(deviceId ? { uuid: deviceId } : {}), + }); + // 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 +112,7 @@ export async function flushTelemetry(timeoutMs = 1000): Promise { }), ]); } catch { - // 埋点逻辑任何异常都不能影响 CLI 主流程 + // Telemetry must never affect the CLI main flow } finally { clearTimeout(deadline); } @@ -111,20 +129,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 deviceId = loadDeviceId(); + appendFileSync(path, JSON.stringify(deviceId ? { ...event, deviceId } : 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/tests/telemetry-device-id.test.ts b/packages/core/tests/telemetry-device-id.test.ts new file mode 100644 index 00000000..790b89f9 --- /dev/null +++ b/packages/core/tests/telemetry-device-id.test.ts @@ -0,0 +1,159 @@ +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 { + DEVICE_ID_FILE_NAME, + ensureDeviceId, + getDeviceIdPath, + loadDeviceId, +} from "../src/telemetry/device-id.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()); + +test("first call writes a UUID v4 and later calls return the same value", () => { + expect(loadDeviceId()).toBeUndefined(); + const id = ensureDeviceId(); + expect(id).toMatch(UUID_V4); + expect(readFileSync(getDeviceIdPath(), "utf8")).toBe(`${id}\n`); + expect(ensureDeviceId()).toBe(id); + expect(loadDeviceId()).toBe(id); +}); + +test("device id path follows BAILIAN_CONFIG_DIR", () => { + ensureDeviceId(); + expect(getDeviceIdPath()).toBe(join(iso.configDir, DEVICE_ID_FILE_NAME)); +}); + +test.skipIf(process.platform === "win32")("file mode is 0600", () => { + ensureDeviceId(); + expect(statSync(getDeviceIdPath()).mode & 0o777).toBe(0o600); +}); + +test("creates the config directory when missing", () => { + process.env.BAILIAN_CONFIG_DIR = join(iso.home, "nested", "cfg"); + expect(ensureDeviceId()).toMatch(UUID_V4); +}); + +test("rebuilds when content is invalid", () => { + writeFileSync(getDeviceIdPath(), "not-a-uuid\n"); + const id = ensureDeviceId(); + expect(id).toMatch(UUID_V4); + expect(readFileSync(getDeviceIdPath(), "utf8").trim()).toBe(id); +}); + +test("accepts upper-case or padded UUID without rewriting the file", () => { + const upper = " 0F8FAD5B-D9CB-469F-A165-70867728950E \n"; + writeFileSync(getDeviceIdPath(), upper); + expect(ensureDeviceId()).toBe("0f8fad5b-d9cb-469f-a165-70867728950e"); + expect(readFileSync(getDeviceIdPath(), "utf8")).toBe(upper); +}); + +test("keeps an ID already published by another process", () => { + const existing = "0f8fad5b-d9cb-469f-a165-70867728950e"; + writeFileSync(getDeviceIdPath(), `${existing}\n`); + expect(ensureDeviceId()).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(ensureDeviceId()).toBeUndefined(); + } finally { + chmodSync(iso.configDir, 0o700); + } + }, +); + +test.skipIf(process.platform === "win32")( + "does not overwrite a path occupied by a directory; returns undefined", + () => { + mkdirSync(getDeviceIdPath()); + expect(ensureDeviceId()).toBeUndefined(); + expect(statSync(getDeviceIdPath()).isDirectory()).toBe(true); + }, +); + +test("leaves no temporary files behind", () => { + ensureDeviceId(); + expect(readdirSync(iso.configDir).filter((name) => name.endsWith(".tmp"))).toEqual([]); +}); + +test("telemetry disabled does not create the device id 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(getDeviceIdPath())).toBe(false); +}); + +test("eight concurrent first runs converge on one ID", async () => { + const deviceIdUrl = pathToFileURL( + fileURLToPath(new URL("../src/telemetry/device-id.ts", import.meta.url)), + ).href; + const script = ` + import { ensureDeviceId } from ${JSON.stringify(deviceIdUrl)}; + process.stdout.write(String(ensureDeviceId() ?? "")); + `; + 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}`)); + }); + }); + const ids = await Promise.all(Array.from({ length: 8 }, () => run())); + const finalId = readFileSync(getDeviceIdPath(), "utf8").trim(); + expect(finalId).toMatch(UUID_V4); + expect(new Set(ids)).toEqual(new Set([finalId])); +}); diff --git a/packages/core/tests/telemetry-send.test.ts b/packages/core/tests/telemetry-send.test.ts index c1d202fd..a677e297 100644 --- a/packages/core/tests/telemetry-send.test.ts +++ b/packages/core/tests/telemetry-send.test.ts @@ -18,6 +18,16 @@ beforeEach(() => { }); afterEach(() => vi.restoreAllMocks()); +test("constructor device_id overrides the built-in MAC hash and reaches send config", () => { + const id = "0f8fad5b-d9cb-469f-a165-70867728950e"; + const tracker = new Tracker({ pid: "fixture-pid", device_id: id }) as unknown as { + getConfig(key: string): unknown; + _getSendConfig(): Record; + }; + expect(tracker.getConfig("device_id")).toBe(id); + expect(tracker._getSendConfig().device_id).toBe(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..dcfe3901 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,22 @@ 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 a persisted 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 { loadDeviceId } = await import("../src/telemetry/device-id.ts"); + expect(fixture.configs[0]).toMatchObject({ + pid: "bailian-cli-node", + device_id: loadDeviceId(), + }); +}); + +test("importing sink without reporting does not create the device id file", async () => { + await import("../src/telemetry/sink.ts"); + const { getDeviceIdPath } = await import("../src/telemetry/device-id.ts"); + expect(existsSync(getDeviceIdPath())).toBe(false); +}); From e455d1c8c1bd989a2ce318704499307cf5848118 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Wed, 11 Nov 2026 11:51:11 +0800 Subject: [PATCH 2/6] refactor(telemetry): replace device_id with uid for telemetry tracking - Remove device_id functionality and replace it with uid, which is now stored in ~/.bailian/telemetry-uid. - Update telemetry sink and related tests to utilize uid instead of device_id. - Ensure uid is generated and managed correctly during telemetry reporting. - Update documentation to reflect changes in telemetry data handling. --- docs/agents/telemetry-change.md | 8 +-- packages/core/src/telemetry/sink.ts | 15 ++-- .../src/telemetry/{device-id.ts => uid.ts} | 25 +++---- packages/core/tests/telemetry-send.test.ts | 10 +-- packages/core/tests/telemetry-sink.test.ts | 13 ++-- ...evice-id.test.ts => telemetry-uid.test.ts} | 69 +++++++++---------- 6 files changed, 69 insertions(+), 71 deletions(-) rename packages/core/src/telemetry/{device-id.ts => uid.ts} (80%) rename packages/core/tests/{telemetry-device-id.test.ts => telemetry-uid.test.ts} (66%) diff --git a/docs/agents/telemetry-change.md b/docs/agents/telemetry-change.md index 7fe90d2a..aed59e65 100644 --- a/docs/agents/telemetry-change.md +++ b/docs/agents/telemetry-change.md @@ -118,11 +118,11 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网 事件同时写入两处: -1. 本地 `~/.bailian/telemetry.jsonl`:权限 `0600`,超过 5 MB 后重建;每行附带 `deviceId`(只读已有文件,不创建) +1. 本地 `~/.bailian/telemetry.jsonl`:权限 `0600`,超过 5 MB 后重建;每行附带 `uid`(只读已有文件,不创建) 2. AEM:`pid=bailian-cli-node`,源码运行自动使用 `env=dev`,npm 安装或编译二进制使用 `env=prod` -3. 设备 ID 文件 `~/.bailian/telemetry-device-id`(遵循 `BAILIAN_CONFIG_DIR`):仅在首次远端上报时由 `ensureDeviceId` 生成 +3. 匿名 install id 文件 `~/.bailian/telemetry-uid`(遵循 `BAILIAN_CONFIG_DIR`):仅在首次远端上报时由 `ensureUid` 生成 -底层 Node tracker 还会附加公共设备字段:OS 类型/版本、Node 应用名与版本、平台,以及 `device_id`。`device_id` 来自 `~/.bailian/telemetry-device-id`(遵循 `BAILIAN_CONFIG_DIR`):首次远端上报时生成随机 UUID v4,以 0600 权限原子发布;生成失败时退回 tracker 内置的 MAC MD5。遥测关闭时不生成该文件。本地 `telemetry.jsonl` 每行附带 `deviceId`。 +底层 Node tracker 还会附加公共设备字段:OS 类型/版本、Node 应用名与版本、平台,以及内置的 `device_id`(本机网卡 MAC 的 MD5,保持不变)。稳定匿名 install id 写入 AEM 的 `uid`:来自 `~/.bailian/telemetry-uid`(遵循 `BAILIAN_CONFIG_DIR`),首次远端上报时生成随机 UUID v4,以 0600 权限原子发布;生成失败时不上报 `uid`。遥测关闭时不生成该文件。本地 `telemetry.jsonl` 每行附带同名 `uid`。注意:这不是账号用户 ID,只是上报到 AEM `uid` 字段的匿名标识;与 Tracker 内置 `device_id` 无关。 从 2.8.2 起,每条命令事件通过 `commandPrefix` 显式记录调用入口,三种入口仍共用 `pid=bailian-cli-node`: @@ -193,7 +193,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/telemetry-device-id.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 +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 06dd52ad..de823700 100644 --- a/packages/core/src/telemetry/sink.ts +++ b/packages/core/src/telemetry/sink.ts @@ -3,7 +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 { ensureDeviceId, loadDeviceId } from "./device-id.ts"; +import { ensureUid, loadUid } from "./uid.ts"; import Tracker from "../../lib/remote-telemetry/tracker.cjs"; import EventPlugin from "../../lib/remote-telemetry/event-plugin.cjs"; @@ -29,22 +29,21 @@ let remoteInitialized = false; /** * Construct the tracker on first remote report so disabled telemetry neither - * scans NICs nor creates a device ID file. Fully synchronous; see ensureDeviceId. + * constructs the tracker nor creates the uid file. Fully synchronous; see ensureUid. */ function initRemote(): void { if (remoteInitialized) return; remoteInitialized = true; try { - const deviceId = ensureDeviceId(); + 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. - // `device_id` overrides the tracker's built-in md5(MAC); when missing, keep the built-in value. + // anonymous install id as `uid`, which AEM exposes as a dashboard field. const client = new Tracker({ pid: "bailian-cli-node", env: detectEnv(), - // ...(deviceId ? { device_id: deviceId } : {}), - ...(deviceId ? { uuid: deviceId } : {}), + ...(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. @@ -133,8 +132,8 @@ export async function localSink(event: TrackingEvent): Promise { } // Read-only; remoteSink creates the file on first report. - const deviceId = loadDeviceId(); - appendFileSync(path, JSON.stringify(deviceId ? { ...event, deviceId } : event) + "\n", { + const uid = loadUid(); + appendFileSync(path, JSON.stringify(uid ? { ...event, uid } : event) + "\n", { mode: 0o600, }); } catch { diff --git a/packages/core/src/telemetry/device-id.ts b/packages/core/src/telemetry/uid.ts similarity index 80% rename from packages/core/src/telemetry/device-id.ts rename to packages/core/src/telemetry/uid.ts index 7ea401a3..17f56b0d 100644 --- a/packages/core/src/telemetry/device-id.ts +++ b/packages/core/src/telemetry/uid.ts @@ -12,7 +12,8 @@ import { import { join } from "path"; import { getConfigDir } from "../config/paths.ts"; -export const DEVICE_ID_FILE_NAME = "telemetry-device-id"; +/** 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}$/; @@ -25,11 +26,11 @@ type ReadResult = | { kind: "invalid" } | { kind: "unreadable" }; -export function getDeviceIdPath(): string { - return join(getConfigDir(), DEVICE_ID_FILE_NAME); +export function getUidPath(): string { + return join(getConfigDir(), UID_FILE_NAME); } -function readDeviceIdFile(path: string): ReadResult { +function readUidFile(path: string): ReadResult { let raw: string; try { raw = readFileSync(path, "utf8"); @@ -43,21 +44,21 @@ function readDeviceIdFile(path: string): ReadResult { } /** Read-only: returns undefined when missing or invalid; never creates the file. */ -export function loadDeviceId(): string | undefined { - const result = readDeviceIdFile(getDeviceIdPath()); +export function loadUid(): string | undefined { + const result = readUidFile(getUidPath()); return result.kind === "ok" ? result.id : undefined; } /** - * Read or create the persistent telemetry device ID. + * 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. - * Any failure returns undefined so the tracker falls back to its built-in MAC hash. + * Any failure returns undefined so remoteSink omits `uid`; built-in `device_id` is unchanged. */ -export function ensureDeviceId(): string | undefined { - const path = getDeviceIdPath(); - const current = readDeviceIdFile(path); +export function ensureUid(): string | undefined { + const path = getUidPath(); + const current = readUidFile(path); if (current.kind === "ok") return current.id; // Do not overwrite unreadable paths (e.g. permission errors); that could damage the environment. if (current.kind === "unreadable") return undefined; @@ -86,7 +87,7 @@ export function ensureDeviceId(): string | undefined { } } - return loadDeviceId(); + return loadUid(); } /** Atomic create-if-absent: first writer wins on concurrent first runs. */ diff --git a/packages/core/tests/telemetry-send.test.ts b/packages/core/tests/telemetry-send.test.ts index a677e297..9fce1ef2 100644 --- a/packages/core/tests/telemetry-send.test.ts +++ b/packages/core/tests/telemetry-send.test.ts @@ -18,14 +18,16 @@ beforeEach(() => { }); afterEach(() => vi.restoreAllMocks()); -test("constructor device_id overrides the built-in MAC hash and reaches send config", () => { +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", device_id: id }) as unknown as { + const tracker = new Tracker({ pid: "fixture-pid", uid: id }) as unknown as { getConfig(key: string): unknown; _getSendConfig(): Record; }; - expect(tracker.getConfig("device_id")).toBe(id); - expect(tracker._getSendConfig().device_id).toBe(id); + 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 () => { diff --git a/packages/core/tests/telemetry-sink.test.ts b/packages/core/tests/telemetry-sink.test.ts index dcfe3901..77dbc8ed 100644 --- a/packages/core/tests/telemetry-sink.test.ts +++ b/packages/core/tests/telemetry-sink.test.ts @@ -81,21 +81,22 @@ test("successful sending and repeated flushes leave no deadlines behind", async expect(vi.getTimerCount()).toBe(0); }); -test("first remoteSink constructs the tracker once with a persisted device_id", async () => { +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 { loadDeviceId } = await import("../src/telemetry/device-id.ts"); + const { loadUid } = await import("../src/telemetry/uid.ts"); expect(fixture.configs[0]).toMatchObject({ pid: "bailian-cli-node", - device_id: loadDeviceId(), + uid: loadUid(), }); + expect(fixture.configs[0]).not.toHaveProperty("device_id"); }); -test("importing sink without reporting does not create the device id file", async () => { +test("importing sink without reporting does not create the uid file", async () => { await import("../src/telemetry/sink.ts"); - const { getDeviceIdPath } = await import("../src/telemetry/device-id.ts"); - expect(existsSync(getDeviceIdPath())).toBe(false); + const { getUidPath } = await import("../src/telemetry/uid.ts"); + expect(existsSync(getUidPath())).toBe(false); }); diff --git a/packages/core/tests/telemetry-device-id.test.ts b/packages/core/tests/telemetry-uid.test.ts similarity index 66% rename from packages/core/tests/telemetry-device-id.test.ts rename to packages/core/tests/telemetry-uid.test.ts index 790b89f9..eb5e6166 100644 --- a/packages/core/tests/telemetry-device-id.test.ts +++ b/packages/core/tests/telemetry-uid.test.ts @@ -12,12 +12,7 @@ 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 { - DEVICE_ID_FILE_NAME, - ensureDeviceId, - getDeviceIdPath, - loadDeviceId, -} from "../src/telemetry/device-id.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"; @@ -30,47 +25,47 @@ beforeEach(() => { afterEach(() => iso.restore()); test("first call writes a UUID v4 and later calls return the same value", () => { - expect(loadDeviceId()).toBeUndefined(); - const id = ensureDeviceId(); + expect(loadUid()).toBeUndefined(); + const id = ensureUid(); expect(id).toMatch(UUID_V4); - expect(readFileSync(getDeviceIdPath(), "utf8")).toBe(`${id}\n`); - expect(ensureDeviceId()).toBe(id); - expect(loadDeviceId()).toBe(id); + expect(readFileSync(getUidPath(), "utf8")).toBe(`${id}\n`); + expect(ensureUid()).toBe(id); + expect(loadUid()).toBe(id); }); -test("device id path follows BAILIAN_CONFIG_DIR", () => { - ensureDeviceId(); - expect(getDeviceIdPath()).toBe(join(iso.configDir, DEVICE_ID_FILE_NAME)); +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", () => { - ensureDeviceId(); - expect(statSync(getDeviceIdPath()).mode & 0o777).toBe(0o600); + 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(ensureDeviceId()).toMatch(UUID_V4); + expect(ensureUid()).toMatch(UUID_V4); }); test("rebuilds when content is invalid", () => { - writeFileSync(getDeviceIdPath(), "not-a-uuid\n"); - const id = ensureDeviceId(); + writeFileSync(getUidPath(), "not-a-uuid\n"); + const id = ensureUid(); expect(id).toMatch(UUID_V4); - expect(readFileSync(getDeviceIdPath(), "utf8").trim()).toBe(id); + 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(getDeviceIdPath(), upper); - expect(ensureDeviceId()).toBe("0f8fad5b-d9cb-469f-a165-70867728950e"); - expect(readFileSync(getDeviceIdPath(), "utf8")).toBe(upper); + 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(getDeviceIdPath(), `${existing}\n`); - expect(ensureDeviceId()).toBe(existing); + writeFileSync(getUidPath(), `${existing}\n`); + expect(ensureUid()).toBe(existing); }); test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( @@ -78,7 +73,7 @@ test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( () => { chmodSync(iso.configDir, 0o500); try { - expect(ensureDeviceId()).toBeUndefined(); + expect(ensureUid()).toBeUndefined(); } finally { chmodSync(iso.configDir, 0o700); } @@ -88,18 +83,18 @@ test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( test.skipIf(process.platform === "win32")( "does not overwrite a path occupied by a directory; returns undefined", () => { - mkdirSync(getDeviceIdPath()); - expect(ensureDeviceId()).toBeUndefined(); - expect(statSync(getDeviceIdPath()).isDirectory()).toBe(true); + mkdirSync(getUidPath()); + expect(ensureUid()).toBeUndefined(); + expect(statSync(getUidPath()).isDirectory()).toBe(true); }, ); test("leaves no temporary files behind", () => { - ensureDeviceId(); + ensureUid(); expect(readdirSync(iso.configDir).filter((name) => name.endsWith(".tmp"))).toEqual([]); }); -test("telemetry disabled does not create the device id file", async () => { +test("telemetry disabled does not create the uid file", async () => { const identity: Identity = { binName: "bl", version: "0.0.0-test", @@ -116,16 +111,16 @@ test("telemetry disabled does not create the device id file", async () => { {}, async () => {}, ); - expect(existsSync(getDeviceIdPath())).toBe(false); + expect(existsSync(getUidPath())).toBe(false); }); test("eight concurrent first runs converge on one ID", async () => { - const deviceIdUrl = pathToFileURL( - fileURLToPath(new URL("../src/telemetry/device-id.ts", import.meta.url)), + const uidModuleUrl = pathToFileURL( + fileURLToPath(new URL("../src/telemetry/uid.ts", import.meta.url)), ).href; const script = ` - import { ensureDeviceId } from ${JSON.stringify(deviceIdUrl)}; - process.stdout.write(String(ensureDeviceId() ?? "")); + import { ensureUid } from ${JSON.stringify(uidModuleUrl)}; + process.stdout.write(String(ensureUid() ?? "")); `; const tsxLoader = import.meta.resolve("tsx"); const run = () => @@ -153,7 +148,7 @@ test("eight concurrent first runs converge on one ID", async () => { }); }); const ids = await Promise.all(Array.from({ length: 8 }, () => run())); - const finalId = readFileSync(getDeviceIdPath(), "utf8").trim(); + const finalId = readFileSync(getUidPath(), "utf8").trim(); expect(finalId).toMatch(UUID_V4); expect(new Set(ids)).toEqual(new Set([finalId])); }); From 07687099cd15599025f7e49789d0bd8955694ac7 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Wed, 11 Nov 2026 15:14:58 +0800 Subject: [PATCH 3/6] fix(telemetry): serialize uid publish with lock and re-read --- docs/agents/telemetry-change.md | 9 +- packages/core/src/telemetry/uid.ts | 148 ++++++++++++++++------ packages/core/tests/telemetry-uid.test.ts | 100 +++++++++------ 3 files changed, 181 insertions(+), 76 deletions(-) diff --git a/docs/agents/telemetry-change.md b/docs/agents/telemetry-change.md index aed59e65..be95abe1 100644 --- a/docs/agents/telemetry-change.md +++ b/docs/agents/telemetry-change.md @@ -116,13 +116,16 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网 `page` 仅记录有限数值页码;Webhook 等命令使用的不透明字符串游标不得进入本地日志或远端 AEM,即使字符串内容看起来是数字。 -事件同时写入两处: +事件写入两处: 1. 本地 `~/.bailian/telemetry.jsonl`:权限 `0600`,超过 5 MB 后重建;每行附带 `uid`(只读已有文件,不创建) 2. AEM:`pid=bailian-cli-node`,源码运行自动使用 `env=dev`,npm 安装或编译二进制使用 `env=prod` -3. 匿名 install id 文件 `~/.bailian/telemetry-uid`(遵循 `BAILIAN_CONFIG_DIR`):仅在首次远端上报时由 `ensureUid` 生成 -底层 Node tracker 还会附加公共设备字段:OS 类型/版本、Node 应用名与版本、平台,以及内置的 `device_id`(本机网卡 MAC 的 MD5,保持不变)。稳定匿名 install id 写入 AEM 的 `uid`:来自 `~/.bailian/telemetry-uid`(遵循 `BAILIAN_CONFIG_DIR`),首次远端上报时生成随机 UUID v4,以 0600 权限原子发布;生成失败时不上报 `uid`。遥测关闭时不生成该文件。本地 `telemetry.jsonl` 每行附带同名 `uid`。注意:这不是账号用户 ID,只是上报到 AEM `uid` 字段的匿名标识;与 Tracker 内置 `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` 并发对齐 aliyun-cli `EnsureInstanceID`:稳态只读不加锁;缺失或损坏时用 `telemetry-uid.lock`(mkdir 互斥)保护,锁内二次读后再 temp+rename 发布;因 `initRemote` 只初始化一次,抢锁最多短等约 1s,超时则读回胜者或省略 `uid`。Node 未暴露 flock / LockFileEx,故用 mkdir 锁并带陈旧锁回收(假定持锁方已崩溃;极端卡住可能导致短暂双发布,磁盘上的文件仍会收敛),而不是依赖硬链接的 create-if-absent。 从 2.8.2 起,每条命令事件通过 `commandPrefix` 显式记录调用入口,三种入口仍共用 `pid=bailian-cli-node`: diff --git a/packages/core/src/telemetry/uid.ts b/packages/core/src/telemetry/uid.ts index 17f56b0d..942c059e 100644 --- a/packages/core/src/telemetry/uid.ts +++ b/packages/core/src/telemetry/uid.ts @@ -1,11 +1,12 @@ import { randomBytes, randomUUID } from "crypto"; import { closeSync, - linkSync, mkdirSync, openSync, readFileSync, renameSync, + rmSync, + statSync, unlinkSync, writeSync, } from "fs"; @@ -15,10 +16,24 @@ 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"; +/** Exclusive mkdir lock; mirrors aliyun-cli's telemetry-instance-id.lock role. */ +export const UID_LOCK_NAME = "telemetry-uid.lock"; + 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}$/; -/** Errnos where hard links are unavailable; fall back to rename. */ -const NO_HARDLINK = new Set(["EPERM", "ENOTSUP", "ENOSYS", "EXDEV", "EOPNOTSUPP"]); +/** + * How long a waiter may block for the publish lock. + * initRemote runs once per process, so we briefly wait instead of giving up + * immediately (aliyun-cli can retry EnsureInstanceID on the next event). + */ +const LOCK_WAIT_MS = 1000; + +/** + * Drop a lock dir left behind by a crashed holder (aliyun flock auto-releases; mkdir does not). + * Assumes the holder has crashed. Extreme stalls (FS hang, clock jump) past this age may + * briefly double-publish different uids; the on-disk file still converges to one value. + */ +const LOCK_STALE_MS = 2000; type ReadResult = | { kind: "ok"; id: string } @@ -30,6 +45,10 @@ export function getUidPath(): string { return join(getConfigDir(), UID_FILE_NAME); } +export function getUidLockPath(): string { + return join(getConfigDir(), UID_LOCK_NAME); +} + function readUidFile(path: string): ReadResult { let raw: string; try { @@ -49,58 +68,113 @@ export function loadUid(): string | undefined { return result.kind === "ok" ? result.id : undefined; } +function sleepSync(ms: number): void { + const buffer = new SharedArrayBuffer(4); + Atomics.wait(new Int32Array(buffer), 0, 0, ms); +} + /** - * 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. - * Any failure returns undefined so remoteSink omits `uid`; built-in `device_id` is unchanged. + * Acquire an exclusive mkdir lock, waiting up to timeoutMs. + * Returns an unlock function, or undefined when the lock stays busy. + * unlock removes this process's lock dir and does not verify ownership tokens. */ -export function ensureUid(): string | undefined { - const path = getUidPath(); - const current = readUidFile(path); - if (current.kind === "ok") return current.id; - // Do not overwrite unreadable paths (e.g. permission errors); that could damage the environment. - if (current.kind === "unreadable") return undefined; +function acquireUidLock(timeoutMs: number): (() => void) | undefined { + const lockPath = getUidLockPath(); + const deadline = Date.now() + timeoutMs; + for (;;) { + try { + mkdirSync(lockPath); + return () => { + try { + rmSync(lockPath, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? ""; + if (code !== "EEXIST") throw error; + + // Stale recovery: treat a lock older than LOCK_STALE_MS as abandoned. + // See LOCK_STALE_MS for the crash-vs-stall trade-off vs kernel flock. + try { + const age = Date.now() - statSync(lockPath).mtimeMs; + if (age > LOCK_STALE_MS) { + rmSync(lockPath, { recursive: true, force: true }); + continue; + } + } catch { + /* ignore and keep waiting / give up */ + } + + if (Date.now() >= deadline) return undefined; + sleepSync(Math.min(5, Math.max(0, deadline - Date.now()))); + } + } +} + +/** Same-directory temp + rename publish (aliyun atomicWriteFile). */ +function publishUid(path: string, id: string): void { const tmp = `${path}.${randomBytes(6).toString("hex")}.tmp`; try { - mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); const fd = openSync(tmp, "wx", 0o600); try { - writeSync(fd, `${randomUUID()}\n`); + writeSync(fd, `${id}\n`); } finally { closeSync(fd); } - if (current.kind === "missing") { - publishIfAbsent(tmp, path); - } else { - renameSync(tmp, path); // invalid: atomic replace - } - } catch { - // Read-only FS, disk full, etc.: stay silent and try reading once more below. + renameSync(tmp, path); } finally { try { - unlinkSync(tmp); // tmp remains after a successful link; rename removes it (ENOENT ok) + unlinkSync(tmp); } catch { - /* ignore */ + /* rename removed tmp; ignore ENOENT */ } } - - return loadUid(); } -/** Atomic create-if-absent: first writer wins on concurrent first runs. */ -function publishIfAbsent(tmp: string, path: string): void { +/** + * 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. + * + * Concurrency follows aliyun-cli EnsureInstanceID: steady-state is lock-free read; + * create/repair takes an exclusive lock, re-reads under the lock, then publishes + * with temp+rename. Lock wait is bounded (LOCK_WAIT_MS); on timeout we loadUid or + * omit `uid` so telemetry never blocks the business exit path indefinitely. + * 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 (e.g. permission errors); that could damage the environment. + if (current.kind === "unreadable") return undefined; + + let unlock: (() => void) | undefined; try { - linkSync(tmp, path); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code ?? ""; - if (code === "EEXIST") return; // another process published first; keep theirs - if (NO_HARDLINK.has(code)) { - renameSync(tmp, path); // no hard links: last writer wins - return; + mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); + unlock = acquireUidLock(LOCK_WAIT_MS); + if (!unlock) { + // Another process held the lock past our wait; use whatever it published. + return loadUid(); } - throw error; + + // Another process may have finished between our first read and lock acquire. + const again = readUidFile(path); + if (again.kind === "ok") return again.id; + if (again.kind === "unreadable") return undefined; + + const id = randomUUID(); + publishUid(path, id); + return id; + } catch { + // Read-only FS, disk full, etc.: stay silent and try reading once more. + return loadUid(); + } finally { + unlock?.(); } } diff --git a/packages/core/tests/telemetry-uid.test.ts b/packages/core/tests/telemetry-uid.test.ts index eb5e6166..4fdd3bec 100644 --- a/packages/core/tests/telemetry-uid.test.ts +++ b/packages/core/tests/telemetry-uid.test.ts @@ -12,7 +12,14 @@ 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 { + UID_FILE_NAME, + UID_LOCK_NAME, + ensureUid, + getUidLockPath, + getUidPath, + loadUid, +} from "../src/telemetry/uid.ts"; import { trackCommandExecution } from "../src/telemetry/tracker.ts"; import { enterIsolatedHome, type IsolatedHomeHandle } from "./helpers/isolation.ts"; @@ -24,6 +31,43 @@ beforeEach(() => { }); 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(); @@ -36,6 +80,7 @@ test("first call writes a UUID v4 and later calls return the same value", () => test("uid path follows BAILIAN_CONFIG_DIR", () => { ensureUid(); expect(getUidPath()).toBe(join(iso.configDir, UID_FILE_NAME)); + expect(getUidLockPath()).toBe(join(iso.configDir, UID_LOCK_NAME)); }); test.skipIf(process.platform === "win32")("file mode is 0600", () => { @@ -89,9 +134,11 @@ test.skipIf(process.platform === "win32")( }, ); -test("leaves no temporary files behind", () => { +test("leaves no temporary files or lock dir behind", () => { ensureUid(); - expect(readdirSync(iso.configDir).filter((name) => name.endsWith(".tmp"))).toEqual([]); + const names = readdirSync(iso.configDir); + expect(names.filter((name) => name.endsWith(".tmp"))).toEqual([]); + expect(names).not.toContain(UID_LOCK_NAME); }); test("telemetry disabled does not create the uid file", async () => { @@ -115,40 +162,21 @@ test("telemetry disabled does not create the uid file", async () => { }); test("eight concurrent first runs converge on one ID", async () => { - const uidModuleUrl = pathToFileURL( - fileURLToPath(new URL("../src/telemetry/uid.ts", import.meta.url)), - ).href; - 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}`)); - }); - }); - const ids = await Promise.all(Array.from({ length: 8 }, () => run())); + const ids = await runEnsureUidChildren(8); + const finalId = readFileSync(getUidPath(), "utf8").trim(); + expect(finalId).toMatch(UUID_V4); + // Every child either published or waited and re-read the same winner. + expect(ids.every((id) => id === finalId)).toBe(true); + expect(new Set(ids)).toEqual(new Set([finalId])); + expect(readdirSync(iso.configDir)).not.toContain(UID_LOCK_NAME); +}); + +test("eight concurrent repairs of an invalid file converge on one 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) => id === finalId)).toBe(true); expect(new Set(ids)).toEqual(new Set([finalId])); + expect(readdirSync(iso.configDir)).not.toContain(UID_LOCK_NAME); }); From 1b757ea4392726e30940210fe11955eca0615c6b Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Wed, 11 Nov 2026 17:29:54 +0800 Subject: [PATCH 4/6] fix(telemetry): reclaim stale uid lock via rename, not rm --- docs/agents/telemetry-change.md | 2 +- packages/core/src/telemetry/uid.ts | 60 +++++++++++++++++------ packages/core/tests/telemetry-uid.test.ts | 45 +++++++++++++++++ 3 files changed, 90 insertions(+), 17 deletions(-) diff --git a/docs/agents/telemetry-change.md b/docs/agents/telemetry-change.md index be95abe1..4e4ff4f8 100644 --- a/docs/agents/telemetry-change.md +++ b/docs/agents/telemetry-change.md @@ -125,7 +125,7 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网 底层 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` 并发对齐 aliyun-cli `EnsureInstanceID`:稳态只读不加锁;缺失或损坏时用 `telemetry-uid.lock`(mkdir 互斥)保护,锁内二次读后再 temp+rename 发布;因 `initRemote` 只初始化一次,抢锁最多短等约 1s,超时则读回胜者或省略 `uid`。Node 未暴露 flock / LockFileEx,故用 mkdir 锁并带陈旧锁回收(假定持锁方已崩溃;极端卡住可能导致短暂双发布,磁盘上的文件仍会收敛),而不是依赖硬链接的 create-if-absent。 +`ensureUid` 并发对齐 aliyun-cli `EnsureInstanceID`:稳态只读不加锁;缺失或损坏时用 `telemetry-uid.lock`(mkdir 互斥 + owner token)保护,锁内二次读后再 temp+rename 发布;因 `initRemote` 只初始化一次,抢锁最多短等约 1s,超时则读回胜者或省略 `uid`。Node 未暴露 flock / LockFileEx,故用 mkdir 锁;陈旧锁通过 `rename` 抢走后再删(禁止按同名路径直接 `rm`,避免误删后来者的新锁),unlock 也仅在仍持有 owner token 时删除。极端卡住仍可能被陈旧回收,但磁盘上的 `telemetry-uid` 最终收敛。 从 2.8.2 起,每条命令事件通过 `commandPrefix` 显式记录调用入口,三种入口仍共用 `pid=bailian-cli-node`: diff --git a/packages/core/src/telemetry/uid.ts b/packages/core/src/telemetry/uid.ts index 942c059e..caa48d41 100644 --- a/packages/core/src/telemetry/uid.ts +++ b/packages/core/src/telemetry/uid.ts @@ -8,6 +8,7 @@ import { rmSync, statSync, unlinkSync, + writeFileSync, writeSync, } from "fs"; import { join } from "path"; @@ -29,9 +30,9 @@ const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f const LOCK_WAIT_MS = 1000; /** - * Drop a lock dir left behind by a crashed holder (aliyun flock auto-releases; mkdir does not). - * Assumes the holder has crashed. Extreme stalls (FS hang, clock jump) past this age may - * briefly double-publish different uids; the on-disk file still converges to one value. + * Reclaim a lock dir left behind by a crashed holder (aliyun flock auto-releases; mkdir does not). + * Assumes the holder has crashed. Extreme stalls past this age may reclaim a live lock; + * unlock still only removes the dir when this process still owns the owner token. */ const LOCK_STALE_MS = 2000; @@ -73,10 +74,34 @@ function sleepSync(ms: number): void { Atomics.wait(new Int32Array(buffer), 0, 0, ms); } +/** + * Claim a stale lock by renaming it away (only one reclaimer wins), then delete the claim. + * Never rmSync the live lock path by name — that can delete a newer holder's mkdir. + */ +function tryReclaimStaleLock(lockPath: string): void { + try { + const age = Date.now() - statSync(lockPath).mtimeMs; + if (age <= LOCK_STALE_MS) return; + const stalePath = `${lockPath}.stale.${randomBytes(6).toString("hex")}`; + try { + renameSync(lockPath, stalePath); + } catch { + return; // another process already claimed or removed it + } + try { + rmSync(stalePath, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } catch { + /* ignore */ + } +} + /** * Acquire an exclusive mkdir lock, waiting up to timeoutMs. * Returns an unlock function, or undefined when the lock stays busy. - * unlock removes this process's lock dir and does not verify ownership tokens. + * unlock only removes the lock when this process still owns the owner token. */ function acquireUidLock(timeoutMs: number): (() => void) | undefined { const lockPath = getUidLockPath(); @@ -85,28 +110,31 @@ function acquireUidLock(timeoutMs: number): (() => void) | undefined { for (;;) { try { mkdirSync(lockPath); - return () => { + const token = randomBytes(8).toString("hex"); + try { + writeFileSync(join(lockPath, "owner"), `${token}\n`, { flag: "wx", mode: 0o600 }); + } catch (error) { try { rmSync(lockPath, { recursive: true, force: true }); } catch { /* ignore */ } + throw error; + } + return () => { + // Rename the owner file first so we only delete a lock we still own. + try { + renameSync(join(lockPath, "owner"), join(lockPath, `releasing.${token}`)); + rmSync(lockPath, { recursive: true, force: true }); + } catch { + /* lock moved/stolen or already gone */ + } }; } catch (error) { const code = (error as NodeJS.ErrnoException).code ?? ""; if (code !== "EEXIST") throw error; - // Stale recovery: treat a lock older than LOCK_STALE_MS as abandoned. - // See LOCK_STALE_MS for the crash-vs-stall trade-off vs kernel flock. - try { - const age = Date.now() - statSync(lockPath).mtimeMs; - if (age > LOCK_STALE_MS) { - rmSync(lockPath, { recursive: true, force: true }); - continue; - } - } catch { - /* ignore and keep waiting / give up */ - } + tryReclaimStaleLock(lockPath); if (Date.now() >= deadline) return undefined; sleepSync(Math.min(5, Math.max(0, deadline - Date.now()))); diff --git a/packages/core/tests/telemetry-uid.test.ts b/packages/core/tests/telemetry-uid.test.ts index 4fdd3bec..63c4b378 100644 --- a/packages/core/tests/telemetry-uid.test.ts +++ b/packages/core/tests/telemetry-uid.test.ts @@ -5,7 +5,9 @@ import { mkdirSync, readdirSync, readFileSync, + rmSync, statSync, + utimesSync, writeFileSync, } from "node:fs"; import { join } from "node:path"; @@ -138,6 +140,7 @@ test("leaves no temporary files or lock dir behind", () => { ensureUid(); const names = readdirSync(iso.configDir); expect(names.filter((name) => name.endsWith(".tmp"))).toEqual([]); + expect(names.filter((name) => name.includes(".stale."))).toEqual([]); expect(names).not.toContain(UID_LOCK_NAME); }); @@ -180,3 +183,45 @@ test("eight concurrent repairs of an invalid file converge on one ID", async () expect(new Set(ids)).toEqual(new Set([finalId])); expect(readdirSync(iso.configDir)).not.toContain(UID_LOCK_NAME); }); + +test("eight concurrent reclaimers of a leftover stale lock converge on one ID", async () => { + // Leftover lock from a crashed holder — the race that blind rmSync could lose. + mkdirSync(getUidLockPath()); + writeFileSync(join(getUidLockPath(), "owner"), "dead-holder\n"); + const pastSeconds = (Date.now() - 10_000) / 1000; + utimesSync(getUidLockPath(), pastSeconds, pastSeconds); + + const ids = await runEnsureUidChildren(8); + const finalId = readFileSync(getUidPath(), "utf8").trim(); + expect(finalId).toMatch(UUID_V4); + expect(ids.every((id) => id === finalId)).toBe(true); + expect(new Set(ids)).toEqual(new Set([finalId])); + const names = readdirSync(iso.configDir); + expect(names).not.toContain(UID_LOCK_NAME); + expect(names.filter((name) => name.includes(".stale."))).toEqual([]); +}); + +test("repeated stale-lock reclaim bursts keep converging", async () => { + for (let round = 0; round < 5; round += 1) { + try { + rmSync(getUidPath(), { force: true }); + } catch { + /* missing */ + } + try { + rmSync(getUidLockPath(), { recursive: true, force: true }); + } catch { + /* missing */ + } + mkdirSync(getUidLockPath()); + writeFileSync(join(getUidLockPath(), "owner"), `dead-${round}\n`); + const pastSeconds = (Date.now() - 10_000) / 1000; + utimesSync(getUidLockPath(), pastSeconds, pastSeconds); + + const ids = await runEnsureUidChildren(6); + const finalId = readFileSync(getUidPath(), "utf8").trim(); + expect(finalId).toMatch(UUID_V4); + expect(new Set(ids)).toEqual(new Set([finalId])); + } + expect(readdirSync(iso.configDir).filter((name) => name.includes(".stale."))).toEqual([]); +}); From 5d4da744b4f096ff49e99444108aed46f2d27c73 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Thu, 12 Nov 2026 14:47:49 +0800 Subject: [PATCH 5/6] refactor(telemetry): drop uid mkdir lock for temp+rename publish --- docs/agents/telemetry-change.md | 2 +- packages/core/src/telemetry/uid.ts | 125 ++-------------------- packages/core/tests/telemetry-uid.test.ts | 77 +++---------- 3 files changed, 19 insertions(+), 185 deletions(-) diff --git a/docs/agents/telemetry-change.md b/docs/agents/telemetry-change.md index 4e4ff4f8..e916c794 100644 --- a/docs/agents/telemetry-change.md +++ b/docs/agents/telemetry-change.md @@ -125,7 +125,7 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网 底层 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` 并发对齐 aliyun-cli `EnsureInstanceID`:稳态只读不加锁;缺失或损坏时用 `telemetry-uid.lock`(mkdir 互斥 + owner token)保护,锁内二次读后再 temp+rename 发布;因 `initRemote` 只初始化一次,抢锁最多短等约 1s,超时则读回胜者或省略 `uid`。Node 未暴露 flock / LockFileEx,故用 mkdir 锁;陈旧锁通过 `rename` 抢走后再删(禁止按同名路径直接 `rm`,避免误删后来者的新锁),unlock 也仅在仍持有 owner token 时删除。极端卡住仍可能被陈旧回收,但磁盘上的 `telemetry-uid` 最终收敛。 +`ensureUid` 无锁(对齐 qwen-code `InstallationManager`):稳态只读;缺失或损坏时同目录 temp 写满后 `rename` 发布(内容原子替换;并发首次创建时后写覆盖先写,磁盘最终为一个 UUID)。失败则省略 `uid`,不阻塞业务退出。 从 2.8.2 起,每条命令事件通过 `commandPrefix` 显式记录调用入口,三种入口仍共用 `pid=bailian-cli-node`: diff --git a/packages/core/src/telemetry/uid.ts b/packages/core/src/telemetry/uid.ts index caa48d41..2145303f 100644 --- a/packages/core/src/telemetry/uid.ts +++ b/packages/core/src/telemetry/uid.ts @@ -5,10 +5,7 @@ import { openSync, readFileSync, renameSync, - rmSync, - statSync, unlinkSync, - writeFileSync, writeSync, } from "fs"; import { join } from "path"; @@ -17,25 +14,8 @@ 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"; -/** Exclusive mkdir lock; mirrors aliyun-cli's telemetry-instance-id.lock role. */ -export const UID_LOCK_NAME = "telemetry-uid.lock"; - 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}$/; -/** - * How long a waiter may block for the publish lock. - * initRemote runs once per process, so we briefly wait instead of giving up - * immediately (aliyun-cli can retry EnsureInstanceID on the next event). - */ -const LOCK_WAIT_MS = 1000; - -/** - * Reclaim a lock dir left behind by a crashed holder (aliyun flock auto-releases; mkdir does not). - * Assumes the holder has crashed. Extreme stalls past this age may reclaim a live lock; - * unlock still only removes the dir when this process still owns the owner token. - */ -const LOCK_STALE_MS = 2000; - type ReadResult = | { kind: "ok"; id: string } | { kind: "missing" } @@ -46,10 +26,6 @@ export function getUidPath(): string { return join(getConfigDir(), UID_FILE_NAME); } -export function getUidLockPath(): string { - return join(getConfigDir(), UID_LOCK_NAME); -} - function readUidFile(path: string): ReadResult { let raw: string; try { @@ -69,80 +45,7 @@ export function loadUid(): string | undefined { return result.kind === "ok" ? result.id : undefined; } -function sleepSync(ms: number): void { - const buffer = new SharedArrayBuffer(4); - Atomics.wait(new Int32Array(buffer), 0, 0, ms); -} - -/** - * Claim a stale lock by renaming it away (only one reclaimer wins), then delete the claim. - * Never rmSync the live lock path by name — that can delete a newer holder's mkdir. - */ -function tryReclaimStaleLock(lockPath: string): void { - try { - const age = Date.now() - statSync(lockPath).mtimeMs; - if (age <= LOCK_STALE_MS) return; - const stalePath = `${lockPath}.stale.${randomBytes(6).toString("hex")}`; - try { - renameSync(lockPath, stalePath); - } catch { - return; // another process already claimed or removed it - } - try { - rmSync(stalePath, { recursive: true, force: true }); - } catch { - /* ignore */ - } - } catch { - /* ignore */ - } -} - -/** - * Acquire an exclusive mkdir lock, waiting up to timeoutMs. - * Returns an unlock function, or undefined when the lock stays busy. - * unlock only removes the lock when this process still owns the owner token. - */ -function acquireUidLock(timeoutMs: number): (() => void) | undefined { - const lockPath = getUidLockPath(); - const deadline = Date.now() + timeoutMs; - - for (;;) { - try { - mkdirSync(lockPath); - const token = randomBytes(8).toString("hex"); - try { - writeFileSync(join(lockPath, "owner"), `${token}\n`, { flag: "wx", mode: 0o600 }); - } catch (error) { - try { - rmSync(lockPath, { recursive: true, force: true }); - } catch { - /* ignore */ - } - throw error; - } - return () => { - // Rename the owner file first so we only delete a lock we still own. - try { - renameSync(join(lockPath, "owner"), join(lockPath, `releasing.${token}`)); - rmSync(lockPath, { recursive: true, force: true }); - } catch { - /* lock moved/stolen or already gone */ - } - }; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code ?? ""; - if (code !== "EEXIST") throw error; - - tryReclaimStaleLock(lockPath); - - if (Date.now() >= deadline) return undefined; - sleepSync(Math.min(5, Math.max(0, deadline - Date.now()))); - } - } -} - -/** Same-directory temp + rename publish (aliyun atomicWriteFile). */ +/** 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 { @@ -168,41 +71,25 @@ function publishUid(path: string, id: string): void { * Must stay synchronous: remoteSink relies on no await between the call and * event enqueue, otherwise flushTelemetry may return before the event is queued. * - * Concurrency follows aliyun-cli EnsureInstanceID: steady-state is lock-free read; - * create/repair takes an exclusive lock, re-reads under the lock, then publishes - * with temp+rename. Lock wait is bounded (LOCK_WAIT_MS); on timeout we loadUid or - * omit `uid` so telemetry never blocks the business exit path indefinitely. - * Any failure returns undefined so remoteSink omits `uid`; built-in Tracker - * `device_id` is unchanged. + * 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 (e.g. permission errors); that could damage the environment. + // Do not overwrite unreadable paths (permission errors, a directory in the way). if (current.kind === "unreadable") return undefined; - let unlock: (() => void) | undefined; try { mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); - unlock = acquireUidLock(LOCK_WAIT_MS); - if (!unlock) { - // Another process held the lock past our wait; use whatever it published. - return loadUid(); - } - - // Another process may have finished between our first read and lock acquire. - const again = readUidFile(path); - if (again.kind === "ok") return again.id; - if (again.kind === "unreadable") return undefined; - const id = randomUUID(); publishUid(path, id); return id; } catch { // Read-only FS, disk full, etc.: stay silent and try reading once more. return loadUid(); - } finally { - unlock?.(); } } diff --git a/packages/core/tests/telemetry-uid.test.ts b/packages/core/tests/telemetry-uid.test.ts index 63c4b378..4898a082 100644 --- a/packages/core/tests/telemetry-uid.test.ts +++ b/packages/core/tests/telemetry-uid.test.ts @@ -5,23 +5,14 @@ import { mkdirSync, readdirSync, readFileSync, - rmSync, statSync, - utimesSync, 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, - UID_LOCK_NAME, - ensureUid, - getUidLockPath, - getUidPath, - loadUid, -} from "../src/telemetry/uid.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"; @@ -82,7 +73,6 @@ test("first call writes a UUID v4 and later calls return the same value", () => test("uid path follows BAILIAN_CONFIG_DIR", () => { ensureUid(); expect(getUidPath()).toBe(join(iso.configDir, UID_FILE_NAME)); - expect(getUidLockPath()).toBe(join(iso.configDir, UID_LOCK_NAME)); }); test.skipIf(process.platform === "win32")("file mode is 0600", () => { @@ -136,12 +126,11 @@ test.skipIf(process.platform === "win32")( }, ); -test("leaves no temporary files or lock dir behind", () => { +test("leaves no temporary files behind", () => { ensureUid(); const names = readdirSync(iso.configDir); expect(names.filter((name) => name.endsWith(".tmp"))).toEqual([]); - expect(names.filter((name) => name.includes(".stale."))).toEqual([]); - expect(names).not.toContain(UID_LOCK_NAME); + expect(names).toContain(UID_FILE_NAME); }); test("telemetry disabled does not create the uid file", async () => { @@ -164,64 +153,22 @@ test("telemetry disabled does not create the uid file", async () => { expect(existsSync(getUidPath())).toBe(false); }); -test("eight concurrent first runs converge on one ID", async () => { +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); - // Every child either published or waited and re-read the same winner. - expect(ids.every((id) => id === finalId)).toBe(true); - expect(new Set(ids)).toEqual(new Set([finalId])); - expect(readdirSync(iso.configDir)).not.toContain(UID_LOCK_NAME); + // 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 converge on one ID", async () => { +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) => id === finalId)).toBe(true); - expect(new Set(ids)).toEqual(new Set([finalId])); - expect(readdirSync(iso.configDir)).not.toContain(UID_LOCK_NAME); -}); - -test("eight concurrent reclaimers of a leftover stale lock converge on one ID", async () => { - // Leftover lock from a crashed holder — the race that blind rmSync could lose. - mkdirSync(getUidLockPath()); - writeFileSync(join(getUidLockPath(), "owner"), "dead-holder\n"); - const pastSeconds = (Date.now() - 10_000) / 1000; - utimesSync(getUidLockPath(), pastSeconds, pastSeconds); - - const ids = await runEnsureUidChildren(8); - const finalId = readFileSync(getUidPath(), "utf8").trim(); - expect(finalId).toMatch(UUID_V4); - expect(ids.every((id) => id === finalId)).toBe(true); - expect(new Set(ids)).toEqual(new Set([finalId])); - const names = readdirSync(iso.configDir); - expect(names).not.toContain(UID_LOCK_NAME); - expect(names.filter((name) => name.includes(".stale."))).toEqual([]); -}); - -test("repeated stale-lock reclaim bursts keep converging", async () => { - for (let round = 0; round < 5; round += 1) { - try { - rmSync(getUidPath(), { force: true }); - } catch { - /* missing */ - } - try { - rmSync(getUidLockPath(), { recursive: true, force: true }); - } catch { - /* missing */ - } - mkdirSync(getUidLockPath()); - writeFileSync(join(getUidLockPath(), "owner"), `dead-${round}\n`); - const pastSeconds = (Date.now() - 10_000) / 1000; - utimesSync(getUidLockPath(), pastSeconds, pastSeconds); - - const ids = await runEnsureUidChildren(6); - const finalId = readFileSync(getUidPath(), "utf8").trim(); - expect(finalId).toMatch(UUID_V4); - expect(new Set(ids)).toEqual(new Set([finalId])); - } - expect(readdirSync(iso.configDir).filter((name) => name.includes(".stale."))).toEqual([]); + expect(ids.every((id) => UUID_V4.test(id))).toBe(true); + expect(ids).toContain(finalId); + expect(readdirSync(iso.configDir).filter((name) => name.endsWith(".tmp"))).toEqual([]); }); From 32a55985d4c6c1a07d1e1ff889bca6c5838c1577 Mon Sep 17 00:00:00 2001 From: clh02467605 Date: Thu, 12 Nov 2026 15:27:57 +0800 Subject: [PATCH 6/6] fix(telemetry): use writeFileSync(fd) to avoid uid short-writes --- packages/core/src/telemetry/uid.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/telemetry/uid.ts b/packages/core/src/telemetry/uid.ts index 2145303f..1122c057 100644 --- a/packages/core/src/telemetry/uid.ts +++ b/packages/core/src/telemetry/uid.ts @@ -6,7 +6,7 @@ import { readFileSync, renameSync, unlinkSync, - writeSync, + writeFileSync, } from "fs"; import { join } from "path"; import { getConfigDir } from "../config/paths.ts"; @@ -51,7 +51,8 @@ function publishUid(path: string, id: string): void { try { const fd = openSync(tmp, "wx", 0o600); try { - writeSync(fd, `${id}\n`); + // writeFileSync(fd) loops until the full buffer is written; writeSync can short-write. + writeFileSync(fd, `${id}\n`); } finally { closeSync(fd); }