Skip to content

feat: report exceptions from Tasks that were never awaited - #213

Open
bobbyg603 wants to merge 2 commits into
mainfrom
fix/unobserved-task-exceptions
Open

feat: report exceptions from Tasks that were never awaited#213
bobbyg603 wants to merge 2 commits into
mainfrom
fix/unobserved-task-exceptions

Conversation

@bobbyg603

Copy link
Copy Markdown
Member

Closes #212

What

A Task that faults with nobody awaiting it never writes to Unity's log, so neither logMessageReceived nor logMessageReceivedThreaded ever sees it — these exceptions were invisible to the SDK entirely. BugSplatManager now subscribes to TaskScheduler.UnobservedTaskException and routes them through the same bounded queue and main-thread drain that #126 built for background-thread exceptions, since the event fires on the finalizer thread where no Unity API is safe.

New toggle: Capture Unobserved Task Exceptions on BugSplatManager, on by default, nested under Register Log Message Received like its sibling.

Design decisions worth reviewing

SetObserved() is deliberately not called. Marking the exception observed would suppress whatever the application does with it next — including the process-terminating behavior a project can opt into via ThrowUnobservedTaskExceptions. Reporting a failure must not change whether that failure happens. The cost is that we report and then let the runtime do what it would have done anyway.

The AggregateException is flattened and each inner exception reported separately. A Task awaiting other Tasks faults with aggregates inside aggregates; reporting the wrapper would bucket unrelated failures together in the dashboard. An empty aggregate (no inners) falls back to reporting the wrapper so nothing is silently dropped.

The queue is shared with the background-thread path. It is created when either toggle is on, so enabling only this one still works. The drop warning was reworded from "background thread exception(s)" to "off-main-thread exception(s)" now that two sources feed it.

Timing is inherently late and not guaranteed. The runtime raises this event only when a GC collects the faulted Task, so a Task that is never collected is never reported. That is a property of the .NET event, not something the SDK can fix; it is documented in the README and the tooltip rather than papered over.

Sample

The sample had been working around this gap by subscribing to TaskScheduler.UnobservedTaskException itself and re-raising on the main thread. That had to go, or every unobserved Task exception would now report twice. Removing it also retires the menu's OnMainThread main-thread dispatcher (the re-raise was its only caller), which is dropped from ICrashScenarioHost along with the now-unused ConcurrentQueue and Update pump.

The "Unobserved Task exception" row loses its KnownGap grey and its description now names the real capture path. This resolves a genuine UI confusion: the row was grey (meaning "the SDK does not capture this") while still producing a report (because the sample worked around it), which read as the menu lying.

Verification

  • dotnet build of Runtime/** + Tests/Runtime/** against Unity 6000.5.6f1 assemblies: 0 errors (2 pre-existing CS0649 warnings in BugSplat.cs).
  • dotnet test of the plain-NUnit queue suite: 22/22 passed, including 5 new cases covering per-inner-exception fan-out, nested-aggregate flattening, null StackTrace → empty string, the empty-aggregate fallback, and null-queue/null-exception safety.
  • dotnet build of Samples~ against UnityEngine + TextMeshPro + UGUI: 0 errors — worth doing explicitly here because Samples~ is tilde-excluded from compilation, so CI does not cover the interface change.

Not verified: the Unity test runner was not executed locally (no licensed editor session); CI covers it. The two new [UnityTest] cases in BugSplatManagerTest exercise the real event end to end, and because the runtime cannot be forced to collect the faulted Task, they report inconclusive rather than failing if the GC declines — a flaky red build would be worse than a missing signal. Real-device behavior is deferred to the 5.0.0 smoke test (#200).

🤖 Generated with Claude Code

A Task that faults with nobody awaiting it never writes to Unity's log,
so neither logMessageReceived nor logMessageReceivedThreaded ever sees
it — these exceptions were invisible to the SDK entirely. The manager
now subscribes to TaskScheduler.UnobservedTaskException and routes them
through the same bounded queue and main-thread drain the background
thread path already uses, since the event fires on the finalizer thread
where no Unity API is safe.

SetObserved is deliberately not called: marking the exception observed
would suppress whatever the application does with it next, and
reporting a failure must not change whether that failure happens.

The AggregateException is flattened and each inner exception reported
separately so unrelated failures land in separate dashboard buckets
rather than collapsing into one wrapper.

The sample previously worked around the gap by subscribing to the same
event itself and re-raising on the main thread. That has to go, or
every unobserved Task exception would report twice — which also
retires the menu's main-thread dispatcher, now unused, and the row's
KNOWN GAP styling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 11, 2026 21:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds first-class reporting for exceptions from faulted Tasks that were never awaited by subscribing to TaskScheduler.UnobservedTaskException in BugSplatManager and routing those exceptions through the existing off-main-thread bounded queue + main-thread drain, making previously-invisible async failures observable by the SDK.

Changes:

  • Subscribe to TaskScheduler.UnobservedTaskException behind a new captureUnobservedTaskExceptions toggle (default on) and enqueue flattened inner exceptions for reporting on the main thread.
  • Extend tests to cover unobserved-task exception enqueue behavior and add play-mode coverage for end-to-end runtime event capture (inconclusive on GC non-collection).
  • Remove the sample’s prior workaround subscription to avoid double-reporting; update sample UI text and README documentation accordingly.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
Runtime/Manager/BugSplatManager.cs Adds unobserved-task capture, shares the off-main-thread queue, and updates drop-warning wording.
Tests/Runtime/Manager/BackgroundLogMessageQueueTest.cs Adds deterministic unit tests for AggregateException flattening/fan-out and safety cases.
Tests/Runtime/Manager/BugSplatManagerTest.cs Adds play-mode tests for unobserved-task capture (with GC timing caveats).
Samples~/my-unity-crasher/Scripts/CrashScenarios.cs Updates the “Unobserved Task exception” scenario text to reflect new SDK behavior.
Samples~/my-unity-crasher/Scripts/CrashScenarioMenu.cs Removes the sample’s TaskScheduler.UnobservedTaskException subscription and main-thread dispatch pump to prevent double reporting.
Samples~/my-unity-crasher/README.md Updates sample documentation to reflect that unobserved task exceptions are now captured by the SDK.
README.md Documents the new unobserved-task exception behavior, defaults, and timing limitations.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +138 to +143
/// <summary>
/// Queues one report per faulted Task. Runs on the finalizer thread, so it only writes to
/// the queue; <see cref="Update"/> posts from the main thread. Reporting each inner
/// exception separately rather than the AggregateException wrapper keeps distinct failures
/// in distinct dashboard buckets.
/// </summary>
The sample throws exceptions by design, so Error Pause halts play mode
on every managed scenario. That reads as the player crashing rather
than surviving — and with Maximize on Play it can drop the Game view
back to its docked size, making the menu look like it disappeared
while the Play button stays lit. Cost real debugging time before
anyone thought to check the Console toolbar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

Exceptions from Tasks that were never awaited are never reported

3 participants