denoland/deno · error
op {} was not marked as #[op2(reentrant)], but re-entrantly
Error message
op {} was not marked as #[op2(reentrant)], but re-entrantly invoked op {} What it means
In debug builds, deno_core wraps every op with a reentrancy guard: if an op is invoked while another op is still on the stack and the outer op is not marked #[op2(reentrant)], the runtime panics naming both ops. The guard exists because re-entering ops can re-borrow OpState and deadlock or corrupt state. It compiles away in release builds, so it only bites debug builds of Deno or of an embedder using deno_core.
Source
Thrown at libs/core/ops.rs:54
#[cfg(debug_assertions)]
impl Drop for ReentrancyGuard {
fn drop(&mut self) {
CURRENT_OP.with(|f| f.set(None));
}
}
/// Creates an op re-entrancy check for the given [`OpDecl`].
#[cfg(debug_assertions)]
#[doc(hidden)]
pub fn reentrancy_check(decl: &'static OpDecl) -> Option<ReentrancyGuard> {
if decl.is_reentrant {
return None;
}
let current = CURRENT_OP.with(|f| f.get());
if let Some(current) = current {
panic!(
"op {} was not marked as #[op2(reentrant)], but re-entrantly invoked op {}",
current.name, decl.name
);
}
CURRENT_OP.with(|f| f.set(Some(decl)));
Some(ReentrancyGuard {})
}
#[derive(Clone, Copy)]
pub struct OpMetadata {
/// A description of the op for use in sanitizer output.
pub sanitizer_details: Option<&'static str>,
/// The fix for the issue described in `sanitizer_details`.
pub sanitizer_fix: Option<&'static str>,
}
impl OpMetadata {
pub const fn default() -> Self {View on GitHub (pinned to 336da420f4)
Solutions
- Mark the outer op #[op2(reentrant)] after auditing that it holds no OpState borrow across the JS call
- Refactor the op to defer JS execution to the event loop instead of re-entering synchronously
- If the named ops are Deno's own, update — the pairing may already be fixed
- Do not 'fix' it by shipping only release builds; treat the panic as a real state-safety warning
Example fix
// before — op re-enters JS (which calls another op) without the marker
#[op2]
fn op_run_hook(scope: &mut v8::HandleScope, code: v8::Local<v8::String>) {
scope.execute_script(v8::String::new(scope, "").unwrap(), code);
}
// after — marked reentrant after verifying no OpState borrow spans the call
#[op2(reentrant)]
fn op_run_hook(scope: &mut v8::HandleScope, code: v8::Local<v8::String>) {
scope.execute_script(v8::String::new(scope, "").unwrap(), code);
} Defensive patterns
Strategy: validation
Validate before calling
// audit rule: any op that synchronously executes JS must opt in
#[op2(reentrant)] // required when this op can call back into JS
fn op_with_js_callback(scope: &mut v8::HandleScope, cb: v8::Local<v8::Function>) {
// scope-based call executes JS which may invoke other ops
} Prevention
- Mark ops #[op2(reentrant)] only after verifying no OpState borrow spans the JS call
- Prefer deferring JS callbacks to the event loop over synchronous re-entry
- Run debug builds of custom extensions in CI so the reentrancy guard actually executes
When it happens
Trigger: Running a debug build where a non-reentrant op synchronously executes JS (execute_script, calling a v8 function, console callbacks) and that JS invokes another op — the classic in-tree example is op_destructure_error re-entering op_apply_source_map, which is explicitly marked reentrant.
Common situations: Contributors and embedders running debug builds; custom ops that synchronously call a JS callback inside the op body without the reentrant marker; deno_core upgrades where a newly added synchronous JS call trips the previously-unnoticed guard.
Related errors
- Inspector deregister handler already exists and is alive.
- wasm streaming callback invoked before the JS handler was se
- expected a function
- ${name} is already registered
- Unknown worker event: "${type}"
AI-assisted analysis of denoland/deno@336da420f4 (2026-08-20).
Data as JSON: /api/errors/465dcda50427c753.
Report an issue: GitHub.