@moonwall/cli
Version:
Testing framework for the Moon family of projects
223 lines (218 loc) • 8.93 kB
JavaScript
// 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);
});
});
});