bundler: don't close a borrowed resolver-cache fd after parsing - #33102
Conversation
Files reached through a symlinked node_modules entry have their file descriptor cached in the resolver's entry cache and handed to the bundler as result.file_fd. ParseTask closed that descriptor after reading but left it in the resolver cache, so a second in-process Bun.build() reused the closed descriptor and failed with EBADF. Only close descriptors the parse task opened itself, matching the module loader (RuntimeTranspilerStore), which closes the input fd only when it was not handed one. Fixes #33099
|
Updated 5:17 PM PT - Jun 29th, 2026
❌ @robobun, your commit 2b7859a has 3 failures in
🧪 To try this PR locally: bunx bun-pr 33102That installs a local version of the PR into your bun-33102 --bun |
|
Found 3 issues this PR may fix:
🤖 Generated with Claude Code |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
Walkthrough
ChangesFD ownership fix and regression test
Documentation formatting updates
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
Comment |
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
These look like the same bug class: a file descriptor the resolver caches for a symlink-resolved package gets reused by a later in-process build after an earlier build already closed it, surfacing as "Unexpected reading file" / EBADF on the second build. #26075 matches most directly (a symlinked workspace package failing on the second request, and the bug vanishing when I've only verified this PR against the #33099 reproduction, so I have not added the |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
test/bundler/bun-build-api.test.ts (1)
457-477: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winUse a disposable temp dir here.
tempDirWithFiles()returns a plain string, so this fixture never gets cleaned up. This test creates extranode_modulesstate and a symlink under that temp root, so it should usetempDir(...)withusinginstead oftempDirWithFiles(...).♻️ Proposed fix
- const dir = tempDirWithFiles("build-symlink-fd-cache", { + using dir = tempDir("build-symlink-fd-cache", { "vendor/pkg/package.json": `{"name":"pkg","version":"1.0.0","type":"module","exports":"./index.js"}`, "vendor/pkg/index.js": `export const value = 1;\n`, "entry.ts": `import { value } from "pkg";\nconsole.log(value);\n`, "repro.test.ts": `- }); + }); mkdirSync(join(dir, "node_modules"), { recursive: true }); symlinkSync("../vendor/pkg", join(dir, "node_modules", "pkg"));Also swap the harness import from
tempDirWithFilestotempDir.As per coding guidelines, tests must be hermetic and leave nothing behind. Based on learnings,
tempDirWithFiles(...)is not disposable whiletempDir(...)is, andtempDirshould use plainusingin Bun tests.🤖 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/bundler/bun-build-api.test.ts` around lines 457 - 477, The test fixture currently uses tempDirWithFiles() for a setup that creates extra node_modules state and a symlink, but that helper returns a plain string and won’t clean up. Update the setup in bun-build-api.test.ts to use tempDir() with a using binding instead, and switch the harness import from tempDirWithFiles to tempDir so the temporary root is disposable and the test stays hermetic.Sources: Coding guidelines, Learnings
🤖 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.
Outside diff comments:
In `@test/bundler/bun-build-api.test.ts`:
- Around line 457-477: The test fixture currently uses tempDirWithFiles() for a
setup that creates extra node_modules state and a symlink, but that helper
returns a plain string and won’t clean up. Update the setup in
bun-build-api.test.ts to use tempDir() with a using binding instead, and switch
the harness import from tempDirWithFiles to tempDir so the temporary root is
disposable and the test stays hermetic.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 38eab4af-f1c9-4650-82d9-2d02f28e71e3
📒 Files selected for processing (1)
test/bundler/bun-build-api.test.ts
|
Switched the regression test to For the out-of-scope flag: the |
There was a problem hiding this comment.
No issues found — the fix is small and well-reasoned (mirrors RuntimeTranspilerStore's ownership check), but since it changes fd-close semantics on the bundler/resolver-cache boundary, it's worth a quick human sign-off on the ownership model.
Extended reasoning...
Overview
The functional change is ~5 lines in src/bundler/ParseTask.rs: will_close_file_descriptor is narrowed so the parse task only closes entry.fd when it opened the file itself (i.e. ContentsOrFd::Fd { file, .. } came in with an invalid file). When file is already valid it was borrowed from the resolver's entry cache (symlink-resolved paths), and closing it left a stale fd that the next in-process Bun.build() would reuse → EBADF / "Unexpected reading file". A regression test in bun-build-api.test.ts spawns a bun test subprocess with a symlinked node_modules package and asserts three consecutive builds succeed. The two .mdx diffs are pure autofix.ci whitespace reflow with no content change.
Security risks
None identified. This is internal fd lifecycle bookkeeping; no auth, crypto, network, or user-input handling is touched. The change strictly removes a close, so the failure mode would be holding an fd open longer — but that fd is owned by the process-lifetime resolver cache, which is the intended owner.
Level of scrutiny
Medium-high. The diff is tiny and the analysis is convincing (it mirrors the existing should_close_input_file_fd = fd.is_none() pattern in RuntimeTranspilerStore.rs), but fd ownership at the bundler↔resolver boundary is exactly the kind of invariant where a maintainer who knows the resolver cache's lifecycle should confirm there's no path where a valid incoming file fd is not cache-owned (which would now go unclosed). That's a domain-knowledge check I can't fully discharge from the diff alone.
Other factors
My earlier nit (use test.concurrent.skipIf) was addressed in 54ac7b3, and the test was further tightened to use a disposable tempDir in 30f1168. The bug hunter found nothing. The robobun CI comment shows build-rust failures on freebsd/android/musl aarch64 cross-targets for an earlier commit — those look like infra and are unrelated to this 5-line guard change. None of the touched paths are covered by CODEOWNERS.
|
On the ownership-model question raised in review (is a valid incoming
The narrowed guard therefore closes exactly the descriptors the task opened itself (the |
|
The bundler and resolver tests this PR touches pass. The red CI is unrelated flaky tests that vary from run to run and don't exercise the bundler or resolver:
This change is a small guard in |
There was a problem hiding this comment.
I didn't find any issues and the ownership analysis checks out against RuntimeTranspilerStore.rs, but since this changes fd lifecycle on the bundler's hot path against the process-lifetime resolver cache, it's worth a maintainer confirming the cache-ownership invariant before merge.
Extended reasoning...
Overview
The substantive change is a ~5-line narrowing of will_close_file_descriptor in src/bundler/ParseTask.rs: the parse task now closes entry.fd only when task.contents_or_fd arrived with an invalid file (i.e. the task opened the fd itself via read_file_with_allocator), and leaves it open when the fd was borrowed from the resolver's entry cache. A regression test in test/bundler/bun-build-api.test.ts reproduces the EBADF on repeated in-process builds over a symlinked node_modules package. The two .mdx diffs are autofix.ci prettier reflow with no content change.
Security risks
None identified. This is local fd-lifecycle bookkeeping; no auth, crypto, network, or untrusted-input parsing is touched. The change is strictly narrower than before (closes a subset of what it used to), so it cannot introduce new use-after-close or double-close — the only theoretical risk is an fd leak if a valid incoming file were ever task-owned rather than cache-owned. The author traced that contents_or_fd.file is populated solely from resolve_result.file_fd ← Entry.cache().fd, which lives in the process-lifetime EntryStore, and the same borrow-without-close pattern already ships in RuntimeTranspilerStore.rs:809 (should_close_input_file_fd = fd.is_none()).
Level of scrutiny
Medium-high. The diff is tiny and the reasoning is sound, but this is resource management on the bundler's per-file hot path interacting with a process-global cache. If the cache-ownership invariant were ever violated (now or by a future change), long-running processes (dev server, watch mode, repeated Bun.build()) would slowly leak fds. That's the kind of invariant a maintainer familiar with resolver.rs / EntryStore should sign off on rather than a bot.
Other factors
My earlier nit (test.concurrent) was addressed in 54ac7b3, and the test was further hardened with using tempDir(...) in 30f1168. The one CI failure (test-net-connect-memleak.js) is an unrelated Node-compat net test. No CODEOWNERS cover src/bundler/. The fix is well-tested (regression test + the existing 400-build stress test in the same file still passes), so this is a confidence-in-invariant question, not a found-a-problem one.
Revert the non-Linux arm of add_file's already-watched branch (and the add_file_by_path_slow cleanup that depended on it) back to main. Both closed the INCOMING descriptor, which the bundler can hand in borrowed from the resolver's entry cache (#33102), so that close has no ownership proof. The Linux branch now closes only the descriptor it just REPLACED in the watchlist slot. That is exactly the descriptor flush_evictions was already authorized to close for this item an instant earlier, with the st_nlink guard added on top, so it is a strict subset of the existing close authority and cannot introduce a new double-close.
Revert the non-Linux arm of add_file's already-watched branch (and the add_file_by_path_slow cleanup that depended on it) back to main. Both closed the INCOMING descriptor, which the bundler can hand in borrowed from the resolver's entry cache (#33102), so that close has no ownership proof. The Linux branch now closes only the descriptor it just REPLACED in the watchlist slot. That is exactly the descriptor flush_evictions was already authorized to close for this item an instant earlier, with the st_nlink guard added on top, so it is a strict subset of the existing close authority and cannot introduce a new double-close.
) Refactors Artifact Kind definitions into one shared, validated contract: every Kind declares its category, readable title path and positive integer schema generation. Existing framework and product consumers migrate together; old metadata no longer silently controls validation, rendering, context selection or status transitions. Tracks [FACT-61](https://makaio.atlassian.net/browse/FACT-61), under [FACT-58](https://makaio.atlassian.net/browse/FACT-58). Consumer PR: [Factory makaio-ai/makaio#929](https://github.com/computeruniverse/ai-factory/pull/929). ## Contract and migration - Require `category`, `titlePath`, numeric `schemaVersion`, description and a data schema. Validate data-relative paths and the declared relation, uniqueness and evidence constraints structurally. - Preserve live-only hooks. New declarative constraints do not introduce a lifecycle or cross-Artifact uniqueness engine. - Remove Kind `scopeSchema`, `observationSchema`, `discriminator`, `conflictPolicy`, `status`, `lifecycle`, `projection` and `defaultContext`. Explicit caller context selectors and custom view builders remain available. - Carry integer generations through registration, revisions, workflow bindings, hooks, MCP and product persistence. Existing TEXT storage uses an explicit strict codec; historical opaque/SemVer values are not guessed or rewritten. - Allow a caller-assigned create identity. Existing primary-key/CAS mechanisms reject collisions without overwrite or duplicate creation events. This preserves retry identity independently of semantic uniqueness. ## Deliberate scope and temporary losses This is a pre-production contract cut, not a replacement of every retired capability. Generic Kind-driven field/summary rendering, surface permission/affordance routing, implicit related-context injection, derived Kind-status events/readiness and automatic natural-key conflict handling are retired. Explicit custom rendering, caller-selected context and existing Workpiece paths remain. Unsupported legacy activation must fail visibly. Reusable Kind views and their shared extensible vocabulary remain a later slice. Semantic uniqueness execution is tracked in [FACT-103](https://makaio.atlassian.net/browse/FACT-103). Configurable Confluence representations are [FACT-65](https://makaio.atlassian.net/browse/FACT-65); the entire existing World Plan is Legacy, with a possible separate Epic recorded in [FACT-104](https://makaio.atlassian.net/browse/FACT-104). ## Implementation decisions to review - Lifecycle tokens in declaration validation are implementation choices for this cut, not a claim that every name was explicitly agreed: Knowledge `valid/retired`, Commitment `proposed/decided/fulfilled/revoked`, Interaction `open/resolved/closed-without-resolution`; Records reject lifecycle-conditioned declarations. - No migration engine or historical-content conversion is introduced. Incompatible old generations fail visibly. - Technical scope/identity boundaries remain distinct from semantic relations. Category is generic Core vocabulary; no product Kind names are added to the shared engine. - Caller-assigned identity addresses technical replay; it neither implements `uniqueness` nor decides whether semantic duplicates should merge. - Authoring fails visibly for closed-object intersections and Zod tuples whose current serialization cannot preserve their live constraints. This cut does not introduce a schema-normalization engine; ordinary object/union Kinds remain supported. Standard hand-authored JSON Schemas are validated according to their declared dialect and constraints. ## Review hardening - Preserve canonical cross-field validation at inline Factory configuration intake and reject retired GitHub field inference/derived modes before provisioning. - Inspect shared and referenced union constraints according to the declared schema dialect. Unsupported dialects are rejected at registration rather than failing every write later. Path inspection checks declared locations/types; it does not prove global schema satisfiability. The original complete schema still validates every write. - Skip blank generated extraction entries without abandoning later valid observations; give attachment Artifacts a readable producer-owned label when supplied names are blank. - Use a stable UUID operation identity for the first review-orchestration snapshot. Concurrent creators recompute through the existing CAS loop; historical identities remain intact. Report-local finding replay remains separate from semantic cross-report consolidation. - Repair a verified process-cleanup classification race exposed by validation: TERM/escalation/final cleanup/probe `EPERM` may proceed to bounded quiescence verification, but never grants success by itself. Safe release still requires an absent group or positively observed zombie-only members; live or unverifiable groups fail closed. No timeout or TERM/escalation policy is relaxed. Additional confirmed review corrections: - Reject asynchronous schemas at registration and compiled async validators before invocation; named anchors are explicitly unsupported in this cut. Zod 4 already enforces safe integer generations, now covered by a boundary regression. - Index primitive leaves within explicitly declared searchable object containers without indexing their keys or undeclared siblings. - Observe status only when an explicit workflow binding supplies request-only `statusPath`. Persisted post-hook values determine existing status events; the status hook sees the latest revision after earlier hooks. No Kind status inference returns. - Terminally supersede stale legacy GitHub projection commands using exact-slot revision/checksum checks. Retain provider identity and uncertain-create evidence, allow safe explicit preparation, and preserve matching prepared-command recovery. Migration 0005 changes only the recovery index; no table rewrite or new routing engine. ## Scoped review decision For this PR only, the maintainer requires fixing all valid findings when a review round contains a merge blocker. A round containing only non-blocking findings is recorded for a prompt post-merge follow-up, without another implementation commit here. The late Codex round was initially deferred, then the final same-head CI added a blocker. Under the mixed-round rule, its valid corrections are included together; [FACT-61 comment 10484](https://makaio.atlassian.net/browse/FACT-61?focusedCommentId=10484) records that disposition. The earlier attached patch remains a draft historical snapshot, not the final implementation. - Registration uses an explicit fragment-only schema-reference profile. External/document-relative references and missing/non-schema fragment targets fail early, including unrelated optional properties. Owned non-string `$ref` values fail; example/default/constant/enum data is untouched. Boolean and recursive schema targets remain supported. Absolute self-contained `$id` forms, percent-encoded URI fragments and paths through intermediate arrays are outside this cut's profile even where AJV could support them; no new schema resolver is introduced. - The retained blueprint materializer forwards its configured status position as a data-relative pointer. Existing actual-value comparison suppresses unchanged status events, including relation-only changes. No Kind status inference returns. - Workflow creation requires explicit initial data. A binding without a create expression remains valid when an existing Artifact reference is supplied or resolved; invalid creation no longer synthesizes `{}`. ## Validation and delivery Current head: `75b9d673e0e077fda670da14a76bd2995731b84a`. full `yarn validate` passed **11,016 files**. The complete supported `MAKAIO_TEST_PLAN=bounded yarn test` plan passed on **Node 22.22.2 / Bun 1.4.2** with **30,438 Vitest tests, 68 skipped, plus 25 Bun tests** (330.1 seconds). Every project and the execution-sensitive lanes are included. Independent reviews of the schema reference, workflow binding and explicit status corrections are clean. CodeRabbit reviewed all eight changed files, including both new regression files, with zero findings. The CI test matrix now pins Bun **1.4.2**, matching the runtime used by the successful same-head distribution build. The existing child-process deadlines, full declaration backend and runtime assertions are unchanged. The complete [CI run 34267280415](https://github.com/makaio-ai/makaio/actions/runs/34267280415) passed on this exact head, including both installed-consumer proofs. The previous failure was: [run 34262513930](https://github.com/makaio-ai/makaio/actions/runs/34262513930) hit both installed-consumer builds' own 270-second deadlines under Bun 1.3.14. The earlier aggregate setup correction remains necessary: deadlines are derived from the existing sequential child limits (recovery 630 seconds plus five seconds hook headroom; local Git 510 plus five). Runtime evidence has distinct limits. The official [Bun makaio-ai/makaio#33102](oven-sh/bun#33102) symlink/repeated-build fixture fails on 1.3.14 and passes on 1.4.2 locally, but our distribution build uses tsdown rather than direct `Bun.build`. The successful standalone job also installs fresh dependencies without a copied lockfile, unlike the locked full-workspace fixture. Therefore neither observation alone establishes the cause of the CI timing gap. A local negative control on Node 24.12.0 / Bun 1.4.2 produced 26 framework-platform/adapter failures involving spawn `EBADF` and worker startup, with 30,412 tests passing. Keeping Bun and the source tree unchanged, the complete Node 22 run passed. Both results are retained in FACT-61 and shared with FACT-102; no claim is made that the local Darwin failure and Linux CI timeout share one cause. The framework archive from `75b9d673e0e077fda670da14a76bd2995731b84a` has SHA256 `5607a7836600842a7c4aa627b9db670b3f908e12c45ef583d8e6483b5e446849`. The private-facade archive has SHA256 `617a685f19dcc920409d94043531011d50a6da76b2a8ef46f065d99dd908169f`; all 249 files were verified byte-for-byte and 43 private-package tests passed. These are local integration archives, not npm publications. These archives include the current schema-reference and workflow-binding corrections. Independent verification matched all 678 framework files and 249 private files against the installed/vendored copies. Factory makaio-ai/makaio#929 passed its complete local canon against those archives: static validation (2,800 files), 4,685 Bun tests, 7,802 Vitest tests (seven skipped), TypeScript, build and real PostgreSQL/server/CLI/MCP/dashboard/auth E2E. Its clean-install CI remains blocked by the committed old published framework until actual publication and pin update. Delivery requires a new `@makaio/framework` publication after upstream merge/sync and a refreshed private `@makaio/cyberport-factory` bundle. No new `@makaio/storage-pg` release is needed: its implementation is unchanged and shared helpers are external framework imports. No deployment, merge, publication or database reset is included. The exact-head CI remains green. Delayed Codex feedback reopened review after the initial 20-minute observation; the maintainer extended final observation to 40 minutes, now running on this unchanged head. The three valid non-blocking items below are explicitly assigned to the prompt post-merge follow-up. The Factory consumer still requires the ensuing framework publication and pin update. ## Delayed review: non-blocking follow-up [FACT-61 comment 10524](https://makaio.atlassian.net/browse/FACT-61?focusedCommentId=10524) assigns all three independently verified findings to the first upstream follow-up promptly after merge, under the maintainer's scoped rule. No current-release blocker was found. - Validate complete dialect-specific schema validity before authoritative registration, including batch replacement. Malformed optional keywords currently pass registration but cause visible compilation failures on writes. No shipped Kind triggers this. - Accept valid implicit root-object schemas using the Artifact data envelope's object guarantee. Preserve correct rejection of nested schemas that can still admit scalar values; do not globally infer object type from properties. - Include managed Artifact display titles in searchableFields, with a focused FTS regression. Paste creation, list/read and content search work, but name-based API/tool discovery misses the separately stored title. Existing registration-signature handling can rebuild derived search data. --- Synced-from: makaio-ai/makaio@7d2c184 Co-authored-by: Makaio Sync Bot <sync-bot@makaio.ai>
Fixes #33099
What happens
Inside a
bun testprocess, if the test file imports a package whosenode_modules/<pkg>entry is a symlink, the first in-processBun.build()of an entry that also imports that package succeeds, but a later build fails reading the file behind the symlink:Minimal repro (all three builds should succeed, only the first does on an unpatched build):
Cause
A file reached through a symlinked
node_modulesentry has its file descriptor cached in the resolver's entry cache (finalize_resultinsrc/resolver/resolver.rs) and handed to the bundler asresult.file_fd.ParseTaskread the file through that descriptor and then closed it (will_close_file_descriptor), but the descriptor was still stored in the resolver cache. The next in-processBun.build()pulled the now-closed descriptor out of the cache andseek/readagainst it failed withEBADF(or returned another file once the fd number was recycled, which is the source of theUnexpected reading file/No matching exportvariants in the report).The module loader (
src/jsc/RuntimeTranspilerStore.rs) already handles this correctly: it closes the input descriptor only when it opened the file itself (should_close_input_file_fd = fd.is_none()). The bundler did not make that distinction.Fix
ParseTasknow closes a descriptor only when the parse task opened it itself. Whentask.contents_or_fd.fileis already valid, the descriptor was borrowed from the resolver cache and must not be closed. This leaves freshly opened descriptors (the non-symlink path) closed as before, and keeps watch-mode behavior unchanged (it already skipped the close when a watcher was present).Verification
test/bundler/bun-build-api.test.tsspawnsbun testover a fixture with a symlinkednode_modulespackage and asserts three consecutive in-process builds succeed. Fails on the unpatched build withEBADF reading file, passes with the fix.bun-build-api.test.tssuite passes (48 pass), including the existing "can be called thousands of times in one process" build stress test.test/bundler/resolver/cache-invalidation.test.ts,cache-runtime.test.ts).