From 0a3db86599b33c22ba9cc5e51d1ed92839031afb Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 12 Nov 2026 14:04:28 +0100 Subject: [PATCH] fix(stack): continue a launch when a timed-out docker start left the container running --- .../src/runtime/Container.integration.test.ts | 28 +++++++++++++++++++ packages/stack/src/runtime/Container.ts | 22 ++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/stack/src/runtime/Container.integration.test.ts b/packages/stack/src/runtime/Container.integration.test.ts index c431ac9791..29c0a9afc8 100644 --- a/packages/stack/src/runtime/Container.integration.test.ts +++ b/packages/stack/src/runtime/Container.integration.test.ts @@ -1577,6 +1577,23 @@ describe("container process adapter", { timeout: 120_000 }, () => { }).pipe(Effect.provide([NodeServices.layer, NodeCrypto.layer])), ); + it.effect("continues a launch whose start timed out once the container is running", () => + Effect.gen(function* () { + const engine = yield* makeStalledStartSpawner("", "running"); + const runtime = yield* makeContainerRuntime({ + target: containerTarget, + root: ".", + hostGateway: sharedHostGateway, + }).pipe(Effect.provide(engine.layer)); + const launch = yield* Effect.scoped( + runtime.launch({ image, stackId: "r".repeat(64), instanceId: "slow-start", env: {} }), + ).pipe(Effect.provide(engine.layer), Effect.exit, Effect.forkChild); + yield* Deferred.await(engine.stderrRead); + yield* TestClock.adjust("30 seconds"); + expect(Exit.isSuccess(yield* Fiber.join(launch))).toBe(true); + }).pipe(Effect.provide([NodeServices.layer, NodeCrypto.layer])), + ); + it.effect("removes the container a hung docker create may still create when interrupted", () => Effect.gen(function* () { const engine = yield* makeHangingCreateSpawner(); @@ -2239,11 +2256,22 @@ const successfulHandle = (stdout = "") => /** Answers every engine command except `start`, which writes `stderr` and never exits. */ const makeStalledStartSpawner = Effect.fn("ContainerTest.stalledStartSpawner")(function* ( stderr: string, + containerStatus = "", ) { const stderrRead = yield* Deferred.make(); const spawner = ChildProcessSpawner.make((command) => { if (!ChildProcess.isStandardCommand(command)) return Effect.die("unexpected piped command"); if (command.args[0] === "info") return Effect.succeed(successfulHandle("fake-daemon-id")); + if (command.args[0] === "inspect") { + const format = command.args.map(String)[2]; + return Effect.succeed( + successfulHandle( + format === "{{.State.Status}}" + ? containerStatus + : `{"Ports":{},"Status":"${containerStatus}","ExitCode":0}`, + ), + ); + } if (command.args[0] !== "start") return Effect.succeed(successfulHandle()); // The second pull only happens once the reader has consumed the stderr line. const stalledStderr = Stream.succeed(new TextEncoder().encode(stderr)).pipe( diff --git a/packages/stack/src/runtime/Container.ts b/packages/stack/src/runtime/Container.ts index 6e6dbe50b6..b21a1b82cc 100644 --- a/packages/stack/src/runtime/Container.ts +++ b/packages/stack/src/runtime/Container.ts @@ -1044,7 +1044,27 @@ export const makeContainerRuntime = (options: { ) .pipe(Effect.mapError((cause) => errorFor("start", cause))) : undefined; - if (!interactive) yield* run(["start", name]); + if (!interactive) + yield* run(["start", name]).pipe( + // A loaded daemon can start the container yet keep the CLI waiting past its + // timeout, so the container's own state decides whether the start failed. + Effect.catchTag("ContainerError", (failure) => + failure.kind !== "engine-timeout" + ? Effect.fail(failure) + : run(["inspect", "--format", "{{.State.Status}}", name], { + timeout: "5 seconds", + }).pipe( + Effect.flatMap((status) => + status === "running" + ? Effect.logWarning( + `Container ${name} is running although '${failure.message}'`, + ) + : Effect.fail(failure), + ), + Effect.catchTag("ContainerError", () => Effect.fail(failure)), + ), + ), + ); const wait: Effect.Effect = attached === undefined ? run(["wait", name], {}).pipe(