Ctrl K
ComunidadGuías…

test/scripts/lock-file.test.ts

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

Archivo original ↗Preguntar a KiBot
import { join } from "node:path";
import { expect } from "@std/expect";
import { afterEach, beforeEach, describe, it as test } from "@std/testing/bdd";
import { stub } from "@std/testing/mock";
import { removeIfPresent } from "#scripts/cleanup.ts";
import { withFileLock } from "#scripts/lock-file.ts";
import { withStaticAssetBuildLock } from "#scripts/static-assets/build-lock.ts";
import { withTempDir } from "#test-utils/files.ts";

const LOCK_PATH = join(
  Deno.env.get("TMPDIR") ?? "/tmp",
  `chobble-tickets-file-lock-test-${Deno.pid}-${Date.now()}.lock`,
);

const HOLD_LOCK_SCRIPT = `
const file = await Deno.open(Deno.args[0], {
  create: true,
  read: true,
  write: true,
});
await file.lock(true);
await Deno.stdout.write(new TextEncoder().encode("locked\\n"));
await Deno.stdin.read(new Uint8Array(1));
file.close();
`;

const EXIT_WITH_LOCK_SCRIPT = `
const file = await Deno.open(Deno.args[0], {
  create: true,
  read: true,
  write: true,
});
await file.lock(true);
await Deno.stdout.write(new TextEncoder().encode("locked\\n"));
Deno.exit(0);
`;

const startHolder = (
  script: string,
  stdin: "piped" | "null" = "piped",
): Deno.ChildProcess =>
  new Deno.Command(Deno.execPath(), {
    args: ["eval", script, "--", LOCK_PATH],
    stdin,
    stdout: "piped",
  }).spawn();

const waitUntilLocked = async (child: Deno.ChildProcess): Promise<void> => {
  const reader = child.stdout.getReader();
  try {
    const { value } = await reader.read();
    expect(new TextDecoder().decode(value)).toBe("locked\n");
  } finally {
    await reader.cancel();
    reader.releaseLock();
  }
};

const releaseHolder = async (child: Deno.ChildProcess): Promise<void> => {
  const writer = child.stdin.getWriter();
  await writer.write(new Uint8Array([1]));
  await writer.close();
  await child.status;
};

const stubOpenedFile = (change: (file: Deno.FsFile) => void) => {
  const open = Deno.open;
  return stub(
    Deno,
    "open",
    async (
      path: string | URL,
      options?: Deno.OpenOptions,
    ): Promise<Deno.FsFile> => {
      const file = await open(path, options);
      change(file);
      return file;
    },
  );
};

/** Signals `asked` when the second opened file is asked to lock. The first
 * open stays untouched, so a test can hold that lock and prove the second
 * call waits. */
const signalSecondLockAsk = (asked: () => void) => {
  let opened = 0;
  return stubOpenedFile((file) => {
    opened++;
    if (opened < 2) return;
    const lock = file.lock.bind(file);
    file.lock = (exclusive?: boolean): Promise<void> => {
      const held = lock(exclusive);
      asked();
      return held;
    };
  });
};

describe("withFileLock", () => {
  beforeEach(() => removeIfPresent(LOCK_PATH));
  afterEach(() => removeIfPresent(LOCK_PATH));

  test("creates the lock file when it is not there yet", async () => {
    await withFileLock(LOCK_PATH, () => Promise.resolve());

    expect((await Deno.stat(LOCK_PATH)).isFile).toBe(true);
  });

  test("keeps two holders in this process from overlapping", async () => {
    const order: string[] = [];
    const firstInside = Promise.withResolvers<void>();
    const releaseFirst = Promise.withResolvers<void>();
    const secondAsked = Promise.withResolvers<void>();

    const openStub = signalSecondLockAsk(secondAsked.resolve);

    try {
      const first = withFileLock(LOCK_PATH, async () => {
        order.push("first in");
        firstInside.resolve();
        await releaseFirst.promise;
        order.push("first out");
      });
      await firstInside.promise;

      const second = withFileLock(LOCK_PATH, () => {
        order.push("second in");
        return Promise.resolve();
      });

      // Wait for the second holder to actually ask for the lock, then give the
      // operating system time to hand it over. Both halves are needed: without
      // the signal the check could run before the request was even made, and
      // without the pause a shared lock would not yet have been granted, so a
      // lock that excludes nobody would still look like it was working.
      await secondAsked.promise;
      await new Promise((resolve) => setTimeout(resolve, 20));
      expect(order).toEqual(["first in"]);

      releaseFirst.resolve();
      await Promise.all([first, second]);
    } finally {
      openStub.restore();
      releaseFirst.resolve();
    }

    expect(order).toEqual(["first in", "first out", "second in"]);
  });

  test("runs the task and returns its value", async () => {
    await expect(
      withFileLock(LOCK_PATH, () => Promise.resolve("checked")),
    ).resolves.toBe("checked");
  });

  test("waits for a lock held by another process", async () => {
    const holder = startHolder(HOLD_LOCK_SCRIPT);
    await waitUntilLocked(holder);

    const lockAttempted = Promise.withResolvers<void>();
    const openStub = stubOpenedFile((file) => {
      const lock = file.lock.bind(file);
      file.lock = (exclusive?: boolean): Promise<void> => {
        lockAttempted.resolve();
        return lock(exclusive);
      };
    });

    let ran = false;
    let waiting: Promise<void> = Promise.resolve();
    try {
      waiting = withFileLock(LOCK_PATH, async () => {
        ran = true;
      });
      await lockAttempted.promise;
      expect(ran).toBe(false);
    } finally {
      openStub.restore();
      await releaseHolder(holder);
    }

    await waiting;
    expect(ran).toBe(true);
  });

  test("waits for unlock before closing and returning", async () => {
    const unlockStarted = Promise.withResolvers<void>();
    const releaseUnlock = Promise.withResolvers<void>();
    let closed = false;
    const openStub = stubOpenedFile((file) => {
      const close = file.close.bind(file);
      file.unlock = () => {
        unlockStarted.resolve();
        return releaseUnlock.promise;
      };
      file.close = () => {
        closed = true;
        close();
      };
    });

    let settled = false;
    const result = withFileLock(LOCK_PATH, () =>
      Promise.resolve("checked"),
    ).finally(() => {
      settled = true;
    });

    try {
      await unlockStarted.promise;
      expect(settled).toBe(false);
      expect(closed).toBe(false);
    } finally {
      releaseUnlock.resolve();
      await result;
      openStub.restore();
    }

    expect(closed).toBe(true);
    await expect(result).resolves.toBe("checked");
  });

  test("acquires after a holder exits without unlocking", async () => {
    const holder = startHolder(EXIT_WITH_LOCK_SCRIPT, "null");
    await waitUntilLocked(holder);
    await holder.status;

    await expect(
      withFileLock(LOCK_PATH, () => Promise.resolve("recovered")),
    ).resolves.toBe("recovered");
  });
});

describe("a lock that stops being the file at its path", () => {
  test("takes the lock again when the folder is swept mid-wait", async () => {
    await withTempDir(async (root) => {
      const path = join(root, "swept", "one.lock");
      let checks = 0;
      const stat = Deno.stat;
      // The first lock taken reads as a file nothing points at, standing in for
      // a clear-up that removed the folder while this queued for it.
      using _stat = stub(Deno, "stat", (async (at: string | URL) => {
        checks += 1;
        const info = await stat(at);
        return checks === 1 ? { ...info, ino: (info.ino ?? 0) + 1 } : info;
      }) as typeof Deno.stat);

      expect(await withFileLock(path, () => Promise.resolve("ran"))).toBe(
        "ran",
      );
      // Once for the stale lock, once for the one it kept.
      expect(checks).toBe(2);
    });
  });

  test("takes the lock again when the folder goes before it opens", async () => {
    await withTempDir(async (root) => {
      const path = join(root, "early", "one.lock");
      let opens = 0;
      const open = Deno.open;
      using _open = stub(Deno, "open", ((at: string | URL, options) => {
        if (!`${at}`.includes("early")) return open(at, options);
        opens += 1;
        return opens === 1
          ? Promise.reject(new Deno.errors.NotFound("swept"))
          : open(at, options);
      }) as typeof Deno.open);

      expect(await withFileLock(path, () => Promise.resolve("ran"))).toBe(
        "ran",
      );
      expect(opens).toBe(2);
    });
  });

  test("takes the lock again when the file at its path has gone", async () => {
    await withTempDir(async (root) => {
      const path = join(root, "one.lock");
      let looks = 0;
      const stat = Deno.stat;
      // The first look finds nothing at the path, as it would for a lock file
      // a clear-up deleted while this was waiting for it.
      using _stat = stub(Deno, "stat", ((at: string | URL) => {
        looks += 1;
        return looks === 1
          ? Promise.reject(new Deno.errors.NotFound("gone"))
          : stat(at);
      }) as typeof Deno.stat);

      expect(await withFileLock(path, () => Promise.resolve("ran"))).toBe(
        "ran",
      );
      expect(looks).toBe(2);
    });
  });

  test("holds a lock on a filesystem that keeps no file numbers", async () => {
    await withTempDir(async (root) => {
      const path = join(root, "one.lock");
      const stat = Deno.stat;
      using _stat = stub(Deno, "stat", (async (at: string | URL) => ({
        ...(await stat(at)),
        ino: null,
      })) as typeof Deno.stat);

      expect(await withFileLock(path, () => Promise.resolve("in"))).toBe("in");
    });
  });
});

describe("withStaticAssetBuildLock", () => {
  test("runs the body once and returns its result", async () => {
    let runs = 0;
    const value = await withStaticAssetBuildLock(() => {
      runs++;
      return Promise.resolve("kept");
    });
    expect(value).toBe("kept");
    expect(runs).toBe(1);
  });

  test("serializes two concurrent calls", async () => {
    const order: string[] = [];
    const firstEntered = Promise.withResolvers<void>();
    const secondAsked = Promise.withResolvers<void>();
    const releaseFirst = Promise.withResolvers<void>();
    const openStub = signalSecondLockAsk(secondAsked.resolve);

    try {
      const first = withStaticAssetBuildLock(() => {
        order.push("first");
        firstEntered.resolve();
        return releaseFirst.promise;
      });
      await firstEntered.promise;
      const second = withStaticAssetBuildLock(() => {
        order.push("second");
        return Promise.resolve();
      });
      // Wait for the second call to actually ask for the lock, then give the
      // operating system time to hand it over. Both halves are needed: without
      // the signal the check could run before the request was even made, and
      // without the pause a lock that excludes nobody would still look like it
      // was working.
      await secondAsked.promise;
      const granted = Promise.withResolvers<void>();
      setTimeout(granted.resolve, 20);
      await granted.promise;
      expect(order).toEqual(["first"]);
      releaseFirst.resolve();
      await Promise.all([first, second]);
    } finally {
      openStub.restore();
      releaseFirst.resolve();
    }

    expect(order).toEqual(["first", "second"]);
  });
});