From d76bfa6474ad08b5367d1aa62204f9db24057495 Mon Sep 17 00:00:00 2001 From: u7-u7 <116776533+u7-u7@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:49:54 +0800 Subject: [PATCH] fix(memhub): preserve migration log write order --- .../src/sdk-migration-log-order.test.ts | 101 ++++++++++++++++++ .../runtime-control/src/sdk-migration-log.ts | 36 +++++-- 2 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 memhub/packages/runtime-control/src/sdk-migration-log-order.test.ts diff --git a/memhub/packages/runtime-control/src/sdk-migration-log-order.test.ts b/memhub/packages/runtime-control/src/sdk-migration-log-order.test.ts new file mode 100644 index 0000000..5e3a891 --- /dev/null +++ b/memhub/packages/runtime-control/src/sdk-migration-log-order.test.ts @@ -0,0 +1,101 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + sdkMigrationLogPath, + writeSdkMigrationLog, +} from "./sdk-migration-log.js"; + +async function events(dataDir: string): Promise { + return (await readFile(sdkMigrationLogPath(dataDir), "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line).event); +} + +describe("SDK migration log ordering", () => { + it("preserves invocation order for concurrent progress and completion writes", async () => { + const dataDir = await mkdtemp( + path.join(os.tmpdir(), "migration-log-order-"), + ); + try { + const expected = Array.from( + { length: 100 }, + (_, i) => `migration.progress.${i}`, + ); + expected.push("migration.finished"); + await Promise.all( + expected.map((event) => + writeSdkMigrationLog(dataDir, { + writer: "desktop", + event, + outcome: "started", + }), + ), + ); + expect(await events(dataDir)).toEqual(expected); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it("awaiting the terminal event also drains preceding progress writes", async () => { + const dataDir = await mkdtemp( + path.join(os.tmpdir(), "migration-log-drain-"), + ); + const pending: Promise[] = []; + try { + const expected = Array.from( + { length: 50 }, + (_, i) => `migration.progress.${i}`, + ); + for (const event of expected) { + pending.push( + writeSdkMigrationLog(dataDir, { + writer: "desktop", + event, + outcome: "started", + }), + ); + } + await writeSdkMigrationLog(dataDir, { + writer: "desktop", + event: "migration.finished", + outcome: "succeeded", + }); + expect(await events(dataDir)).toEqual([ + ...expected, + "migration.finished", + ]); + } finally { + await Promise.all(pending); + await rm(dataDir, { recursive: true, force: true }); + } + }); + + it("does not let a failed append block subsequent writes to the same home", async () => { + const dataDir = await mkdtemp( + path.join(os.tmpdir(), "migration-log-recovery-"), + ); + try { + await writeFile(path.join(dataDir, "logs"), "not a directory"); + await expect( + writeSdkMigrationLog(dataDir, { + writer: "desktop", + event: "migration.progress", + outcome: "started", + }), + ).resolves.toBeUndefined(); + await rm(path.join(dataDir, "logs")); + await writeSdkMigrationLog(dataDir, { + writer: "desktop", + event: "migration.finished", + outcome: "succeeded", + }); + expect(await events(dataDir)).toEqual(["migration.finished"]); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/memhub/packages/runtime-control/src/sdk-migration-log.ts b/memhub/packages/runtime-control/src/sdk-migration-log.ts index 71bc831..ba4936e 100644 --- a/memhub/packages/runtime-control/src/sdk-migration-log.ts +++ b/memhub/packages/runtime-control/src/sdk-migration-log.ts @@ -33,6 +33,9 @@ export const SDK_MIGRATION_LOG_NAME = "sdk-migration.log"; const MAX_BYTES = 4 * 1024 * 1024; const SAFE_TEXT = /^[A-Za-z0-9_.:/@+-]{0,160}$/; const MAX_ITEMS = 32; +// Preserve invocation order within this process, including fire-and-forget +// progress followed by an awaited terminal event. Homes remain independent. +const pendingWrites = new Map>(); export function sdkMigrationLogPath(dataDir: string): string { return path.join(dataDir, "logs", SDK_MIGRATION_LOG_NAME); @@ -103,17 +106,28 @@ export async function writeSdkMigrationLog( platform: process.platform, ...(entry.details === undefined ? {} : { details: entry.details }), }); - const target = sdkMigrationLogPath(dataDir); - await mkdir(path.dirname(target), { recursive: true, mode: 0o700 }); - const size = await stat(target).then( - (info) => info.size, - () => 0, - ); - if (size > MAX_BYTES) await rename(target, `${target}.1`); - await appendFile(target, `${JSON.stringify(line)}\n`, { - encoding: "utf8", - mode: 0o600, - }); + const target = path.resolve(sdkMigrationLogPath(dataDir)); + const previous = pendingWrites.get(target) ?? Promise.resolve(); + const pending = previous + .catch(() => undefined) + .then(async () => { + await mkdir(path.dirname(target), { recursive: true, mode: 0o700 }); + const size = await stat(target).then( + (info) => info.size, + () => 0, + ); + if (size > MAX_BYTES) await rename(target, `${target}.1`); + await appendFile(target, `${JSON.stringify(line)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + }); + pendingWrites.set(target, pending); + try { + await pending; + } finally { + if (pendingWrites.get(target) === pending) pendingWrites.delete(target); + } } catch { // Never let the log decide whether a migration or a startup happens. }