fix(codegen): make FFI provenance part of the object cache entry (#6439) - #6459
Conversation
Effect's `api.ts` linked from a cold cache and failed from a warm one with `Undefined symbols: _js_ws_connect_start` — same sources, same flags, same compiler. Whether a program linked depended on whether `node_modules/.cache` happened to be populated. The ext_registry (#835/#846) records which external-provider FFI symbols codegen emits, and the driver folds that into `needs_stdlib` + `native_module_imports` to bring `perry-ext-*` wrappers onto the link line. But it is populated as a *side effect of codegen*: `record_ffi_call` fires from `LlBlock::call`. An object-cache hit skips `compile_module` entirely, so nothing records the provider, the well-known flip never happens, and the wrapper silently drops off the link line. Provenance is a property of the compiled object, so it now lives with it: - `ext_registry`: a thread-local capture, scoped around `compile_module`, attributes emissions to one module (perry-codegen uses no rayon, so a module's codegen runs start-to-finish on its worker thread). `replay_ffi_symbols` re-records a manifest as if codegen had just run. Manifests store symbol *names*, not resolved owners, so routing is always today's table rather than a stale decision baked into a cache entry. - `object_cache`: each `<key>.o` gains a sorted `<key>.ffi` manifest. `lookup_path_with_ffi` only reports a hit when both halves are present — a manifest-less object was written by an older perry, its provenance is unknowable, and guessing "no providers" is precisely this bug. Reporting a miss recompiles that module once and the entry self-heals; no cache wipe. - `run_pipeline`: hit replays the manifest, miss captures and stores it (manifest first, so a `.o` is never visible without one). Also registers the 16 ws symbols codegen emits (`js_ws_connect_start` and friends) against `perry-ext-ws`. `@effect/platform`'s `Socket.ts` lowers these directly, with no `import "ws"` for the import-driven path to see — exactly the case the registry exists to cover. The manifests show this was not ws-specific: modules also record `js_readable_stream_new_from_source_object`, so the streams routing from #835/#846 carried the same latent warm-cache bug. Effect `api.ts` now links and runs byte-identical to node on both a cold and a warm cache (311/311 objects reused), with no `PERRY_FORCE_WELL_KNOWN` override. `logger.ts` and `forking.ts` stay byte-identical; `web.ts` now links, leaving only the unrelated runtime failure tracked in #6454.
📝 WalkthroughWalkthroughThe FFI registry now covers additional WebSocket symbols and captures per-module registry usage. Object-cache entries persist FFI manifests, while the compile pipeline replays manifests on cache hits and stores them after cache misses. ChangesFFI provenance and cache integration
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Compiler
participant ObjectCache
participant FFIRegistry
participant Linker
Compiler->>ObjectCache: Lookup object and FFI manifest
ObjectCache-->>Compiler: Cached object and symbols
Compiler->>FFIRegistry: Replay symbols
FFIRegistry-->>Compiler: Restore providers
Compiler->>Linker: Link with selected providers
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
The #6439 FFI-provenance change grew object_cache.rs to 2035 lines, one past the 2000-line CI cap (scripts/check_file_size.sh). Move the `#[cfg(test)] mod object_cache_tests` block (930 lines) verbatim into a sibling `object_cache/object_cache_tests.rs`, declared `#[cfg(test)] mod object_cache_tests;`. `super::*` still resolves to the `object_cache` module, so every test reaches the same private items unchanged. Trunk is now 1109 lines; no production code moved.
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)
crates/perry/src/commands/compile/object_cache.rs (1)
1018-1036: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winDo not store the object after its manifest write fails.
This silently creates an incomplete cache entry:
run_pipeline.rsproceeds to store the.o, but every FFI-aware lookup must reject it and recompile. ReturnResult/bool, record the failure, and gate the object store on success; add a manifest-write-failure test.Proposed direction
-pub fn store_ffi_manifest(&self, key: u64, symbols: &[&str]) { +pub fn store_ffi_manifest(&self, key: u64, symbols: &[&str]) -> bool { let Some(path) = self.ffi_manifest_path_for(key) else { - return; + return false; }; // ... - if fs::write(&tmp_path, body) + let stored = fs::write(&tmp_path, body) .and_then(|_| fs::rename(&tmp_path, &path)) - .is_err() - { + .is_ok(); + if !stored { let _ = fs::remove_file(&tmp_path); + self.store_errors.fetch_add(1, Ordering::Relaxed); } + stored }🤖 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 `@crates/perry/src/commands/compile/object_cache.rs` around lines 1018 - 1036, Update ObjectCache::store_ffi_manifest to return a success indicator or error instead of silently ignoring write/rename failures, while preserving cleanup of temporary files. In the run_pipeline.rs flow that stores the object, capture this result and skip the .o cache write when manifest persistence fails. Add a test covering manifest-write failure and confirming the object is not stored.
🤖 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 `@crates/perry/src/commands/compile/object_cache.rs`:
- Around line 1018-1036: Update ObjectCache::store_ffi_manifest to return a
success indicator or error instead of silently ignoring write/rename failures,
while preserving cleanup of temporary files. In the run_pipeline.rs flow that
stores the object, capture this result and skip the .o cache write when manifest
persistence fails. Add a test covering manifest-write failure and confirming the
object is not stored.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b36b4146-4033-420b-a50c-68510a285ca5
📒 Files selected for processing (2)
crates/perry/src/commands/compile/object_cache.rscrates/perry/src/commands/compile/object_cache/object_cache_tests.rs
Problem
Effect's
api.ts(the effect-compiled-experiment repro, #6439) linked from a cold object cache and failed from a warm one withUndefined symbols: _js_ws_connect_start— same sources, same flags, same compiler. Whether a program linked depended on whethernode_modules/.cache/perryhappened to be populated.Root cause
The ext_registry (#835/#846) records which external-provider FFI symbols codegen emits; the driver folds that into
needs_stdlib+native_module_imports, which is what bringsperry-ext-*wrapper archives onto the link line. But the registry is populated as a side effect of codegen:record_ffi_callfires fromLlBlock::call. An object-cache hit returns the cached.oand skipscompile_moduleentirely — so nothing records the provider, the well-known flip never happens, and the wrapper silently drops off the link line.The trigger doesn't need
import "ws"anywhere:@effect/platform'sSocket.tslowersjs_ws_connect_startdirectly (compiled but never imported), which is exactly the case the registry exists to cover — and exactly the case import-driven feature detection can't see.Fix
FFI provenance is a property of the compiled object, so it now lives with it:
compile_moduleattributes emissions to one module (perry-codegen uses no rayon, so a module's codegen runs start-to-finish on its worker thread).replay_ffi_symbolsre-records a manifest as if codegen had just run. Manifests store symbol names, not resolved owners, so routing always uses today's table rather than a stale decision baked into a cache entry.<key>.ogains a sorted<key>.ffimanifest.lookup_path_with_ffireports a hit only when both halves are present — a manifest-less.owas written by a pre-fix perry, its provenance is unknowable, and guessing "no providers" is precisely this bug. Reporting a miss recompiles that module once and the entry self-heals; no cache wipe, no user action..ois never visible without one).Also registers the 16 ws symbols codegen emits (
js_ws_connect_startand friends) againstperry-ext-ws— previously only the 3*_client_i64entries were routed.Not ws-specific
The manifests written for the Effect build also record
js_readable_stream_new_from_source_object— the streams routing from #835/#846 carried the same latent warm-cache bug.Validation
api.ts: links and runs byte-identical to node on both a cold and a warm cache (311/311 objects reused), with noPERRY_FORCE_WELL_KNOWNoverride.web.tslinks too (its remaining runtime failure is GetIterator on a class declaration throws 'is not iterable' — INT32 ClassRef rejected by js_get_iterator's primitive guard (blocks Effect'syield* SomeTag) #6454, unrelated).cargo test -p perry object_cache: 46 passed (3 new: manifest round-trip, empty-manifest hit, legacy-object miss).cargo test -p perry-codegen --lib ext_registry: 8 passed (3 new: capture collects/sorts/dedups, capture reset between modules, replay routes like live codegen).cargo fmt --all -- --checkclean.Fixes #6439.
https://claude.ai/code/session_01R4phoN4f7eFbSQTkuB2oQP
Summary by CodeRabbit
Bug Fixes
Performance