From 79c7543503bf3514855db3d08302256e1d15b80b Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Thu, 19 Mar 2026 10:49:18 +0300 Subject: [PATCH 01/12] Enable CI for all major platforms Since GitHub Actions now support it --- .github/workflows/ci.yaml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 29caf1a..1a0a526 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,7 +12,10 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest] # [macOS-latest, windows-latest] do not come with docker :( + os: + - ubuntu-latest + - macos-26-intel + - windows-latest cabal: ["3.12.1.0"] ghc: - 9.2.8 @@ -46,6 +49,10 @@ jobs: cabal configure -O0 --enable-tests --enable-benchmarks --test-show-details=direct cabal build all + - name: Set up Docker + if: runner.os == 'macOS' + uses: docker/setup-docker-action@v5 + - name: Test run: | cabal test all From c23690d053eb58a5ea169600e417180850988306 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Thu, 19 Mar 2026 12:00:21 +0300 Subject: [PATCH 02/12] Disable fail-fast to see the results of all runs --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1a0a526..734aeb6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,6 +11,7 @@ jobs: name: ${{ matrix.os }} / ghc ${{ matrix.ghc }} runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: - ubuntu-latest From c7dcc0efad003818fd625d04f9c7344c4ceb7330 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Thu, 11 Jun 2026 14:37:54 +0300 Subject: [PATCH 03/12] Add ubuntu-24.04-arm to CI matrix --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 734aeb6..4e64881 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,6 +15,7 @@ jobs: matrix: os: - ubuntu-latest + - ubuntu-24.04-arm - macos-26-intel - windows-latest cabal: ["3.12.1.0"] From 596506e3179d4196781247967dd65852ee5d23b2 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Thu, 11 Jun 2026 15:25:19 +0300 Subject: [PATCH 04/12] Update the images --- .github/workflows/ci.yaml | 4 ++++ src/TestContainers/Docker.hs | 25 +++++++++++++++++++------ src/TestContainers/Docker/Reaper.hs | 2 +- src/TestContainers/Image.hs | 8 ++++---- test/TestContainers/TastySpec.hs | 8 ++++---- 5 files changed, 32 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4e64881..19a05b7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -56,6 +56,10 @@ jobs: uses: docker/setup-docker-action@v5 - name: Test + # GitHub-hosted Windows runners have no Linux Docker daemon, so the + # Docker-dependent test suite cannot run there. The Build step above still + # verifies the library compiles on Windows. + if: runner.os != 'Windows' run: | cabal test all diff --git a/src/TestContainers/Docker.hs b/src/TestContainers/Docker.hs index f6011d2..6b04dac 100644 --- a/src/TestContainers/Docker.hs +++ b/src/TestContainers/Docker.hs @@ -670,11 +670,24 @@ run request = do -- @since 0.5.0.0 createRyukReaper :: TestContainer Reaper createRyukReaper = do - dockerSocketLocation <- - liftIO $ - lookupEnv "DOCKER_HOST" - <&> (>>= stripPrefix "unix://") - <&> fromMaybe "/var/run/docker.sock" + -- Ryuk needs access to the Docker daemon socket to reap resources. The way + -- that socket is exposed depends on whether the daemon runs Linux or Windows + -- containers. Docker automatically selects the matching ryuk image variant + -- from the multi-arch manifest based on the daemon's OS. + dockerSocketMount <- do + onLinux <- isDockerOnLinux + if onLinux + then do + dockerSocketLocation <- + liftIO $ + lookupEnv "DOCKER_HOST" + <&> (>>= stripPrefix "unix://") + <&> fromMaybe "/var/run/docker.sock" + pure (pack dockerSocketLocation, "/var/run/docker.sock") + else + -- Windows containers talk to the daemon through the named pipe rather + -- than a unix socket. This is what the windows/amd64 ryuk build expects. + pure ("\\\\.\\pipe\\docker_engine", "\\\\.\\pipe\\docker_engine") ryukContainer <- run $ containerRequest (fromTag ryukImageTag) @@ -682,7 +695,7 @@ createRyukReaper = do -- Ryuk destroys itself once it reaped the resources, -- no need to register itself with itself. withoutReaper - & setVolumeMounts [(pack dockerSocketLocation, "/var/run/docker.sock")] + & setVolumeMounts [dockerSocketMount] & setExpose [ryukPort] & setWaitingFor (waitUntilMappedPortReachable ryukPort) & setRm True diff --git a/src/TestContainers/Docker/Reaper.hs b/src/TestContainers/Docker/Reaper.hs index faaf5c6..fdd0d03 100644 --- a/src/TestContainers/Docker/Reaper.hs +++ b/src/TestContainers/Docker/Reaper.hs @@ -58,7 +58,7 @@ newtype Ryuk = Ryuk {ryukSocket :: Socket.Socket} -- @since 0.5.0.0 ryukImageTag :: Text ryukImageTag = - "docker.io/testcontainers/ryuk:0.3.4" + "docker.io/testcontainers/ryuk:0.14.0" -- | Exposed port for the ryuk reaper. -- diff --git a/src/TestContainers/Image.hs b/src/TestContainers/Image.hs index a2308cb..fdb2793 100644 --- a/src/TestContainers/Image.hs +++ b/src/TestContainers/Image.hs @@ -23,21 +23,21 @@ import TestContainers.Docker as Docker -- | Image for Redis database. -- -- @ --- redis = fromTag "redis:5.0" +-- redis = fromTag "redis:7.4" -- @ -- -- @since 0.1.0.0 redis :: ToImage redis = - fromTag "redis:5.0" + fromTag "redis:7.4" -- | Image for Mongo database. -- -- @ --- mongo = Tag "mongo:4.0.17" +-- mongo = Tag "mongo:7.0" -- @ -- -- @since 0.1.0.0 mongo :: ToImage mongo = - fromTag "mongo:4.0.17" + fromTag "mongo:7.0" diff --git a/test/TestContainers/TastySpec.hs b/test/TestContainers/TastySpec.hs index a2fba0b..2c05487 100644 --- a/test/TestContainers/TastySpec.hs +++ b/test/TestContainers/TastySpec.hs @@ -51,7 +51,7 @@ containers1 = do _rabbitmq <- run $ - containerRequest (fromTag "rabbitmq:3.8.4") + containerRequest (fromTag "rabbitmq:3.13") & setRm False & setExpose [5672] & withNetwork net @@ -63,7 +63,7 @@ containers1 = do _nginx <- run $ - containerRequest (fromTag "nginx:1.23.1-alpine") + containerRequest (fromTag "nginx:1.27-alpine") & setExpose [80] & withNetwork net & setWaitingFor @@ -73,7 +73,7 @@ containers1 = do _jaeger <- run $ - containerRequest (fromTag "jaegertracing/all-in-one:1.6") + containerRequest (fromTag "jaegertracing/all-in-one:1.62.0") & setExpose ["5775/udp", "6831/udp", "6832/udp", "5778", "16686/tcp"] & withNetwork net & setWaitingFor @@ -81,7 +81,7 @@ containers1 = do _postgres <- run $ - containerRequest (fromTag "postgres:16-alpine") + containerRequest (fromTag "postgres:17-alpine") & withCopyFileToContainer "test/data/init-script.sql" "/docker-entrypoint-initdb.d/" _helloWorld <- From 683747696edb4c05348bea3c24f7bcf9527299b5 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Thu, 11 Jun 2026 16:27:30 +0300 Subject: [PATCH 05/12] Modernize the lint workflow --- .github/workflows/lint.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index b33fcfa..dbda448 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -10,12 +10,12 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.ref == 'refs/heads/master' - name: Check code is formatted using Ormolu run: | - curl -L https://github.com/tweag/ormolu/releases/download/0.7.2.0/ormolu-Linux.zip -o ormolu.zip + curl -L https://github.com/tweag/ormolu/releases/download/0.8.1.0/ormolu-x86_64-linux.zip -o ormolu.zip unzip ormolu.zip git ls-files | grep \.hs | xargs ./ormolu --mode=inplace From cc7d2029a6ef2ad381ca825d235c59513327477a Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Thu, 11 Jun 2026 17:22:12 +0300 Subject: [PATCH 06/12] Modernize --- .github/workflows/ci.yaml | 12 ++++++------ testcontainers.cabal | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 19a05b7..4103648 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,18 +18,17 @@ jobs: - ubuntu-24.04-arm - macos-26-intel - windows-latest - cabal: ["3.12.1.0"] + cabal: + - "3.14" ghc: - - 9.2.8 - - 9.4.8 - - 9.6.4 - - 9.8.2 + - "9.2" + - "9.12" steps: - uses: actions/checkout@v2 if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.ref == 'refs/heads/master' - - uses: haskell-actions/setup@v2.6.1 + - uses: haskell-actions/setup@v2 id: setup-haskell-cabal name: Setup Haskell with: @@ -38,6 +37,7 @@ jobs: - name: Freeze run: | + cabal configure --enable-tests --enable-benchmarks cabal freeze - uses: actions/cache@v3 diff --git a/testcontainers.cabal b/testcontainers.cabal index 6c89bc2..b6b1d95 100644 --- a/testcontainers.cabal +++ b/testcontainers.cabal @@ -86,7 +86,7 @@ test-suite tests , hspec >=2.0 && <3.0 , tasty , tasty-discover >=4.2.1 && <6 - , tasty-hspec + , tasty-hspec >=1.1.3 && <1.3 , tasty-hunit , testcontainers , text From eedcf13e6df3115a867e9618566a5957c1ce1e8e Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Thu, 11 Jun 2026 18:28:02 +0300 Subject: [PATCH 07/12] Fix CI failures: ryuk wait strategy and rabbitmq log pattern - Replace waitUntilMappedPortReachable for ryuk with waitForLogLine on ryuk's "Started" log message. A TCP probe to ryuk's protocol port registers as a client that immediately disconnects without labels; ryuk 0.14.0 treats this as a completed session and exits (1 ns reconnection timeout when no resources are registered), causing the subsequent real connection from newRyukReaper to get ECONNRESET. - Simplify rabbitmq readiness to waitUntilMappedPortReachable 5672. RabbitMQ 3.13 changed its startup log format and no longer outputs the "completed with" line that the previous wait condition relied on. Co-Authored-By: Claude Sonnet 4.6 --- src/TestContainers/Docker.hs | 7 ++++++- test/TestContainers/TastySpec.hs | 9 ++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/TestContainers/Docker.hs b/src/TestContainers/Docker.hs index 6b04dac..6de16de 100644 --- a/src/TestContainers/Docker.hs +++ b/src/TestContainers/Docker.hs @@ -697,7 +697,12 @@ createRyukReaper = do withoutReaper & setVolumeMounts [dockerSocketMount] & setExpose [ryukPort] - & setWaitingFor (waitUntilMappedPortReachable ryukPort) + -- Wait for ryuk's own startup log rather than probing its port. + -- A TCP probe (waitUntilMappedPortReachable) registers as a client + -- that immediately disconnects without sending any labels; ryuk 0.14.0 + -- treats this as a completed session and exits with its 1 ns + -- reconnection timeout, so the subsequent real connection gets RST. + & setWaitingFor (waitForLogLine Stdout ("Started" `LazyText.isInfixOf`)) & setRm True let (ryukContainerAddress, ryukContainerPort) = diff --git a/test/TestContainers/TastySpec.hs b/test/TestContainers/TastySpec.hs index 2c05487..482d2b0 100644 --- a/test/TestContainers/TastySpec.hs +++ b/test/TestContainers/TastySpec.hs @@ -3,12 +3,10 @@ module TestContainers.TastySpec (main, test_all) where -import Data.Text.Lazy (isInfixOf) import Test.Tasty (TestTree, defaultMain, testGroup) import Test.Tasty.HUnit import TestContainers.Tasty - ( Pipe (Stdout), - TestContainer, + ( TestContainer, consoleLogConsumer, containerRequest, createNetwork, @@ -22,7 +20,6 @@ import TestContainers.Tasty setWaitingFor, successfulExit, waitForHttp, - waitForLogLine, waitForState, waitUntilMappedPortReachable, waitUntilTimeout, @@ -57,9 +54,7 @@ containers1 = do & withNetwork net & withFollowLogs consoleLogConsumer & setWaitingFor - ( waitForLogLine Stdout (("completed with" `isInfixOf`)) - <> waitUntilMappedPortReachable 5672 - ) + (waitUntilMappedPortReachable 5672) _nginx <- run $ From 0955e63d5fa3fe95bc8002dbc230bb3cd3ea5c71 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 14 Jun 2026 14:52:18 +0300 Subject: [PATCH 08/12] Fix for macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: After ryuk logs "Started", Docker Desktop/Colima on macOS has a small delay before the VM-to-host port forwarding becomes active. The original newRyukReaper made one immediate Socket.connect with no retry, so it hit ECONNREFUSED on macOS even though ryuk was actually running. Using waitUntilMappedPortReachable as the wait strategy isn't an option (it probe-connects and immediately disconnects, which ryuk 0.14.0 treats as a completed session causing it to exit). Fix: Added a connectWithRetry 60 loop inside newRyukReaper — up to 60 attempts with 500 ms delay between them (30 s total budget), catching IOException specifically on each attempt and closing the failed socket before retrying with a fresh one. This mirrors the retry pattern already used by waitUntilMappedPortReachable and waitForHttp, without triggering ryuk's session-termination logic. --- src/TestContainers/Docker/Reaper.hs | 46 +++++++++++++++++++---------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/TestContainers/Docker/Reaper.hs b/src/TestContainers/Docker/Reaper.hs index fdd0d03..0b5b01e 100644 --- a/src/TestContainers/Docker/Reaper.hs +++ b/src/TestContainers/Docker/Reaper.hs @@ -2,6 +2,7 @@ {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} module TestContainers.Docker.Reaper ( Reaper (..), @@ -14,6 +15,8 @@ module TestContainers.Docker.Reaper ) where +import Control.Concurrent (threadDelay) +import Control.Exception (IOException, try) import Control.Monad (replicateM) import Control.Monad.Trans.Resource (MonadResource, allocate) import Data.Text (Text, pack, unpack) @@ -84,24 +87,12 @@ newRyukReaper host port = do (_releaseKey, (_socket, ryuk)) <- allocate ( do - let hints = - Socket.defaultHints {Socket.addrSocketType = Socket.Stream} - address <- - head <$> Socket.getAddrInfo (Just hints) (Just (unpack host)) (Just (show port)) - socket <- - Socket.socket - (Socket.addrFamily address) - (Socket.addrSocketType address) - (Socket.addrProtocol address) - Socket.connect socket (Socket.addrAddress address) - - -- Construct the reaper and regiter the session with it. - -- Doing it here intead of in the teardown (like we did before) + socket <- connectWithRetry 60 + -- Construct the reaper and register the session with it. + -- Doing it here instead of in the teardown (like we did before) -- guarantees the Reaper knows about our session. - let reaper = - newReaper sessionId (Ryuk socket) + let reaper = newReaper sessionId (Ryuk socket) register reaper sessionIdLabel sessionId - pure (socket, reaper) ) ( \(socket, _ryuk) -> do @@ -111,6 +102,29 @@ newRyukReaper host port = do ) pure ryuk + where + -- On macOS (Docker Desktop / Colima), port-forwarding from the VM to the + -- host can lag a moment behind the container's own "Started" log line. + -- Retry the connect rather than failing immediately. + connectWithRetry :: Int -> IO Socket.Socket + connectWithRetry 0 = do + let hints = Socket.defaultHints {Socket.addrSocketType = Socket.Stream} + address <- head <$> Socket.getAddrInfo (Just hints) (Just (unpack host)) (Just (show port)) + socket <- Socket.socket (Socket.addrFamily address) (Socket.addrSocketType address) (Socket.addrProtocol address) + Socket.connect socket (Socket.addrAddress address) + pure socket + connectWithRetry attemptsLeft = do + let hints = Socket.defaultHints {Socket.addrSocketType = Socket.Stream} + address <- head <$> Socket.getAddrInfo (Just hints) (Just (unpack host)) (Just (show port)) + socket <- Socket.socket (Socket.addrFamily address) (Socket.addrSocketType address) (Socket.addrProtocol address) + result <- try (Socket.connect socket (Socket.addrAddress address)) + case result of + Right () -> + pure socket + Left (_ :: IOException) -> do + Socket.close socket + threadDelay 500000 + connectWithRetry (attemptsLeft - 1) newReaper :: -- | Session id From 26f2b0248bce9b1bb83f5fd984b70ccf9f072703 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Sun, 14 Jun 2026 22:53:41 +0300 Subject: [PATCH 09/12] Correct "configure" --- .github/workflows/ci.yaml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4103648..47ce14a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,10 +35,11 @@ jobs: ghc-version: ${{ matrix.ghc }} cabal-version: ${{ matrix.cabal }} + - name: Configure + run: cabal configure -O0 --enable-tests --enable-benchmarks --test-show-details=direct + - name: Freeze - run: | - cabal configure --enable-tests --enable-benchmarks - cabal freeze + run: cabal freeze - uses: actions/cache@v3 name: Cache ~/.cabal/store @@ -47,9 +48,7 @@ jobs: key: ${{ runner.os }}-${{ matrix.ghc }}-${{ hashFiles('cabal.project.freeze') }} - name: Build - run: | - cabal configure -O0 --enable-tests --enable-benchmarks --test-show-details=direct - cabal build all + run: cabal build all - name: Set up Docker if: runner.os == 'macOS' From 4d380bac10f0b215dd0f4b9690d3afc872d453fb Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Mon, 15 Jun 2026 08:54:30 +0300 Subject: [PATCH 10/12] Update the tests to avoid macOS flakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: waitUntilMappedPortReachable 5672 is unreliable on macOS because port-forwarding through Lima's SSH tunnel can cause Socket.connect to block indefinitely (hanging rather than returning ECONNREFUSED). The System.Timeout.timeout can't always interrupt a blocking FFI syscall in time, so the 60-second default timeout doesn't reliably fire. Fix: Switch back to waitForLogLine Stdout ("started TCP listener" LazyText.isInfixOf). RabbitMQ 3.13 logs "started TCP listener on [::]:5672" the moment it binds the AMQP port — this doesn't involve host port-forwarding at all (it reads from docker logs --follow inside the container), so it's immune to macOS networking quirks. The original log-based approach ("completed with") broke when RabbitMQ 3.13 changed its startup log format; "started TCP listener" is the correct replacement. --- test/TestContainers/TastySpec.hs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/TestContainers/TastySpec.hs b/test/TestContainers/TastySpec.hs index 482d2b0..43ac441 100644 --- a/test/TestContainers/TastySpec.hs +++ b/test/TestContainers/TastySpec.hs @@ -3,10 +3,12 @@ module TestContainers.TastySpec (main, test_all) where +import qualified Data.Text.Lazy as LazyText import Test.Tasty (TestTree, defaultMain, testGroup) import Test.Tasty.HUnit import TestContainers.Tasty - ( TestContainer, + ( Pipe (Stdout), + TestContainer, consoleLogConsumer, containerRequest, createNetwork, @@ -20,6 +22,7 @@ import TestContainers.Tasty setWaitingFor, successfulExit, waitForHttp, + waitForLogLine, waitForState, waitUntilMappedPortReachable, waitUntilTimeout, @@ -54,7 +57,7 @@ containers1 = do & withNetwork net & withFollowLogs consoleLogConsumer & setWaitingFor - (waitUntilMappedPortReachable 5672) + (waitForLogLine Stdout ("started TCP listener" `LazyText.isInfixOf`)) _nginx <- run $ From d3d4d2d7832c09eb3822f6587b88c3a577a38d74 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Mon, 15 Jun 2026 11:02:23 +0300 Subject: [PATCH 11/12] Fix macOS CI: RabbitMQ wait strategy and default-timeout semantics - configDefaultWaitTimeout was applied as an outer cap in waitUntilReady, silently overriding any explicit waitUntilTimeout the caller provided. Fix: skip the default when the input is already a WaitUntilTimeout so that explicit timeouts take precedence. - RabbitMQ readiness: switch from waitUntilMappedPortReachable (flaky on macOS because Socket.connect through Lima's SSH tunnel can block rather than returning ECONNREFUSED) to waitForLogLine Stdout on "started TCP listener", wrapped with waitUntilTimeout 300. The macOS CI runner (Intel/QEMU, 3.8 GB RAM) needs well over 60 s to boot RabbitMQ; the log-based strategy avoids host port-forwarding entirely and the explicit 300 s timeout now works as intended after the semantics fix above. --- src/TestContainers/Docker.hs | 10 +++++++--- test/TestContainers/TastySpec.hs | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/TestContainers/Docker.hs b/src/TestContainers/Docker.hs index 6de16de..a6e9d70 100644 --- a/src/TestContainers/Docker.hs +++ b/src/TestContainers/Docker.hs @@ -1122,9 +1122,13 @@ waitForLogLine whereToLook matches = waitWithLogs $ \Container {id} stdout stder waitUntilReady :: Container -> WaitUntilReady -> TestContainer () waitUntilReady container@Container {id} input = do Config {configDefaultWaitTimeout} <- ask - interpreter $ case configDefaultWaitTimeout of - Just seconds -> waitUntilTimeout seconds input - Nothing -> input + interpreter $ case input of + WaitUntilTimeout {} -> + input + _ -> + case configDefaultWaitTimeout of + Just seconds -> waitUntilTimeout seconds input + Nothing -> input where interpreter :: WaitUntilReady -> TestContainer () interpreter wait = diff --git a/test/TestContainers/TastySpec.hs b/test/TestContainers/TastySpec.hs index 43ac441..0f6dbcf 100644 --- a/test/TestContainers/TastySpec.hs +++ b/test/TestContainers/TastySpec.hs @@ -57,7 +57,7 @@ containers1 = do & withNetwork net & withFollowLogs consoleLogConsumer & setWaitingFor - (waitForLogLine Stdout ("started TCP listener" `LazyText.isInfixOf`)) + (waitUntilTimeout 300 (waitForLogLine Stdout ("started TCP listener" `LazyText.isInfixOf`))) _nginx <- run $ From b9401e32bb5de483638d9482b58b684dc40c6846 Mon Sep 17 00:00:00 2001 From: Nikita Volkov Date: Mon, 15 Jun 2026 12:00:32 +0300 Subject: [PATCH 12/12] Retry docker pull on transient network failures On macOS/Lima, DNS lookups inside the VM can time out transiently, causing docker pull to fail with an i/o timeout. Retry up to 3 times with a 2 s delay before propagating the exception. --- src/TestContainers/Docker.hs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/TestContainers/Docker.hs b/src/TestContainers/Docker.hs index a6e9d70..66f8786 100644 --- a/src/TestContainers/Docker.hs +++ b/src/TestContainers/Docker.hs @@ -804,11 +804,20 @@ defaultToImage action = fromTag :: ImageTag -> ToImage fromTag tag = defaultToImage $ do tracer <- askTracer - output <- docker tracer ["pull", "--quiet", tag] - return $ - Image - { tag = strip (pack output) - } + pullWithRetry tracer 3 + where + pull tracer = do + output <- docker tracer ["pull", "--quiet", tag] + pure $ Image {tag = strip (pack output)} + pullWithRetry :: Tracer -> Int -> TestContainer Image + pullWithRetry tracer 0 = pull tracer + pullWithRetry tracer n = do + result <- try (pull tracer) + case result of + Right image -> pure image + Left (_ :: DockerException) -> do + liftIO (threadDelay 2000000) + pullWithRetry tracer (n - 1) -- | Get an `Image` from an image id. This doesn't run @docker pull@ or any other Docker command -- on construction.