mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix(gateway): derive lock and coordinator paths from the resolved state dir (#120110)
This commit is contained in:
committed by
GitHub
parent
2013123472
commit
70abd88e03
@@ -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-<uid>` (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 <port>`.
|
||||
|
||||
+7
-12
@@ -275,18 +275,13 @@ fly machine update <machine-id> --vm-memory 2048 -y
|
||||
|
||||
Gateway refuses to start with "already running" errors after a container restart.
|
||||
|
||||
The runtime lock files live at `<tmpdir>/openclaw-<uid>/gateway.<hash>.lock`
|
||||
and `gateway.state.<hash>.lock` (Linux:
|
||||
`/tmp/openclaw-<uid>/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 <machine-id>
|
||||
```
|
||||
With `OPENCLAW_STATE_DIR=/data`, the lock tree lives under
|
||||
`/data/tmp/openclaw-<uid>` 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
|
||||
|
||||
|
||||
+6
-5
@@ -402,14 +402,15 @@ export function resolveDefaultConfigCandidates(
|
||||
export const DEFAULT_GATEWAY_PORT = 18789;
|
||||
|
||||
/**
|
||||
* Gateway lock directory (ephemeral).
|
||||
* Default: os.tmpdir()/openclaw-<uid> (uid suffix when available).
|
||||
* Gateway lock directory inside the selected state tree.
|
||||
* Default: $OPENCLAW_STATE_DIR/tmp/openclaw-<uid> (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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)),
|
||||
|
||||
+13
-20
@@ -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<string> }> {
|
||||
const snapshotPaths = new Set<string>();
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>):
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -100,7 +100,10 @@ function withDeviceIdentityCoordinator<T>(
|
||||
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);
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof acquireGatewayLock>>>;
|
||||
|
||||
function expectGatewayLock(lock: Awaited<ReturnType<typeof acquireGatewayLock>>): 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,11 +21,10 @@ type GatewayLock = NonNullable<Awaited<ReturnType<typeof acquireGatewayLock>>>;
|
||||
type GatewayLockOptions = NonNullable<Parameters<typeof acquireGatewayLock>[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),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -535,6 +535,7 @@ export async function migrateLegacyDeviceIdentity(params: {
|
||||
try {
|
||||
identityCoordinator = acquireDeviceIdentityCoordinator({
|
||||
databasePath: resolveDeviceIdentityStore({ env, identityKey: IDENTITY_KEY }).databasePath,
|
||||
env,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user