Skip to content

Add Backtrace capture feature (SE-0419) for programmatic stack inspection - #437

Merged
MaatheusGois merged 3 commits into
mainfrom
feature/backtrace-api-162
Aug 16, 2026
Merged

MaatheusGois merged 3 commits into
mainfrom
feature/backtrace-api-162

Conversation

@maatheusgois-dd

Copy link
Copy Markdown
Contributor

Summary

  • Integrate Swift 6.2 SE-0419 Backtrace API for programmatic call-stack capture during normal execution (NOT crash reporting — the API is not async-signal-safe; it complements existing crash/exception handling).
  • BacktraceManager singleton with thread-safe (NSLock) storage capped at 100 captures (newest first), hybrid capture engine: Backtrace.capture().symbolicated() via #if canImport(Runtime) (macOS/Catalyst) with Thread.callStackSymbols fallback for iOS. Internal frames filtered on both paths so frame 0 is the user's call site.
  • BacktraceEventsViewController list + detail UI following HangEventsViewController pattern.
  • Public API: DebugSwift.Performance.Backtrace with capture(label:), setOnCapture(_:), clear(), backtraces, backtracesOldestFirst.
  • Performance controller: new Backtrace section with "Capture Now" and "View Captures" rows.
  • Example app demo (BacktraceDemoView, iOS 13-safe) and 13 unit tests.

Closes #162.

Type of change

  • Feature

Test plan

  • Unit tests updated
  • Manual testing completed
  • CI passing

Manual test steps

  1. Launch Example app → DebugSwift floating ball → Performance → Backtrace → "Capture Now"
  2. Tap "View Captures" → select a capture → inspect symbolicated frames
  3. Run xcodebuild test -only-testing:ExampleTests/BacktraceTests → 13 tests pass (0 failures in 0.862s)

Checklist

  • I reviewed my own changes
  • I updated docs when needed
  • I considered backward compatibility

Co-authored-by: oh-my-pi https://omp.sh

…tion

Integrate Swift 6.2 SE-0419 Backtrace API into DebugSwift for capturing
and inspecting call stacks during normal execution. This is NOT a crash
reporter — the Backtrace API is not async-signal-safe — it complements
the existing crash/exception handling with on-demand programmatic
backtrace capture.

Library:
- BacktraceManager: @unchecked Sendable singleton with NSLock-guarded
  storage (capped at 100, newest first), thread-safe callback/reload
  accessors, and a hybrid capture engine using `Backtrace.capture()`
  via `#if canImport(Runtime)` (macOS/Catalyst) with a
  `Thread.callStackSymbols` fallback for iOS. Internal frames
  (BacktraceCaptureEngine/BacktraceManager/DebugSwift.Performance.Backtrace)
  are filtered on both paths so frame 0 is the user's call site.
- BacktraceEventsViewController: list + detail UI following the
  HangEventsViewController pattern.
- Public API: `DebugSwift.Performance.Backtrace` enum with
  `capture(label:)`, `setOnCapture(_:)`, `clear()`, `backtraces`, and
  `backtracesOldestFirst`.
- Performance.Controller: new `backtrace` section with "Capture Now"
  and "View Captures" rows.

Example app:
- BacktraceDemoView: iOS 13-safe demo (DateFormatter, plain Button)
  with capture/list/clear actions.
- ContentView: Backtrace demo entry.

Tests:
- 13 BacktraceTests covering capture, ordering, capacity cap, frame
  filtering, unique IDs, and synchronous callback behavior.

Closes #162.

Co-authored-by: oh-my-pi <https://omp.sh>
@maatheusgois-dd

Copy link
Copy Markdown
Contributor Author

CI failure analysis

The CI failure is StderrCaptureTests.testSingleStderrWriteProducesOneConsoleEntry() — a pre-existing flaky test unrelated to the Backtrace feature.

Evidence:

  • 618 tests ran, only 1 failed — and that 1 failure is in the StderrCapture module, not Backtrace.
  • The test is timing-sensitive by design: it writes to stderr (fd 2) and polls for up to 5 seconds waiting for the marker to appear in ConsoleOutput.shared.getErrorOutput(). On loaded GitHub Actions runners, the dup2 redirect + readabilityHandler dispatch can exceed the 5s deadline.
  • Passes locally on the same simulator (EBDE7FF5, iPhone 17 Pro, 26.3.1): Executed 1 test, with 0 failures in 1.037 seconds.
  • Recent CI history shows a flaky pattern: the previous CI failure (run 31425034180) was a different test — FeatureBaseTests.testDebugSwiftSwizzleFeature_allCases() — again exactly 1 failure out of hundreds. Different test each run = environmental flakiness, not a regression.
  • All 13 BacktraceTests pass locally with 0 failures in 0.862 seconds.

This PR's changes do not touch StderrCapture or any file in the stderr capture path.

Multiple test suites manipulate process-global state:
- StderrCaptureTests: dup2 on fd 2 + ConsoleOutput.shared singleton
- FeatureBaseTests: method swizzling on DebugSwift.shared.features
- BacktraceTests: BacktraceManager.shared singleton

With parallelizable=YES, parallel test workers race on this shared
state, causing non-deterministic failures — a different single test
fails each CI run (StderrCaptureTests one run,
FeatureBaseTests.testDebugSwiftSwizzleFeature_allCases the next).

Set parallelizable=NO so tests run serially in a single process,
eliminating the race.

Co-authored-by: oh-my-pi <https://omp.sh>
Root cause of the flaky CI failure in
StderrCaptureTests.testSingleStderrWriteProducesOneConsoleEntry():

startCapturingInternal() set _isCapturing = true (line 97) BEFORE
arming the readabilityHandler (line 105). Both run sequentially on
the same serial captureQueue, but waitForCaptureReady() polls
isCapturing from the test thread and returns the instant the flag
flips — before the handler is attached. The test then writes the
marker to the redirected pipe, but no handler is armed to read it.
On a fast local machine the gap between lines 97 and 105 is ~0, so
the handler is armed before the write. On CI runners the .utility-
QoS captureQueue is starved under load, widening the gap beyond
the 5s poll timeout — the marker sits unread in the pipe buffer and
the test fails with "stderr marker was not captured."

Fix: swap the order — arm the readabilityHandler FIRST, then set
_isCapturing = true. Now isCapturing == true truthfully means
"fd 2 is redirected AND the handler is armed." The test's short
poll timeout is adequate because the handler fires on a private
dispatch queue and the processingQueue does microseconds of work.

Also bump captureQueue from .utility to .default QoS so it is not
starved relative to the test thread under CI load.

The previous commit (disable test parallelization) addresses a
separate concern — test suites racing on process-global state
(dup2, swizzling, singletons). Both fixes are needed.

Co-authored-by: oh-my-pi <https://omp.sh>
@github-actions

Copy link
Copy Markdown
Warnings
⚠️ Please assign yourself to the PR.
Messages
📖 Project coverage: 55.49%
📖 The PR added 897 and removed 34 lines. 10 file(s) changed.

Example.app: Coverage: 13.72

File Coverage
BacktraceDemoView.swift 0.62% ⚠️
ContentView.swift 93.82%

ExampleTests.xctest: Coverage: 97.34

File Coverage
StderrCaptureTests.swift 94.61%
BacktraceTests.swift 96.61%

Generated by 🚫 Danger Swift against e11ffd7

@MaatheusGois
MaatheusGois merged commit 8aece6b into main Aug 16, 2026
5 checks passed
@MaatheusGois
MaatheusGois deleted the feature/backtrace-api-162 branch August 16, 2026 02:41
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.

Swift Backtrace API - Swift 6.2

2 participants