Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions packages/testcontainers/src/common/file-lock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { existsSync, unlinkSync } from "fs";
import { writeFile } from "fs/promises";
import path from "path";
import { withFileLock } from "./file-lock";

describe("withFileLock", () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running this test suite fails intermittently on my machine. It's because the tests by default run in parallel, and these tests which share a file lock inherently cannot run in parallel. Needs to be describe.sequential.

const testFileName = "test-file-lock.lock";

afterEach(async () => {
// Clean up any test lock files - wait a bit for locks to be fully released
await new Promise((resolve) => setTimeout(resolve, 100));
const tmp = await import("tmp");
const file = path.resolve(tmp.tmpdir, testFileName);
try {
if (existsSync(file)) {
unlinkSync(file);
}
} catch {
// Ignore cleanup errors
}
});

it("should execute function and return its result", async () => {
const result = await withFileLock(testFileName, () => {
return "test-result";
});

expect(result).toBe("test-result");
});

it("should execute async function and return its result", async () => {
const result = await withFileLock(testFileName, async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
return 42;
});

expect(result).toBe(42);
});

it("should create lock file in tmp directory", async () => {
const tmp = await import("tmp");
const expectedFile = path.resolve(tmp.tmpdir, testFileName);

let fileExistedDuringLock = false;
await withFileLock(testFileName, () => {
fileExistedDuringLock = existsSync(expectedFile);
});

expect(fileExistedDuringLock).toBe(true);
});

it("should release lock after function completes", async () => {
let lockAcquired = false;

await withFileLock(testFileName, () => {
lockAcquired = true;
});

// If lock was released, we should be able to acquire it again immediately
await withFileLock(testFileName, () => {
expect(lockAcquired).toBe(true);
});
});

it("should release lock even if function throws error", async () => {
const error = new Error("Test error");

await expect(
withFileLock(testFileName, () => {
throw error;
})
).rejects.toThrow("Test error");

// Lock should be released, so we can acquire it again
const result = await withFileLock(testFileName, () => "success");
expect(result).toBe("success");
});

it("should release lock even if async function throws error", async () => {
await expect(
withFileLock(testFileName, async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
throw new Error("Async error");
})
).rejects.toThrow("Async error");

// Lock should be released
const result = await withFileLock(testFileName, () => "success");
expect(result).toBe("success");
});

it("should handle concurrent lock attempts by waiting", async () => {
const results: string[] = [];
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const promise1 = withFileLock(testFileName, async () => {
results.push("lock1-acquired");
await delay(50);
results.push("lock1-released");
return "result1";
});

// Start second lock attempt after a small delay
await delay(10);
const promise2 = withFileLock(testFileName, async () => {
results.push("lock2-acquired");
await delay(20);
results.push("lock2-released");
return "result2";
});

const [result1, result2] = await Promise.all([promise1, promise2]);

expect(result1).toBe("result1");
expect(result2).toBe("result2");
// Verify sequential execution
expect(results).toEqual(["lock1-acquired", "lock1-released", "lock2-acquired", "lock2-released"]);
});

it("should handle existing lock file gracefully", async () => {
const tmp = await import("tmp");
const file = path.resolve(tmp.tmpdir, testFileName);

// Pre-create the lock file (this simulates the file already existing)
await writeFile(file, "", { flag: "w" });

// Should still work with existing file - the wx flag will fail, but EEXIST should be caught
const result = await withFileLock(testFileName, () => {
return "works-with-existing-file";
});

expect(result).toBe("works-with-existing-file");
});

it("should reuse existing lock file on multiple calls", async () => {
const tmp = await import("tmp");
const file = path.resolve(tmp.tmpdir, testFileName);

await withFileLock(testFileName, () => "first");
expect(existsSync(file)).toBe(true);

await withFileLock(testFileName, () => "second");
expect(existsSync(file)).toBe(true);

await withFileLock(testFileName, () => "third");
expect(existsSync(file)).toBe(true);
});

it("should propagate return value of synchronous function", async () => {
const obj = { value: 123, nested: { data: "test" } };
const result = await withFileLock(testFileName, () => obj);

expect(result).toEqual(obj);
});

it("should propagate return value of async function", async () => {
const result = await withFileLock(testFileName, async () => {
const data = await Promise.resolve({ status: "success" });
return data;
});

expect(result).toEqual({ status: "success" });
});
});
9 changes: 8 additions & 1 deletion packages/testcontainers/src/common/file-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export async function withFileLock<T>(fileName: string, fn: () => T): Promise<T>
async function createEmptyTmpFile(fileName: string): Promise<string> {
const tmp = await import("tmp");
const file = path.resolve(tmp.tmpdir, fileName);
await writeFile(file, "");
try {
await writeFile(file, "", { flag: "wx" });
} catch (err) {
const isExistError = err && typeof err === "object" && "code" in err && err.code === "EEXIST";
if (!isExistError) {
throw err;
}
}
return file;
}