Skip to content

s3: abort in-flight tasks at VM teardown by request id instead of reading task.http - #38353

Open
robobun wants to merge 1 commit into
mainfrom
farm/9b0da86b/s3-teardown-abort-by-id
Open

s3: abort in-flight tasks at VM teardown by request id instead of reading task.http#38353
robobun wants to merge 1 commit into
mainfrom
farm/9b0da86b/s3-teardown-abort-by-id

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Both S3 task types keep their AsyncHTTP inline in a http field. While a request is in flight the HTTP thread bitwise-overwrites that whole field on every progress callback: S3HttpSimpleTask::stage_http_result (src/runtime/webcore/s3/simple_request.rs:409, no lock at all) and S3HttpDownloadStreamingTask::update_state (src/runtime/webcore/s3/download_stream.rs:210, under the task's mutex).
  • VM teardown's stop phase (stop_active_handles, also run at the bun test --isolate global swap) calls each registered task's stop_for_vm_teardown on the JS thread. Registered means the request is still out on the HTTP thread, and both impls did http_thread().schedule_shutdown((*this).http.assume_init_ref()) (simple_request.rs:483, download_stream.rs:355): a &AsyncHTTP over the field, and a read of http.async_http_id through it, concurrent with the other thread's ptr::write of the same bytes, with nothing ordering the two. That is a data race; it is benign natively only because the id has the same value in every copy.
  • The streaming task already had this worked out for its cancel path: s3: wake the HTTP thread on stream cancel so the wrapper actually frees #32608 added async_http_id so S3DownloadStreamWrapper::on_stream_cancelled aborts by id without touching http. The teardown path added later in Worker / worker_threads: WebCore-shaped lifetimes, joined threads, one ordered VM teardown #37075 went back to reading http, and the simple task had no captured id to use.

Fix

  • S3HttpSimpleTask gets the async_http_id field the streaming task already had. Each task type gets a schedule(this, http) that owns the hand-off: it records http.async_http_id, writes http into the task, queues it, bumps the VM's embedded-work count and registers the active handle (for the streaming task it also turns on body streaming, as before). The three places that used to inline that sequence (execute_simple_s3_request, list_objects, download_stream) call it instead, so the id is captured in exactly one place per type and nothing outside the two task files touches http any more.
  • Both stop_for_vm_teardown impls now set the abort flag and call schedule_shutdown_by_id((*this).async_http_id). Correct because the id is what schedule_shutdown forwarded anyway (HttpThread::schedule_shutdown is schedule_shutdown_by_id(http.async_http_id)), it is the same value the HTTP thread's copies carry (the copy is a ptr::read of the original and nothing reassigns the id), and the field is written once on the JS thread before the hand-off and never by the HTTP thread, so reading it there needs no synchronisation. The field and stop_for_vm_teardown comments now state the ownership window; the old SAFETY comments claimed http was readable there.
  • test/internal/source-lints/s3-task-http-field.test.ts pins the invariant: in src/runtime/webcore/s3/, the http field may only be dereferenced (assume_init*, as_ptr, as_mut_ptr, write) inside schedule, the HTTP thread's overwrite (stage_http_result / update_state) and Drop's release_portable; a ratchet check requires each allowed function to still touch the field. On main it reports 8 sites, including the two teardown reads above; with this change it is clean.
  • test/js/node/worker_threads/worker_threads.test.ts ("VM teardown ordering") gains two tests, one per task type, that terminate a worker with two requests in flight against a server that sends headers plus one chunk and then stalls. terminate() can only resolve if the abort reaches the HTTP thread, since that is the only thing that makes it hand the requests back. Two requests because request ids come from a counter that starts at 0, so a task that never captured its id still aborts the process's first request by accident; the existing single-upload teardown test passes even with the capture removed, while each new test hangs when its task type's capture is removed and passes with it (details below).
  • Verified: the lint fails on main and passes here, both through bun bd test; the five "VM teardown ordering" tests pass on the debug build; the credential-free S3 files (s3-stream-cancel-leak, s3-stream-error-gc, s3-connection-close, s3-list-objects) pass except s3-list-objects' "fall back to NoSuchKey" case, which times out identically on an unmodified debug build of main and is tracked separately; the whole source-lints directory passes; cargo clippy -p bun_runtime --no-deps and rustfmt are clean.
  • Related open PRs, none of which changes the teardown reads: s3: reach the streaming download task through its raw pointer on both threads #38351 reworks the streaming task's callbacks through raw pointers and folds release_portable into Drop (whichever lands second renames that entry in the lint's allowlist), http: move the cross-thread state out of HttpThread and claim HTTP_THREAD on the HTTP thread #37694 renames the http_thread() calls these lines sit on, http: widen the per-request id to 64 bits #37437 widens the id to 64 bits (the new field follows). FetchTasklet is intentionally not touched: its http is a Box whose fields the HTTP thread updates selectively via sync_progress_from, which never writes the id, so it is a different shape.

Background

  • An S3 request is driven by a heap task owned jointly by two threads. The JS thread builds the AsyncHTTP into the task's http field and queues it; the HTTP thread ptr::reads it into its own working copy (start_queued_task), runs the request, and on each callback the task copies that working copy back over http so the final state is there when the JS thread eventually reads or drops the task. Between queueing and the final callback the field is therefore the HTTP thread's.
  • async_http_id is the per-request number the HTTP thread keys its abort tracker by. schedule_shutdown_by_id pushes it onto a queue and wakes the HTTP thread, which closes the matching socket; the request then fails with Aborted and is handed back like any other completion. Ids are handed out from a process-wide counter starting at 0, and only requests created with an abort signal (every S3 request, every fetch) get one and enter the tracker.
  • VM teardown (worker terminate(), process.exit()) first stops every registered active handle, then waits for the HTTP thread to hand back every request the VM still has out (wait_for_embedded_work) before it closes its task queue. A request whose abort never reaches the HTTP thread therefore blocks teardown until the server ends it.
Lint on main, and the negative runs behind the two worker tests

Lint against main (git stash push -- src/ && bun bd test test/internal/source-lints/s3-task-http-field.test.ts):

+   "src/runtime/webcore/s3/client.rs:346 (in fn list_objects): .http.write(",
+   "src/runtime/webcore/s3/client.rs:373 (in fn list_objects): .http.assume_init_mut(",
+   "src/runtime/webcore/s3/client.rs:1286 (in fn download_stream): .http.write(",
+   "src/runtime/webcore/s3/client.rs:1309 (in fn download_stream): .http.assume_init_mut(",
+   "src/runtime/webcore/s3/download_stream.rs:355 (in fn stop_for_vm_teardown): .http.assume_init_ref(",
+   "src/runtime/webcore/s3/simple_request.rs:483 (in fn stop_for_vm_teardown): .http.assume_init_ref(",
+   "src/runtime/webcore/s3/simple_request.rs:707 (in fn execute_simple_s3_request): .http.write(",
+   "src/runtime/webcore/s3/simple_request.rs:712 (in fn execute_simple_s3_request): .http.assume_init_mut(",

With the (*this).async_http_id = http.async_http_id; line deleted from S3HttpDownloadStreamingTask::schedule (debug build):

(fail) VM teardown ordering > terminating a worker with S3 streaming downloads in flight aborts them [90001.09ms]
  ^ this test timed out after 90000ms.
(pass) VM teardown ordering > terminating a worker with buffered S3 downloads in flight aborts them

With the same line deleted from S3HttpSimpleTask::schedule instead:

(pass) VM teardown ordering > terminating a worker mid S3 upload does not retry onto the dead VM
(pass) VM teardown ordering > terminating a worker with S3 streaming downloads in flight aborts them
(fail) VM teardown ordering > terminating a worker with buffered S3 downloads in flight aborts them [90000.85ms]
  ^ this test timed out after 90000ms.

The pre-existing upload test passes in that second run because the upload is the process's first abortable request and so has id 0, the same value as the uncaptured field. With a single download the new tests passed the same way, which is why they use two. With both captures in place all five "VM teardown ordering" tests pass (about 3 s each on the debug build, dominated by worker startup).

…ding task.http

While an S3 request is in flight, the HTTP thread bitwise-overwrites the
task's `http` field on every progress callback (stage_http_result /
update_state). Both stop_for_vm_teardown impls ran on the JS thread in that
window and read `http.async_http_id` through `http.assume_init_ref()`, a
data race on the field (benign in practice because the id never changes).

Give S3HttpSimpleTask the `async_http_id` field S3HttpDownloadStreamingTask
already had for its cancel path, capture it in a new `schedule` on each task
(which also owns the hand-off: write `http`, queue it, embedded-work count,
active-handle registration), and make both teardown paths call
schedule_shutdown_by_id with it. The three scheduling sites in client.rs and
execute_simple_s3_request now go through `schedule`, so nothing outside the
task files touches `http`.

A source lint pins that: `http` may only be dereferenced in `schedule`, the
HTTP-thread overwrite, and Drop. Two worker-terminate tests cover the by-id
abort for each task type with two requests in flight each, since request ids
start at 0 and a single request would be aborted even by an uncaptured id.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6be1de51-39e8-4a82-9565-bc8d191642f6

📥 Commits

Reviewing files that changed from the base of the PR and between 1cf8af0 and 748ccd1.

📒 Files selected for processing (5)
  • src/runtime/webcore/s3/client.rs
  • src/runtime/webcore/s3/download_stream.rs
  • src/runtime/webcore/s3/simple_request.rs
  • test/internal/source-lints/s3-task-http-field.test.ts
  • test/js/node/worker_threads/worker_threads.test.ts

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced by reading: on main, S3HttpSimpleTask::stop_for_vm_teardown (src/runtime/webcore/s3/simple_request.rs:483) and S3HttpDownloadStreamingTask::stop_for_vm_teardown (src/runtime/webcore/s3/download_stream.rs:355) read the task's http field on the JS thread while the HTTP thread overwrites it on every callback (stage_http_result / update_state). There is no runtime symptom to reproduce (the raced value is identical on both sides), so the fail-before test is the source lint in test/internal/source-lints/s3-task-http-field.test.ts, which reports both sites on main and is clean here; the two new worker-terminate tests cover the by-id abort for each task type and were checked to hang when the corresponding id capture is removed.

Comment on lines +2655 to +2658
env: envWithoutProxy,
stdin: "pipe",
stdout: "pipe",
stderr: "inherit",

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.

🟡 await bothInFlight.promise is only ever resolved from the server's onResponded callback — nothing wires the child's exit to reject it, so if the spawned child dies before both requests land (worker eval throws, S3Client init fails, subprocess crashes) the test hangs to the file timeout instead of failing fast. Consider racing it against proc.exited, e.g. await Promise.race([bothInFlight.promise, proc.exited.then(c => Promise.reject(new Error('child exited early with ' + c)))]). (Diagnostics-only: the test can't spuriously pass, it just times out with a worse error signal.)

Extended reasoning...

What the bug is

The new "terminating a worker with buffered S3 downloads in flight aborts them" test creates a Promise.withResolvers() pair bothInFlight, resolves it from stalledS3Server's onResponded callback once two requests have arrived, and then does a bare await bothInFlight.promise; before telling the child (over stdin) to terminate the worker. Nothing ever rejects bothInFlight — in particular the spawned child's exit is not wired to it.

REVIEW.md's test rules call this shape out directly: "Wire EVERY failure event (error, close, abort, process exit) to reject the awaited promise."

Step-by-step: how it hangs

  1. bothInFlight = Promise.withResolvers() and responded = 0 are set up.
  2. The stall server is started; its data callback does if (++responded === 2) bothInFlight.resolve(). Nothing anywhere calls bothInFlight.reject.
  3. The child is spawned with await using proc = Bun.spawn(...). The await using disposer only runs when control leaves the block.
  4. Suppose the worker's eval'd body throws (e.g. Bun.S3Client construction or s3.file(key).text() throws synchronously in a future refactor), or the child process crashes before making both HTTP requests. The child exits; proc.exited settles; the local server never sees a second data event.
  5. Control is parked at await bothInFlight.promise; (line 2660). Nothing settles it. proc.exited is not awaited yet, and await using disposal cannot run because the scope has not been left.
  6. The test hangs until the test/file timeout fires. stderr: "inherit" may print something useful, but the failure is reported as a bare timeout with no assertion.

Why existing code doesn't prevent it

The sibling streaming-download test and the pre-existing upload test go straight to Promise.all([proc.stdout.text(), proc.exited]) with no intermediate test-side await, so an early child exit settles both and produces an immediate expect(stdout).toBe(...) mismatch. The buffered case is different because a buffered download exposes nothing observable to JS mid-flight, so the test adds an out-of-band signal (server → test) — but that signal has no failure branch.

Impact

Diagnostics-only. The test cannot pass spuriously; it can only degrade a real failure into a slow, opaque timeout instead of a fast assertion with the child's exit code. So this is nit severity.

Fix

Race the readiness signal against the child's lifecycle, e.g.:

await Promise.race([
  bothInFlight.promise,
  proc.exited.then(code => Promise.reject(new Error(`child exited (${code}) before both requests were in flight`))),
]);

(or equivalently proc.exited.then(c => bothInFlight.reject(...)) set up before the await). Either way, an early child death now fails the test immediately with a message that names what happened.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:04 AM PT - Aug 14th, 2026

@robobun, your commit 748ccd161e61b22f5cfc3f6057626c2e1dda43fd passed in Build #95412! 🎉


🧪   To try this PR locally:

bunx bun-pr 38353

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

bun-38353 --bun

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:05 PM PT - Aug 13th, 2026

@robobun, your commit 748ccd1 is building: #95412

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Relaying a review note that landed on #38361 (stacked on this PR) but concerns this PR's test, "terminating a worker with buffered S3 downloads in flight aborts them" in test/js/node/worker_threads/worker_threads.test.ts:

await bothInFlight.promise is resolved only by the stub server's second response. If the child exits before both requests reach the server (worker startup failure, a regression in the request path), nothing rejects it and the test reports a bare timeout instead of the child's exit code. Racing it against the child's exit keeps the happy path unchanged and makes that failure mode diagnosable:

await Promise.race([
  bothInFlight.promise,
  proc.exited.then(code => Promise.reject(new Error(`child exited (${code}) before both requests were in flight`))),
]);

(REVIEW.md, "wire every failure event to reject the awaited promise".) The streaming variant does not need it: it only awaits stdout and exit.

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