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

  1. Mark the outer op #[op2(reentrant)] after auditing that it holds no OpState borrow across the JS call
  2. Refactor the op to defer JS execution to the event loop instead of re-entering synchronously
  3. If the named ops are Deno's own, update — the pairing may already be fixed
  4. 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

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


AI-assisted analysis of denoland/deno@336da420f4 (2026-08-20). Data as JSON: /api/errors/465dcda50427c753. Report an issue: GitHub.