Skip to content

s3: fix path double-free when presign throws after blob creation#30351

Closed
robobun wants to merge 4 commits into
mainfrom
farm/23ec0f9e/s3-presign-path-double-free
Closed

s3: fix path double-free when presign throws after blob creation#30351
robobun wants to merge 4 commits into
mainfrom
farm/23ec0f9e/s3-presign-path-double-free

Conversation

@robobun

@robobun robobun commented May 7, 2026

Copy link
Copy Markdown
Collaborator

What

S3Client.presign (both static and instance) crashed in debug builds with a reached unreachable code panic (string refcount assertion) when the presign operation threw synchronously — for example with missing credentials or an invalid expiresIn.

Bun.S3Client.presign("some-path"); // panic: reached unreachable code

Why

Blob.Store.initS3 takes ownership of the PathLike it receives. After constructS3FileInternalStore / constructS3FileWithS3CredentialsAndOptions succeed, the temporary blob owns the path and defer blob.deinit() will release it.

But the caller still has an errdefer path.deinit() in scope. When a later call (getPresignUrlFromsignRequest) returns error.JSError, both the defer blob.deinit() and the errdefer path.deinit() fire, deref'ing the same WTFStringImpl twice and tripping bun.assert(self.hasAtLeastOneRef()) in wtf.zig.

How

After the blob takes ownership of the path, neutralize the caller's path variable to an empty PathLike.string so the errdefer becomes a no-op. This matches the existing pattern in Blob.findOrCreateFileFromPath.

Applied to all S3 helpers that share this errdefer path.deinit() + defer blob.deinit() shape (presign, unlink, write, size, exists, stat — both the S3File statics and the S3Client instance methods), since they all have the same latent double-free on any synchronous error after blob construction.

Fuzzer fingerprint: 5cc454d012677426

Blob.Store.initS3 takes ownership of the PathLike, so once the
temporary blob is constructed the caller's errdefer on the path must
become a no-op. Otherwise a synchronous throw from the subsequent
operation (e.g. signRequest failing with missing credentials, or an
invalid expiresIn) triggers both defer blob.deinit() and errdefer
path.deinit(), underflowing the string refcount and asserting in
debug builds.

Neutralize the caller's path after ownership transfer in all the
S3Client static/instance helpers that follow this pattern.
@robobun

robobun commented May 7, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:35 AM PT - May 7th, 2026

@robobun, your commit a5092c4 has 1 failures in Build #52487 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30351

That installs a local version of the PR into your bun-30351 executable, so you can run:

bun-30351 --bun

@github-actions github-actions Bot added the claude label May 7, 2026
@coderabbitai

coderabbitai Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR refactors S3 path ownership semantics to prevent double-free errors during error handling. S3Client methods now explicitly acquire paths from JavaScript, transfer ownership to blob objects, and clear the local path variable. S3File implements matching logic to neutralize path_or_blob references after blob construction. A new test suite verifies synchronous presign failures do not cause path dereferencing violations under garbage collection.

Changes

S3 Path Ownership Lifecycle Refactor

Layer / File(s) Summary
S3File internal store and constructors
src/runtime/webcore/S3File.zig
Internal constructors now accept *jsc.Node.PathLike, use path.* for S3 store initialization, and clear path.* to bun.PathString.empty. Call sites in presign, unlink, write, size, and stat pass &...path instead of values.
S3Client path acquisition and blob construction
src/runtime/webcore/S3Client.zig
Methods file, presign, exists, size, stat, write, unlink, listObjects, staticFile, and staticListObjects create local var path from PathLike.fromJS() (or local empty_path) with errdefer path.deinit() and pass &path (or &empty_path) into S3File constructors.
Blob.constructBunFile S3 fast-path
src/runtime/webcore/Blob.zig
S3 fast-path now passes &path.path into S3File.constructInternalJS and adds errdefer path.deinit() on that branch.
S3 path double-free verification tests
test/js/bun/s3/s3-path-double-free.test.ts
New subprocess tests force synchronous presign errors using invalid options and a throwing options.type getter, invoking Bun.gc(true) after failure and asserting there is no panic/double-free.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main fix: preventing a path double-free error that occurs when S3 presign operations throw after blob creation.
Description check ✅ Passed The PR description comprehensively covers the template requirements with clear 'What' (the bug), 'Why' (root cause), and 'How' (the fix) sections, exceeding the minimal template structure.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webcore/S3File.zig`:
- Around line 86-87: The transferred path must be neutralized inside the
constructor helpers immediately after Blob.Store.initS3* succeeds to avoid
double-deinit on later fallible work; update constructS3FileInternalStore and
all constructS3FileWithS3Credentials* variants so that right after calling
Blob.Store.initS3* (and upon its success) the helper clears/neutralizes the
original path (the same operation currently done by assigning
bun.PathString.empty to path_or_blob.path) before performing any further
fallible operations (e.g., reading options.type), and remove the external
reassignment in the caller so only the constructor owns the responsibility for
clearing the transferred path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cec82f97-97bd-4d48-a9bf-7bc90de89185

📥 Commits

Reviewing files that changed from the base of the PR and between c5a2f8f and 97cc97e.

📒 Files selected for processing (3)
  • src/runtime/webcore/S3Client.zig
  • src/runtime/webcore/S3File.zig
  • test/js/bun/s3/s3-path-double-free.test.ts

Comment thread src/runtime/webcore/S3File.zig Outdated
@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(s3): double free of path when S3Client operation throws #29656 - Same S3 path double-free fix across S3Client.zig and S3File.zig using identical neutralization pattern after blob takes ownership
  2. fix(s3): don't double-free path when S3Client static ops throw after blob creation #29081 - Fixes same double-free in all 6 S3File static methods by neutralizing path_or_blob after blob constructor takes ownership
  3. Fix double-free in S3 static methods when path is passed as string #28592 - Fixes same double-free in all 6 S3File static methods using path_or_blob reassignment after ownership transfer
  4. Fix double-free of path in S3 static methods on error paths #28495 - Closest mechanical duplicate: identical const pathvar path + PathString.empty approach in S3Client.zig, same neutralization in S3File.zig

🤖 Generated with Claude Code

The constructors do fallible JS work (reading options.type) after
Blob.Store.initS3 has already taken ownership of the path. If that
work throws, errdefer store.deinit() releases the path and the
caller's errdefer releases it again. Pass the path by pointer and
clear it immediately after initS3 succeeds so any caller errdefer
becomes a no-op once ownership has transferred, covering both
post-construct errors and errors inside the constructor itself.

Also add the previously-missing errdefer on the path in
constructInternal, staticFile, and the Bun.file s3:// branch.
Comment thread src/runtime/webcore/S3File.zig Outdated
The previous test crashed the test runner process directly, so the
junit reporter saw zero tests instead of a failure. Spawn each case
in a child process and fail the test on non-zero exit / signal so the
regression is recorded as a proper test failure.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/webcore/Blob.zig`:
- Around line 2072-2073: The early-return branch for s3:// currently uses
errdefer path.deinit() and calls S3File.constructInternalJS(&path.path, ...)
which only takes a PathLike and cannot release the JS protection on the outer
PathOrFileDescriptor; change the cleanup so that deinitAndUnprotect() is invoked
on the PathOrFileDescriptor (not just path.deinit()) on both success and error
paths before returning or propagating errors from S3File.constructInternalJS;
ensure you call PathOrFileDescriptor.deinitAndUnprotect() (or the equivalent
method) instead of path.deinit() and maintain the errdefer semantics so the
wrapped JS path is unprotected on all code paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d60c5688-8a45-437e-8fed-d1c5b5653521

📥 Commits

Reviewing files that changed from the base of the PR and between 97cc97e and 861406a.

📒 Files selected for processing (4)
  • src/runtime/webcore/Blob.zig
  • src/runtime/webcore/S3Client.zig
  • src/runtime/webcore/S3File.zig
  • test/js/bun/s3/s3-path-double-free.test.ts

Comment thread src/runtime/webcore/Blob.zig
Comment thread test/js/bun/s3/s3-path-double-free.test.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/s3/s3-path-double-free.test.ts`:
- Around line 12-25: The test's run function redundantly overrides the
BUN_DEBUG_QUIET_LOGS env var even though bunEnv already contains it; update the
Bun.spawnSync call inside run to stop setting BUN_DEBUG_QUIET_LOGS explicitly
(replace env: { ...bunEnv, BUN_DEBUG_QUIET_LOGS: "1" } with simply bunEnv or {
...bunEnv }), leaving the rest of the spawnSync options untouched.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d4c333da-d394-4e90-a703-38cf704ac22b

📥 Commits

Reviewing files that changed from the base of the PR and between 861406a and a5092c4.

📒 Files selected for processing (1)
  • test/js/bun/s3/s3-path-double-free.test.ts

Comment on lines +12 to +25
function run(body: string) {
const { exitCode, signalCode, stderr } = Bun.spawnSync({
cmd: [bunExe(), "-e", body],
env: { ...bunEnv, BUN_DEBUG_QUIET_LOGS: "1" },
stderr: "pipe",
stdout: "ignore",
timeout: 30_000,
});
expect({ stderr: stderr.toString(), exitCode, signalCode }).toEqual({
stderr: "",
exitCode: 0,
signalCode: undefined,
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | 💤 Low value

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm bunEnv already sets BUN_DEBUG_QUIET_LOGS
rg -n "BUN_DEBUG_QUIET_LOGS" --type ts

Repository: oven-sh/bun

Length of output: 5953


Remove redundant BUN_DEBUG_QUIET_LOGS override

bunEnv from the harness module already includes BUN_DEBUG_QUIET_LOGS: "1", so the explicit override can be removed:

Diff
-      env: { ...bunEnv, BUN_DEBUG_QUIET_LOGS: "1" },
+      env: bunEnv,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function run(body: string) {
const { exitCode, signalCode, stderr } = Bun.spawnSync({
cmd: [bunExe(), "-e", body],
env: { ...bunEnv, BUN_DEBUG_QUIET_LOGS: "1" },
stderr: "pipe",
stdout: "ignore",
timeout: 30_000,
});
expect({ stderr: stderr.toString(), exitCode, signalCode }).toEqual({
stderr: "",
exitCode: 0,
signalCode: undefined,
});
}
function run(body: string) {
const { exitCode, signalCode, stderr } = Bun.spawnSync({
cmd: [bunExe(), "-e", body],
env: bunEnv,
stderr: "pipe",
stdout: "ignore",
timeout: 30_000,
});
expect({ stderr: stderr.toString(), exitCode, signalCode }).toEqual({
stderr: "",
exitCode: 0,
signalCode: undefined,
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/bun/s3/s3-path-double-free.test.ts` around lines 12 - 25, The test's
run function redundantly overrides the BUN_DEBUG_QUIET_LOGS env var even though
bunEnv already contains it; update the Bun.spawnSync call inside run to stop
setting BUN_DEBUG_QUIET_LOGS explicitly (replace env: { ...bunEnv,
BUN_DEBUG_QUIET_LOGS: "1" } with simply bunEnv or { ...bunEnv }), leaving the
rest of the spawnSync options untouched.

@robobun

robobun commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #30495 (same fix against current main, minimal diff, deterministic test).

@robobun robobun closed this May 11, 2026
@robobun robobun deleted the farm/23ec0f9e/s3-presign-path-double-free branch May 11, 2026 13:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant