UNPKG

@moonwall/cli

Version:

Testing framework for the Moon family of projects

223 lines (218 loc) 8.93 kB
// src/internal/effect/__tests__/FileLock.test.ts import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { Duration as Duration2, Effect as Effect2, Exit } from "effect"; import * as fs from "fs"; import * as path from "path"; import * as os2 from "os"; // src/internal/effect/FileLock.ts import { FileSystem } from "@effect/platform"; import { Duration, Effect, Schedule } from "effect"; import * as os from "os"; // src/internal/effect/errors.ts import { Data } from "effect"; var PortDiscoveryError = class extends Data.TaggedError("PortDiscoveryError") { }; var NodeLaunchError = class extends Data.TaggedError("NodeLaunchError") { }; var NodeReadinessError = class extends Data.TaggedError("NodeReadinessError") { }; var ProcessError = class extends Data.TaggedError("ProcessError") { }; var StartupCacheError = class extends Data.TaggedError("StartupCacheError") { }; var FileLockError = class extends Data.TaggedError("FileLockError") { }; // src/internal/effect/FileLock.ts var LOCK_MAX_AGE = Duration.minutes(2); var LOCK_POLL_INTERVAL = Duration.millis(500); var isProcessAlive = (pid) => Effect.try(() => { process.kill(pid, 0); return true; }).pipe(Effect.orElseSucceed(() => false)); var isLockStale = (info) => Effect.gen(function* () { const isTimedOut = Date.now() - info.timestamp > Duration.toMillis(LOCK_MAX_AGE); if (isTimedOut) return true; const isSameHost = info.hostname === os.hostname(); if (!isSameHost) return false; const alive = yield* isProcessAlive(info.pid); return !alive; }); var cleanupStaleLock = (lockPath) => Effect.gen(function* () { const fs2 = yield* FileSystem.FileSystem; const infoPath = `${lockPath}/lock.json`; const exists = yield* fs2.exists(infoPath).pipe(Effect.orElseSucceed(() => false)); if (!exists) return; const content = yield* fs2.readFileString(infoPath).pipe(Effect.orElseSucceed(() => "")); const info = yield* Effect.try(() => JSON.parse(content)).pipe( Effect.orElseSucceed(() => null) ); if (!info) return; const stale = yield* isLockStale(info); if (stale) { yield* fs2.remove(lockPath, { recursive: true }).pipe(Effect.ignore); } }); var writeLockInfo = (lockPath) => Effect.gen(function* () { const fs2 = yield* FileSystem.FileSystem; const info = { pid: process.pid, timestamp: Date.now(), hostname: os.hostname() }; yield* fs2.writeFileString(`${lockPath}/lock.json`, JSON.stringify(info)).pipe(Effect.ignore); }); var tryAcquireLock = (lockPath) => Effect.gen(function* () { const fs2 = yield* FileSystem.FileSystem; yield* cleanupStaleLock(lockPath); yield* fs2.makeDirectory(lockPath).pipe(Effect.mapError(() => new FileLockError({ reason: "acquisition_failed", lockPath }))); yield* writeLockInfo(lockPath); }); var acquireFileLock = (lockPath, timeout = Duration.minutes(2)) => tryAcquireLock(lockPath).pipe( Effect.retry(Schedule.fixed(LOCK_POLL_INTERVAL).pipe(Schedule.upTo(timeout))), Effect.catchAll(() => Effect.fail(new FileLockError({ reason: "timeout", lockPath }))) ); var releaseFileLock = (lockPath) => Effect.gen(function* () { const fs2 = yield* FileSystem.FileSystem; yield* fs2.remove(lockPath, { recursive: true }).pipe(Effect.ignore); }); var withFileLock = (lockPath, effect, timeout = Duration.minutes(2)) => Effect.acquireUseRelease( acquireFileLock(lockPath, timeout), () => effect, () => releaseFileLock(lockPath) ); // src/internal/effect/__tests__/FileLock.test.ts import { NodeFileSystem } from "@effect/platform-node"; describe("FileLock", () => { let testDir; let lockPath; beforeEach(() => { testDir = fs.mkdtempSync(path.join(os2.tmpdir(), "filelock-test-")); lockPath = path.join(testDir, "test.lock"); }); afterEach(() => { fs.rmSync(testDir, { recursive: true, force: true }); }); describe("acquireFileLock", () => { it("should acquire lock by creating directory", async () => { const program = acquireFileLock(lockPath, Duration2.seconds(5)).pipe( Effect2.provide(NodeFileSystem.layer) ); const exit = await Effect2.runPromiseExit(program); expect(Exit.isSuccess(exit)).toBe(true); expect(fs.existsSync(lockPath)).toBe(true); expect(fs.existsSync(path.join(lockPath, "lock.json"))).toBe(true); const lockInfo = JSON.parse(fs.readFileSync(path.join(lockPath, "lock.json"), "utf-8")); expect(lockInfo.pid).toBe(process.pid); expect(lockInfo.hostname).toBe(os2.hostname()); expect(typeof lockInfo.timestamp).toBe("number"); }); it("should fail with timeout if lock already held", async () => { fs.mkdirSync(lockPath); fs.writeFileSync( path.join(lockPath, "lock.json"), JSON.stringify({ pid: process.pid, timestamp: Date.now(), hostname: os2.hostname() }) ); const program = acquireFileLock(lockPath, Duration2.seconds(1)).pipe( Effect2.provide(NodeFileSystem.layer) ); const exit = await Effect2.runPromiseExit(program); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit) && exit.cause._tag === "Fail") { expect(exit.cause.error).toBeInstanceOf(FileLockError); } }); }); describe("releaseFileLock", () => { it("should remove lock directory", async () => { fs.mkdirSync(lockPath); fs.writeFileSync(path.join(lockPath, "lock.json"), "{}"); const program = releaseFileLock(lockPath).pipe(Effect2.provide(NodeFileSystem.layer)); await Effect2.runPromise(program); expect(fs.existsSync(lockPath)).toBe(false); }); it("should not fail if lock does not exist", async () => { const program = releaseFileLock(lockPath).pipe(Effect2.provide(NodeFileSystem.layer)); const exit = await Effect2.runPromiseExit(program); expect(Exit.isSuccess(exit)).toBe(true); }); }); describe("staleness detection", () => { it("should clean up lock from dead process", async () => { fs.mkdirSync(lockPath); fs.writeFileSync( path.join(lockPath, "lock.json"), JSON.stringify({ pid: 999999, timestamp: Date.now(), hostname: os2.hostname() }) ); const program = acquireFileLock(lockPath, Duration2.seconds(5)).pipe( Effect2.provide(NodeFileSystem.layer) ); const exit = await Effect2.runPromiseExit(program); expect(Exit.isSuccess(exit)).toBe(true); const lockInfo = JSON.parse(fs.readFileSync(path.join(lockPath, "lock.json"), "utf-8")); expect(lockInfo.pid).toBe(process.pid); }); it("should clean up expired lock regardless of PID", async () => { fs.mkdirSync(lockPath); fs.writeFileSync( path.join(lockPath, "lock.json"), JSON.stringify({ pid: process.pid, // Same PID but expired timestamp: Date.now() - 13e4, // 130 seconds ago (> 120s max age) hostname: os2.hostname() }) ); const program = acquireFileLock(lockPath, Duration2.seconds(5)).pipe( Effect2.provide(NodeFileSystem.layer) ); const exit = await Effect2.runPromiseExit(program); expect(Exit.isSuccess(exit)).toBe(true); const lockInfo = JSON.parse(fs.readFileSync(path.join(lockPath, "lock.json"), "utf-8")); expect(lockInfo.timestamp).toBeGreaterThan(Date.now() - 5e3); }); it("should NOT clean up valid lock from different host", async () => { fs.mkdirSync(lockPath); fs.writeFileSync( path.join(lockPath, "lock.json"), JSON.stringify({ pid: 12345, timestamp: Date.now(), // Fresh timestamp hostname: "other-host.local" // Different host }) ); const program = acquireFileLock(lockPath, Duration2.seconds(1)).pipe( Effect2.provide(NodeFileSystem.layer) ); const exit = await Effect2.runPromiseExit(program); expect(Exit.isFailure(exit)).toBe(true); }); }); describe("withFileLock", () => { it("should execute effect while holding lock and release after", async () => { let executed = false; const program = withFileLock( lockPath, Effect2.sync(() => { executed = true; expect(fs.existsSync(lockPath)).toBe(true); return "result"; }) ).pipe(Effect2.provide(NodeFileSystem.layer)); const result = await Effect2.runPromise(program); expect(executed).toBe(true); expect(result).toBe("result"); expect(fs.existsSync(lockPath)).toBe(false); }); it("should release lock even if effect fails", async () => { const program = withFileLock(lockPath, Effect2.fail(new Error("test error"))).pipe( Effect2.provide(NodeFileSystem.layer) ); const exit = await Effect2.runPromiseExit(program); expect(Exit.isFailure(exit)).toBe(true); expect(fs.existsSync(lockPath)).toBe(false); }); }); });