Split out of #302, which quieted the deliberate panics of the hot-reload stress crate and deliberately left the generated wrappers alone.
Why
A caught panic is not an incident: the generated extern "C" wrapper records the message in its channel and Julia raises RustCall.RustPanicError (#244). Rust's default hook runs first, though, and prints thread '<unnamed>' panicked at … to stderr before the unwind ever reaches catch_unwind. Every panic that RustCall handles correctly still looks like a crash in the log.
The scale is small but the shape is wrong: after #302, Pkg.test() shows 24 such lines, all from #[julia] wrappers doing exactly what they are supposed to do.
Why it is not a one-liner
A first attempt (in #302, reverted there) gave each wrapper a Once-guarded hook that stays silent while the thread is inside that wrapper's boundary and otherwise delegates to the previous hook. Review found two defects, both real:
- A hook closure installed from a
cdylib can outlive the cdylib. With the default static std, each image owns its own hook registry and closing the image drops both together. With RUSTFLAGS=-C prefer-dynamic — which RustCall tracks in ArtifactId and supports — std and its hook registry are shared, so after unload_library(name; close = true) the registry holds a callback into unmapped code. Any later panic anywhere in the process jumps into freed memory. This matters most on Windows, where the repository requires libraries to be unloaded before their temporary trees can be removed.
- Per-wrapper
Once values do not serialize take_hook/set_hook. Two first calls to different wrappers of one library can both take the default hook before either sets its replacement; the last setter wins and the other wrapper's hook is silently dropped. The pair also briefly exposes the default hook to already-initialised wrappers.
Design
- One hook per library, not per wrapper. A single
OnceLock guarding one take_hook/set_hook pair; the wrappers only bump an "inside the boundary" thread-local counter. This is the part that needs a decision: #[julia] expands one item at a time and has no crate-wide state to hang a shared item on — the same constraint that made the panic channel per wrapper (see the comment on panic_channel in deps/rustcall_core/src/codegen.rs). The inline path (expand.rs, which emits a whole file) can emit one; the proc-macro path cannot without a convention the user has to follow.
- An uninstall entry point. Export
rustcall_uninstall_panic_hook(), which restores the hook that was taken. Resolve it eagerly at load time, exactly as the panic channel is, and have the loader call it through the generation snapshot before dlclose — in close_artifact_handle! / the retire-close path — skipping it when the symbol is absent, so artifacts built by an older RustCall still close.
- Do not install the hook at all when the build environment contains
prefer-dynamic. ArtifactId already records RUSTFLAGS, so the decision is available at generation time. Document the exception in docs/src/panics.md.
Acceptance criteria
- A
#[julia] function that panics produces no panicked at line on stderr, and still raises RustCall.RustPanicError with the message
- After
unload_library(name; close = true), a panic in another library prints through the restored hook — asserted on captured stderr, not inferred
- Two wrappers of one library, first called concurrently from two threads, both end up quiet
- An artifact built with
-C prefer-dynamic keeps the default hook, and says so in docs/src/panics.md
- The golden corpus is regenerated and reviewed
🤖 Generated with Claude Code
https://claude.ai/code/session_01Bqtry6ehnv5YzeEdrTPCTD
Split out of #302, which quieted the deliberate panics of the hot-reload stress crate and deliberately left the generated wrappers alone.
Why
A caught panic is not an incident: the generated
extern "C"wrapper records the message in its channel and Julia raisesRustCall.RustPanicError(#244). Rust's default hook runs first, though, and printsthread '<unnamed>' panicked at …to stderr before the unwind ever reachescatch_unwind. Every panic that RustCall handles correctly still looks like a crash in the log.The scale is small but the shape is wrong: after #302,
Pkg.test()shows 24 such lines, all from#[julia]wrappers doing exactly what they are supposed to do.Why it is not a one-liner
A first attempt (in #302, reverted there) gave each wrapper a
Once-guarded hook that stays silent while the thread is inside that wrapper's boundary and otherwise delegates to the previous hook. Review found two defects, both real:cdylibcan outlive thecdylib. With the default staticstd, each image owns its own hook registry and closing the image drops both together. WithRUSTFLAGS=-C prefer-dynamic— which RustCall tracks inArtifactIdand supports —stdand its hook registry are shared, so afterunload_library(name; close = true)the registry holds a callback into unmapped code. Any later panic anywhere in the process jumps into freed memory. This matters most on Windows, where the repository requires libraries to be unloaded before their temporary trees can be removed.Oncevalues do not serializetake_hook/set_hook. Two first calls to different wrappers of one library can both take the default hook before either sets its replacement; the last setter wins and the other wrapper's hook is silently dropped. The pair also briefly exposes the default hook to already-initialised wrappers.Design
OnceLockguarding onetake_hook/set_hookpair; the wrappers only bump an "inside the boundary" thread-local counter. This is the part that needs a decision:#[julia]expands one item at a time and has no crate-wide state to hang a shared item on — the same constraint that made the panic channel per wrapper (see the comment onpanic_channelindeps/rustcall_core/src/codegen.rs). The inline path (expand.rs, which emits a whole file) can emit one; the proc-macro path cannot without a convention the user has to follow.rustcall_uninstall_panic_hook(), which restores the hook that was taken. Resolve it eagerly at load time, exactly as the panic channel is, and have the loader call it through the generation snapshot beforedlclose— inclose_artifact_handle!/ the retire-close path — skipping it when the symbol is absent, so artifacts built by an older RustCall still close.prefer-dynamic.ArtifactIdalready recordsRUSTFLAGS, so the decision is available at generation time. Document the exception indocs/src/panics.md.Acceptance criteria
#[julia]function that panics produces nopanicked atline on stderr, and still raisesRustCall.RustPanicErrorwith the messageunload_library(name; close = true), a panic in another library prints through the restored hook — asserted on captured stderr, not inferred-C prefer-dynamickeeps the default hook, and says so indocs/src/panics.md🤖 Generated with Claude Code
https://claude.ai/code/session_01Bqtry6ehnv5YzeEdrTPCTD