diff --git a/docs/gateway/gateway-lock.md b/docs/gateway/gateway-lock.md index 40b7dedfcc5f..bd86ae0a0316 100644 --- a/docs/gateway/gateway-lock.md +++ b/docs/gateway/gateway-lock.md @@ -24,6 +24,9 @@ Each layer can fail independently and throws its own `GatewayLockError`. ### State and config locks +- Lock files, SQLite coordinators, and transient reclaim guards live under + `$OPENCLAW_STATE_DIR/tmp/openclaw-` (or `openclaw` on platforms without + a user ID). An overridden state directory therefore owns its complete lock tree. - Lock liveness comes from the recorded PID, platform process start identity when available, and Gateway process identity. A verified owner remains authoritative during startup before its port begins listening. - A dedicated SQLite coordinator serializes metadata inspection, stale-owner reclamation, and lock replacement. Its exclusive transaction is released automatically if the owning process crashes. - If a lock file is missing or the recorded owner process is gone, startup reclaims the lock and continues. @@ -51,6 +54,10 @@ Each layer can fail independently and throws its own `GatewayLockError`. On shutdown, the gateway closes the HTTP/WebSocket server and removes its state and config lock files. +The state-local layout is a clean version boundary. Binaries from before this +change use the process temp directory, so an old and new binary sharing one state +directory during an upgrade do not exclude each other through these locks. + ## Operational notes - If the port is occupied by a different, non-gateway process, the error is the same; free the port or choose another with `openclaw gateway --port `. diff --git a/docs/install/fly.md b/docs/install/fly.md index 513f5679dc74..292c56664a5d 100644 --- a/docs/install/fly.md +++ b/docs/install/fly.md @@ -275,18 +275,13 @@ fly machine update --vm-memory 2048 -y Gateway refuses to start with "already running" errors after a container restart. -The runtime lock files live at `/openclaw-/gateway..lock` -and `gateway.state..lock` (Linux: -`/tmp/openclaw-/gateway.*.lock`), not on the persistent `/data` volume, so -a full container restart normally clears them along with the rest of the -container filesystem. If a lock survives (for example a `fly machine restart` -that preserves the container filesystem) and blocks startup, remove it -manually: - -```bash -fly ssh console --command "rm -f /tmp/openclaw-*/gateway.*.lock" -fly machine restart -``` +With `OPENCLAW_STATE_DIR=/data`, the lock tree lives under +`/data/tmp/openclaw-` and persists with the volume. OpenClaw normally +reclaims stale owners automatically. If startup continues to report an owner, +first use `fly status` and `fly logs` to verify that no other machine or Gateway +process is using the volume. Do not delete the lock tree while an owner may +still be running; see [Gateway lock](/gateway/gateway-lock) for the ownership +and stale-recovery contract. ### Config not being read diff --git a/src/config/paths.ts b/src/config/paths.ts index 59be6d7011e1..2fc5b00505df 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -402,14 +402,15 @@ export function resolveDefaultConfigCandidates( export const DEFAULT_GATEWAY_PORT = 18789; /** - * Gateway lock directory (ephemeral). - * Default: os.tmpdir()/openclaw- (uid suffix when available). + * Gateway lock directory inside the selected state tree. + * Default: $OPENCLAW_STATE_DIR/tmp/openclaw- (uid suffix when available). */ -export function resolveGatewayLockDir(tmpdir: () => string = os.tmpdir): string { - const base = tmpdir(); +export function resolveGatewayLockDir(stateDir: string = resolveStateDir()): string { const uid = typeof process.getuid === "function" ? process.getuid() : undefined; const suffix = uid != null ? `openclaw-${uid}` : "openclaw"; - return path.join(base, suffix); + // Clean break: older binaries still use process temp and do not exclude a + // state-local binary during a mixed-version upgrade. + return path.join(normalizePathForComparison(stateDir), "tmp", suffix); } /** diff --git a/src/infra/backup-create.test.ts b/src/infra/backup-create.test.ts index 0e6466e3f478..1abce24c95af 100644 --- a/src/infra/backup-create.test.ts +++ b/src/infra/backup-create.test.ts @@ -8,7 +8,6 @@ import * as tar from "tar"; import { describe, expect, it, vi } from "vitest"; import { saveAuthProfileStore } from "../agents/auth-profiles/store.js"; import { backupVerifyCommand } from "../commands/backup-verify.js"; -import { isPathWithin } from "../commands/cleanup-utils.js"; import { CONFIG_AUDIT_MAX_ENTRIES, CONFIG_AUDIT_SCOPE } from "../config/io.audit.js"; import { resolveGatewayLockDir } from "../config/paths.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -2028,7 +2027,7 @@ describe("createBackupArchive", () => { ); }); - it("backs up durable SQLite while live gateway coordinators remain held under state", async () => { + it("excludes the state-local gateway lock tree while backing up durable SQLite", async () => { await withOpenClawTestState( { layout: "state-only", @@ -2038,7 +2037,7 @@ describe("createBackupArchive", () => { async (state) => { const outputDir = state.path("backups"); const extractDir = state.path("extract"); - const lockDir = resolveGatewayLockDir(() => state.statePath("tmp")); + const lockDir = resolveGatewayLockDir(state.stateDir); const pluginDbPath = state.statePath("plugins", "dedicated", "durable.sqlite"); const producerShapedDbPath = state.statePath( "plugins", @@ -2075,7 +2074,6 @@ describe("createBackupArchive", () => { if (!gatewayLock) { throw new Error("expected test gateway lock"); } - expect(isPathWithin(resolveGatewayLockDir(), state.stateDir)).toBe(false); const gatewayCoordinatorPaths = [ `${gatewayLock.lockPath}.sqlite`, `${gatewayLock.stateLockPath}.sqlite`, @@ -2122,9 +2120,9 @@ describe("createBackupArchive", () => { entry.endsWith("/state/plugins/dedicated/gateway.12345678.lock.sqlite"), ), ).toBe(true); - expect( - entries.some((entry) => entry.endsWith(`/${path.basename(lockDir)}/retained.sqlite`)), - ).toBe(true); + expect(entries.some((entry) => entry.includes(`/${path.basename(lockDir)}/`))).toBe( + false, + ); const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; await expect( @@ -2135,7 +2133,6 @@ describe("createBackupArchive", () => { for (const [entrySuffix, value] of [ ["/state/plugins/dedicated/durable.sqlite", "plugin-state"], ["/state/plugins/dedicated/gateway.12345678.lock.sqlite", "producer-shaped-state"], - [`/${path.basename(lockDir)}/retained.sqlite`, "colocated-state"], ] as const) { const archivedEntry = expectDefined( entries.find((entry) => entry.endsWith(entrySuffix)), diff --git a/src/infra/backup-create.ts b/src/infra/backup-create.ts index edbf89a8219f..2575e9e75aac 100644 --- a/src/infra/backup-create.ts +++ b/src/infra/backup-create.ts @@ -446,7 +446,6 @@ function resolveSqliteBackupDatabasePath(sourcePath: string): string | undefined function classifyStateSqliteBackupSourcePath( sourcePath: string, stateDir: string, - gatewayLockDirs: readonly string[], ): "excluded" | "sqlite" | undefined { const resolvedSourcePath = path.resolve(sourcePath); if (!isPathWithin(resolvedSourcePath, stateDir)) { @@ -455,7 +454,7 @@ function classifyStateSqliteBackupSourcePath( if (isStatePackageContentPath(resolvedSourcePath, stateDir)) { return undefined; } - if (isTransientSqliteBackupPath(resolvedSourcePath, gatewayLockDirs)) { + if (isTransientSqliteBackupPath(resolvedSourcePath)) { return "excluded"; } const databasePath = resolveSqliteBackupDatabasePath(resolvedSourcePath); @@ -472,7 +471,7 @@ function isBackupTarFilterFile(entry: import("node:fs").Stats | import("tar").Re async function listStateSqlitePaths(params: { stateDir: string; globalStateSqlitePath: string; - gatewayLockDirs: readonly string[]; + gatewayLockDir: string; preservedStatePaths?: readonly string[]; }): Promise<{ snapshotPaths: string[]; discoveredSourcePaths: Set }> { const snapshotPaths = new Set(); @@ -512,7 +511,11 @@ async function listStateSqlitePaths(params: { continue; } if (entry.isDirectory()) { - if (stateFilter(entryPath) && !isStatePackageContentPath(entryPath, params.stateDir)) { + if ( + stateFilter(entryPath) && + !isPathWithin(entryPath, params.gatewayLockDir) && + !isStatePackageContentPath(entryPath, params.stateDir) + ) { await visit(entryPath); } } else if ( @@ -524,7 +527,6 @@ async function listStateSqlitePaths(params: { const sqliteSourceKind = classifyStateSqliteBackupSourcePath( resolvedEntryPath, params.stateDir, - params.gatewayLockDirs, ); if (sqliteSourceKind === "sqlite") { discoveredSourcePaths.add(resolvedEntryPath); @@ -596,12 +598,7 @@ async function createStateSqliteBackupPlan(params: { const discovery = await listStateSqlitePaths({ stateDir: params.stateDir, globalStateSqlitePath, - // CLI and managed services use different temp roots for the same - // disposable gateway/device coordination databases. - gatewayLockDirs: [ - resolveGatewayLockDir(), - resolveGatewayLockDir(() => path.join(params.stateDir, "tmp")), - ], + gatewayLockDir: resolveGatewayLockDir(params.stateDir), preservedStatePaths: params.preservedStatePaths, }); const globalStateIdentity = await fs.stat(globalStateSqlitePath).catch((error: unknown) => { @@ -866,10 +863,7 @@ export async function createBackupArchive( const stateFilter = stateAsset ? buildStateBackupFilter(stateAsset.sourcePath, preservedStatePaths) : undefined; - const gatewayLockDirs = [ - resolveGatewayLockDir(), - resolveGatewayLockDir(() => path.join(plan.stateDir, "tmp")), - ]; + const gatewayLockDir = resolveGatewayLockDir(plan.stateDir); const volatilePlan = { stateDirs: [stateAsset?.sourcePath ?? plan.stateDir] }; let skippedVolatileCount = 0; // node-tar invokes filters from async stat callbacks, so throwing inside @@ -888,6 +882,9 @@ export async function createBackupArchive( if (stateFilter && !stateFilter(entryPath)) { return false; } + if (isPathWithin(resolvedEntryPath, gatewayLockDir)) { + return false; + } if ( stateAsset && isLegacyAuditMigrationBackupPath(resolvedEntryPath, stateAsset.sourcePath) @@ -895,11 +892,7 @@ export async function createBackupArchive( return false; } const sqliteSourceKind = stateAsset - ? classifyStateSqliteBackupSourcePath( - resolvedEntryPath, - stateAsset.sourcePath, - gatewayLockDirs, - ) + ? classifyStateSqliteBackupSourcePath(resolvedEntryPath, stateAsset.sourcePath) : undefined; if (sqliteSourceKind === "excluded") { return false; diff --git a/src/infra/backup-volatile-filter.test.ts b/src/infra/backup-volatile-filter.test.ts index 65321d4dae81..009ac0868e9d 100644 --- a/src/infra/backup-volatile-filter.test.ts +++ b/src/infra/backup-volatile-filter.test.ts @@ -128,14 +128,6 @@ describe("isVolatileBackupPath", () => { }); describe("isTransientSqliteBackupPath", () => { - it.each([ - "tmp/openclaw-502/gateway.12345678.lock.sqlite", - "tmp/openclaw-502/gateway.12345678.lock.sqlite-wal", - "tmp/openclaw-502/device-identity.12345678.lock.sqlite-journal", - ])("classifies transient coordinator state: %s", (filePath) => { - expect(isTransientSqliteBackupPath(filePath, ["tmp/openclaw-502"])).toBe(true); - }); - it.each([ "memory/main.sqlite.reindex-lock.sqlite", "memory/main.sqlite.reindex-lock.sqlite-shm", @@ -145,19 +137,15 @@ describe("isTransientSqliteBackupPath", () => { }); it.each([ + "tmp/openclaw-502/gateway.state.lock.sqlite", + "tmp/openclaw-502/gateway.12345678.lock.sqlite-wal", + "tmp/openclaw-502/device-identity.12345678.lock.sqlite-journal", "tmp/openclaw-502/retained.sqlite", "plugins/dedicated/durable.sqlite", "plugins/dedicated/cache.lock.sqlite", "plugins/dedicated/durable.locked.sqlite", "plugins/dedicated/lock.sqlite", ])("preserves durable SQLite state: %s", (filePath) => { - expect(isTransientSqliteBackupPath(filePath, ["tmp/openclaw-502"])).toBe(false); - }); - - it.each([ - "plugins/dedicated/gateway.12345678.lock.sqlite", - "plugins/dedicated/device-identity.12345678.lock.sqlite", - ])("preserves coordinator-shaped databases outside the lock directory: %s", (filePath) => { - expect(isTransientSqliteBackupPath(filePath, ["tmp/openclaw-502"])).toBe(false); + expect(isTransientSqliteBackupPath(filePath)).toBe(false); }); }); diff --git a/src/infra/backup-volatile-filter.ts b/src/infra/backup-volatile-filter.ts index 4b65e9ee278e..9477214c0b19 100644 --- a/src/infra/backup-volatile-filter.ts +++ b/src/infra/backup-volatile-filter.ts @@ -1,6 +1,5 @@ // Filters volatile files from backup manifests. import path from "node:path"; -import { isPathInside } from "./path-guards.js"; /** * Paths that are known to change during a live backup and commonly trigger @@ -14,8 +13,6 @@ import { isPathInside } from "./path-guards.js"; */ const STATE_TRANSIENT_EXTENSIONS = new Set([".sock", ".pid", ".tmp"]); -const SQLITE_COORDINATOR_BASENAME_PATTERN = - /^(?:gateway(?:\.state)?|device-identity)\.[0-9a-f]{8}\.lock\.sqlite(?:-wal|-shm|-journal)?$/iu; const SQLITE_REINDEX_TRANSIENT_PATH_PATTERN = /(?:^|\/)(?:[^/]+\.sqlite\.reindex-lock\.sqlite|[^/]+\.sqlite\.(?:backup|memory-reindex|tmp)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:-wal|-shm|-journal)?$/iu; @@ -45,18 +42,9 @@ function hasExtensionInSet(filePosix: string, extensions: ReadonlySet): return extensions.has(path.posix.extname(filePosix).toLowerCase()); } -export function isTransientSqliteBackupPath( - filePath: string, - coordinatorDirs: readonly string[] = [], -): boolean { +export function isTransientSqliteBackupPath(filePath: string): boolean { const normalizedPath = normalizePosix(filePath); - if (SQLITE_REINDEX_TRANSIENT_PATH_PATTERN.test(normalizedPath)) { - return true; - } - if (!SQLITE_COORDINATOR_BASENAME_PATTERN.test(path.posix.basename(normalizedPath))) { - return false; - } - return coordinatorDirs.some((coordinatorDir) => isPathInside(coordinatorDir, filePath)); + return SQLITE_REINDEX_TRANSIENT_PATH_PATTERN.test(normalizedPath); } function isAgentSessionTranscriptPath(filePosix: string, stateDirPosix: string): boolean { diff --git a/src/infra/device-identity-coordinator.ts b/src/infra/device-identity-coordinator.ts index 296d680adadf..136f70a17665 100644 --- a/src/infra/device-identity-coordinator.ts +++ b/src/infra/device-identity-coordinator.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { resolveGatewayLockDir } from "../config/paths.js"; +import { resolveGatewayLockDir, resolveStateDir } from "../config/paths.js"; import { openNodeSqliteDatabase } from "./node-sqlite.js"; const DEFAULT_BUSY_TIMEOUT_MS = 5000; @@ -39,10 +39,7 @@ function canonicalizeDatabasePath(databasePath: string): string { } } -function resolveDeviceIdentityCoordinatorPath( - databasePath: string, - lockDir = resolveGatewayLockDir(), -): string { +function resolveDeviceIdentityCoordinatorPath(databasePath: string, lockDir: string): string { const canonicalPath = canonicalizeDatabasePath(databasePath); const databaseHash = crypto.createHash("sha256").update(canonicalPath).digest("hex").slice(0, 8); return path.join(lockDir, `device-identity.${databaseHash}.lock.sqlite`); @@ -57,7 +54,7 @@ function ensurePrivateCoordinatorDirectory(lockDir: string): void { throw error; } try { - fs.mkdirSync(lockDir, { mode: 0o700 }); + fs.mkdirSync(lockDir, { mode: 0o700, recursive: true }); } catch (mkdirError) { if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") { throw mkdirError; @@ -90,9 +87,12 @@ function ensurePrivateCoordinatorDirectory(lockDir: string): void { export function acquireDeviceIdentityCoordinator(params: { databasePath: string; busyTimeoutMs?: number; + env?: NodeJS.ProcessEnv; lockDir?: string; }): { release: () => void } { - const coordinatorPath = resolveDeviceIdentityCoordinatorPath(params.databasePath, params.lockDir); + const lockDir = + params.lockDir ?? resolveGatewayLockDir(resolveStateDir(params.env ?? process.env)); + const coordinatorPath = resolveDeviceIdentityCoordinatorPath(params.databasePath, lockDir); ensurePrivateCoordinatorDirectory(path.dirname(coordinatorPath)); const database = openNodeSqliteDatabase(coordinatorPath); try { diff --git a/src/infra/device-identity.state-dir.test.ts b/src/infra/device-identity.state-dir.test.ts index 1f791006e7ec..21c1654e2d4b 100644 --- a/src/infra/device-identity.state-dir.test.ts +++ b/src/infra/device-identity.state-dir.test.ts @@ -1,13 +1,17 @@ // Covers default device identity SQLite path under the state dir. import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveGatewayLockDir } from "../config/paths.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; +import { withTempDir } from "../test-utils/temp-dir.js"; import { loadDeviceIdentityIfPresent, loadOrCreateDeviceIdentity } from "./device-identity.js"; afterEach(() => { closeOpenClawStateDatabaseForTest(); + vi.restoreAllMocks(); }); describe("device identity state dir defaults", () => { @@ -15,9 +19,13 @@ describe("device identity state dir defaults", () => { await withStateDirEnv("openclaw-identity-state-", async ({ stateDir }) => { const identity = loadOrCreateDeviceIdentity(); const databasePath = path.join(stateDir, "state", "openclaw.sqlite"); + const lockDir = resolveGatewayLockDir(stateDir); expect(loadDeviceIdentityIfPresent()).toEqual(identity); expect(fs.existsSync(databasePath)).toBe(true); + expect(fs.readdirSync(lockDir)).toContainEqual( + expect.stringMatching(/^device-identity\.[0-9a-f]{8}\.lock\.sqlite$/u), + ); expect(fs.existsSync(path.join(stateDir, "identity", "device.json"))).toBe(false); }); }); @@ -31,6 +39,32 @@ describe("device identity state dir defaults", () => { }); }); + it("uses the supplied state environment for its coordinator", async () => { + await withTempDir("openclaw-identity-env-state-", async (rootDir) => { + const stateDir = path.join(rootDir, "selected-state"); + const fakeHome = path.join(rootDir, "home"); + const legacyTmpDir = path.join(rootDir, "legacy-process-tmp"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.mkdirSync(fakeHome, { recursive: true }); + fs.mkdirSync(legacyTmpDir, { recursive: true }); + vi.spyOn(os, "tmpdir").mockReturnValue(legacyTmpDir); + const env = { + ...process.env, + HOME: fakeHome, + OPENCLAW_HOME: fakeHome, + OPENCLAW_STATE_DIR: stateDir, + }; + + loadOrCreateDeviceIdentity({ env }); + + expect(fs.readdirSync(resolveGatewayLockDir(stateDir))).toContainEqual( + expect.stringMatching(/^device-identity\.[0-9a-f]{8}\.lock\.sqlite$/u), + ); + expect(fs.readdirSync(legacyTmpDir)).toEqual([]); + expect(fs.existsSync(path.join(fakeHome, ".openclaw"))).toBe(false); + }); + }); + it("keeps read-only lookup non-creating when the default database is absent", async () => { await withStateDirEnv("openclaw-identity-state-", async ({ stateDir }) => { const databasePath = path.join(stateDir, "state", "openclaw.sqlite"); diff --git a/src/infra/device-identity.ts b/src/infra/device-identity.ts index 406a1a3d83d2..f9a8fa8c827c 100644 --- a/src/infra/device-identity.ts +++ b/src/infra/device-identity.ts @@ -100,7 +100,10 @@ function withDeviceIdentityCoordinator( path: resolved.databasePath, identityKey: resolved.identityKey, }; - const coordinator = acquireDeviceIdentityCoordinator({ databasePath: resolved.databasePath }); + const coordinator = acquireDeviceIdentityCoordinator({ + databasePath: resolved.databasePath, + env: options.env, + }); let result: T; try { result = operation(resolved, resolvedOptions); diff --git a/src/infra/gateway-lock.state-dir.test.ts b/src/infra/gateway-lock.state-dir.test.ts new file mode 100644 index 000000000000..535c3915ac6f --- /dev/null +++ b/src/infra/gateway-lock.state-dir.test.ts @@ -0,0 +1,81 @@ +// Covers the production Gateway lock layout under an overridden state directory. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveGatewayLockDir } from "../config/paths.js"; +import { withTempDir } from "../test-utils/temp-dir.js"; +import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js"; + +type GatewayLock = NonNullable>>; + +function expectGatewayLock(lock: Awaited>): GatewayLock { + if (!lock) { + throw new Error("Expected gateway lock"); + } + return lock; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("gateway lock state directory", () => { + it("keeps lock, coordinator, and reclaim paths inside the selected state", async () => { + await withTempDir("openclaw-gateway-lock-state-", async (root) => { + const canonicalRoot = await fs.realpath(root); + const stateDir = path.join(canonicalRoot, "selected-state"); + const fakeHome = path.join(canonicalRoot, "home"); + const legacyTmpDir = path.join(canonicalRoot, "legacy-process-tmp"); + await fs.mkdir(stateDir, { recursive: true }); + await fs.mkdir(fakeHome, { recursive: true }); + await fs.mkdir(legacyTmpDir, { recursive: true }); + const configPath = path.join(stateDir, "openclaw.json"); + await fs.writeFile(configPath, "{}", "utf8"); + vi.spyOn(os, "tmpdir").mockReturnValue(legacyTmpDir); + const env = { + ...process.env, + HOME: fakeHome, + OPENCLAW_HOME: fakeHome, + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_STATE_DIR: stateDir, + }; + + const lock = expectGatewayLock( + await acquireGatewayLock({ allowInTests: true, env, timeoutMs: 30 }), + ); + const lockDir = resolveGatewayLockDir(stateDir); + const stateLockPath = path.join(lockDir, "gateway.state.lock"); + try { + expect(lock.stateLockPath).toBe(stateLockPath); + expect(path.dirname(lock.lockPath)).toBe(lockDir); + expect(path.basename(lock.lockPath)).toMatch(/^gateway\.[0-9a-f]{8}\.lock$/u); + await expect(fs.access(`${lock.lockPath}.sqlite`)).resolves.toBeUndefined(); + await expect(fs.access(`${lock.stateLockPath}.sqlite`)).resolves.toBeUndefined(); + } finally { + await lock.release(); + } + + const reclaimPath = `${stateLockPath}.reclaim`; + await fs.mkdir(reclaimPath); + try { + await expect( + acquireGatewayLock({ + allowInTests: true, + env, + pollIntervalMs: 2, + timeoutMs: 10, + }), + ).rejects.toBeInstanceOf(GatewayLockError); + expect(reclaimPath.startsWith(`${stateDir}${path.sep}`)).toBe(true); + } finally { + await fs.rmdir(reclaimPath); + } + + await expect(fs.readdir(legacyTmpDir)).resolves.toEqual([]); + await expect(fs.access(path.join(fakeHome, ".openclaw"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }); +}); diff --git a/src/infra/gateway-lock.test.ts b/src/infra/gateway-lock.test.ts index 655db00585ac..8a5c00bbf3e3 100644 --- a/src/infra/gateway-lock.test.ts +++ b/src/infra/gateway-lock.test.ts @@ -21,11 +21,10 @@ type GatewayLock = NonNullable>>; type GatewayLockOptions = NonNullable[0]>; const fixtureRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-gateway-lock-" }); -let fixtureRoot = ""; const realNow = Date.now.bind(Date); -function resolveTestLockDir() { - return path.join(fixtureRoot, "__locks"); +function resolveTestLockDir(env: NodeJS.ProcessEnv) { + return path.join(resolveStateDir(env), "__locks"); } async function makeEnv() { @@ -52,7 +51,7 @@ async function acquireForTest( sleep: async (ms) => { await nativeSleep(ms); }, - lockDir: resolveTestLockDir(), + lockDir: resolveTestLockDir(env), ...opts, }); } @@ -69,13 +68,12 @@ function resolveLockPath(env: NodeJS.ProcessEnv) { const stateDir = resolveStateDir(env); const configPath = resolveConfigPath(env, stateDir); const configHash = createHash("sha256").update(configPath).digest("hex").slice(0, 8); - const canonicalStateDir = fsSync.realpathSync.native(path.resolve(stateDir)); - const stateHash = createHash("sha256").update(canonicalStateDir).digest("hex").slice(0, 8); - const lockDir = resolveTestLockDir(); + const lockDir = resolveTestLockDir(env); + fsSync.mkdirSync(lockDir, { recursive: true }); return { lockPath: path.join(lockDir, `gateway.${configHash}.lock`), configPath, - stateLockPath: path.join(lockDir, `gateway.state.${stateHash}.lock`), + stateLockPath: path.join(lockDir, "gateway.state.lock"), }; } @@ -152,7 +150,7 @@ async function writeRecentLockFile(env: NodeJS.ProcessEnv, startTime = 111) { describe("gateway lock", () => { beforeAll(async () => { - fixtureRoot = await fixtureRootTracker.setup(); + await fixtureRootTracker.setup(); }); beforeEach(() => { @@ -165,7 +163,6 @@ describe("gateway lock", () => { afterAll(async () => { await fixtureRootTracker.cleanup(); - fixtureRoot = ""; }); afterEach(() => { @@ -278,7 +275,7 @@ describe("gateway lock", () => { await expect( readActiveGatewayLockPort({ env, - lockDir: resolveTestLockDir(), + lockDir: resolveTestLockDir(env), platform: "darwin", readProcessCmdline: () => ["openclaw-gateway"], }), @@ -304,7 +301,7 @@ describe("gateway lock", () => { }; const firstIdentity = await readActiveGatewayLockIdentity({ env, - lockDir: resolveTestLockDir(), + lockDir: resolveTestLockDir(env), platform: "darwin", readProcessCmdline: options.readProcessCmdline, }); @@ -315,7 +312,7 @@ describe("gateway lock", () => { try { const secondIdentity = await readActiveGatewayLockIdentity({ env, - lockDir: resolveTestLockDir(), + lockDir: resolveTestLockDir(env), platform: "darwin", readProcessCmdline: options.readProcessCmdline, }); @@ -355,7 +352,7 @@ describe("gateway lock", () => { await expect( readActiveGatewayLockPort({ env, - lockDir: resolveTestLockDir(), + lockDir: resolveTestLockDir(env), platform: "darwin", readProcessCmdline: () => ["openclaw-gateway"], }), @@ -384,7 +381,7 @@ describe("gateway lock", () => { await expect( readActiveGatewayLockPort({ env: envB, - lockDir: resolveTestLockDir(), + lockDir: resolveTestLockDir(envB), platform: "darwin", readProcessCmdline: () => ["openclaw-gateway"], }), @@ -475,7 +472,7 @@ describe("gateway lock", () => { await expect( readActiveGatewayLockPort({ env, - lockDir: resolveTestLockDir(), + lockDir: resolveTestLockDir(env), platform: "darwin", readProcessCmdline: () => null, }), @@ -872,7 +869,7 @@ describe("gateway lock", () => { sleepDelays.push(ms); now = 10; }, - lockDir: resolveTestLockDir(), + lockDir: resolveTestLockDir(env), readProcessCmdline: () => ["/usr/local/bin/openclaw", "gateway", "run"], readProcessStartTime: () => 111, }), @@ -888,7 +885,7 @@ describe("gateway lock", () => { await acquireGatewayLock({ allowInTests: true, env: { ...env, OPENCLAW_ALLOW_MULTI_GATEWAY: "1", VITEST: "" }, - lockDir: resolveTestLockDir(), + lockDir: resolveTestLockDir(env), }), ); @@ -900,7 +897,7 @@ describe("gateway lock", () => { acquireGatewayLock({ allowInTests: true, env, - lockDir: resolveTestLockDir(), + lockDir: resolveTestLockDir(env), platform: "darwin", readProcessCmdline: () => ["openclaw-gateway"], timeoutMs: 15, @@ -915,7 +912,7 @@ describe("gateway lock", () => { const env = await makeEnv(); const lock = await acquireGatewayLock({ env: { ...env, VITEST: "1" }, - lockDir: resolveTestLockDir(), + lockDir: resolveTestLockDir(env), }); expect(lock).toBeNull(); }); @@ -931,7 +928,7 @@ describe("gateway lock", () => { pollIntervalMs: 2, now: () => 8_640_000_000_000_001, sleep: async () => {}, - lockDir: resolveTestLockDir(), + lockDir: resolveTestLockDir(env), }), ); diff --git a/src/infra/gateway-lock.ts b/src/infra/gateway-lock.ts index eb732ce81777..e658d9752881 100644 --- a/src/infra/gateway-lock.ts +++ b/src/infra/gateway-lock.ts @@ -293,17 +293,17 @@ function canonicalizeStateDir(stateDir: string): string { } } -function resolveGatewayLockPaths(env: NodeJS.ProcessEnv, lockDir = resolveGatewayLockDir()) { +function resolveGatewayLockPaths(env: NodeJS.ProcessEnv, suppliedLockDir?: string) { const resolvedStateDir = resolveStateDir(env); const stateDir = canonicalizeStateDir(resolvedStateDir); + const lockDir = suppliedLockDir ?? resolveGatewayLockDir(stateDir); const configPath = resolveConfigPath(env, resolvedStateDir); const configHash = sha256HexPrefix(configPath, 8); - const stateHash = sha256HexPrefix(stateDir, 8); return { configLockPath: path.join(lockDir, `gateway.${configHash}.lock`), configPath, stateDir, - stateLockPath: path.join(lockDir, `gateway.state.${stateHash}.lock`), + stateLockPath: path.join(lockDir, "gateway.state.lock"), }; } diff --git a/src/infra/state-migrations.device-identity.ts b/src/infra/state-migrations.device-identity.ts index aa8666744509..5acaafc25131 100644 --- a/src/infra/state-migrations.device-identity.ts +++ b/src/infra/state-migrations.device-identity.ts @@ -535,6 +535,7 @@ export async function migrateLegacyDeviceIdentity(params: { try { identityCoordinator = acquireDeviceIdentityCoordinator({ databasePath: resolveDeviceIdentityStore({ env, identityKey: IDENTITY_KEY }).databasePath, + env, }); } catch (error) { return {