unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
denoland/deno
senior9/10
error-handlingconcurrencyobservability

fix(desktop): never block the JS thread on the error-report dialog

Project: denoland/deno | Language: Rust | Concepts: error-handling, concurrency, observability | Difficulty: senior
Link: https://github.com/denoland/deno/pull/36575

The problem

In Deno's desktop app, unhandled promise rejections and uncaught errors triggered a blocking dialog via op_desktop_alert on the JavaScript thread. This blocked the entire JS runtime—including timers, servers, and signal handlers—until the dialog was dismissed. If the dialog couldn't be interacted with (e.g., hidden window, headless process), the runtime would freeze indefinitely, and signals like SIGTERM would be ignored because their handlers couldn't run.

Additionally, unhandled rejections in desktop apps weren't logged to stderr, making these freezes hard to diagnose.

Why it's non-trivial

A naive fix might just make the alert non-blocking or move it to another thread, but this would introduce new problems:

  • If the dialog is fire-and-forget, the process might exit before the dialog is shown.
  • Without proper deduplication, multiple errors could stack multiple dialogs.
  • If the dialog can't be shown (e.g., in headless mode), the process could hang forever unless a timeout is enforced.
  • Thread safety is subtle: the alert must not be called concurrently, and the flag guarding this must be robust even if the dialog panics or the thread is killed.

The approach

1. Always log errors to stderr

Before showing a dialog, errors are always logged to stderr. This ensures that in headless or hidden window scenarios, the error is still visible somewhere.

+    if (err !== null && err !== undefined) {
+      console.error("Uncaught (desktop):", err);
+    } else {
+      console.error("Uncaught (desktop):", String(message));
+      if (stack) console.error(String(stack));
+    }

This logs the error object if present, preserving stack formatting, or falls back to a string and stack if not. This is critical for observability in environments where dialogs can't be seen.

2. Use an async op to show the dialog without blocking the JS thread

A new async op, op_desktop_alert_async, is introduced. It spawns the dialog on a blocking thread pool, returning a promise that resolves when the dialog is dismissed or times out.

+async fn op_desktop_alert_async(
+  state: std::rc::Rc<std::cell::RefCell<OpState>>,
+  #[string] title: String,
+  #[string] message: String,
+) {
+  ...
+  let dialog = deno_core::unsync::spawn_blocking(move || {
+    let _guard = ErrorDialogGuard;
+    api.alert(&title, &message);
+  });
+  let _ = tokio::time::timeout(ERROR_DIALOG_TIMEOUT, dialog).await;
+}

This ensures the JS thread is never blocked by the dialog, preventing deadlocks and keeping the runtime responsive. The timeout ensures the process doesn't hang forever if the dialog can't be interacted with.

3. Prevent multiple dialogs and ensure only the first error owns the exit

A process-global atomic flag, ERROR_DIALOG_SHOWING, ensures only one error dialog is ever shown at a time, regardless of how the op is called.

+static ERROR_DIALOG_SHOWING: std::sync::atomic::AtomicBool =
+  std::sync::atomic::AtomicBool::new(false);
+
+struct ErrorDialogGuard;
+
+impl Drop for ErrorDialogGuard {
+  fn drop(&mut self) {
+    ERROR_DIALOG_SHOWING.store(false, std::sync::atomic::Ordering::SeqCst);
+  }
+}

The guard ensures the flag is cleared even if the dialog panics, so future errors can still show dialogs. The flag is set atomically before showing the dialog and cleared when the dialog is dismissed or the thread unwinds.

4. JS handler prevents default error handling and controls process exit

The JS error handler now calls preventDefault() to take over error handling, shows the dialog asynchronously, and then exits the process when the dialog is dismissed or fails to show.

+    ev.preventDefault();
+    if (_exiting) return;
+    _exiting = true;
+
+    let shown;
+    try {
+      shown = op_desktop_alert_async("Application Error", String(message));
+    } catch (_) {
+      Deno.exit(1);
+      return;
+    }
+    shown.then(() => Deno.exit(1), () => Deno.exit(1));

This ensures the process stays alive for the dialog's lifetime and exits cleanly after, mirroring the old behavior but without freezing the JS thread.

5. Tests for correct behavior

Tests assert that:

  • The async op is used and the blocking one is not.
  • Errors always reach stderr.
  • Only one dialog can be shown at a time, and the flag is cleared on both normal and panic paths.
+  #[test]
+  fn error_dialog_flag_is_single_flight_and_panic_safe() {
+    ...
+    assert!(!ERROR_DIALOG_SHOWING.swap(true, Ordering::SeqCst));
+    assert!(ERROR_DIALOG_SHOWING.swap(true, Ordering::SeqCst));
+    ...
+    {
+      let _guard = ErrorDialogGuard;
+    }
+    assert!(
+      !ERROR_DIALOG_SHOWING.load(Ordering::SeqCst),
+      "the guard must clear the flag on the normal path"
+    );
+    ...
+    let panicked = std::panic::catch_unwind(|| {
+      let _guard = ErrorDialogGuard;
+      assert!(!ERROR_DIALOG_SHOWING.swap(true, Ordering::SeqCst));
+      panic!("backend blew up mid-dialog");
+    });
+    assert!(
+      panicked.is_err(),
+      "the panic must propagate, not be swallowed"
+    );
+    assert!(
+      !ERROR_DIALOG_SHOWING.load(Ordering::SeqCst),
+      "the flag must be clear again so later errors can still show a dialog"
+    );
+  }

This ensures the single-flight logic is robust even in the face of panics.

From the review thread

The pending promise is what keeps the process alive now, so the dialog gets its full lifetime, and the JS thread is still free the whole time — timers, servers and the SIGTERM handler stay live, which was the point of the PR. Exit code 1 and "dialog, then die" match the pre-PR observable behaviour; only the freeze is gone. Both listeners now pass ev through, and the promise's rejection path exits too, so a dialog that can't be shown can't strand the app.


Deleting ERROR_DIALOG_SHOWING was a mistake. I argued it was redundant because the JS dedups, but that reasoning only holds for callers that go through handleError, and the op is on core.ops and survives removeImportedOps() — so it isn't only reachable from the handler that respects _exiting. You'd flagged that exposure in your first pass and I talked past it.

The atomic is back, with the drop guard you asked for originally:

struct ErrorDialogGuard;
impl Drop for ErrorDialogGuard {
  fn drop(&mut self) { ERROR_DIALOG_SHOWING.store(false, Ordering::SeqCst); }
}

Native single-flight caps dialogs at one regardless of caller; _exiting keeps the separate "only the first error owns the exit" policy in JS.

Takeaways

  • Never block the main or JS thread for UI dialogs in event-driven runtimes. Use background threads or async operations to keep the runtime responsive.
  • Always log errors to a non-interactive channel (like stderr) before attempting UI reporting. This ensures errors are visible even in headless or non-interactive environments.
  • Use atomic flags and drop guards to enforce single-flight behavior for global resources, and ensure cleanup on both normal and panic paths. This prevents resource leaks and deadlocks even in the presence of unexpected failures.
  • Timeouts are essential for UI operations in environments where user interaction may not be possible. This prevents processes from hanging indefinitely due to unclickable dialogs.
  • Tests that manipulate process-global state must be written carefully to avoid race conditions and flakiness, especially when run in parallel.