Files
obsidian-foundry-sync/tests/synclock.test.ts
Kaysser Kayyali 8fd56a22d9 feat(E0): shared sync primitives — per-uuid lock, ccHash, schema versions
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>
2026-06-22 22:01:57 +00:00

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");
});
});