From 6224d7849d0ba0aaa45357e1b8a92a7ba8ff8bf2 Mon Sep 17 00:00:00 2001 From: Rafli Surya Wijaya <260355617@qq.com> Date: Fri, 24 Jul 2026 04:13:58 +0800 Subject: [PATCH] fix(infra): preserve large inode identity in session migrations (#112478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(infra): use bigint stat in resolveSessionStorePathRelationship to prevent inode precision loss on large-inode filesystems fs.statSync() returns ino as a JS Number, which can only safely represent integers up to 2^53. On virtiofs / Kata Container filesystems (and any other fs whose inodes exceed 2^53) two adjacent directory inodes — e.g. 72057594037932382 and 72057594037932383 — are both rounded to the same Number value (72057594037932380), causing sameFileIdentity() to treat two distinct paths as the same file object. In resolveSessionStorePathRelationship() this manifests as a false-positive alias detection: the agent state directory and its sessions/ child map to the same inode Number, so the gateway migration refuses to continue and prints: Deferred session key migration in aliased store …; atomic replacement cannot update distinct filesystem aliases as one operation … and the gateway refuses to start. The fix is a one-line change: pass { bigint: true } to fs.statSync() so that ino is returned as a BigInt that preserves the full 64-bit value. FileIdentityStat already accepts bigint, and sameFileIdentity already handles mixed number/bigint comparison, so no further changes are needed. Two regression tests are added to file-identity.test.ts documenting that adjacent inodes above 2^53 are correctly distinguished when represented as BigInt (the pre-fix Number path falsely collapses them). Closes #112341 * test(infra): exercise bigint store identity Co-authored-by: Rafli Surya Wijaya <260355617@qq.com> --------- Co-authored-by: Peter Steinberger --- .../state-migrations.orphan-keys.test.ts | 69 +++++++++++++++++++ src/infra/state-migrations.session-store.ts | 7 +- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/infra/state-migrations.orphan-keys.test.ts b/src/infra/state-migrations.orphan-keys.test.ts index a1335892242e..e4d9b339f7ef 100644 --- a/src/infra/state-migrations.orphan-keys.test.ts +++ b/src/infra/state-migrations.orphan-keys.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { migrateOrphanedSessionKeys } from "./state-migrations.js"; +import { resolveSessionStoreOwnership } from "./state-migrations.session-store.js"; const listPluginDoctorSessionStoreAgentIdsMock = vi.hoisted(() => vi.fn((): string[] => [])); @@ -173,6 +174,74 @@ describe("migrateOrphanedSessionKeys", () => { }); }); + it("distinguishes large adjacent inodes before planning store aliases", async () => { + await withStateFixture(async ({ tmpDir, stateDir }) => { + const configuredStorePath = path.join(tmpDir, "configured-sessions.json"); + const targetStorePath = path.join(stateDir, "agents", "voice", "sessions", "sessions.json"); + writeStore(configuredStorePath, {}); + writeStore(targetStorePath, {}); + const cfg = { + session: { store: configuredStorePath }, + agents: { list: [{ id: "ops", default: true }] }, + } as OpenClawConfig; + const realStatSync = fs.statSync.bind(fs); + const largeInodes = new Map([ + [configuredStorePath, 72057594037932382n], + [targetStorePath, 72057594037932383n], + ]); + const statSpy = vi.spyOn(fs, "statSync").mockImplementation((( + candidate: Parameters[0], + options?: { bigint?: boolean }, + ) => { + const resolvedPath = path.resolve(candidate.toString()); + const inode = largeInodes.get(resolvedPath); + const useBigInt = options?.bigint === true; + const stat = useBigInt + ? realStatSync(candidate, { bigint: true }) + : realStatSync(candidate); + if (inode === undefined) { + return stat; + } + return new Proxy(stat, { + get(target, property, receiver) { + if (property === "dev") { + return useBigInt ? 2n : 2; + } + if (property === "ino") { + return useBigInt ? inode : Number(inode); + } + return Reflect.get(target, property, receiver); + }, + }); + }) as typeof fs.statSync); + + let ownership: ReturnType; + try { + ownership = resolveSessionStoreOwnership({ + cfg, + env: { OPENCLAW_STATE_DIR: stateDir }, + stateDir, + targetAgentId: "voice", + pluginSessionStoreAgentIds: ["voice"], + }); + expect(statSpy).toHaveBeenCalledWith(configuredStorePath, { bigint: true }); + expect(statSpy).toHaveBeenCalledWith(targetStorePath, { bigint: true }); + } finally { + statSpy.mockRestore(); + } + + expect(ownership).toEqual({ + preserveAmbiguousKeys: false, + preserveForeignMainAliases: false, + targetStoreAliases: { + hasDistinctAliases: false, + hasFinalSymlink: false, + hasUnresolvedIdentity: false, + }, + }); + }); + }); + it("discovers plugin-owned agents through doctor contracts", async () => { await withStateFixture(async ({ tmpDir, stateDir }) => { listPluginDoctorSessionStoreAgentIdsMock.mockReturnValue(["voice"]); diff --git a/src/infra/state-migrations.session-store.ts b/src/infra/state-migrations.session-store.ts index 256b5fe2f374..afcb29b0c218 100644 --- a/src/infra/state-migrations.session-store.ts +++ b/src/infra/state-migrations.session-store.ts @@ -1214,7 +1214,12 @@ function resolveSessionStorePathRelationship( return "same"; } try { - return sameFileIdentity(fs.statSync(left), fs.statSync(right)) ? "same" : "different"; + return sameFileIdentity( + fs.statSync(left, { bigint: true }), + fs.statSync(right, { bigint: true }), + ) + ? "same" + : "different"; } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code !== "ENOENT" && code !== "ENOTDIR") {