fix(infra): preserve large inode identity in session migrations (#112478)

* 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 <steipete@gmail.com>
This commit is contained in:
Rafli Surya Wijaya
2026-07-24 04:13:58 +08:00
committed by GitHub
parent cbf94c0d8b
commit 6224d7849d
2 changed files with 75 additions and 1 deletions
@@ -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<typeof fs.statSync>[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<typeof resolveSessionStoreOwnership>;
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"]);
+6 -1
View File
@@ -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") {