Ctrl K
ComunidadGuías…

tests/processLock.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// The cross-process sync lock (server/processLock.ts), and gitSync's use of it.
//
// The gap this closes is a deployment, not a hypothesis: the desktop app and a
// systemd service over ONE vault are two processes with two `busy` flags and
// one `.git`, so both could enter `git add -A` / `commit` at the same moment
// and collide on `.git/index.lock`. The properties that make the lockfile a
// real answer — and every one of them is a way it could go wrong instead:
//
//   * a held lock refuses a second acquisition (that is the whole product);
//   * a lock whose holder is DEAD is broken, or a crash wedges backup forever;
//   * a lock nobody has touched for the ceiling is broken too — the only
//     recovery available when the holder is on another machine, or when the
//     file is unreadable because the holder died mid-write;
//   * a lock whose holder is ALIVE and fresh is never broken, because breaking
//     it recreates exactly the collision this exists to prevent;
//   * a pid is only consulted on the machine that wrote it;
//   * release deletes OUR lock and never a successor's;
//   * a pass that throws still releases — a lock leaked by a failure would
//     make every later sync wait out the full 15-minute ceiling.

import assert from "node:assert/strict";
import { execFileSync, spawn, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, statSync, utimesSync, writeFileSync } from "node:fs";
import { hostname } from "node:os";
import path from "node:path";
import { after, before, describe, it } from "node:test";
import { snapshotNow } from "../server/gitSync.ts";
import { acquireLock, inspectLock, readHolder, STALE_MS } from "../server/processLock.ts";
import { initSite } from "../server/site.ts";
import { initVault, VaultError } from "../server/vault.ts";
import { makeDir, makeVault, removeVault } from "./helpers/vault.ts";

const dir = makeDir();

/** Every acquisition in this file is heartbeat-free: the tests move mtimes by
 *  hand, and a background timer refreshing them would make the age cases race
 *  their own fixture. The heartbeat itself is proven in its own case below. */
const quiet: { heartbeatMs: number; onWarn: (m: string) => void } = {
  heartbeatMs: 0,
  onWarn: () => {},
};

/** A fresh lock path per case — one shared file would let a leaked lock from
 *  one test decide the next one's outcome. */
let n = 0;
function lockPath(): string {
  n += 1;
  return path.join(dir, `case-${n}`, "astrolabe-sync.lock");
}

/** Options that also collect the warnings, so "it was broken" can be asserted
 *  as a fact and not inferred from the outcome. */
function collecting(warnings: string[]): { heartbeatMs: number; onWarn: (m: string) => void } {
  return { heartbeatMs: 0, onWarn: (m) => warnings.push(m) };
}

/** A pid that is certainly gone: spawnSync waits for the child and reaps it,
 *  so by the time this returns the number addresses nothing. */
function deadPid(): number {
  const done = spawnSync(process.execPath, ["-e", ""]);
  assert.ok(typeof done.pid === "number" && done.pid > 0);
  return done.pid as number;
}

/** Write a lock file by hand — the only way to stand in for another process. */
function plant(file: string, holder: Record<string, unknown> | string): void {
  const body = typeof holder === "string" ? holder : `${JSON.stringify(holder)}\n`;
  mkdirSync(path.dirname(file), { recursive: true });
  writeFileSync(file, body);
}

function ageBy(file: string, ms: number): void {
  const when = new Date(Date.now() - ms);
  utimesSync(file, when, when);
}

after(() => removeVault(dir));

describe("the sync lock — taking and releasing", () => {
  it("creates the file, records this process, and hands back a handle", () => {
    const file = lockPath();
    const result = acquireLock(file, quiet);
    assert.equal(result.held, true);
    assert.ok(result.held && result.lock);
    if (!result.held) return;
    assert.ok(existsSync(file));
    const holder = readHolder(file);
    assert.equal(holder?.pid, process.pid);
    assert.equal(holder?.host, hostname());
    assert.equal(holder?.token, result.lock.holder.token);
    assert.ok(!Number.isNaN(Date.parse(holder?.at ?? "")));
    result.lock.release();
  });

  it("makes the directory it needs — a vault that is not a repository yet has no .git", () => {
    const file = path.join(dir, "no-git-here", ".git", "astrolabe-sync.lock");
    const result = acquireLock(file, quiet);
    assert.equal(result.held, true);
    if (result.held) result.lock.release();
  });

  it("removes the file on release, and releasing twice is not an error", () => {
    const file = lockPath();
    const result = acquireLock(file, quiet);
    assert.ok(result.held);
    if (!result.held) return;
    result.lock.release();
    assert.equal(existsSync(file), false);
    result.lock.release(); // idempotent
    assert.equal(existsSync(file), false);
    // ...and the lock is immediately available again.
    const next = acquireLock(file, quiet);
    assert.ok(next.held);
    if (next.held) next.lock.release();
  });

  it("reports a free path as free", () => {
    assert.equal(inspectLock(lockPath()), null);
  });
});

describe("the sync lock — contention", () => {
  it("refuses a second acquisition and names the holder", () => {
    const file = lockPath();
    const first = acquireLock(file, quiet);
    assert.ok(first.held);
    if (!first.held) return;

    const second = acquireLock(file, quiet);
    assert.equal(second.held, false);
    if (second.held) return;
    assert.equal(second.holder?.pid, process.pid);
    assert.equal(second.holder?.token, first.lock.holder.token);

    // The refusal changed nothing: the first holder still holds it.
    assert.equal(readHolder(file)?.token, first.lock.holder.token);
    first.lock.release();
  });

  it("does not break a lock held by a LIVE process on this machine", () => {
    const file = lockPath();
    // A real, live, foreign pid — the desktop app, as far as this process can
    // tell. Anything less than a live pid would prove nothing here.
    const other = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], {
      detached: true,
      stdio: "ignore",
    });
    other.unref();
    try {
      plant(file, { pid: other.pid, host: hostname(), at: new Date().toISOString(), token: "theirs" });
      const warnings: string[] = [];
      const result = acquireLock(file, collecting(warnings));
      assert.equal(result.held, false);
      if (!result.held) assert.equal(result.holder?.pid, other.pid);
      assert.deepEqual(warnings, []);
      assert.equal(readHolder(file)?.token, "theirs");
    } finally {
      try {
        process.kill(other.pid as number);
      } catch {
        /* already gone */
      }
    }
  });
});

describe("the sync lock — staleness recovery", () => {
  it("breaks a lock whose holder is dead, and says so", () => {
    const file = lockPath();
    const gone = deadPid();
    plant(file, { pid: gone, host: hostname(), at: new Date().toISOString(), token: "corpse" });

    const warnings: string[] = [];
    const result = acquireLock(file, collecting(warnings));
    assert.equal(result.held, true);
    if (!result.held) return;
    assert.equal(readHolder(file)?.pid, process.pid);
    assert.equal(warnings.length, 1);
    assert.match(warnings[0], /stale sync lock/);
    assert.match(warnings[0], /dead-pid/);
    assert.match(warnings[0], new RegExp(`pid ${gone}`));
    result.lock.release();
  });

  it("breaks a lock nobody has touched for longer than the ceiling", () => {
    const file = lockPath();
    const held = acquireLock(file, quiet);
    assert.ok(held.held);
    if (!held.held) return;
    // Held by THIS process — a live pid — so age is the only test that can
    // free it, which is exactly the wedged-holder case.
    ageBy(file, STALE_MS + 60_000);
    assert.equal(inspectLock(file, quiet)?.stale, true);
    assert.equal(inspectLock(file, quiet)?.reason, "age");

    const warnings: string[] = [];
    const taken = acquireLock(file, collecting(warnings));
    assert.equal(taken.held, true);
    if (!taken.held) return;
    assert.notEqual(taken.lock.holder.token, held.lock.holder.token);
    assert.equal(warnings.length, 1);
    assert.match(warnings[0], /age/);
    taken.lock.release();
  });

  it("does not consult a pid written by another machine — only age can free it", () => {
    const file = lockPath();
    const gone = deadPid(); // dead HERE, meaningless THERE
    plant(file, { pid: gone, host: `${hostname()}-elsewhere`, at: new Date().toISOString(), token: "remote" });

    const fresh = acquireLock(file, quiet);
    assert.equal(fresh.held, false, "a dead pid on another host is not evidence of anything");
    assert.equal(readHolder(file)?.token, "remote");

    ageBy(file, STALE_MS + 1000);
    const aged = acquireLock(file, quiet);
    assert.equal(aged.held, true);
    if (aged.held) aged.lock.release();
  });

  it("treats an unreadable lock as a lock, until it ages out", () => {
    const file = lockPath();
    plant(file, "{ half a json objec"); // a holder that died between create and write
    assert.equal(readHolder(file), null);

    const fresh = acquireLock(file, quiet);
    assert.equal(fresh.held, false);
    if (!fresh.held) assert.equal(fresh.holder, null);

    ageBy(file, STALE_MS + 1000);
    assert.equal(inspectLock(file, quiet)?.reason, "unreadable");
    const warnings: string[] = [];
    const aged = acquireLock(file, collecting(warnings));
    assert.equal(aged.held, true);
    assert.match(warnings[0] ?? "", /unreadable lock file/);
    if (aged.held) aged.lock.release();
  });

  it("a broken-out holder does not delete its successor's lock", () => {
    const file = lockPath();
    const first = acquireLock(file, quiet);
    assert.ok(first.held);
    if (!first.held) return;
    ageBy(file, STALE_MS + 1000);
    const second = acquireLock(file, quiet);
    assert.ok(second.held);
    if (!second.held) return;

    // The loser finally-releases, believing it still holds the vault.
    first.lock.release();
    assert.ok(existsSync(file), "the successor is mid-pass: its lock must survive");
    assert.equal(readHolder(file)?.token, second.lock.holder.token);

    second.lock.release();
    assert.equal(existsSync(file), false);
  });

  it("keeps its own mtime fresh while it is held", async () => {
    const file = lockPath();
    const result = acquireLock(file, { heartbeatMs: 20, onWarn: () => {} });
    assert.ok(result.held);
    if (!result.held) return;
    try {
      ageBy(file, STALE_MS * 2); // pretend the clock ran away
      const stalled = statSync(file).mtimeMs;
      await new Promise((r) => setTimeout(r, 250));
      assert.ok(
        statSync(file).mtimeMs > stalled,
        "a long but healthy pass must not be mistaken for a crashed one",
      );
      assert.equal(inspectLock(file, quiet)?.stale, false);
    } finally {
      result.lock.release();
    }
  });
});

describe("the sync lock — a pass that fails still releases", () => {
  it("releases in `finally` when the body throws", async () => {
    const file = lockPath();
    const pass = async (): Promise<void> => {
      const claim = acquireLock(file, quiet);
      assert.ok(claim.held);
      if (!claim.held) return;
      try {
        await Promise.resolve();
        throw new Error("git exploded");
      } finally {
        claim.lock.release();
      }
    };
    await assert.rejects(pass(), /git exploded/);
    assert.equal(existsSync(file), false, "a failed pass must not leave the vault locked");
    const next = acquireLock(file, quiet);
    assert.equal(next.held, true);
    if (next.held) next.lock.release();
  });
});

// ---------------------------------------------------------------- the wiring
//
// The unit cases above prove the lock. These prove that the mutating git paths
// actually TAKE it — the part a refactor could quietly drop.

const data = makeDir();
const vault = makeVault({ "Home.md": "# Home\n" });
const lockFile = path.join(vault, ".git", "astrolabe-sync.lock");

function git(args: string[]): string {
  return execFileSync("git", args, {
    cwd: vault,
    encoding: "utf8",
    env: {
      ...process.env,
      GIT_AUTHOR_NAME: "Test",
      GIT_AUTHOR_EMAIL: "test@example.com",
      GIT_COMMITTER_NAME: "Test",
      GIT_COMMITTER_EMAIL: "test@example.com",
      GIT_CONFIG_GLOBAL: "/dev/null",
      GIT_CONFIG_SYSTEM: "/dev/null",
    },
  });
}

before(() => {
  initSite({ ASTROLABE_DATA: data });
  initVault(vault);
  git(["init", "-q", "-b", "main", "."]);
  git(["add", "-A"]);
  git(["commit", "-qm", "first"]);
});

after(() => {
  removeVault(vault);
  removeVault(data);
});

describe("gitSync takes the vault lock", () => {
  it("refuses a snapshot while another process holds the lock", async () => {
    const other = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], {
      detached: true,
      stdio: "ignore",
    });
    other.unref();
    plant(lockFile, { pid: other.pid, host: hostname(), at: new Date().toISOString(), token: "other-astrolabe" });
    try {
      await assert.rejects(
        snapshotNow(),
        (err: unknown) =>
          err instanceof VaultError &&
          err.status === 409 &&
          /Another Astrolabe is syncing this vault/.test(err.message) &&
          err.message.includes(String(other.pid)),
      );
      // Refused, and it left the other process's lock exactly as it found it.
      assert.equal(readHolder(lockFile)?.token, "other-astrolabe");
    } finally {
      try {
        process.kill(other.pid as number);
      } catch {
        /* already gone */
      }
    }
  });

  it("breaks a dead holder's lock and syncs, leaving no lock behind", async () => {
    plant(lockFile, { pid: deadPid(), host: hostname(), at: new Date().toISOString(), token: "crashed" });
    writeFileSync(path.join(vault, "Home.md"), "# Home\n\nan edit\n");
    const made = await snapshotNow();
    assert.equal(made.committed, true);
    assert.ok(made.sha);
    assert.equal(existsSync(lockFile), false, "the pass must release what it took");
    assert.match(git(["log", "-1", "--pretty=%s"]), /^astrolabe snapshot: /);
  });

  it("releases the lock even when the pass fails", async () => {
    const plain = makeVault({ "Home.md": "# Home\n" }); // no repository at all
    initVault(plain);
    try {
      await assert.rejects(snapshotNow(), /not a git repository/);
      assert.equal(existsSync(path.join(plain, ".git", "astrolabe-sync.lock")), false);
    } finally {
      initVault(vault);
      removeVault(plain);
    }
  });
});