From 4d53e540ac788e542d8ce3ff9d803ac2a01b1054 Mon Sep 17 00:00:00 2001 From: Arvo Bendi Date: Sat, 20 Sep 2025 00:05:36 +0300 Subject: [PATCH 1/2] refactor: replace in-memory locks with file locks --- package-lock.json | 11 ++--------- packages/testcontainers/package.json | 2 -- .../clients/image/docker-image-client.ts | 6 ++---- .../src/container-runtime/utils/image-exists.ts | 5 ++--- .../src/generic-container/generic-container.ts | 7 ++----- .../generic-container/started-generic-container.ts | 6 ++---- 6 files changed, 10 insertions(+), 27 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4593ac559..c74316c05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7478,12 +7478,6 @@ "@types/readdir-glob": "*" } }, - "node_modules/@types/async-lock": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@types/async-lock/-/async-lock-1.4.2.tgz", - "integrity": "sha512-HlZ6Dcr205BmNhwkdXqrg2vkFMN2PluI7Lgr8In3B3wE5PiQHhjRqtW/lGdVU9gw+sM0JcIDx2AN+cW8oSWIcw==", - "dev": true - }, "node_modules/@types/big.js": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/@types/big.js/-/big.js-6.2.2.tgz", @@ -9117,7 +9111,8 @@ "node_modules/async-lock": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", - "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==" + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "dev": true }, "node_modules/async-mutex": { "version": "0.5.0", @@ -22430,7 +22425,6 @@ "@balena/dockerignore": "^1.0.2", "@types/dockerode": "^3.3.43", "archiver": "^7.0.1", - "async-lock": "^1.4.1", "byline": "^5.0.0", "debug": "^4.4.3", "docker-compose": "^1.3.0", @@ -22445,7 +22439,6 @@ }, "devDependencies": { "@types/archiver": "^6.0.3", - "@types/async-lock": "^1.4.2", "@types/byline": "^4.2.36", "@types/debug": "^4.1.12", "@types/proper-lockfile": "^4.1.4", diff --git a/packages/testcontainers/package.json b/packages/testcontainers/package.json index d3a13bd6e..6c5949f58 100644 --- a/packages/testcontainers/package.json +++ b/packages/testcontainers/package.json @@ -33,7 +33,6 @@ "@balena/dockerignore": "^1.0.2", "@types/dockerode": "^3.3.43", "archiver": "^7.0.1", - "async-lock": "^1.4.1", "byline": "^5.0.0", "debug": "^4.4.3", "docker-compose": "^1.3.0", @@ -48,7 +47,6 @@ }, "devDependencies": { "@types/archiver": "^6.0.3", - "@types/async-lock": "^1.4.2", "@types/byline": "^4.2.36", "@types/debug": "^4.1.12", "@types/proper-lockfile": "^4.1.4", diff --git a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts index 901e0c3be..ef2d46341 100644 --- a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts @@ -1,18 +1,16 @@ import dockerIgnore from "@balena/dockerignore"; -import AsyncLock from "async-lock"; import byline from "byline"; import Dockerode, { ImageBuildOptions, ImageInspectInfo } from "dockerode"; import { existsSync, promises as fs } from "fs"; import path from "path"; import tar from "tar-fs"; -import { buildLog, log, pullLog } from "../../../common"; +import { buildLog, hash, log, pullLog, withFileLock } from "../../../common"; import { getAuthConfig } from "../../auth/get-auth-config"; import { ImageName } from "../../image-name"; import { ImageClient } from "./image-client"; export class DockerImageClient implements ImageClient { private readonly existingImages = new Set(); - private readonly imageExistsLock = new AsyncLock(); constructor( protected readonly dockerode: Dockerode, @@ -81,7 +79,7 @@ export class DockerImageClient implements ImageClient { } async exists(imageName: ImageName): Promise { - return this.imageExistsLock.acquire(imageName.string, async () => { + return withFileLock(`testcontainers-node-exists-${hash(imageName.string)}.lock`, async () => { if (this.existingImages.has(imageName.string)) { return true; } diff --git a/packages/testcontainers/src/container-runtime/utils/image-exists.ts b/packages/testcontainers/src/container-runtime/utils/image-exists.ts index b8f385aa6..3e4a7e135 100644 --- a/packages/testcontainers/src/container-runtime/utils/image-exists.ts +++ b/packages/testcontainers/src/container-runtime/utils/image-exists.ts @@ -1,12 +1,11 @@ -import AsyncLock from "async-lock"; import Dockerode from "dockerode"; +import { hash, withFileLock } from "../../common"; import { ImageName } from "../image-name"; const existingImages = new Set(); -const imageCheckLock = new AsyncLock(); export async function imageExists(dockerode: Dockerode, imageName: ImageName): Promise { - return imageCheckLock.acquire(imageName.string, async () => { + return withFileLock(`testcontainers-node-image-${hash(imageName.string)}.lock`, async () => { if (existingImages.has(imageName.string)) { return true; } diff --git a/packages/testcontainers/src/generic-container/generic-container.ts b/packages/testcontainers/src/generic-container/generic-container.ts index bada4e422..f0dfac151 100644 --- a/packages/testcontainers/src/generic-container/generic-container.ts +++ b/packages/testcontainers/src/generic-container/generic-container.ts @@ -1,8 +1,7 @@ import archiver from "archiver"; -import AsyncLock from "async-lock"; import { Container, ContainerCreateOptions, HostConfig } from "dockerode"; import { Readable } from "stream"; -import { containerLog, hash, log, toNanos } from "../common"; +import { containerLog, hash, log, toNanos, withFileLock } from "../common"; import { ContainerRuntimeClient, getContainerRuntimeClient, ImageName } from "../container-runtime"; import { CONTAINER_STATUSES } from "../container-runtime/clients/container/types"; import { StartedNetwork } from "../network/network"; @@ -36,8 +35,6 @@ import { GenericContainerBuilder } from "./generic-container-builder"; import { inspectContainerUntilPortsExposed } from "./inspect-container-util-ports-exposed"; import { StartedGenericContainer } from "./started-generic-container"; -const reusableContainerCreationLock = new AsyncLock(); - export class GenericContainer implements TestContainer { public static fromDockerfile(context: string, dockerfileName = "Dockerfile"): GenericContainerBuilder { return new GenericContainerBuilder(context, dockerfileName); @@ -122,7 +119,7 @@ export class GenericContainer implements TestContainer { this.createOpts.Labels = { ...this.createOpts.Labels, [LABEL_TESTCONTAINERS_CONTAINER_HASH]: containerHash }; log.debug(`Container reuse has been enabled with hash "${containerHash}"`); - return reusableContainerCreationLock.acquire(containerHash, async () => { + return withFileLock(`testcontainers-node-reuse-${containerHash.substring(0, 12)}.lock`, async () => { const container = await client.container.fetchByLabel(LABEL_TESTCONTAINERS_CONTAINER_HASH, containerHash, { status: CONTAINER_STATUSES.filter( (status) => status !== "removing" && status !== "dead" && status !== "restarting" diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 32f7d3925..860549b19 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -1,8 +1,7 @@ import archiver from "archiver"; -import AsyncLock from "async-lock"; import Dockerode, { ContainerInspectInfo } from "dockerode"; import { Readable } from "stream"; -import { containerLog, log } from "../common"; +import { containerLog, log, withFileLock } from "../common"; import { ContainerRuntimeClient, getContainerRuntimeClient } from "../container-runtime"; import { getReaper } from "../reaper/reaper"; import { RestartOptions, StartedTestContainer, StopOptions, StoppedTestContainer } from "../test-container"; @@ -18,7 +17,6 @@ import { StoppedGenericContainer } from "./stopped-generic-container"; export class StartedGenericContainer implements StartedTestContainer { private stoppedContainer?: StoppedTestContainer; - private readonly stopContainerLock = new AsyncLock(); constructor( private readonly container: Dockerode.Container, @@ -33,7 +31,7 @@ export class StartedGenericContainer implements StartedTestContainer { protected containerIsStopping?(): Promise; public async stop(options: Partial = {}): Promise { - return this.stopContainerLock.acquire("stop", async () => { + return withFileLock(`testcontainers-node-stop-${this.container.id}.lock`, async () => { if (this.stoppedContainer) { return this.stoppedContainer; } From 5f064bba228a34bbc9af731becc726cfd30899b4 Mon Sep 17 00:00:00 2001 From: Arvo Bendi Date: Sat, 20 Sep 2025 01:31:20 +0300 Subject: [PATCH 2/2] consistent lock file names --- .../clients/image/docker-image-client.ts | 35 ++++++++++--------- .../container-runtime/utils/image-exists.ts | 2 +- .../started-generic-container.ts | 2 +- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts index ef2d46341..39226a81c 100644 --- a/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts +++ b/packages/testcontainers/src/container-runtime/clients/image/docker-image-client.ts @@ -79,25 +79,28 @@ export class DockerImageClient implements ImageClient { } async exists(imageName: ImageName): Promise { - return withFileLock(`testcontainers-node-exists-${hash(imageName.string)}.lock`, async () => { - if (this.existingImages.has(imageName.string)) { - return true; - } - try { - log.debug(`Checking if image exists "${imageName.string}"...`); - await this.dockerode.getImage(imageName.string).inspect(); - this.existingImages.add(imageName.string); - log.debug(`Checked if image exists "${imageName.string}"`); - return true; - } catch (err) { - if (err instanceof Error && err.message.toLowerCase().includes("no such image")) { + return withFileLock( + `testcontainers-node-image-exists-${hash(imageName.string).substring(0, 12)}.lock`, + async () => { + if (this.existingImages.has(imageName.string)) { + return true; + } + try { + log.debug(`Checking if image exists "${imageName.string}"...`); + await this.dockerode.getImage(imageName.string).inspect(); + this.existingImages.add(imageName.string); log.debug(`Checked if image exists "${imageName.string}"`); - return false; + return true; + } catch (err) { + if (err instanceof Error && err.message.toLowerCase().includes("no such image")) { + log.debug(`Checked if image exists "${imageName.string}"`); + return false; + } + log.debug(`Failed to check if image exists "${imageName.string}"`); + throw err; } - log.debug(`Failed to check if image exists "${imageName.string}"`); - throw err; } - }); + ); } async pull(imageName: ImageName, opts?: { force: boolean; platform: string | undefined }): Promise { diff --git a/packages/testcontainers/src/container-runtime/utils/image-exists.ts b/packages/testcontainers/src/container-runtime/utils/image-exists.ts index 3e4a7e135..73464e9ce 100644 --- a/packages/testcontainers/src/container-runtime/utils/image-exists.ts +++ b/packages/testcontainers/src/container-runtime/utils/image-exists.ts @@ -5,7 +5,7 @@ import { ImageName } from "../image-name"; const existingImages = new Set(); export async function imageExists(dockerode: Dockerode, imageName: ImageName): Promise { - return withFileLock(`testcontainers-node-image-${hash(imageName.string)}.lock`, async () => { + return withFileLock(`testcontainers-node-image-exists-${hash(imageName.string).substring(0, 12)}.lock`, async () => { if (existingImages.has(imageName.string)) { return true; } diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts index 860549b19..d2aa089ee 100644 --- a/packages/testcontainers/src/generic-container/started-generic-container.ts +++ b/packages/testcontainers/src/generic-container/started-generic-container.ts @@ -31,7 +31,7 @@ export class StartedGenericContainer implements StartedTestContainer { protected containerIsStopping?(): Promise; public async stop(options: Partial = {}): Promise { - return withFileLock(`testcontainers-node-stop-${this.container.id}.lock`, async () => { + return withFileLock(`testcontainers-node-stop-${this.container.id.substring(0, 12)}.lock`, async () => { if (this.stoppedContainer) { return this.stoppedContainer; }