mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
cbf94c0d8b
commit
6224d7849d
@@ -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"]);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user