mirror of
https://github.com/openclaw/lobster.git
synced 2026-08-25 12:50:33 -04:00
1081 lines
36 KiB
TypeScript
1081 lines
36 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { mkdtempSync, promises as fsp } from "node:fs";
|
|
import { createDefaultRegistry } from "../src/commands/registry.js";
|
|
import { runPipeline } from "../src/runtime.js";
|
|
import { diffLast, diffAndStoreValue } from "../src/sdk/primitives/diff.js";
|
|
import { stateSet, readState, writeState } from "../src/sdk/primitives/state.js";
|
|
import {
|
|
createApprovalIndex,
|
|
consumeResumeState,
|
|
deleteResumeStateWithRollback,
|
|
diffAndStore,
|
|
keyToPath,
|
|
withFileLock,
|
|
ensureDirectory,
|
|
stripExtendedLengthPrefix,
|
|
writeStateJson,
|
|
readStateJsonWithLock as readStateJson,
|
|
writeFileAtomic,
|
|
writeFileAtomicExclusive,
|
|
} from "../src/state/store.js";
|
|
|
|
function streamOf(items) {
|
|
return (async function* () {
|
|
for (const item of items) yield item;
|
|
})();
|
|
}
|
|
|
|
test("state key boundary trimming preserves normalized filenames", () => {
|
|
const tmp = path.join(os.tmpdir(), "lobster-state");
|
|
|
|
for (const [key, filename] of [
|
|
["_Demo KEY_", "demo_key.json"],
|
|
["___Demo KEY___", "demo_key.json"],
|
|
[" Demo KEY ", "demo_key.json"],
|
|
["-Demo KEY-", "-demo_key-.json"],
|
|
[".Demo KEY.", ".demo_key..json"],
|
|
]) {
|
|
assert.equal(keyToPath(tmp, key), path.join(tmp, filename));
|
|
}
|
|
assert.throws(() => keyToPath(tmp, "_"), /state key is empty\/invalid/);
|
|
assert.throws(() => keyToPath(tmp, "___"), /state key is empty\/invalid/);
|
|
});
|
|
|
|
test("state.set writes and state.get reads", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-"));
|
|
const registry = createDefaultRegistry();
|
|
|
|
const env = { ...process.env, LOBSTER_STATE_DIR: tmp };
|
|
|
|
// write
|
|
const setCmd = registry.get("state.set");
|
|
await setCmd.run({
|
|
input: streamOf([{ a: 1 }]),
|
|
args: { _: ["demo-key"] },
|
|
ctx: {
|
|
stdin: process.stdin,
|
|
stdout: process.stdout,
|
|
stderr: process.stderr,
|
|
env,
|
|
registry,
|
|
mode: "tool",
|
|
render: { json() {}, lines() {} },
|
|
},
|
|
});
|
|
|
|
// read
|
|
const getCmd = registry.get("state.get");
|
|
const res = await getCmd.run({
|
|
input: streamOf([]),
|
|
args: { _: ["demo-key"] },
|
|
ctx: {
|
|
stdin: process.stdin,
|
|
stdout: process.stdout,
|
|
stderr: process.stderr,
|
|
env,
|
|
registry,
|
|
mode: "tool",
|
|
render: { json() {}, lines() {} },
|
|
},
|
|
});
|
|
|
|
const items = [];
|
|
for await (const it of res.output) items.push(it);
|
|
assert.deepEqual(items, [{ a: 1 }]);
|
|
});
|
|
|
|
test("state.get returns null for missing key", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-"));
|
|
const registry = createDefaultRegistry();
|
|
const env = { ...process.env, LOBSTER_STATE_DIR: tmp };
|
|
|
|
const output = await runPipeline({
|
|
pipeline: [{ name: "state.get", args: { _: ["missing"] }, raw: "state.get missing" }],
|
|
registry,
|
|
input: [],
|
|
stdin: process.stdin,
|
|
stdout: process.stdout,
|
|
stderr: process.stderr,
|
|
env,
|
|
mode: "tool",
|
|
});
|
|
|
|
assert.deepEqual(output.items, [null]);
|
|
});
|
|
|
|
test("ordinary state reads work when creating a coordination lock is forbidden", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-readonly-state-"));
|
|
const env = { ...process.env, LOBSTER_STATE_DIR: tmp };
|
|
const key = "demo";
|
|
const value = { readable: true };
|
|
const statePath = keyToPath(tmp, key);
|
|
const lockPath = `${statePath}.lock`;
|
|
const originalMkdir = fsp.mkdir;
|
|
await fsp.writeFile(statePath, JSON.stringify(value), "utf8");
|
|
Object.defineProperty(fsp, "mkdir", {
|
|
configurable: true,
|
|
writable: true,
|
|
async value(
|
|
filePath: Parameters<typeof fsp.mkdir>[0],
|
|
options?: Parameters<typeof fsp.mkdir>[1],
|
|
) {
|
|
if (String(filePath) === lockPath) {
|
|
throw Object.assign(new Error("read-only state directory"), { code: "EACCES" });
|
|
}
|
|
return originalMkdir(filePath, options);
|
|
},
|
|
});
|
|
try {
|
|
assert.deepEqual(await readState(key, { env }), value);
|
|
const registry = createDefaultRegistry();
|
|
const output = await runPipeline({
|
|
pipeline: [{ name: "state.get", args: { _: [key] }, raw: `state.get ${key}` }],
|
|
registry,
|
|
input: [],
|
|
stdin: process.stdin,
|
|
stdout: process.stdout,
|
|
stderr: process.stderr,
|
|
env,
|
|
mode: "tool",
|
|
});
|
|
assert.deepEqual(output.items, [value]);
|
|
} finally {
|
|
Object.defineProperty(fsp, "mkdir", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: originalMkdir,
|
|
});
|
|
await fsp.rm(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// --- Atomic-write behavior proofs (issues #108, #109) ---
|
|
//
|
|
// Plain fsp.writeFile truncates the target before writing, so a concurrent
|
|
// reader (or a crash mid-write) can observe an empty/partial file and fail to
|
|
// JSON.parse it. These tests drive many large writes while reading in parallel
|
|
// and assert the reader NEVER sees a truncated value. They fail against the
|
|
// pre-fix non-atomic writeFile and pass with writeFileAtomic (stage + rename).
|
|
|
|
test("writeStateJson is atomic: concurrent reads never observe truncated state (#108)", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-store-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
const key = "pipeline-resume";
|
|
const payload = "x".repeat(256 * 1024); // large enough that writeFile is not instantaneous
|
|
|
|
await writeStateJson({ env, key, value: { payload, n: 0 } });
|
|
|
|
let readErrors = 0;
|
|
let partialReads = 0;
|
|
const reader = (async () => {
|
|
for (let i = 0; i < 500; i++) {
|
|
try {
|
|
const v = await readStateJson({ env, key });
|
|
if (!v || v.payload !== payload) partialReads++;
|
|
} catch {
|
|
readErrors++; // JSON.parse on truncated content throws SyntaxError
|
|
}
|
|
}
|
|
})();
|
|
const writer = (async () => {
|
|
for (let n = 1; n <= 150; n++) {
|
|
await writeStateJson({ env, key, value: { payload, n } });
|
|
}
|
|
})();
|
|
await Promise.all([reader, writer]);
|
|
|
|
assert.equal(readErrors, 0, "reader must never hit a parse/IO error mid-write");
|
|
assert.equal(partialReads, 0, "reader must never observe truncated/empty state");
|
|
|
|
const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
|
|
assert.deepEqual(leftovers, [], "atomic write must not leave temp files behind");
|
|
});
|
|
|
|
test("writeFileAtomic creates private files and preserves existing modes", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-mode-"));
|
|
const freshPath = path.join(tmp, "fresh.json");
|
|
const existingPath = path.join(tmp, "existing.json");
|
|
|
|
await writeFileAtomic(freshPath, '{"ok":true}\n');
|
|
assert.equal((await fsp.stat(freshPath)).mode & 0o777, 0o600);
|
|
|
|
await fsp.writeFile(existingPath, '{"old":true}\n', { mode: 0o640 });
|
|
await fsp.chmod(existingPath, 0o640);
|
|
await writeFileAtomic(existingPath, '{"ok":true}\n');
|
|
assert.equal((await fsp.stat(existingPath)).mode & 0o777, 0o640);
|
|
});
|
|
|
|
test("writeFileAtomic removes temp files when replacement fails", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-cleanup-"));
|
|
const targetDir = path.join(tmp, "state.json");
|
|
await fsp.mkdir(targetDir);
|
|
|
|
await assert.rejects(() => writeFileAtomic(targetDir, '{"ok":true}\n'));
|
|
const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
|
|
assert.deepEqual(leftovers, []);
|
|
});
|
|
|
|
test("writeFileAtomic leaves existing target untouched when publish fails", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-fault-"));
|
|
const target = path.join(tmp, "state.json");
|
|
await fsp.writeFile(target, '{"old":true}\n', { mode: 0o600 });
|
|
const fault = Object.assign(new Error("rename failed"), { code: "EIO" });
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
writeFileAtomic(target, '{"new":true}\n', {
|
|
async renameFile() {
|
|
throw fault;
|
|
},
|
|
}),
|
|
(err: NodeJS.ErrnoException) => err?.code === "EIO",
|
|
);
|
|
|
|
assert.equal(await fsp.readFile(target, "utf8"), '{"old":true}\n');
|
|
const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
|
|
assert.deepEqual(leftovers, []);
|
|
});
|
|
|
|
test("writeFileAtomic propagates parent directory sync failures", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-dir-sync-"));
|
|
const target = path.join(tmp, "state.json");
|
|
const fault = Object.assign(new Error("dir sync failed"), { code: "EIO" });
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
writeFileAtomic(target, '{"ok":true}\n', {
|
|
async syncParentDir() {
|
|
throw fault;
|
|
},
|
|
}),
|
|
(err: NodeJS.ErrnoException) => err?.code === "EIO",
|
|
);
|
|
|
|
assert.equal(await fsp.readFile(target, "utf8"), '{"ok":true}\n');
|
|
const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
|
|
assert.deepEqual(leftovers, []);
|
|
});
|
|
|
|
test("consumeResumeState restores a published marker when parent sync fails", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-consume-dir-sync-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
const expectedState = { haltType: "approval_request", pipeline: [] };
|
|
const fault = Object.assign(new Error("dir sync failed after resume claim"), { code: "EIO" });
|
|
const originalOpen = fsp.open;
|
|
let failNextDirectorySync = true;
|
|
|
|
await writeStateJson({ env, key: "resume", value: expectedState });
|
|
Object.defineProperty(fsp, "open", {
|
|
configurable: true,
|
|
writable: true,
|
|
async value(...args: any[]) {
|
|
const handle = await (originalOpen as any)(...args);
|
|
if (failNextDirectorySync && String(args[0]) === tmp && args[1] === "r") {
|
|
failNextDirectorySync = false;
|
|
return new Proxy(handle, {
|
|
get(target, property) {
|
|
if (property === "sync") return async () => Promise.reject(fault);
|
|
const value = Reflect.get(target, property);
|
|
return typeof value === "function" ? value.bind(target) : value;
|
|
},
|
|
});
|
|
}
|
|
return handle;
|
|
},
|
|
});
|
|
|
|
try {
|
|
await assert.rejects(
|
|
() => consumeResumeState({ env, key: "resume", expectedState }),
|
|
(err: NodeJS.ErrnoException) => err?.code === "EIO",
|
|
);
|
|
assert.deepEqual(await readStateJson({ env, key: "resume" }), expectedState);
|
|
} finally {
|
|
Object.defineProperty(fsp, "open", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: originalOpen,
|
|
});
|
|
}
|
|
});
|
|
|
|
test("deleteResumeStateWithRollback restores a published marker when parent sync fails", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-delete-resume-dir-sync-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
const expectedState = { haltType: "input_request", pipeline: [] };
|
|
const fault = Object.assign(new Error("dir sync failed after terminal resume claim"), {
|
|
code: "EIO",
|
|
});
|
|
const originalOpen = fsp.open;
|
|
let failNextDirectorySync = true;
|
|
|
|
await writeStateJson({ env, key: "resume", value: expectedState });
|
|
Object.defineProperty(fsp, "open", {
|
|
configurable: true,
|
|
writable: true,
|
|
async value(...args: any[]) {
|
|
const handle = await (originalOpen as any)(...args);
|
|
if (failNextDirectorySync && String(args[0]) === tmp && args[1] === "r") {
|
|
failNextDirectorySync = false;
|
|
return new Proxy(handle, {
|
|
get(target, property) {
|
|
if (property === "sync") return async () => Promise.reject(fault);
|
|
const value = Reflect.get(target, property);
|
|
return typeof value === "function" ? value.bind(target) : value;
|
|
},
|
|
});
|
|
}
|
|
return handle;
|
|
},
|
|
});
|
|
|
|
try {
|
|
await assert.rejects(
|
|
() => deleteResumeStateWithRollback({ env, key: "resume", expectedState }),
|
|
(err: NodeJS.ErrnoException) => err?.code === "EIO",
|
|
);
|
|
assert.deepEqual(await readStateJson({ env, key: "resume" }), expectedState);
|
|
} finally {
|
|
Object.defineProperty(fsp, "open", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: originalOpen,
|
|
});
|
|
}
|
|
});
|
|
|
|
test("readStateJson surfaces malformed authoritative state", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-corrupt-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
await fsp.writeFile(path.join(tmp, "resume.json"), '{"partial"', "utf8");
|
|
|
|
await assert.rejects(() => readStateJson({ env, key: "resume" }), SyntaxError);
|
|
});
|
|
|
|
test("writeFileAtomicExclusive creates private files without replacing existing targets", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-exclusive-"));
|
|
const target = path.join(tmp, "approval_deadbeef.json");
|
|
|
|
await writeFileAtomicExclusive(target, '{"stateKey":"original"}\n');
|
|
assert.equal((await fsp.stat(target)).mode & 0o777, 0o600);
|
|
|
|
await assert.rejects(
|
|
() => writeFileAtomicExclusive(target, '{"stateKey":"replacement"}\n'),
|
|
(err: NodeJS.ErrnoException) => err?.code === "EEXIST",
|
|
);
|
|
assert.equal(await fsp.readFile(target, "utf8"), '{"stateKey":"original"}\n');
|
|
|
|
const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
|
|
assert.deepEqual(leftovers, []);
|
|
});
|
|
|
|
test("writeFileAtomicExclusive removes temp link before final directory sync", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-exclusive-sync-order-"));
|
|
const target = path.join(tmp, "approval_deadbeef.json");
|
|
let filesAtSync: string[] = [];
|
|
|
|
await writeFileAtomicExclusive(target, '{"stateKey":"original"}\n', {
|
|
async syncParentDir() {
|
|
filesAtSync = await fsp.readdir(tmp);
|
|
},
|
|
});
|
|
|
|
assert.equal(await fsp.readFile(target, "utf8"), '{"stateKey":"original"}\n');
|
|
assert.ok(filesAtSync.includes("approval_deadbeef.json"));
|
|
assert.deepEqual(
|
|
filesAtSync.filter((file) => file.includes(".tmp")),
|
|
[],
|
|
);
|
|
});
|
|
|
|
test("writeFileAtomicExclusive rejects unsupported hard links without a partial target", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-exclusive-unsupported-"));
|
|
const target = path.join(tmp, "approval_deadbeef.json");
|
|
const unsupported = Object.assign(new Error("operation not supported"), { code: "ENOTSUP" });
|
|
const options = {
|
|
async linkFile() {
|
|
throw unsupported;
|
|
},
|
|
};
|
|
|
|
await assert.rejects(
|
|
() => writeFileAtomicExclusive(target, '{"stateKey":"original"}\n', options),
|
|
(err: NodeJS.ErrnoException) => err?.code === "ENOTSUP",
|
|
);
|
|
await assert.rejects(
|
|
() => fsp.stat(target),
|
|
(err: NodeJS.ErrnoException) => err?.code === "ENOENT",
|
|
);
|
|
|
|
const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
|
|
assert.deepEqual(leftovers, []);
|
|
});
|
|
|
|
test("writeFileAtomicExclusive removes published target when parent directory sync fails", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-exclusive-dir-sync-"));
|
|
const target = path.join(tmp, "approval_deadbeef.json");
|
|
const fault = Object.assign(new Error("dir sync failed"), { code: "EIO" });
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
writeFileAtomicExclusive(target, '{"stateKey":"original"}\n', {
|
|
async syncParentDir() {
|
|
throw fault;
|
|
},
|
|
}),
|
|
(err: NodeJS.ErrnoException) => err?.code === "EIO",
|
|
);
|
|
await assert.rejects(
|
|
() => fsp.stat(target),
|
|
(err: NodeJS.ErrnoException) => err?.code === "ENOENT",
|
|
);
|
|
|
|
const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
|
|
assert.deepEqual(leftovers, []);
|
|
});
|
|
|
|
test("createApprovalIndex omits short ID when atomic exclusive publish is unsupported", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-approval-index-unsupported-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
const unsupported = Object.assign(new Error("operation not supported"), { code: "ENOTSUP" });
|
|
|
|
const approvalId = await createApprovalIndex({
|
|
env,
|
|
stateKey: "workflow_resume_1",
|
|
options: {
|
|
async linkFile() {
|
|
throw unsupported;
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.equal(approvalId, null);
|
|
const files = await fsp.readdir(tmp);
|
|
assert.deepEqual(files, []);
|
|
});
|
|
|
|
test("createApprovalIndex omits short ID when approval index durability fails", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-approval-index-sync-fails-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
const fault = Object.assign(new Error("dir sync failed"), { code: "EIO" });
|
|
|
|
const approvalId = await createApprovalIndex({
|
|
env,
|
|
stateKey: "workflow_resume_1",
|
|
options: {
|
|
async syncParentDir() {
|
|
throw fault;
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.equal(approvalId, null);
|
|
const files = await fsp.readdir(tmp);
|
|
assert.deepEqual(files, []);
|
|
});
|
|
|
|
test("diffAndStore treats corrupt previous state as a miss and rewrites atomically (#112)", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-corrupt-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
await fsp.writeFile(path.join(tmp, "snapshot.json"), '{"partial"', "utf8");
|
|
|
|
const result = await diffAndStore({ env, key: "snapshot", value: { ok: true } });
|
|
|
|
assert.equal(result.before, null);
|
|
assert.equal(result.changed, true);
|
|
assert.deepEqual(await readStateJson({ env, key: "snapshot" }), { ok: true });
|
|
});
|
|
|
|
test("diffAndStore rolls back a state publication when its parent-directory sync fails", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-dir-sync-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
await writeStateJson({ env, key: "snapshot", value: { version: "before" } });
|
|
const fault = Object.assign(new Error("dir sync failed after state publication"), {
|
|
code: "EIO",
|
|
});
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
diffAndStore({
|
|
env,
|
|
key: "snapshot",
|
|
value: { version: "after" },
|
|
atomicWriteOptions: {
|
|
async syncParentDir() {
|
|
throw fault;
|
|
},
|
|
},
|
|
}),
|
|
(err: NodeJS.ErrnoException) => err?.code === "EIO",
|
|
);
|
|
|
|
assert.deepEqual(await readStateJson({ env, key: "snapshot" }), { version: "before" });
|
|
await fsp.rm(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
test("diffAndStore does not publish a snapshot after cancellation before atomic replace", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-cancel-publish-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
await writeStateJson({ env, key: "snapshot", value: { version: "before" } });
|
|
|
|
const controller = new AbortController();
|
|
const signal = controller.signal;
|
|
const throwIfAborted = signal.throwIfAborted.bind(signal);
|
|
let signalChecks = 0;
|
|
Object.defineProperty(signal, "throwIfAborted", {
|
|
value() {
|
|
signalChecks += 1;
|
|
if (signalChecks === 2) controller.abort(new Error("abort before state publish"));
|
|
throwIfAborted();
|
|
},
|
|
});
|
|
|
|
await assert.rejects(
|
|
() => diffAndStore({ env, key: "snapshot", value: { version: "after" }, signal }),
|
|
/abort before state publish/,
|
|
);
|
|
assert.equal(signalChecks, 2);
|
|
assert.deepEqual(await readStateJson({ env, key: "snapshot" }), { version: "before" });
|
|
const leftovers = (await fsp.readdir(tmp)).filter((file) => file.includes(".tmp"));
|
|
assert.deepEqual(leftovers, []);
|
|
});
|
|
|
|
test("diffAndStore restores the previous snapshot when cancellation arrives during atomic rename", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-cancel-rename-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
await writeStateJson({ env, key: "snapshot", value: { version: "before" } });
|
|
|
|
const controller = new AbortController();
|
|
await assert.rejects(
|
|
() =>
|
|
diffAndStore({
|
|
env,
|
|
key: "snapshot",
|
|
value: { version: "after" },
|
|
signal: controller.signal,
|
|
atomicWriteOptions: {
|
|
async renameFile(from, to) {
|
|
await fsp.rename(from, to);
|
|
controller.abort(new Error("abort during atomic rename"));
|
|
},
|
|
},
|
|
}),
|
|
/abort during atomic rename/,
|
|
);
|
|
assert.deepEqual(await readStateJson({ env, key: "snapshot" }), { version: "before" });
|
|
const leftovers = (await fsp.readdir(tmp)).filter((file) => file.includes(".tmp"));
|
|
assert.deepEqual(leftovers, []);
|
|
});
|
|
|
|
test("diffAndStore removes a newly published snapshot when cancellation arrives during atomic rename", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-cancel-new-rename-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
const controller = new AbortController();
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
diffAndStore({
|
|
env,
|
|
key: "snapshot",
|
|
value: { version: "after" },
|
|
signal: controller.signal,
|
|
atomicWriteOptions: {
|
|
async renameFile(from, to) {
|
|
await fsp.rename(from, to);
|
|
controller.abort(new Error("abort during initial atomic rename"));
|
|
},
|
|
},
|
|
}),
|
|
/abort during initial atomic rename/,
|
|
);
|
|
assert.equal(await readStateJson({ env, key: "snapshot" }), null);
|
|
const leftovers = (await fsp.readdir(tmp)).filter((file) => file.includes(".tmp"));
|
|
assert.deepEqual(leftovers, []);
|
|
});
|
|
|
|
test("diffAndStore restores an existing null snapshot when cancellation arrives during atomic rename", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-cancel-null-rename-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
await writeStateJson({ env, key: "snapshot", value: null });
|
|
const snapshotPath = keyToPath(tmp, "snapshot");
|
|
|
|
const controller = new AbortController();
|
|
await assert.rejects(
|
|
() =>
|
|
diffAndStore({
|
|
env,
|
|
key: "snapshot",
|
|
value: { version: "after" },
|
|
signal: controller.signal,
|
|
atomicWriteOptions: {
|
|
async renameFile(from, to) {
|
|
await fsp.rename(from, to);
|
|
controller.abort(new Error("abort during null snapshot rename"));
|
|
},
|
|
},
|
|
}),
|
|
/abort during null snapshot rename/,
|
|
);
|
|
assert.equal(await readStateJson({ env, key: "snapshot" }), null);
|
|
assert.equal(await fsp.readFile(snapshotPath, "utf8"), "null\n");
|
|
});
|
|
|
|
test("diffAndStore serializes cancellation rollback before a concurrent snapshot update", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-cancel-concurrent-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
await writeStateJson({ env, key: "snapshot", value: { version: "before" } });
|
|
|
|
const controller = new AbortController();
|
|
let publishCancelledSnapshot!: () => void;
|
|
const cancelledSnapshotPublished = new Promise<void>((resolve) => {
|
|
publishCancelledSnapshot = resolve;
|
|
});
|
|
let allowCancellation!: () => void;
|
|
const waitForCancellation = new Promise<void>((resolve) => {
|
|
allowCancellation = resolve;
|
|
});
|
|
const cancelled = diffAndStore({
|
|
env,
|
|
key: "snapshot",
|
|
value: { version: "cancelled-A" },
|
|
signal: controller.signal,
|
|
atomicWriteOptions: {
|
|
async renameFile(from, to) {
|
|
await fsp.rename(from, to);
|
|
publishCancelledSnapshot();
|
|
await waitForCancellation;
|
|
},
|
|
},
|
|
});
|
|
await cancelledSnapshotPublished;
|
|
|
|
let successfulSnapshotPublished = false;
|
|
const successful = writeState("snapshot", { version: "successful-B" }, { env }).then(() => {
|
|
successfulSnapshotPublished = true;
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
assert.equal(successfulSnapshotPublished, false, "the next writer must wait for rollback");
|
|
|
|
controller.abort(new Error("abort during concurrent atomic rename"));
|
|
allowCancellation();
|
|
await assert.rejects(cancelled, /abort during concurrent atomic rename/);
|
|
await successful;
|
|
assert.equal(successfulSnapshotPublished, true);
|
|
assert.deepEqual(await readStateJson({ env, key: "snapshot" }), { version: "successful-B" });
|
|
const leftovers = (await fsp.readdir(tmp)).filter(
|
|
(file) => file.includes(".tmp") || file.endsWith(".lock"),
|
|
);
|
|
assert.deepEqual(leftovers, []);
|
|
});
|
|
|
|
test("state.get waits for a diff publication to commit or roll back", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-read-transaction-"));
|
|
const env = { ...process.env, LOBSTER_STATE_DIR: tmp };
|
|
await writeStateJson({ env, key: "snapshot", value: { version: "before" } });
|
|
let markPublished!: () => void;
|
|
const published = new Promise<void>((resolve) => {
|
|
markPublished = resolve;
|
|
});
|
|
let release!: () => void;
|
|
const releasePublication = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const transaction = diffAndStore({
|
|
env,
|
|
key: "snapshot",
|
|
value: { version: "new" },
|
|
afterStore: async () => {
|
|
markPublished();
|
|
await releasePublication;
|
|
throw new Error("paired publication failed");
|
|
},
|
|
});
|
|
await published;
|
|
|
|
const getCmd = createDefaultRegistry().get("state.get");
|
|
const pendingRead = getCmd.run({
|
|
input: streamOf([]),
|
|
args: { _: ["snapshot"] },
|
|
ctx: { stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, env },
|
|
});
|
|
const early = await Promise.race([
|
|
pendingRead.then(() => "settled" as const),
|
|
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 25)),
|
|
]);
|
|
assert.equal(early, "pending", "state.get must not expose a snapshot pending rollback");
|
|
|
|
release();
|
|
await assert.rejects(transaction, /paired publication failed/);
|
|
const result = await pendingRead;
|
|
const items = [];
|
|
for await (const item of result.output) items.push(item);
|
|
assert.deepEqual(items, [{ version: "before" }]);
|
|
await fsp.rm(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
test("state.set stops waiting for a live state lock when its signal is aborted", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-lock-abort-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
const key = "blocked";
|
|
const lockPath = `${keyToPath(tmp, key)}.lock`;
|
|
await fsp.mkdir(lockPath);
|
|
await fsp.writeFile(path.join(lockPath, "owner"), `${process.pid}::live-writer\n`, "utf8");
|
|
|
|
const controller = new AbortController();
|
|
const stateSet = createDefaultRegistry().get("state.set");
|
|
const pending = stateSet.run({
|
|
input: streamOf([{ value: true }]),
|
|
args: { _: [key] },
|
|
ctx: {
|
|
stdin: process.stdin,
|
|
stdout: process.stdout,
|
|
stderr: process.stderr,
|
|
env,
|
|
signal: controller.signal,
|
|
},
|
|
});
|
|
const completion = pending.then(
|
|
() => ({ kind: "success" as const }),
|
|
(error) => ({ kind: "error" as const, error }),
|
|
);
|
|
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
controller.abort(new Error("state lock cancelled"));
|
|
const early = await Promise.race([
|
|
completion,
|
|
new Promise<{ kind: "timeout" }>((resolve) =>
|
|
setTimeout(() => resolve({ kind: "timeout" }), 75),
|
|
),
|
|
]);
|
|
if (early.kind === "timeout") await fsp.rm(lockPath, { recursive: true, force: true });
|
|
const settled = early.kind === "timeout" ? await completion : early;
|
|
|
|
assert.notEqual(early.kind, "timeout", "state.set must not remain blocked after cancellation");
|
|
assert.equal(settled.kind, "error");
|
|
if (settled.kind === "error") assert.match(settled.error?.message ?? "", /state lock cancelled/);
|
|
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
});
|
|
|
|
test("diffAndStore does not reclaim a live fallback lock after a short heartbeat gap", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-lock-lease-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
const key = "snapshot";
|
|
const lockPath = `${keyToPath(tmp, key)}.lock`;
|
|
const ownerPath = path.join(lockPath, "owner");
|
|
await fsp.mkdir(lockPath);
|
|
await fsp.writeFile(ownerPath, `${process.pid}::live-writer\n`, "utf8");
|
|
const briefGap = new Date(Date.now() - 2_000);
|
|
await fsp.utimes(lockPath, briefGap, briefGap);
|
|
await fsp.utimes(ownerPath, briefGap, briefGap);
|
|
|
|
const controller = new AbortController();
|
|
const abort = setTimeout(
|
|
() => controller.abort(new Error("live fallback lock remained held")),
|
|
100,
|
|
);
|
|
try {
|
|
await assert.rejects(
|
|
() => diffAndStore({ env, key, value: { version: "new" }, signal: controller.signal }),
|
|
/live fallback lock remained held/,
|
|
);
|
|
} finally {
|
|
clearTimeout(abort);
|
|
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
}
|
|
assert.equal(await readStateJson({ env, key }), null);
|
|
});
|
|
|
|
test("diffAndStore reclaims an old lock with a malformed owner", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-malformed-lock-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
const lockPath = `${keyToPath(tmp, "snapshot")}.lock`;
|
|
await fsp.mkdir(lockPath);
|
|
await fsp.writeFile(path.join(lockPath, "owner"), "\n", "utf8");
|
|
const staleAt = new Date(Date.now() - 10_000);
|
|
await fsp.utimes(lockPath, staleAt, staleAt);
|
|
|
|
await diffAndStore({ env, key: "snapshot", value: { version: "recovered" } });
|
|
assert.deepEqual(await readStateJson({ env, key: "snapshot" }), { version: "recovered" });
|
|
await assert.rejects(fsp.access(lockPath));
|
|
});
|
|
|
|
test(
|
|
"diffAndStore reclaims an old lock after its owner PID is reused",
|
|
{ skip: process.platform !== "linux" },
|
|
async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-diff-reused-pid-lock-"));
|
|
const env = { LOBSTER_STATE_DIR: tmp };
|
|
const lockPath = `${keyToPath(tmp, "snapshot")}.lock`;
|
|
const ownerPath = path.join(lockPath, "owner");
|
|
await fsp.mkdir(lockPath);
|
|
await fsp.writeFile(ownerPath, `${process.pid}:0:stale-owner\n`, "utf8");
|
|
const staleAt = new Date(Date.now() - 10_000);
|
|
await fsp.utimes(lockPath, staleAt, staleAt);
|
|
await fsp.utimes(ownerPath, staleAt, staleAt);
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(
|
|
() => controller.abort(new Error("reused lock was not reclaimed")),
|
|
250,
|
|
);
|
|
try {
|
|
await diffAndStore({
|
|
env,
|
|
key: "snapshot",
|
|
value: { version: "recovered" },
|
|
signal: controller.signal,
|
|
});
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
assert.equal(controller.signal.aborted, false);
|
|
assert.deepEqual(await readStateJson({ env, key: "snapshot" }), { version: "recovered" });
|
|
await assert.rejects(fsp.access(lockPath));
|
|
},
|
|
);
|
|
|
|
test("withFileLock does not reclaim a replacement lock after observing a stale one", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-lock-replacement-"));
|
|
const filePath = path.join(tmp, "snapshot.json");
|
|
const lockPath = `${filePath}.lock`;
|
|
await fsp.mkdir(lockPath);
|
|
await fsp.writeFile(path.join(lockPath, "owner"), `${process.pid}:0:stale-owner\n`, "utf8");
|
|
const staleAt = new Date(Date.now() - 10_000);
|
|
await fsp.utimes(lockPath, staleAt, staleAt);
|
|
await fsp.utimes(path.join(lockPath, "owner"), staleAt, staleAt);
|
|
|
|
const originalReadFile = fsp.readFile;
|
|
let replaced = false;
|
|
let replacementActive = false;
|
|
let overlap = false;
|
|
let replacement: Promise<void> | undefined;
|
|
let releaseReplacement!: () => void;
|
|
const replacementReleased = new Promise<void>((resolve) => {
|
|
releaseReplacement = resolve;
|
|
});
|
|
let replacementStarted!: () => void;
|
|
const replacementEntered = new Promise<void>((resolve) => {
|
|
replacementStarted = resolve;
|
|
});
|
|
|
|
Object.defineProperty(fsp, "readFile", {
|
|
configurable: true,
|
|
writable: true,
|
|
async value(
|
|
filePathArg: Parameters<typeof fsp.readFile>[0],
|
|
options?: Parameters<typeof fsp.readFile>[1],
|
|
) {
|
|
const result = await originalReadFile(filePathArg, options);
|
|
if (!replaced && String(filePathArg) === path.join(lockPath, "owner")) {
|
|
replaced = true;
|
|
await fsp.rm(lockPath, { recursive: true, force: true });
|
|
replacement = withFileLock({
|
|
filePath,
|
|
task: async () => {
|
|
replacementActive = true;
|
|
replacementStarted();
|
|
await replacementReleased;
|
|
replacementActive = false;
|
|
},
|
|
});
|
|
await replacementEntered;
|
|
}
|
|
return result;
|
|
},
|
|
});
|
|
|
|
try {
|
|
const original = withFileLock({
|
|
filePath,
|
|
task: async () => {
|
|
if (replacementActive) overlap = true;
|
|
},
|
|
});
|
|
await replacementEntered;
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
assert.equal(overlap, false, "the stale reclaimer must not enter beside the replacement");
|
|
releaseReplacement();
|
|
if (!replacement) throw new Error("replacement lock did not start");
|
|
await Promise.all([original, replacement]);
|
|
} finally {
|
|
Object.defineProperty(fsp, "readFile", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: originalReadFile,
|
|
});
|
|
await fsp.rm(tmp, { recursive: true, force: true });
|
|
}
|
|
|
|
assert.equal(replaced, true);
|
|
assert.equal(overlap, false);
|
|
});
|
|
|
|
test("withFileLock releases its key when best-effort cleanup cannot remove the detached lock", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-state-lock-release-failure-"));
|
|
const filePath = path.join(tmp, "snapshot.json");
|
|
const lockPath = `${filePath}.lock`;
|
|
const originalRm = fsp.rm;
|
|
let failReleaseOnce = true;
|
|
|
|
Object.defineProperty(fsp, "rm", {
|
|
configurable: true,
|
|
writable: true,
|
|
async value(filePathArg: Parameters<typeof fsp.rm>[0], options?: Parameters<typeof fsp.rm>[1]) {
|
|
if (failReleaseOnce && String(filePathArg).startsWith(lockPath)) {
|
|
failReleaseOnce = false;
|
|
throw Object.assign(new Error("simulated lock cleanup failure"), { code: "EIO" });
|
|
}
|
|
return originalRm(filePathArg, options);
|
|
},
|
|
});
|
|
|
|
let second: Promise<string> | undefined;
|
|
try {
|
|
await withFileLock({ filePath, task: async () => {} });
|
|
second = withFileLock({ filePath, task: async () => "reacquired" });
|
|
const settled = await Promise.race([
|
|
second,
|
|
new Promise<"timed out">((resolve) => setTimeout(() => resolve("timed out"), 500)),
|
|
]);
|
|
if (settled === "timed out") await originalRm(lockPath, { recursive: true, force: true });
|
|
assert.equal(settled, "reacquired", "a failed cleanup must not poison the live state lock");
|
|
await second;
|
|
} finally {
|
|
Object.defineProperty(fsp, "rm", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: originalRm,
|
|
});
|
|
await originalRm(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("SDK diff primitives treat corrupt previous state as a miss (#112)", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-sdk-diff-corrupt-"));
|
|
const ctx = { env: { LOBSTER_STATE_DIR: tmp } };
|
|
await fsp.writeFile(path.join(tmp, "sdk-snapshot.json"), '{"partial"', "utf8");
|
|
|
|
const direct = await diffAndStoreValue("sdk-snapshot", { next: true }, ctx);
|
|
assert.equal(direct.before, null);
|
|
assert.equal(direct.changed, true);
|
|
|
|
await fsp.writeFile(path.join(tmp, "stage-snapshot.json"), '{"partial"', "utf8");
|
|
const stage = diffLast("stage-snapshot");
|
|
const result = await stage.run({ input: streamOf([{ next: true }]), ctx });
|
|
const output = [];
|
|
for await (const item of result.output) output.push(item);
|
|
|
|
assert.deepEqual(output, [
|
|
{
|
|
kind: "diff.last",
|
|
key: "stage-snapshot",
|
|
changed: true,
|
|
before: null,
|
|
after: { next: true },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("SDK stateSet/readState is atomic under concurrent reads (#109)", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-sdk-"));
|
|
const ctx = { env: { LOBSTER_STATE_DIR: tmp } };
|
|
const key = "sdk-state";
|
|
const payload = "y".repeat(256 * 1024);
|
|
|
|
const writeOnce = async (n: number) => {
|
|
const prim = stateSet(key);
|
|
const input = (async function* () {
|
|
yield { payload, n };
|
|
})();
|
|
const res = await prim.run({ input, ctx });
|
|
for await (const _ of res.output) {
|
|
void _;
|
|
}
|
|
};
|
|
|
|
await writeOnce(0);
|
|
|
|
let readErrors = 0;
|
|
let partialReads = 0;
|
|
const reader = (async () => {
|
|
for (let i = 0; i < 500; i++) {
|
|
try {
|
|
const v = await readState(key, ctx);
|
|
if (!v || v.payload !== payload) partialReads++;
|
|
} catch {
|
|
readErrors++;
|
|
}
|
|
}
|
|
})();
|
|
const writer = (async () => {
|
|
for (let n = 1; n <= 120; n++) {
|
|
await writeOnce(n);
|
|
}
|
|
})();
|
|
await Promise.all([reader, writer]);
|
|
|
|
assert.equal(readErrors, 0, "SDK reader must never hit a parse/IO error mid-write");
|
|
assert.equal(partialReads, 0, "SDK reader must never observe truncated/empty state");
|
|
});
|
|
|
|
test("SDK writeState preserves restricted state-file mode", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-sdk-mode-"));
|
|
const ctx = { env: { LOBSTER_STATE_DIR: tmp } };
|
|
const filePath = path.join(tmp, "sdk-state.json");
|
|
await fsp.mkdir(tmp, { recursive: true });
|
|
await fsp.writeFile(filePath, '{"old":true}\n', { mode: 0o600 });
|
|
await fsp.chmod(filePath, 0o600);
|
|
|
|
await writeState("sdk-state", { ok: true }, ctx);
|
|
|
|
assert.equal((await fsp.stat(filePath)).mode & 0o777, 0o600);
|
|
assert.deepEqual(await readState("sdk-state", ctx), { ok: true });
|
|
});
|
|
|
|
test("SDK writeState removes temp files when replacement fails", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-atomic-sdk-cleanup-"));
|
|
const ctx = { env: { LOBSTER_STATE_DIR: tmp } };
|
|
await fsp.mkdir(path.join(tmp, "sdk-state.json"));
|
|
|
|
await assert.rejects(() => writeState("sdk-state", { ok: true }, ctx));
|
|
const leftovers = (await fsp.readdir(tmp)).filter((f) => f.includes(".tmp"));
|
|
assert.deepEqual(leftovers, []);
|
|
});
|
|
|
|
test("ensureDirectory creates missing parent directories", async () => {
|
|
const tmp = mkdtempSync(path.join(os.tmpdir(), "lobster-ensure-dir-"));
|
|
const nested = path.join(tmp, "alpha", "beta", "gamma");
|
|
|
|
await ensureDirectory(nested);
|
|
assert.equal((await fsp.stat(nested)).isDirectory(), true);
|
|
|
|
// Re-running must stay a no-op once the whole chain already exists.
|
|
await ensureDirectory(nested);
|
|
assert.equal((await fsp.stat(nested)).isDirectory(), true);
|
|
});
|
|
|
|
test("stripExtendedLengthPrefix maps only namespaces with a plain equivalent", () => {
|
|
assert.equal(stripExtendedLengthPrefix("\\\\?\\C:\\lobster\\state"), "C:\\lobster\\state");
|
|
assert.equal(
|
|
stripExtendedLengthPrefix("\\\\?\\UNC\\server\\share\\state"),
|
|
"\\\\server\\share\\state",
|
|
);
|
|
|
|
// Windows matches the namespace component case-insensitively, so a lowercase
|
|
// marker names the same share and must map to the same plain path.
|
|
assert.equal(
|
|
stripExtendedLengthPrefix("\\\\?\\unc\\server\\share\\state"),
|
|
"\\\\server\\share\\state",
|
|
);
|
|
|
|
// A device namespace has no drive-letter form, so stripping it would leave a
|
|
// relative path and break an explicitly configured state directory.
|
|
const volume = "\\\\?\\Volume{6f4c2b1a-0000-0000-0000-000000000000}\\lobster\\state";
|
|
assert.equal(stripExtendedLengthPrefix(volume), volume);
|
|
|
|
assert.equal(stripExtendedLengthPrefix("/tmp/lobster/state"), "/tmp/lobster/state");
|
|
});
|