Skip to content

Handle floating promises and enable noFloatingPromises - #1488

Open
cristianrgreco wants to merge 5 commits into
mainfrom
claude/floating-promises-de3b40
Open

cristianrgreco wants to merge 5 commits into
mainfrom
claude/floating-promises-de3b40

Conversation

@cristianrgreco

@cristianrgreco cristianrgreco commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Enables Biome's nursery/noFloatingPromises rule at error level and fixes everything it reports. A floating promise is one that is never awaited, never given a rejection handler, and never explicitly ignored, so its errors are lost or become unhandled rejections.

Finding on main Fix
generic-container.ts:209: archive.finalize() not handled await Promise.all([archive.finalize(), putArchive(...)]) (see below)
nats-container.test.ts:21: (async () => { ... expect(...) })().then() Keep the promise, collect messages, and assert on them after nc.drain()
smoke-test.js:3: top-level async IIFE not handled try/catch inside the function that logs and exits with code 1. The call is marked void: a try/catch alone still leaves the un-awaited call flagged, and CommonJS has no top-level await. smoke-test.mjs (top-level await) and smoke-test.jest.js (Jest awaits the test) don't need it.
generic-container-dockerfile.test.ts:89: dockerPullEventPromise.then(...) void, with a comment explaining why: waitForDockerEvent never rejects and the test only records whether a pull happened

The same tar.finalize() pattern is also in StartedGenericContainer.copyFilesToContainer, copyDirectoriesToContainer and copyContentToContainer. The rule doesn't flag them because Biome can't infer archiver("tar")'s return type there, while createArchiveToCopyToContainer() declares one. They get the same fix so all four call sites behave the same way.

Why Promise.all and not await archive.finalize()

archiver's finalize() promise resolves when its internal tar module emits end and rejects when that module emits error (archiver/lib/core.js, Archiver.prototype.finalize). The module only reaches end once its output has been read. The archiver stream buffers just 1 MiB (highWaterMark), and only putArchive() reads it. Awaiting finalize() before calling putArchive() would therefore hang for any archive larger than the buffer. Running both concurrently keeps the existing ordering: finalize() is still called first and putArchive() consumes the stream. start() and the copy*ToContainer methods now also see a finalize() rejection, and Promise.all handles whichever promise loses the race, so neither rejection is left unhandled.

Today the tar module's errors already reach putArchive(): archiver re-emits them on its own stream, and docker-modem destroys the request on that error. So the user-visible change is that the duplicate rejection from finalize() is no longer left unhandled.

Regression test for the archive fix: not feasible

Every archive failure I could trigger that makes finalize() reject (a file that changes size between stat and read, which produces tar-stream's Size mismatch) also throws an uncaught exception inside tar-stream. tar-stream emits error on the tar entry stream and nothing listens for it. Vitest fails a run on that uncaught exception both before and after this change, so a test can't isolate the difference. The only way around it is to stub archiver's private internals, which would test the stub more than the behaviour.

To show the difference, I ran a standalone script with the same wiring docker-modem uses: pipe the stream into the request, and destroy the request on the stream's error. The copied file shrinks between stat and read:

== before (finalize() floating)
  uncaughtException: Size mismatch <- at Sink._final (node_modules/tar-stream/pack.js:103:17)
  start(): rejected: Size mismatch
  unhandledRejection: Size mismatch
== after (Promise.all)
  uncaughtException: Size mismatch <- at Sink._final (node_modules/tar-stream/pack.js:103:17)
  start(): rejected: Size mismatch

The remaining uncaught exception comes from archiver and tar-stream, not this repo's code. It's out of scope here; see the follow-up note at the end.

Rule level and nursery caveat

The rule is set to error. lint:ci runs biome ci --error-on-warnings, so warn would also fail CI, but error makes npm run lint fail locally too, so authors see the problem before pushing. This is a nursery rule: Biome may change its behaviour, options or group between upgrades, including minor ones. A Biome bump may therefore need this config entry to be renamed or moved, or new findings fixed.

#1485 also edits linter.rules in biome.json; main is merged into this branch with both rules enabled.

Red → green

Lint rule

Before the fixes, with the rule enabled (npx biome ci --error-on-warnings package.json packages):

packages/modules/nats/src/nats-container.test.ts:21:5 lint/nursery/noFloatingPromises
packages/testcontainers/smoke-test.js:3:1 lint/nursery/noFloatingPromises
packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts:89:7 lint/nursery/noFloatingPromises
packages/testcontainers/src/generic-container/generic-container.ts:209:7 lint/nursery/noFloatingPromises
Checked 450 files in 1071ms. No fixes applied.
Found 4 errors.
exit=1

After the fixes:

Checked 450 files in 1078ms. No fixes applied.
exit=0

npx biome lint --only=nursery/noFloatingPromises . over the whole repo goes from Found 4 infos. (the rule's default severity) to no findings.

NATS pub/sub test

The old test ran its assertion inside an un-awaited async loop, so it never affected the test result. I applied temporary mutations to each version of the test (not committed):

Mutation Before After
None ✅ pass ✅ pass
Expect "WRONG" instead of the payload Test passes. Vitest separately reports Unhandled Rejection: AssertionError: expected 'WORLD' to deeply equal 'WRONG', not attributed to the test ❌ Test fails: expected [ 'WORLD' ] to deeply equal [ 'WRONG' ]
Remove nc.publish(...) Test passes, exit 0 ❌ Test fails: expected [] to deeply equal [ 'WORLD' ]

This test is the natsPubsub snippet included in docs/modules/nats.md, so the docs pick up the corrected example automatically.

Verification

  • npm ci: package-lock.json unchanged
  • npm run format: no changes
  • npm run lint: no changes, no diagnostics
  • npm run check-compiles: exit 0
  • npx biome ci --error-on-warnings package.json packages: exit 0, compared with exit 1 and 4 errors before the fixes
  • npx vitest run packages/testcontainers/src/generic-container/generic-container.test.ts -t copy: 13 passed. This covers copying files, directories, content and archives before start and into a started container, so it exercises all four changed call sites.
  • npx vitest run packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts packages/modules/nats/src/nats-container.test.ts: 15 passed
  • npm run build -w testcontainers && node packages/testcontainers/smoke-test.js: exit 0. With DOCKER_HOST=tcp://127.0.0.1:1, it prints the error and exits 1.

Why this is not breaking

No public API, types or options change. The only runtime change is in GenericContainer.start() (when files, directories or content are copied) and in StartedGenericContainer.copyFilesToContainer, copyDirectoriesToContainer and copyContentToContainer:

  • Success: Docker has to read the whole tar, including its end-of-archive blocks, before putArchive() succeeds. By then finalize() has resolved too, so behaviour and timing don't change. The copy tests above confirm this.
  • Failure: these methods already rejected through putArchive(). Now they may reject with the same error a little earlier, through finalize(), and no unhandled rejection is left behind.

The other edits are to tests, the smoke-test script and lint config.

Follow-up (not in this PR)

While probing archiver I found that copying a file that becomes unreadable (EACCES) or is deleted (ENOENT) between stat and read crashes the process with an uncaught exception from inside archiver (lazystream) instead of rejecting. A file that changes size does the same (tar-stream Size mismatch). This happens before and after this PR and is worth tracking separately.

Enable Biome's nursery/noFloatingPromises rule at error level and fix
everything it reports:

- Await archiver's finalize() together with putArchive() so a finalize
  rejection reaches the caller instead of becoming an unhandled
  rejection. Apply the same fix to the three copy*ToContainer methods
  on StartedGenericContainer, which the rule cannot see because it
  cannot infer archiver()'s return type.
- Make the NATS pub/sub test assert on the messages it received after
  draining, so a wrong or missing message fails the test.
- Make smoke-test.js exit non-zero explicitly when it fails.
- Mark the Dockerfile pull-event observer as intentionally not awaited.
@cristianrgreco cristianrgreco added bug Something isn't working patch Backward compatible bug fix labels Oct 9, 2026
@netlify

netlify Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for testcontainers-node ready!

Name Link
🔨 Latest commit 38d2cfd
🔍 Latest deploy log https://app.netlify.com/projects/testcontainers-node/deploys/6ac8bcd9174d840008b6bd03
😎 Deploy Preview https://deploy-preview-1488--testcontainers-node.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@cristianrgreco
cristianrgreco marked this pull request as ready for review October 10, 2026 13:44
@coderabbitai

coderabbitai Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: cc2ea7e4-8baf-4c41-87ea-3cd5e2e8a93b

📥 Commits

Reviewing files that changed from the base of the PR and between 6041b04 and 0c851c0.


📒 Files selected for processing (1)
  • packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 4 remain after this review.



📝 Walkthrough

Walkthrough

Biome now enables the noFloatingPromises rule at error severity. The NATS test collects decoded messages before asserting the received list. The smoke test catches errors from container startup or shutdown, logs them, and exits with status 1. The Dockerfile test builds with a uniquely tagged local image and removes the tag in a finally block. Container archive operations await both archive finalization and upload.


Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 0c851

The changes preserve the tested archive and asynchronous test outcomes; no material regression is established, so the PR is mergeable under normal checks.

Pre-merge checks | Passed 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check Passed The title clearly identifies the main changes: enabling Biome's noFloatingPromises rule and handling floating promises.
Description check Passed The description is detailed and directly explains the rule change, affected code, rationale, and verification results.
Docstring Coverage Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 5…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

A try/catch reads better than a trailing .catch(). The async function
call itself is still an unawaited promise, so it is marked with void.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟡 Minor · Wait for event delivery before asserting no pull. · generic-container-dockerfile.test.ts:89-90

packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts:89-90
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Wait for event delivery before asserting no pull.

containerSpec.build() waits for the build response stream to end, but dockerPullEventPromise listens on a separate Docker event stream. The test asserts hasResolved immediately after the build completes. A pull event can reach the event stream after that assertion, so a pull regression can pass.

Keep the observer active through a bounded post-build observation window, or use an equivalent timeout-aware event assertion before checking that no pull occurred.


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 9ca9f9ef-078f-4120-9e76-f7d9bb2e1f66
📥 Commits

Reviewing files that changed from the base of the PR and between 38d2cfd and 5355e1d.

📒 Files selected for processing (2)
  • biome.json
  • packages/testcontainers/smoke-test.js

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

Docker events arrive on a separate stream from the build response, so a
pull event could be delivered after the build finished and after the
test had already asserted that none arrived. Keep observing for a short
window after the build before asserting.

Run the test apart from the file's other tests: the event stream is
daemon-wide and a neighbouring test pulls the same image.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts (1)

92-93: 🎯 Functional Correctness | 🔵 Trivial | 🏗️ Heavy lift

Synchronize the no-pull assertion with event delivery.

build() resolves after the build response ends, but Docker delivers GET /events through a separate stream. Docker does not define ordering or a delivery deadline between these streams. A matching pull event may therefore arrive after the 500 ms delay, allowing this assertion to pass incorrectly.

Use a completion signal for the relevant event, or assert the pull decision at its source.


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 0307cb3b-80b0-45a6-806b-eb1384fa2bed
📥 Commits

Reviewing files that changed from the base of the PR and between 5355e1d and 6041b04.

📒 Files selected for processing (1)
  • packages/testcontainers/src/generic-container/generic-container-dockerfile.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 5 remain after this review.

Watching the Docker event stream for the absence of a pull event cannot
be made deterministic: events arrive on a separate stream with no
delivery deadline, and the stream is daemon-wide.

Build from a tag that only exists locally instead. The build succeeds
when the image is not pulled and fails when a pull is attempted. This
replaces the post-build observation window and lets the test run
concurrently again.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working patch Backward compatible bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant