From 8f7c5d217a637c0bdd3cabd6abc5bb9dce3b0a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 13:57:46 +0200 Subject: [PATCH 1/2] =?UTF-8?q?size:=20FsThreadCodec=20hook=20=E2=80=94=20?= =?UTF-8?q?unpin=20fs=20FileHandle=20machinery=20from=20the=20always-linke?= =?UTF-8?q?d=20cross-thread=20codec=20(armed=20at=20first=20FileHandle=20m?= =?UTF-8?q?int)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/perry-runtime/src/fs/filehandle.rs | 17 +++++++ crates/perry-runtime/src/thread.rs | 60 ++++++++++++++++++++--- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/crates/perry-runtime/src/fs/filehandle.rs b/crates/perry-runtime/src/fs/filehandle.rs index 170a4dbf3f..bd0a81d858 100644 --- a/crates/perry-runtime/src/fs/filehandle.rs +++ b/crates/perry-runtime/src/fs/filehandle.rs @@ -1451,7 +1451,24 @@ pub(crate) extern "C" fn filehandle_read_lines_impl( interface } +/// Cross-thread codec for FileHandle values (see `thread::FsThreadCodec`): +/// keeps the fs surface out of binaries that never create a FileHandle. The +/// probe unifies the two identity checks the thread serializer used to call +/// directly. +static FS_THREAD_CODEC_IMPL: crate::thread::FsThreadCodec = crate::thread::FsThreadCodec { + is_filehandle: fs_codec_is_filehandle, + build_detached: build_detached_filehandle_object, +}; + +fn fs_codec_is_filehandle(value: f64) -> bool { + crate::fs::is_fs_filehandle_value(value) || crate::fs::filehandle_object_fd(value).is_some() +} + fn build_filehandle_object(fd: i32) -> f64 { + // Arm BEFORE the object exists: every FileHandle (attached or detached) + // is minted here, so the thread codec is live from the first moment a + // probe could ever match. + crate::thread::arm_fs_thread_codec(&FS_THREAD_CODEC_IMPL); crate::closure::js_register_closure_arity(filehandle_stat_impl as *const u8, 1); crate::closure::js_register_closure_arity(filehandle_write_file_impl as *const u8, 2); crate::closure::js_register_closure_arity(filehandle_append_file_impl as *const u8, 2); diff --git a/crates/perry-runtime/src/thread.rs b/crates/perry-runtime/src/thread.rs index 17a7e45bef..91edb4e8d4 100644 --- a/crates/perry-runtime/src/thread.rs +++ b/crates/perry-runtime/src/thread.rs @@ -139,7 +139,7 @@ //! ``` use std::ptr; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicPtr, AtomicUsize, Ordering}; use crate::bigint::{self, BigIntHeader, BIGINT_LIMBS}; use crate::closure::{self, real_capture_count, ClosureHeader}; @@ -269,6 +269,8 @@ pub enum SerializedValue { /// An `fs.promises.FileHandle` crossing a `perry/thread` boundary. /// Perry's fd registry is thread-local, so handles are not transferable; /// deserialize as a FileHandle-shaped object with `fd === -1`. + /// Recognised/built via [`FsThreadCodec`] so the fs surface is linked + /// only when a FileHandle can actually exist. DetachedFileHandle, /// A `SharedArrayBuffer` crossing a `perry/thread` boundary (#4913). @@ -312,6 +314,47 @@ unsafe impl Sync for SerializedValue {} /// # Safety /// The `bits` must be a valid NaN-boxed JSValue. Pointer-tagged values must /// point to valid, live objects in the current thread's arena or malloc heap. +/// Cross-thread codec hook for `fs.promises` FileHandle values (binary +/// size). The serializer's FileHandle probe and the deserializer's +/// detached-handle builder live in `crate::fs`; referencing them statically +/// from this always-linked codec pinned the whole fs surface into every +/// binary (the microtask pump drains diagnostics publishes through the +/// codec, so it is reachable from `main` unconditionally). `crate::fs` arms +/// the hook at the top of `build_filehandle_object` — before the first +/// FileHandle object can exist — so a program that never creates one links +/// none of it and can never observe the difference: with no FileHandle in +/// the process, the probe cannot match and the variant is never produced. +pub(crate) struct FsThreadCodec { + /// `is_fs_filehandle_value(v) || filehandle_object_fd(v).is_some()`. + pub is_filehandle: fn(f64) -> bool, + /// `build_detached_filehandle_object` (FileHandle shape, `fd === -1`). + pub build_detached: fn() -> f64, +} + +static FS_THREAD_CODEC: AtomicPtr = AtomicPtr::new(ptr::null_mut()); + +pub(crate) fn arm_fs_thread_codec(codec: &'static FsThreadCodec) { + // `black_box` for the same reason as `NM_INSTALL_ALL_HOOK`: a + // single-store AtomicPtr gets speculatively devirtualized by + // whole-program optimization (only one value is ever stored, so the + // compiler proves it and re-materializes the direct reference — + // re-pinning everything this hook exists to unpin). + FS_THREAD_CODEC.store( + std::hint::black_box(codec as *const FsThreadCodec as *mut FsThreadCodec), + Ordering::Release, + ); +} + +fn fs_thread_codec() -> Option<&'static FsThreadCodec> { + let p = FS_THREAD_CODEC.load(Ordering::Acquire); + if p.is_null() { + None + } else { + // SAFETY: only ever stores `&'static FsThreadCodec`. + Some(unsafe { &*p }) + } +} + pub unsafe fn serialize_nanbox_for_thread(bits: u64) -> SerializedValue { let tag = bits & TAG_MASK; @@ -377,9 +420,7 @@ pub unsafe fn serialize_nanbox_for_thread(bits: u64) -> SerializedValue { } gc::GC_TYPE_OBJECT => { let value = f64::from_bits(bits); - if crate::fs::is_fs_filehandle_value(value) - || crate::fs::filehandle_object_fd(value).is_some() - { + if fs_thread_codec().is_some_and(|codec| (codec.is_filehandle)(value)) { return SerializedValue::DetachedFileHandle; } return serialize_object(raw_ptr as *const crate::object::ObjectHeader); @@ -804,9 +845,14 @@ pub unsafe fn deserialize_nanbox_on_current_thread(sv: &SerializedValue) -> u64 crate::date::alloc_date_cell(*ts).to_bits() } - SerializedValue::DetachedFileHandle => { - crate::fs::build_detached_filehandle_object().to_bits() - } + SerializedValue::DetachedFileHandle => match fs_thread_codec() { + Some(codec) => (codec.build_detached)().to_bits(), + // Unreachable in practice: the variant is only produced by an + // armed serializer, and arming is process-global (all agents + // share one binary's statics). Defensive `undefined` mirrors the + // `Unsupported` fallback below rather than panicking. + None => TAG_UNDEFINED, + }, SerializedValue::SharedArrayBuffer { addr } => { // Alias the same process-global backing store (#4913) — no copy. From fd2fbd34196ba6f22ec9974db5f07830d495918a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 28 Jul 2026 14:45:33 +0200 Subject: [PATCH 2/2] changelog: fragment for #6922 --- changelog.d/6922-fs-thread-codec.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/6922-fs-thread-codec.md diff --git a/changelog.d/6922-fs-thread-codec.md b/changelog.d/6922-fs-thread-codec.md new file mode 100644 index 0000000000..1ae36aaf3f --- /dev/null +++ b/changelog.d/6922-fs-thread-codec.md @@ -0,0 +1 @@ +**Binary size:** unpin the `fs.promises` FileHandle machinery from the always-linked cross-thread value codec via an armed `FsThreadCodec` hook (fs arms it at the single FileHandle mint point). Programs that never create a FileHandle no longer link its prototype/impl tree; cross-thread detach semantics (`fd === -1` on the far side) are unchanged. Default hello world: 4.6 → 4.5 MB.