fix: smarter stack trace filtering that preserves TUnit-internal traces#5506
fix: smarter stack trace filtering that preserves TUnit-internal traces#5506
Conversation
The previous filter stopped at the first `at TUnit` frame and discarded everything after it. This caused two problems: 1. When a bug originates inside TUnit itself (all frames are TUnit.*), the entire stack trace was replaced with an empty string, making diagnosis impossible. 2. User frames appearing after TUnit frames (e.g. when assertion internals are at the top of the trace) were silently dropped. The new approach: - Removes TUnit-internal frames surgically instead of truncating - Preserves ALL user frames regardless of position - If every frame is TUnit-internal, keeps the full trace unmodified - Appends a hint about --detailed-stacktrace when frames are omitted - Uses "at TUnit." (with dot) to avoid false positives on user types whose names happen to start with "TUnit"
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 24 |
TIP This summary will be updated as you push new changes. Give us feedback
There was a problem hiding this comment.
Code Review
This is a solid fix that addresses three distinct bugs in the original stack trace filtering logic. The test coverage is thorough and the reasoning in the comments is clear. I have one actionable concern and a couple of minor observations.
Hardcoded CLI flag name in hint text (actionable)
File: TUnit.Engine/Exceptions/TUnitFailedException.cs:72
vsb.Append(" --- TUnit internals omitted (run with --detailed-stacktrace for full trace) ---");The string "detailed-stacktrace" is hardcoded here, but the constant already exists one namespace away:
// TUnit.Engine/CommandLineProviders/DetailedStacktraceCommandProvider.cs
public const string DetailedStackTrace = "detailed-stacktrace";If the option name is ever changed (or the legacy flag is deprecated in favour of the verbosity system — which TUnitMessageBus already partially supports via ShowDetailedStackTrace), this hint will silently diverge and mislead users. Prefer referencing the constant:
vsb.Append($" --- TUnit internals omitted (run with --{DetailedStacktraceCommandProvider.DetailedStackTrace} for full trace) ---");Also worth noting: TUnitMessageBus.SimplifyStacktrace bypasses the filter when verbosityService?.ShowDetailedStackTrace == true (i.e. debug logging), but the hint only mentions the --detailed-stacktrace flag. Not a correctness bug, but a minor UX gap for users already using verbose logging.
Non-frame lines set hasUserFrame = true (minor, possibly intentional)
In the first pass, lines that are neither TUnit frames nor actual stack frames — such as --- End of stack trace from previous location --- — evaluate !IsTUnitInternalFrame(trimmed) as true and set hasUserFrame = true. This means a trace consisting purely of TUnit frames separated by async infrastructure lines will run through the filter (stripping TUnit frames, keeping the separator, adding the hint) instead of being returned wholesale.
The AsyncInfrastructureLines_ArePreserved test confirms the separator is kept correctly, so the observable behaviour is fine. But it's worth being aware that the "all TUnit → return full trace" path is never triggered when async infrastructure lines are present between TUnit frames. If this is intentional, a comment would help future readers.
Two-pass traversal is the right call here
The two-pass approach avoids buffering all frames into an intermediate list, which would undermine the ValueStringBuilder/stackalloc allocation strategy. Good choice.
Test coverage
The 9 new tests cover the important scenarios well, including the tricky case of assertion internals appearing above the user's test method, and the false-positive guard on TUnitExtensions. No gaps that would let a regression slip through unnoticed.
Summary: The fix is correct and the approach is clean. The only actionable item is referencing DetailedStacktraceCommandProvider.DetailedStackTrace instead of the inlined string literal.
- Merge two-pass approach into single pass (avoids iterating twice) - Hoist stackTrace.AsSpan() to a local variable - Bump stackalloc from 256 to 1024 chars (better fit for typical traces) - Replace ternary append with conditional if for clarity - Use DetailedStacktraceCommandProvider.DetailedStackTrace constant instead of hardcoded magic string - Remove redundant guard after early return
Async infrastructure lines like "--- End of stack trace from previous location ---" were incorrectly counted as user frames, causing all-TUnit traces with async separators to be filtered instead of returned unmodified. Now only lines starting with "at " count.
Code Review:
|
| Area | Finding | Severity |
|---|---|---|
| Core logic correctness | All traced scenarios produce correct output | No issue |
| Orphaned separator edge case | Separator appears before user frames when TUnit frames precede it in output | Minor cosmetic, not a regression |
| Hint string allocation | String interpolation allocates on every filtered failure — use const interpolated string instead |
Minor perf |
--log-level Debug not mentioned in hint |
Hint only mentions --detailed-stacktrace; accurate for context but worth a comment |
Documentation gap |
| Test coverage | Missing test for async-separator-at-top edge case and exact hint formatting | Minor |
The changes are sound and represent a clear improvement over the previous break-on-first-TUnit-frame approach. The bug fix for lost user frames after assertion internals is the most impactful change and is correctly implemented.
- Hoist hint text to a const interpolated string (compile-time constant, zero allocation per filtered failure) - Skip non-frame separator lines if no frame has been emitted yet, so "End of stack trace from previous location" no longer orphans at the top of the output when the preceding TUnit frame was stripped - Add test for orphaned-separator behaviour - Add test for exact hint formatting - Document why the hint only mentions --detailed-stacktrace (filtering is bypassed entirely on debug log levels)
There was a problem hiding this comment.
Code Review
This is a well-considered fix to a genuinely broken stack trace filter. The before/after behavior is accurately described in the PR summary, and the implementation handles the edge cases thoughtfully.
What Works Well
Algorithm correctness: The surgical skip-and-continue approach is the right model here. The original break-on-first-TUnit-frame was fundamentally broken for assertion-first traces.
"at TUnit." with dot: Smart precision. Avoids false positives on user types like TUnitExtensions.MyHelper that happened to share a prefix with TUnit internals.
vsb.Dispose() on early return (line 82): Correct and important — the ValueStringBuilder may have rented from ArrayPool<char> if it grew beyond the 1024-char stack-allocated buffer. ToString() calls Dispose() internally (confirmed in ValueStringBuilder.cs:85), so the happy path is fine too.
Test coverage: 9 focused unit tests covering the main scenarios — all TUnit frames, all user frames, interleaved, async separators, and the orphaned-separator edge case. Making FilterStackTrace internal to enable direct unit testing is a pragmatic call.
Issues and Suggestions
1. Trailing async separator before the hint (edge case, worth considering)
When the trace ends like this — a user frame, then an async separator, then a TUnit frame — the separator slips through because added is already true:
at MyApp.Tests.MyTest() in C:\src\Tests.cs:line 15
--- End of stack trace from previous location ---
at TUnit.Core.RunHelpers.RunAsync()
Output:
at MyApp.Tests.MyTest() in C:\src\Tests.cs:line 15
--- End of stack trace from previous location ---
--- TUnit internals omitted (run with --detailed-stacktrace for full trace) ---
Two "separator-like" lines back-to-back is a bit visually confusing. One option: only emit separator lines if they're followed by at least one user frame. That requires a lookahead (or a second pass), which may not be worth the complexity. At minimum, this should have a dedicated test case so any future change doesn't silently regress it.
2. Async separator between filtered TUnit frames leaks context
at MyApp.First()
at TUnit.Core.Something() ← stripped
--- End of stack trace from previous location ---
at MyApp.Second()
at TUnit.Engine.Executor() ← stripped
Output:
at MyApp.First()
--- End of stack trace from previous location ---
at MyApp.Second()
--- TUnit internals omitted ...
The separator now implies First() and Second() have an async boundary between them — but the boundary was really between TUnit.Core.Something() and MyApp.Second(). This is slightly misleading. Again, fixing it properly requires lookahead, so whether it warrants addressing is a judgment call. But a test documenting the current behavior would prevent silent regressions.
3. Stack buffer size bump (minor)
Increased from 256 → 1024 chars. For deep traces (very common) that exceed 1024 chars, a heap allocation happens anyway. That said, 1 KB extra on the stack per test failure is totally fine in practice. No action needed, just worth noting.
Missing Test Cases
| Scenario | Coverage |
|---|---|
| Separator before hint (see issue 1) | Missing |
| Separator between filtered TUnit frames (see issue 2) | Missing |
Summary
The core fix is correct and a clear improvement over the previous break-on-first-TUnit behavior. The FilterStackTrace algorithm handles all the documented scenarios properly, and the ValueStringBuilder lifecycle is managed correctly. The two edge cases above are both low-severity and involve async separator placement — they don't affect correctness for the primary use case. Adding test cases documenting those behaviors would make this bulletproof.
- Add fast-path: skip split/copy loop when no "at TUnit." appears in the trace - Drop hasUserFrame variable (equivalent to `added` given the non-frame skip guard)
There was a problem hiding this comment.
Code Review (follow-up)
This is a follow-up review against the previous two rounds of feedback. All previously flagged issues have been addressed — this is in excellent shape.
Previous feedback: all resolved ✅
| Issue | Status |
|---|---|
| Hardcoded CLI flag name in hint — should reference | ✅ Fixed — is now a private const string compiled at build time, zero allocation per filtered failure |
| Async infrastructure lines incorrectly counting as user frames | ✅ Fixed — the only count actual stack frames commit ensures only "at "-prefixed lines affect the guard |
| Orphaned separator at top of output when leading TUnit frames are stripped | ✅ Fixed — the !added && !isFrame guard skips non-frame lines until at least one frame has been emitted |
| Missing test for orphaned separator | ✅ Added: OrphanedSeparator_AfterStrippedTUnitFrame_IsRemoved |
| Missing test for exact hint format | ✅ Added: FilteredTrace_HintHasCorrectFormat |
hasUserFrame variable incorrectly set by separator lines |
✅ Dropped — added is now the single source of truth, which is correct given the !added && !isFrame gate |
One remaining edge case (minor, cosmetic)
The "trailing separator before hint" scenario from the previous review round is still untested. When the trace ends with a user frame → async separator → TUnit frame:
at MyApp.Tests.MyTest() in C:\src\Tests.cs:line 15
--- End of stack trace from previous location ---
at TUnit.Core.RunHelpers.RunAsync()
The current output is:
at MyApp.Tests.MyTest() in C:\src\Tests.cs:line 15
--- End of stack trace from previous location ---
--- TUnit internals omitted (run with --detailed-stacktrace for full trace) ---
The separator before the hint reads a little oddly (implying there were frames after it that were elided). This is distinct from the leading separator case (which is now correctly handled). Stripping trailing separators would require lookahead, which isn't worth the added complexity — but a test documenting the known behavior would lock in the current output and prevent silent future regressions.
The existing AsyncInfrastructureLines_ArePreserved test places the separator between two user frames, not at the end, so it doesn't cover this case.
Fast path (new addition — looks good)
if (span.IndexOf("at TUnit.".AsSpan()) < 0)
return stackTrace;This is a clean constant-time optimization for the common case where there are no TUnit frames at all. The "at TUnit." substring check can false-positive on exception messages containing that text, but the downstream !omittedAny guard in the main loop handles that correctly — it returns the original stackTrace unmodified. No issue.
Summary
The implementation is correct, well-tested, and all previous actionable feedback has been addressed. The only remaining suggestion is a test for the trailing-separator edge case — worth adding but not a blocker. The core fix (surgical skip-and-continue vs. the original break-on-first-TUnit-frame) is solid and the ValueStringBuilder lifecycle is managed correctly throughout.
There was a problem hiding this comment.
Code Review (follow-up)
This is a follow-up review against the previous two rounds of feedback. All previously flagged issues have been addressed — this is in excellent shape.
Previous feedback: all resolved
| Issue | Status |
|---|---|
Hardcoded CLI flag name in hint — should reference DetailedStacktraceCommandProvider.DetailedStackTrace |
Fixed — OmittedHint is now a private const string compiled at build time, zero allocation per filtered failure |
| Async infrastructure lines incorrectly counting as user frames | Fixed — the "only count actual stack frames" commit ensures only "at "-prefixed lines affect the guard |
| Orphaned separator at top of output when leading TUnit frames are stripped | Fixed — the !added && !isFrame guard skips non-frame lines until at least one frame has been emitted |
| Missing test for orphaned separator | Added: OrphanedSeparator_AfterStrippedTUnitFrame_IsRemoved |
| Missing test for exact hint format | Added: FilteredTrace_HintHasCorrectFormat |
hasUserFrame variable incorrectly set by separator lines |
Dropped — added is now the single source of truth, correct given the !added && !isFrame gate |
One remaining edge case (minor, cosmetic)
The "trailing separator before hint" scenario from the previous review round is still untested. When the trace ends with a user frame → async separator → TUnit frame:
at MyApp.Tests.MyTest() in C:\src\Tests.cs:line 15
--- End of stack trace from previous location ---
at TUnit.Core.RunHelpers.RunAsync()
The current output is:
at MyApp.Tests.MyTest() in C:\src\Tests.cs:line 15
--- End of stack trace from previous location ---
--- TUnit internals omitted (run with --detailed-stacktrace for full trace) ---
The separator before the hint reads a little oddly — it implies there were frames after it that were elided. This is distinct from the leading separator case (which is now correctly handled). Stripping trailing separators would require lookahead, which isn't worth the added complexity — but a test documenting the known behavior would lock in the current output and prevent silent future regressions.
The existing AsyncInfrastructureLines_ArePreserved test places the separator between two user frames, not at the end, so it doesn't cover this case.
Fast path (new addition)
if (span.IndexOf("at TUnit.".AsSpan()) < 0)
return stackTrace;Clean constant-time optimization for the common case where there are no TUnit frames at all. The "at TUnit." substring check can false-positive on exception messages containing that text, but the downstream !omittedAny guard in the main loop handles that correctly — it returns the original stackTrace unmodified. No issue.
Summary
The implementation is correct, well-tested, and all previous actionable feedback has been addressed. The only remaining suggestion is a test for the trailing-separator edge case — worth adding but not a blocker. The core fix (surgical skip-and-continue vs. the original break-on-first-TUnit-frame) is solid and the ValueStringBuilder lifecycle is managed correctly throughout.
Addresses two edge cases from PR review: 1. Trailing separator orphaned before the omitted hint: when a separator originally pointed at a TUnit frame that we now strip, the separator would dangle right above the "TUnit internals omitted" line. Now we buffer separators and drop the buffered one when the next line is a stripped TUnit frame. 2. Misleading separator between two surviving user frames: when a TUnit frame originally sat between a separator and the next user frame, the separator's "before" anchor is gone after filtering, making it look like there's an async boundary directly between two user frames. Now we drop separators whose previous non-separator line was stripped. Rule: only emit a separator if both the previous and the next surviving non-separator lines are kept user frames.
There was a problem hiding this comment.
Code Review (round 4 — final follow-up)
All previous feedback has been addressed. Checking the one remaining open item from round 3:
Previous feedback: all resolved ✅
| Issue | Status |
|---|---|
| Hardcoded CLI flag name in hint | ✅ Fixed (rounds 1–2) |
| Async infrastructure lines counting as user frames | ✅ Fixed (rounds 1–2) |
| Orphaned separator at top when leading TUnit frames stripped | ✅ Fixed (rounds 2–3) |
| Trailing separator before hint not tested | ✅ Fixed in this round — see below |
Trailing separator: now fixed (not just tested)
The round-3 feedback noted this output was odd when a separator preceded a stripped TUnit frame at the end of the trace:
at MyApp.Tests.MyTest() in C:\src\Tests.cs:line 15
--- End of stack trace from previous location ---
--- TUnit internals omitted (run with --detailed-stacktrace for full trace) ---
The current implementation addresses this correctly via the pendingSeparator/hasPendingSeparator deferred-emit strategy. Separators are only flushed when a surviving user frame follows — so a separator that precedes only stripped TUnit frames is silently dropped. Tracing through the new algorithm for the case above:
at MyApp.Tests.MyTest()→ emitted,added = true,lastWasStripped = false--- End of stack trace ---→ buffered aspendingSeparatorat TUnit.Core.RunHelpers.RunAsync()→ stripped; clearshasPendingSeparator,lastWasStripped = true- End of loop:
added && omittedAny→ append hint only
Output: at MyApp.Tests.MyTest() ...\n --- hint --- ✓
The three new tests cover every separator scenario precisely:
TrailingSeparator_BeforeOnlyStrippedFrames_IsRemoved— exactly the edge case flagged in round 3Separator_BetweenStrippedTUnitAndUserFrame_IsRemoved— separator whose "before" anchor is goneSeparator_BetweenTwoUserFrames_IsPreserved— separator with both anchors surviving, must be kept
No new concerns
The pendingSeparator mechanism is clean and handles all edge cases without lookahead. The lastWasStripped and hasPendingSeparator state interact correctly — separators are not silently promoted to "user frame territory" after a stripping event. The ValueStringBuilder lifecycle (including the vsb.Dispose() early-return path) is correct throughout.
This PR is ready to merge. All three root bugs from the original filter are fixed, all previously raised concerns are addressed, and the test suite comprehensively locks in the correct behavior.
…filter - string.IsNullOrEmpty lacks [NotNullWhen(false)] on netstandard2.0, so the compiler couldn't narrow stackTrace to non-null after the check. Replaced with `is null || Length == 0` pattern, which the flow analyzer understands. - The Range polyfill on netstandard2.0 triggers CS8600 on `default`. Switched the buffered separator from Range to ReadOnlySpan<char>, which is also cleaner — no need to re-slice when flushing.
Follow-up ReviewThis is a follow-up to my previous review. All items I raised have been addressed. Previous issues — resolved
New separator tests are well-designedThe four separator edge-case tests added in this revision ( One case not explicitly tested (separator that comes before a stripped TUnit frame, i.e. The code handles it correctly — when the TUnit frame is processed it clears Implementation qualityThe This PR is in excellent shape. All previous feedback has been addressed, the separator edge cases are now thoroughly tested, and the implementation is correct and performant. Ready to merge. |
Summary
at TUnit.(with dot) to avoid false positives on user namespaces that happen to start with "TUnit"--detailed-stacktracewhen frames are omitted, so users know how to get the full traceBefore
The filter walked top-to-bottom and
breaked at the firstat TUnitline, discarding it and everything after. This meant:After
The filter removes TUnit frames surgically (skip + continue), preserving all user frames regardless of position. When every frame is TUnit-internal, the full trace is returned unmodified.
Test plan
StackTraceFilterTestscovering all scenarios (9 tests)TestExecutionExceptionTestsstill pass