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
11 changes: 2 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions packages/testcontainers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>();
private readonly imageExistsLock = new AsyncLock();

constructor(
protected readonly dockerode: Dockerode,
Expand Down Expand Up @@ -81,25 +79,28 @@ export class DockerImageClient implements ImageClient {
}

async exists(imageName: ImageName): Promise<boolean> {
return this.imageExistsLock.acquire(imageName.string, 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<void> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>();
const imageCheckLock = new AsyncLock();

export async function imageExists(dockerode: Dockerode, imageName: ImageName): Promise<boolean> {
return imageCheckLock.acquire(imageName.string, async () => {
return withFileLock(`testcontainers-node-image-exists-${hash(imageName.string).substring(0, 12)}.lock`, async () => {
if (existingImages.has(imageName.string)) {
return true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 () => {
Copy link
Contributor Author

@abendi abendi Sep 23, 2025

Choose a reason for hiding this comment

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

I think it's also worth mentioning that async-lock preserved the order of call queue within a single process, but withFileLock doesn't have that guarantee (especially with randomized delay). Should I look into it or is it a non-issue?

const container = await client.container.fetchByLabel(LABEL_TESTCONTAINERS_CONTAINER_HASH, containerHash, {
status: CONTAINER_STATUSES.filter(
(status) => status !== "removing" && status !== "dead" && status !== "restarting"
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand All @@ -33,7 +31,7 @@ export class StartedGenericContainer implements StartedTestContainer {
protected containerIsStopping?(): Promise<void>;

public async stop(options: Partial<StopOptions> = {}): Promise<StoppedTestContainer> {
return this.stopContainerLock.acquire("stop", async () => {
return withFileLock(`testcontainers-node-stop-${this.container.id.substring(0, 12)}.lock`, async () => {
if (this.stoppedContainer) {
return this.stoppedContainer;
}
Expand Down