Skip to content

fix(dart): Make sentryOnError synchronous in runZonedGuarded#3697

Open
theprantadutta wants to merge 2 commits into
getsentry:mainfrom
theprantadutta:fix/run-zoned-guarded-async-handler
Open

fix(dart): Make sentryOnError synchronous in runZonedGuarded#3697
theprantadutta wants to merge 2 commits into
getsentry:mainfrom
theprantadutta:fix/run-zoned-guarded-async-handler

Conversation

@theprantadutta
Copy link
Copy Markdown

📜 Description

SentryRunZonedGuarded.sentryRunZonedGuarded wraps the user's onError in sentryOnError, which is passed to Dart's runZonedGuarded. The wrapper was declared async, so it returned a Future<void> that the framework silently coerced to void and discarded.

Two consequences:

  1. _captureError(...) was awaited inside an unawaited future, which meant the user-supplied onError only ran after the awaited microtask resolved (rather than synchronously during error dispatch).
  2. If the user's onError rethrew the error — a common pattern to preserve normal crash output — the throw became an uncaught async error in the same zone and recursively re-entered sentryOnError until Dart silently dropped it. The unhandled exception was swallowed entirely (no stack trace printed, no Sentry event guaranteed to have been sent).

This PR makes the handler synchronous, fires _captureError via unawaited, and calls the user's onError synchronously so a rethrow propagates out through runZonedGuarded instead of looping back into itself.

// Before
final sentryOnError = (exception, stackTrace) async {
  await _captureError(hub, options, exception, stackTrace);
  if (onError != null) onError(exception, stackTrace);
};

// After
final sentryOnError = (Object exception, StackTrace stackTrace) {
  unawaited(_captureError(hub, options, exception, stackTrace));
  if (onError != null) onError(exception, stackTrace);
};

The synchronous prefix of _captureError — the options.log call, span-status update via hub.configureScope, and the call into hub.captureEvent — all still run before the first await, so the existing tests that observe scope and event state continue to pass.

💡 Motivation and Context

Closes #3541

💚 How did you test it?

  • Added a regression test (invokes user onError synchronously and captures the event (regression for #3541)) that observes the user's onError synchronously from the caller frame. With the broken async handler this snapshot was false because the user callback only ran after await _captureError(...); with the fix it is true.
  • Verified the regression test fails when the source fix is reverted (Expected: true / Actual: <false>), then passes once restored.
  • The existing 4 tests (calls onError, calls zoneSpecification print, marks transaction as internal error if no status, sets level to error instead of fatal) all continue to pass — the synchronous prefix of _captureError still sets the scope/span state before the existing tests observe it.
  • dart analyze --fatal-warnings on packages/dart/ — clean.
  • Ran hub_test.dart alongside to check for nearby regressions — all 72 tests pass.

📝 Checklist

  • I reviewed submitted code
  • I added tests to verify changes
  • No new PII added or SDK only sends newly added PII if sendDefaultPii is enabled
  • I updated the docs if needed (no doc changes — internal behaviour)
  • All tests passing
  • No breaking changes (public API unchanged; the handler's external contract was already void Function(Object, StackTrace), the async return was being discarded)

The handler passed to `runZonedGuarded` inside
`SentryRunZonedGuarded.sentryRunZonedGuarded` was declared `async`,
returning a `Future<void>` that the framework silently coerced to
`void` and discarded. Two consequences followed:

1. `_captureError` was awaited inside an unawaited future, so the
   user-supplied `onError` only ran after the awaited microtask
   resolved.
2. If the user's `onError` rethrew the error (a common pattern to
   preserve normal crash output), the throw became an uncaught async
   error of the same zone and recursively re-entered `sentryOnError`
   until Dart silently dropped it — swallowing the unhandled
   exception entirely.

Make the handler synchronous, fire `_captureError` via `unawaited`,
and call the user's `onError` synchronously so a rethrow propagates
out through `runZonedGuarded` instead of looping. The internal
synchronous prefix of `_captureError` (including the span-status
update and the call into `hub.captureEvent`) is unchanged, so the
existing tests that observe scope and event state continue to pass.

Closes getsentry#3541
await span?.finish();
});

test(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can add the reference to the issue regression as a comment instead

Comment on lines +96 to +108
// Before the fix `sentryOnError` was declared `async`, returning a
// `Future<void>` that `runZonedGuarded` discarded. The user's
// `onError` only ran after `await _captureError(...)` resolved on
// a later microtask, which meant a rethrow from `onError` became
// an uncaught async error of the same zone — recursively
// re-entering `sentryOnError` until Dart silently dropped it.
//
// After the fix `sentryOnError` is synchronous: `_captureError` is
// fire-and-forget via `unawaited`, and the user's `onError` runs
// (and may rethrow cleanly) before `sentryRunZonedGuarded`
// returns. The clearest sync-vs-async discriminator is whether
// the user's onError has been invoked by the time control returns
// to the caller.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't think all this comment is needed

Comment on lines +123 to +137
// Synchronous snapshot: with the sync handler this must already
// be `true`; with the broken async handler it would still be
// `false` because `await _captureError(...)` had not yet
// resolved.
userOnErrorCalledSyncFromCaller = userOnErrorCalled;

expect(userOnErrorCalledSyncFromCaller, isTrue,
reason: "sentryOnError must invoke the user's onError synchronously, "
"not after an awaited microtask. Otherwise a rethrow from "
"onError becomes an unhandled async error of the same zone "
"and recursively re-enters sentryOnError until Dart drops "
"the error.");

// The unawaited `_captureError` still reaches the client; we just
// observe it after microtasks drain.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, we can remove these comments, they're quite verbose imo

@codecov
Copy link
Copy Markdown

codecov Bot commented May 13, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.96%. Comparing base (87fcdd8) to head (f2f0e7b).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #3697   +/-   ##
=======================================
  Coverage   86.96%   86.96%           
=======================================
  Files         335      335           
  Lines       11976    11976           
=======================================
  Hits        10415    10415           
  Misses       1561     1561           
Flag Coverage Δ
sentry 86.77% <100.00%> (ø)
sentry_dio 97.73% <ø> (ø)
sentry_drift 93.57% <ø> (ø)
sentry_file 65.29% <ø> (ø)
sentry_firebase_remote_config 100.00% <ø> (ø)
sentry_flutter 91.52% <ø> (ø)
sentry_hive 77.48% <ø> (ø)
sentry_isar 74.37% <ø> (ø)
sentry_link 21.50% <ø> (ø)
sentry_logging 97.01% <ø> (ø)
sentry_sqflite 88.81% <ø> (ø)
sentry_supabase 97.27% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

- Move "regression for getsentry#3541" out of the test name into a leading
  comment.
- Drop the long bug-history paragraph; the issue link covers it.
- Drop the "we just observe it after microtasks drain" comment.
- Simplify the `reason` text and inline-variable comment.
@theprantadutta
Copy link
Copy Markdown
Author

Thanks for the review @buenaflor! Addressed all three notes in f2f0e7b6:

All 5 tests still pass locally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runZonedGuarded error handler is async, causing errors to be silently swallowed

2 participants