neon-bindings/neon · error
Must call `into_inner` or `drop` on `neon::handle::Root`
Error message
Must call `into_inner` or `drop` on `neon::handle::Root`
What it means
`Root::drop` checks whether the Root still holds an un-consumed reference when it is dropped. If the event loop is still running (`IS_RUNNING`), dropping a Root without calling `into_inner` means the underlying JS value reference leaks, so Neon panics to surface the bug. If the thread is panicking already, it only prints a warning to avoid double-panics.
Solutions
- Always consume the Root via `root.into_inner(cx)` when you are done with it, inside the same function or an explicit cleanup path.
- Store Roots in a container whose Drop consumes them, or use `defer`/finalize hooks to release Roots on the JS thread.
- Restructure code so Roots are short-lived: convert to `Handle` promptly and keep only Rust-side data.
- If a Root must live for the program's lifetime, intentionally leak it (e.g. `std::mem::forget` / `Box::leak`) as a documented choice so Drop never sees it un-consumed.
Example fix
// before
struct Cache { root: Root<JsObject> }
impl Drop for Cache { fn drop(&mut self) {} } // Root unconsumed at drop -> panic
// after
impl Cache {
fn clear(mut self, cx: &mut FunctionContext) {
let _obj = self.root.into_inner(cx); // consume before dropping
}
} Defensive patterns
Strategy: validation
Validate before calling
// before releasing a struct holding a Root, require an explicit consume path
fn validate_cache_clean(cache: &mut Option<Root<JsObject>>) {
assert!(cache.is_none(), "Root must be into_inner'd before drop");
} Try / catch
// avoid panics on error paths: settle/consume before `?`
fn step(root: Root<JsObject>, cx: &mut FunctionContext) -> NeonResult<()> {
let obj = root.into_inner(cx); // always consume first
work(cx, &obj)
} Prevention
- Call into_inner on every path, including early returns — consume before using `?`
- Model consumed-ness in types: Option<Root> set to None after into_inner
- Never overwrite a Root field without consuming the old value
- Use finalize/defer hooks to release long-lived Roots on the JS thread
When it happens
Trigger: Letting a `Root` value go out of scope (or be discarded) without calling `into_inner`, in code running while the Node event loop is alive — e.g. overwriting a Root field with a new Root, returning early with `?` while a local Root is alive and unconsumed, or storing Roots in a struct that is dropped at runtime.
Common situations: Caching JS objects in long-lived Rust structs and forgetting to consume the Root during cleanup; error paths that skip `into_inner`; holding Roots in `HashMap` entries that get `remove`d/`clear`ed while the server runs.
Understand the failure class
Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.
Related errors
- Attempted to dereference a `neon::handle::Root` from the…
- class must be implemented for a type name
- try_catch: unexpected Err(Throw) when VM is not in a…
- The `neon::main` macro must only be used once
- u32 overflow ocurred in Lifecycle InstanceId
AI-assisted analysis of neon-bindings/neon@38960e4381 (2026-09-13).
Data as JSON: /api/errors/81e62470f631d74c.
Report an issue: GitHub.
Appendix: source
Thrown at crates/neon/src/handle/root.rs:225
impl<T> Drop for Root<T> {
#[cfg(not(feature = "napi-6"))]
fn drop(&mut self) {
// If `None`, the `NapiRef` has already been manually dropped
if self.internal.is_none() {
return;
}
// Destructors are called during stack unwinding, prevent a double
// panic and instead prefer to leak.
if std::thread::panicking() {
eprintln!("Warning: neon::handle::Root leaked during a panic");
return;
}
// Only panic if the event loop is still running
if let Ok(true) = crate::context::internal::IS_RUNNING.try_with(|v| *v.borrow()) {
panic!("Must call `into_inner` or `drop` on `neon::handle::Root`");
}
}
#[cfg(feature = "napi-6")]
fn drop(&mut self) {
// If `None`, the `NapiRef` has already been manually dropped
if let Some(internal) = self.internal.take() {
let _ = self.drop_queue.call(DropData::Ref(internal), None);
}
}
}
View on GitHub (pinned to 38960e4381)