Foundation for the live-sync epic plan (E0). Frozen-contract primitives that gate E1a/E1b/E2, grounded in the real code shapes. - synclock.ts: per-UUID bidirectional lock (push/pull/baseline) replacing the per-relPath inflight Set. acquire/release/withLock/isHeld/heldOps, skip|queue policy, reentrant-NO, relPath: fallback. Queue path retries until acquired or throws LockAcquireTimeout (no silent drop); fairness — acquire defers to queued waiters. SYNC_LOCK_ENABLED flag (default on; off = byte-identical legacy inflight). - cchash.ts: ccHash(entry, inverse) + ccHashFromGet, frozen CC_HASH_CONTRACT. Contract corrected from epic prose: the body spans data.description + data.notes (CcData object, not a string), with ## Secrets re-inserted. Throws CcHashError on missing description (not coerced to ""). Three E1a-gate assumptions documented (sidebar exclusion, Secrets heading/case, section order). - schema-version.ts: FLAGS_SCHEMA_VERSION / SYNC_STATE_SCHEMA_VERSION branded constants + parseSchemaVersion (branded discriminated union). - server.ts: AutoSyncController refactored to use the lock (single read; uuid resolved from the same read used to gate — no stale-uuid gap; uuidCache powers the debounce pre-check without a file read). AutoSyncController/State exported, lock public for E2's poll path. - 43 tests across 4 files (synclock, cchash, schema-version, server-lock integration incl. cross-direction block + flag-off byte-identical). tsc clean. Co-Authored-By: Claude <noreply@anthropic.com>
254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { SyncLock, relPathLockKey, LockAcquireTimeout } from "../src/synclock.js";
|
|
|
|
// Helper: a deferred promise the test controls, so a held `withLock` body can
|
|
// be kept alive until an assertion outside it has run.
|
|
interface Deferred<T = void> {
|
|
promise: Promise<T>;
|
|
resolve: (v: T) => void;
|
|
reject: (e: unknown) => void;
|
|
}
|
|
function deferred<T = void>(): Deferred<T> {
|
|
let resolve!: (v: T) => void;
|
|
let reject!: (e: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
describe("SyncLock acquire/release/isHeld/heldOps (E0.1)", () => {
|
|
it("acquires when free and reports held", () => {
|
|
const lock = new SyncLock();
|
|
expect(lock.acquire("u1", "push")).toEqual({ acquired: true });
|
|
expect(lock.isHeld("u1")).toBe(true);
|
|
expect(lock.heldOps()).toEqual({ u1: "push" });
|
|
lock.release("u1", "push");
|
|
expect(lock.isHeld("u1")).toBe(false);
|
|
expect(lock.heldOps()).toEqual({});
|
|
});
|
|
|
|
it("acquire on a held uuid returns false with the held op", () => {
|
|
const lock = new SyncLock();
|
|
lock.acquire("u1", "push");
|
|
expect(lock.acquire("u1", "pull")).toEqual({ acquired: false, heldOp: "push" });
|
|
});
|
|
|
|
it("release of an un-held uuid is a no-op (does not throw)", () => {
|
|
const lock = new SyncLock();
|
|
expect(() => lock.release("never", "push")).not.toThrow();
|
|
// wrong op on a held uuid is also a no-op
|
|
lock.acquire("u1", "push");
|
|
expect(() => lock.release("u1", "pull")).not.toThrow();
|
|
expect(lock.isHeld("u1")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("SyncLock cross-direction exclusion (E0.1, FR-3.1/FR-5.5)", () => {
|
|
it("an O→F withLock running blocks an F→O acquire on the same uuid", async () => {
|
|
const lock = new SyncLock();
|
|
const gate = deferred<void>();
|
|
let pushStarted = false;
|
|
const pushDone = lock.withLock("u1", "push", async () => {
|
|
pushStarted = true;
|
|
await gate.promise;
|
|
});
|
|
// Wait until the push body is actually running.
|
|
await new Promise<void>((r) => setImmediate(r));
|
|
expect(pushStarted).toBe(true);
|
|
|
|
// While the push holds the uuid, an F→O acquire must be refused.
|
|
expect(lock.acquire("u1", "pull")).toEqual({ acquired: false, heldOp: "push" });
|
|
|
|
gate.resolve(void 0);
|
|
await pushDone;
|
|
// After release, the pull can acquire.
|
|
expect(lock.acquire("u1", "pull")).toEqual({ acquired: true });
|
|
});
|
|
});
|
|
|
|
describe("SyncLock burst serializes same-uuid ops (E0.1, NFR-3)", () => {
|
|
it("N concurrent queue-policy withLock(uuid,'push') calls execute strictly one at a time", async () => {
|
|
const lock = new SyncLock();
|
|
const N = 10;
|
|
let inFlight = 0;
|
|
let maxInFlight = 0;
|
|
const completed: number[] = [];
|
|
|
|
const tasks = Array.from({ length: N }, (_, i) =>
|
|
lock.withLock("u1", "push", async () => {
|
|
inFlight++;
|
|
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
// Yield a few microtasks so other queued tasks would visibly overlap if allowed.
|
|
await new Promise<void>((r) => setImmediate(r));
|
|
await new Promise<void>((r) => setImmediate(r));
|
|
completed.push(i);
|
|
inFlight--;
|
|
return i;
|
|
}, { policy: "queue", maxWaitMs: 1000 }),
|
|
);
|
|
|
|
const results = await Promise.all(tasks);
|
|
expect(maxInFlight).toBe(1); // strictly one at a time
|
|
expect(results).toHaveLength(N);
|
|
expect(results.every((r) => r !== undefined)).toBe(true); // none skipped/timed out
|
|
expect(completed).toHaveLength(N);
|
|
});
|
|
|
|
it("skip-policy drops redundant same-uuid ops instead of queuing", async () => {
|
|
const lock = new SyncLock();
|
|
const gate = deferred<void>();
|
|
let ran = 0;
|
|
const first = lock.withLock("u1", "push", async () => { ran++; await gate.promise; }, { policy: "skip" });
|
|
await new Promise<void>((r) => setImmediate(r));
|
|
// Second skip-policy op while the first holds: dropped (undefined), does not run.
|
|
const second = await lock.withLock("u1", "push", async () => { ran++; }, { policy: "skip" });
|
|
expect(second).toBeUndefined();
|
|
gate.resolve(void 0);
|
|
await first;
|
|
expect(ran).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("SyncLock release-on-throw (E0.1)", () => {
|
|
it("a rejecting fn releases the lock so the next acquire succeeds", async () => {
|
|
const lock = new SyncLock();
|
|
await expect(lock.withLock("u1", "push", async () => { throw new Error("boom"); })).rejects.toThrow("boom");
|
|
expect(lock.isHeld("u1")).toBe(false);
|
|
expect(lock.acquire("u1", "push")).toEqual({ acquired: true });
|
|
});
|
|
|
|
it("a queued waiter still acquires after the holder rejects (slot is released)", async () => {
|
|
const lock = new SyncLock();
|
|
let waiterRan = false;
|
|
const holder = lock.withLock("u1", "push", async () => { throw new Error("holder fails"); });
|
|
const waiter = lock.withLock("u1", "pull", async () => { waiterRan = true; }, { policy: "queue", maxWaitMs: 1000 });
|
|
await expect(holder).rejects.toThrow("holder fails");
|
|
const result = await waiter;
|
|
expect(result).toBeUndefined(); // fn returned void → resolved to undefined
|
|
expect(waiterRan).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("SyncLock different-uuid concurrency (E0.1 — no global lock)", () => {
|
|
it("two different uuids proceed concurrently", async () => {
|
|
const lock = new SyncLock();
|
|
let maxInFlight = 0;
|
|
let inFlight = 0;
|
|
const both = Promise.all([
|
|
lock.withLock("u1", "push", async () => {
|
|
inFlight++; maxInFlight = Math.max(maxInFlight, inFlight);
|
|
await new Promise<void>((r) => setImmediate(r));
|
|
inFlight--;
|
|
}),
|
|
lock.withLock("u2", "push", async () => {
|
|
inFlight++; maxInFlight = Math.max(maxInFlight, inFlight);
|
|
await new Promise<void>((r) => setImmediate(r));
|
|
inFlight--;
|
|
}),
|
|
]);
|
|
await both;
|
|
expect(maxInFlight).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("SyncLock reentrant-NO (E0.1)", () => {
|
|
it("a second acquire of the same uuid from inside a held withLock returns false", async () => {
|
|
const lock = new SyncLock();
|
|
let innerAcquire: { acquired: boolean; heldOp?: string } | null = null;
|
|
await lock.withLock("u1", "push", async () => {
|
|
innerAcquire = lock.acquire("u1", "push"); // re-entrant attempt
|
|
});
|
|
expect(innerAcquire).toEqual({ acquired: false, heldOp: "push" });
|
|
});
|
|
|
|
it("a skip-policy nested withLock for the same uuid returns undefined (no deadlock)", async () => {
|
|
const lock = new SyncLock();
|
|
let nestedRan = false;
|
|
let nestedResult: unknown = "untouched";
|
|
await lock.withLock("u1", "push", async () => {
|
|
nestedResult = await lock.withLock("u1", "push", async () => { nestedRan = true; }, { policy: "skip" });
|
|
});
|
|
expect(nestedRan).toBe(false);
|
|
expect(nestedResult).toBeUndefined();
|
|
// Outer lock was still released.
|
|
expect(lock.isHeld("u1")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("SyncLock queue timeout (E0.1)", () => {
|
|
it("a queue-policy op THROWS LockAcquireTimeout after maxWaitMs if it cannot acquire (not a silent drop)", async () => {
|
|
const lock = new SyncLock();
|
|
const gate = deferred<void>();
|
|
const holder = lock.withLock("u1", "push", async () => { await gate.promise; });
|
|
await new Promise<void>((r) => setImmediate(r));
|
|
await expect(
|
|
lock.withLock("u1", "pull", async () => "ran", { policy: "queue", maxWaitMs: 30 }),
|
|
).rejects.toBeInstanceOf(LockAcquireTimeout);
|
|
gate.resolve(void 0);
|
|
await holder;
|
|
});
|
|
|
|
it("a nested queue-policy withLock for the same uuid throws after maxWait (no indefinite stall)", async () => {
|
|
const lock = new SyncLock();
|
|
// Outer holds; an inner queue-policy withLock for the same uuid cannot ever
|
|
// acquire (the outer won't release until the inner returns) → throws after
|
|
// maxWait instead of hanging forever.
|
|
await expect(
|
|
lock.withLock("u1", "push", async () => {
|
|
await lock.withLock("u1", "push", async () => "inner", { policy: "queue", maxWaitMs: 20 });
|
|
}),
|
|
).rejects.toBeInstanceOf(LockAcquireTimeout);
|
|
expect(lock.isHeld("u1")).toBe(false); // outer released in its finally despite inner throw
|
|
});
|
|
});
|
|
|
|
describe("SyncLock fairness (E0.1)", () => {
|
|
it("a fresh public acquire DEFERS when waiters are queued (does not jump the queue)", async () => {
|
|
const lock = new SyncLock();
|
|
expect(lock.acquire("u1", "push")).toEqual({ acquired: true });
|
|
|
|
// Two queued waiters register while held.
|
|
const gate = deferred<void>();
|
|
const q1 = lock.withLock("u1", "pull", async () => { await gate.promise; return "q1"; }, { policy: "queue", maxWaitMs: 2000 });
|
|
const q2 = lock.withLock("u1", "baseline", async () => "q2", { policy: "queue", maxWaitMs: 2000 });
|
|
await new Promise<void>((r) => setImmediate(r)); // let both register in `waiters`
|
|
|
|
// Synchronously release, then synchronously acquire — between release and
|
|
// the woken waiter's microtask, the lock is FREE with q2 still queued, so a
|
|
// fresh public acquire must defer (not grab).
|
|
lock.release("u1", "push"); // wakeOne shifts q1 (sync); waiters=[q2]; held deleted
|
|
const fresh = lock.acquire("u1", "push");
|
|
expect(fresh).toEqual({ acquired: false, deferred: true });
|
|
|
|
// Let q1 wake, grab, and run (it blocks on gate). q1 now holds; q2 queued.
|
|
await new Promise<void>((r) => setImmediate(r));
|
|
expect(lock.isHeld("u1")).toBe(true);
|
|
gate.resolve(void 0);
|
|
expect(await q1).toBe("q1"); // q1 releases → wakeOne shifts q2
|
|
expect(await q2).toBe("q2"); // q2 grabs (FIFO after q1)
|
|
expect(lock.isHeld("u1")).toBe(false);
|
|
});
|
|
|
|
it("a queued waiter is served even if a fresh skip acquire is present (retry-loop, no silent give-up)", async () => {
|
|
const lock = new SyncLock();
|
|
const gate = deferred<void>();
|
|
const holder = lock.withLock("u1", "push", async () => { await gate.promise; });
|
|
const queued = lock.withLock("u1", "pull", async () => "served", { policy: "queue", maxWaitMs: 2000 });
|
|
await new Promise<void>((r) => setImmediate(r));
|
|
// A fresh skip op while held → drops (undefined), does not block the queue.
|
|
const skip = await lock.withLock("u1", "push", async () => "skip-ran", { policy: "skip" });
|
|
expect(skip).toBeUndefined();
|
|
gate.resolve(void 0);
|
|
expect(await queued).toBe("served");
|
|
await holder;
|
|
});
|
|
});
|
|
|
|
describe("relPathLockKey fallback (E0.1)", () => {
|
|
it("namespaces unlinked relPaths as a distinct pseudo-uuid", () => {
|
|
expect(relPathLockKey("npcs/Roland.md")).toBe("relPath:npcs/Roland.md");
|
|
expect(relPathLockKey("npcs/Roland.md")).not.toBe(relPathLockKey("npcs/Susan.md"));
|
|
// The fallback is a distinct key from a real uuid — the bidirectional
|
|
// claim holds for linked notes; the fallback only covers the unlinked tail.
|
|
expect(relPathLockKey("npcs/Roland.md")).not.toBe("JournalEntry.abc1");
|
|
});
|
|
}); |