Repository navigation
Conversation
|
Status: reproduced with Bun 1.4.0 (error-state requests: fds 116 -> 20716 over 200 requests, pipelined pair unanswered) and with a debug build using Both new tests in |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes fd ownership hand-off across the parse-worker → bundle-thread → watcher boundary and adds state to the dev server's request/bundle machine, a human look would still be worthwhile.
What was reviewed:
owns_filepropagation through everyContentsOrFd::Fdconstruction site and bothrunexit paths (success and theentry.deinit()error return at ParseTask.rs:2683 —deinitfrees contents only, not the fd, so the owned fd still reacheson_parse_task_complete).- The
adoptedshort-circuit in bundle_v2.rs againstWatcher::add_file's three outcomes andshould_add_watcher() == false; matches the establishedFdOwnershippattern at bundle_v2.rs:4548, jsc_hooks.rs:3611, RuntimeTranspilerStore.rs:947. Resolver-cached fds (owns_fd = false) are never closed here. rebundled_for_failureslifecycle: singleRouteBundleconstruction site initializes it, cleared only underhad_reload_event;failures_added.clear()pairs with the existing scope-exit clear.
Extended reasoning...
Overview
Two coupled fixes plus tests. (1) RouteBundle::rebundled_for_failures bounds the release-build "distrust incremental tracking" re-bundle in check_route_failures to once per route per file change; start_async_bundle clears it for every route when had_reload_event. A stale incremental_result.failures_added is now cleared before the trace. (2) ContentsOrFd::Fd and WatcherData gain owns_file/owns_fd so on_parse_task_complete can close a task-opened descriptor when Watcher::add_file returns FdOwnership::Caller (or the file is excluded from watching / add_file errored). Two new dev-server tests and two harness helpers.
Security risks
None. No user-controlled input reaches new parsing or allocation; the change tightens resource release and gates a rebuild.
Level of scrutiny
High. This is fd lifecycle across a thread boundary with three ownership sources (resolver cache, task-opened, stdio) and three sinks (immediate close, watcher adoption, bundle-thread close). I traced each combination and did not find a double-close or a new leak, and the pattern matches four existing FdOwnership::Watcher call sites — but someone who owns the dev server / bundler should confirm the had_reload_event gate is the right reset point for the flag (a request-triggered bundle with had_reload_event = false intentionally does not clear it, which appears to be the desired behaviour since the escape hatch is for file changes).
Other factors
Tests are well-constructed: both are verified to fail against the release binary (Received: 5 bundles / Received: 6 fds) per the robobun comment; the fd test counts only descriptors pointing at the specific file via /proc/<pid>/fd readlink, so it is not sensitive to unrelated fds; the re-bundle test loops two distinct broken writes to cover the flag being cleared. The PR description also flags an unrelated latent issue in start_next_bundle_if_present (deferred requests when queued routes yield no entry points) that is out of scope here.
|
Thanks for the pass. To confirm the one point raised: clearing the flag only on The read error path works the same way as the parse error path: the parse task only sets CI is green so far (178 of 179 jobs passed, one still running). |
…ing its fds While a route has bundling failures, check_route_failures marked everything the route imports stale and re-bundled it on every request, since there was no record that this had already been done. Each of those re-bundles (and every hot reload of an already watched file) also leaked one file descriptor per re-parsed module: on_parse_task_complete ignored whether the watcher adopted the descriptor the parse had kept open for it. RouteBundle now remembers that a request already re-bundled the route because of its failures; the flag is cleared whenever a hot-reload bundle starts, so the re-bundle happens once per route per file change and later requests serve the stored failures. check_route_failures also clears the previous bundle's failures_added before tracing so the served page only contains the failures this route reaches. ParseTask records whether the descriptor it hands over was opened by the parse itself (as opposed to borrowed from the resolver's entry cache), and the bundle thread closes it when the watcher does not take it.
600a9e1 to
773bb14
Compare
|
Warning Review limit reached
Next review available in: 11 minutes Limit details: You’ve used all 5 included reviews currently available under your plan. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (6)
Comment |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it threads fd-ownership state through the parse pipeline and adds state to the dev server's route-bundle lifecycle — both areas where a subtle mistake is a double-close or a stuck route — a human look is still worthwhile.
Checked: owns_file is only set when opened_own_fd && entry.fd.is_valid() and the resolver-cache-borrowed fd path stays owns_file: false, so the new close in on_parse_task_complete cannot hit a borrowed fd. The !adopted branch matches the pattern already used at jsc_hooks.rs:3602 and bundle_v2.rs:4492. rebundled_for_failures is initialized at the sole RouteBundle construction site and cleared only on had_reload_event, which the author's follow-up comment justifies. The failures_added.clear() before the trace is correct — the existing scopeguard clears on exit but not on entry.
Extended reasoning...
Overview
Two coupled fixes in the dev server + bundler:
src/runtime/bake/DevServer.rs/route_bundle.rs: addsRouteBundle::rebundled_for_failuressocheck_route_failuresre-bundles a failing route once per file change instead of on every request. Also clearsincremental_result.failures_addedbefore theFindErrorstrace (previously carried the last bundle's list, doubling the rendered failures on the first served page). Restores the lost doc comment onassume_perfect_incremental_bundling.src/bundler/ParseTask.rs/bundle_v2.rs: threadsowns_file/owns_fdthroughContentsOrFd::Fd→WatcherDatasoon_parse_task_completecan close a parse-opened descriptor whenWatcher::add_filereturnsFdOwnership::Caller(already-watched file). Previously the return value was discarded and every re-parse under the dev server leaked one fd per module.
Tests: two new devTests in test/bake/dev/bundle.test.ts (bundle-count assertion via new waitForBundle/countBundles harness helpers; Linux-only /proc/<pid>/fd count for the leak). Both are documented as failing on the release binary.
Security risks
None. No user-controlled input handling changes; the fd close is gated on owns_fd which is only set for descriptors this parse opened itself, so there's no double-close of the resolver's cached fd.
Level of scrutiny
High. Per the repo's review guidance, fd/resource ownership is the most-blocked category — "pair every acquisition with its release at the acquisition site" and "every allocation has exactly one named owner, released exactly once". This PR changes who closes a descriptor and when, across a thread boundary (parse worker → bundle thread) and across two code paths (watcher present vs. absent). Getting owns_fd wrong in either direction is a double-close or a leak. The rebundled_for_failures flag also adds state to a state machine that already has five State variants and interacts with had_reload_event / current_bundle / deferred requests.
Other factors
- The fd-ownership change follows the exact pattern already used at three other
add_filecall sites (jsc_hooks.rs:3602,RuntimeTranspilerStore.rs:946/973,bundle_v2.rs:4492), which reduces risk. - All
ContentsOrFd::Fdconstruction sites are updated (Rust would not compile otherwise); the resolver-provided fd (ParseTask.rs:274) and the entry-point fd (bundle_v2.rs:4750) both correctly getowns_file: false. - The author's follow-up comment addresses the "why only clear on
had_reload_event" question and the read-error path; both explanations check out against the code. - CI reported 178/179 green; the tests are stated to fail on the unfixed binary and pass with the change.
- The PR notes but does not fix a related issue in
start_next_bundle_if_present(deferred requests left unanswered when queued routes produce no entry points) — flagged as independent, worth a maintainer glance to confirm scope.
|
Nothing new to address from the second pass. For anyone comparing the two revisions: the rebase only resolved a conflict with #39448, which removed the watcher's CI for the rebased revision is at 175 of 179 jobs passed with the rest still running, none failed. |
Problem
bun ./index.htmlapp (orBun.serve({development: true, routes})) sits on a build error, every request for the page re-bundles everything the route imports:check_route_failures(src/runtime/bake/DevServer.rs) marks all reachable files stale and returnsRebuildwhenever failures are reachable, and nothing records that this was already done, so the failed bundle puts the route straight back into the same state for the next request.ParseTaskkeeps the descriptor it read the file through so the bundle thread can hand it to the watcher, buton_parse_task_complete(src/bundler/bundle_v2.rs) discarded theFdOwnershipresult ofWatcher::add_file. For a file that is already watched (every re-parse under the dev server, including a plain hot reload of an already watched file)add_filedeclines the descriptor and nobody closed it.assume_perfect_incremental_bundlingdefaults to true in debug builds, which is why the bake test harness setsBUN_ASSUME_PERFECT_INCREMENTAL=0for every test. The doc comment explaining the field was lost in the port; this restores it.Fix
RouteBundle::rebundled_for_failures:check_route_failuresre-bundles a failing route once and sets the flag; later requests serve the stored failures directly (the same pathBUN_ASSUME_PERFECT_INCREMENTAL=1takes, so the page is served synchronously and pipelined requests are answered).start_async_bundleclears the flag on every route when a hot-reload bundle starts, so each file change allows exactly one more re-bundle per route. This keeps the escape hatch the field documents (a refresh after a save still gets a full re-bundle) without making it fire on every request.check_route_failuresnow clearsincremental_result.failures_addedbefore tracing. It still held the previous bundle's list, and the trace appends to it; without this the first served page after the re-bundle listed every failure twice (verified: 368 byte payload vs 184 with the clear).ContentsOrFd::Fd/WatcherDatacarryowns_file/owns_fd, set only when the parse opened the descriptor itself (not when it was borrowed from the resolver's entry cache, which is why the earlier fix in watcher: stop serving the watchlist's stored fd to --hot reload reads #37050 left this site alone).on_parse_task_completecloses an owned descriptor the watcher did not adopt. Behaviour without a watcher is unchanged (the descriptor is still closed right after the read, as before).test/bake/dev/bundle.test.ts, both fail against the current release and pass with this change:dev.waitForBundle,dev.countBundles) built on the existing watch-synchronization events./proc/<pid>/fd): 5 hot reloads leave the descriptor count for the file unchanged (was +1 per reload).BUN_ASSUME_PERFECT_INCREMENTAL=0: fds 117 -> 3207 and 0 pipelined responses before; fds 116 -> 116, 2 bundles total, pipelined pair answered 500 + 500 after.test/bake/dev/*(bundle, css, html, hot, esm, react-spa, plugins, sourcemap, server-sourcemap),test/bake/framework-router.test.ts,test/bake/deinitialization.test.ts,test/bake/dev-and-prod.test.ts,test/bundler/{bundler_files,bundler_plugin,bundler_defer,bun-build-api}.test.tsandtest/cli/watch/watch.test.tspass on the debug build;cargo clippyis clean forbun_bundlerandbun_runtime.Background
RouteBundle::server_statetracks whether the route still needs bundling, andPossibleBundlingFailuresmeans "some file somewhere failed, trace this route's imports to find out whether one of them is reachable" (check_route_failures).bundling_failures;incremental_result.failures_addedis scratch space that a bundle fills while indexing failures and that theFindErrorstrace also appends to.send_serialized_failuresrenders whatever list it is given into the error page.assume_perfect_incremental_bundlingis the dev server's distrust of its own incremental tracking: when false (release builds), a route with failures is fully re-bundled before its errors are shown, so a bug in incremental invalidation cannot leave a stale error page. The flag added here bounds that to once per file change.on_parse_task_completehands it toWatcher::add_fileon the bundle thread (kqueue needs one descriptor per watched file; on Linux the watcher just stores and later closes it).add_filereturnsFdOwnership::Watcherwhen it stored the descriptor andFdOwnership::Callerwhen the file was already in the watchlist, in which case the caller still owns it. The resolver can also cache a descriptor for symlinked files and lend it to the parse, which is the caseowns_filedistinguishes: that descriptor must never be closed here.start_next_bundle_if_presentstarts no bundle when the queued routes produce no entry points, but leaves the requests that were deferred to that bundle unanswered and the routes in theBundlingstate. The synchronous path inensure_route_is_bundledhandles that case ("possible with layouts"); the deferred path does not. It is independent of this change, since the re-bundle issued bycheck_route_failuresalways produces entry points (it marks the route's own files stale).Reproduction used (from the report, Linux)
Bun 1.4.0 release:
healthy 200 req: fds 116 | pipelined: 2 responses,error-state 200 req: fds 20716 | pipelined: 0 responses,fixed: 200 fds 20820.This branch (debug build,
BUN_ASSUME_PERFECT_INCREMENTAL=0, 50 requests):healthy: fds 116 | 2 responses,error-state: fds 116 -> 116 | 2 responses,fixed: 200 fds 116; the build error is printed twice in total (the reload and the single re-bundle) instead of once per request.[review] gate passed · iteration 0 · 6 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file