Skip to content

test(macos): subprocess soak + perf baselines, signpost instrumentation, chart identity fix#494

Merged
willwashburn merged 4 commits into
mainfrom
claude/zealous-maxwell-2aienr-soak
Jul 3, 2026
Merged

test(macos): subprocess soak + perf baselines, signpost instrumentation, chart identity fix#494
willwashburn merged 4 commits into
mainfrom
claude/zealous-maxwell-2aienr-soak

Conversation

@willwashburn

@willwashburn willwashburn commented Jul 3, 2026

Copy link
Copy Markdown
Member

Targets the reported BurnOSX slowness + memory leaks with empirical leak/perf tests, profiling hooks, and perf fixes. Originally stacked on #491; retargeted to main after that merged.

No Swift toolchain in the authoring environment — Swift here is written conservatively and verified by the macos-app-tests workflow (macos-14 runner).

The soak test caught a real leak

The first CI run of testRepeatedRunsDoNotLeakFDsOrMemory failed with "open fd count grew by 398 over 200 runs" — ~2 fds leaked per subprocess spawn. Parent-side Pipe/FileHandle objects are Obj-C autoreleased, and on GCD/cooperative threads the pool drains lazily, so each run's descriptors lingered long after the child exited. With a spawn every ~3s on the live tab, this is a real production leak mechanism, not test noise.

Fix (in blockingCapture): explicitly close() the parent-side read handles after each drain (and defensively on the timeout path, after a bounded re-wait so we never close under an in-flight legacy read), and wrap the whole blocking body in autoreleasepool so remaining autoreleased objects drain per call. The test thresholds were left untouched (<10 fds, <20 MB) — the production code must be leak-free without the caller's help.

Cooperative-pool fix (review finding)

capture used to run DispatchGroup.wait + usleep inside the actor method — i.e. on a Swift Concurrency cooperative-pool thread, blocking it for up to captureTimeout (30s prod). With the live tab polling every 3s across providers plus spend queries, that can starve the pool (a plausible slowness cause). Now:

  • The blocking Process/Pipe body is a pure static blockingCapture(timeout:_:) (no actor state).
  • It runs on a serial DispatchQueue("com.agentworkforce.burn.subprocess", qos: .utility); capture is async and awaits a checked continuation resumed from that queue. The serial queue preserves the one-subprocess-at-a-time invariant (guarded by testConcurrentCallersSerializeWithoutPileup) while the cooperative thread is released for the child's whole run.
  • loginShell/resolveTool became async; the actor is reentrant at the await, so two concurrent first calls may both run the PATH probe — benign, both converge on the same cached value (commented in code).

What each test guards

SubprocessSoakTests.swift — leaks in the subprocess machinery

Drives the real SystemBurnRunner Process/Pipe/timeout code against a temp fake-burn shell script; measures open fds via /dev/fd and memory via phys_footprint (TASK_VM_INFO).

  • testRepeatedRunsDoNotLeakFDsOrMemory — 10 warmup runs, then 200 measured run(["summary","--json"]). Asserts fd delta < 10 and footprint delta < 20 MB. (Already earned its keep — see above.)
  • testTimeoutPathReapsProcessAndFDs — a sleep 60 script with an injected 1s timeout (production 30s is too slow for CI). Asserts run() returns nil and fds return to baseline after the SIGTERM→SIGKILL reap.
  • testConcurrentCallersSerializeWithoutPileup — 10 concurrent run calls via a task group; all must complete with output and fd/footprint deltas stay bounded (guards the serialization invariant that prevents subprocess pile-up).

PerformanceTests.swift — hot-path baselines

measure(metrics: [XCTClockMetric(), XCTMemoryMetric()]) over:

  • BurndownBuilder.build with a 10,000-sample series.
  • BurnLedger.timeseries parsing a 5,000-bucket payload via a FakeRunner.
  • UsageHistoryStore.record against a store pre-seeded with 50 series × 1,000 samples (the known full-cache-rewrite-per-record cost). Each iteration advances a deterministic clock by 61s so no iteration hits the store's <1s duplicate-collapse path — every measured call does the same append + full-rewrite work.

No stored baselines, so these never fail CI on timing — they establish measurements in the test report and catch crashes/regressions.

Signposts — profiling slowness in Instruments

Sources/Burn/Signposts.swift exposes one OSSignposter per category under subsystem com.agentworkforce.burn:

  • refresh — intervals around LiveBurnViewModel.refresh(), UsageViewModel.refresh(force:), and loadSpend.
  • subprocess — an interval around every capture (queue wait + child run), with the first CLI arg (e.g. summary) as the interval message.

To profile:

xctrace record --template 'Time Profiler' --attach BurnOSX

then filter the os_signpost lane by subsystem com.agentworkforce.burn (categories refresh, subprocess).

Chart identity fix

LiveBurnSample was Identifiable via let id = UUID(), so every ~3s refresh minted brand-new identities for all samples, forcing Swift Charts to rebuild the entire chart instead of diffing. Changed to var id: Date { date }. Series are stored per-provider and each chart ForEach iterates a single provider's array (verified in LiveBurnView.swift), where bucket dates are unique, so per-series date identity suffices.

Production changes

  • SystemBurnRunner.init(explicitBinaryURL: URL? = nil, timeout: TimeInterval = 30) — explicit URL short-circuits resolution to direct exec (like the bundled case, no login shell); injectable timeout. Defaults reproduce prior behavior.
  • capture async on a serial exec queue + static blockingCapture (above); explicit handle closes + per-call autoreleasepool (the fd-leak fix).
  • Signpost begin/end around the two refresh loops, loadSpend, and capture.
  • LiveBurnSample identity change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KJPzcAQdxufCWMnap8vTTL

…on, chart identity fix

Add a SubprocessSoakTests suite that drives the real SystemBurnRunner
Process/Pipe/timeout machinery against a fake burn script and asserts open
file descriptors and phys_footprint stay flat across 200 spawns, that the
timeout path reaps a hung child and reclaims its fds, and that concurrent
callers serialize without pile-up. To enable this without spawning the real
binary, SystemBurnRunner gains init(explicitBinaryURL:timeout:): an explicit
URL is exec'd directly (like the bundled helper) and the capture timeout is
injectable (default 30s preserves production behavior).

Add PerformanceTests establishing clock+memory baselines for the hot paths:
BurndownBuilder.build over 10k samples, BurnLedger.timeseries parsing 5k
buckets, and UsageHistoryStore.record's full-rewrite cost against a 50k-sample
store. These have no stored baselines so they never fail CI on timing.

Add os_signpost instrumentation (subsystem com.agentworkforce.burn): a
"refresh" category around the live/usage refresh loops and loadSpend, and a
"subprocess" category around each capture with the first CLI arg as the
interval message, so slowness can be profiled in Instruments.

Fix chart identity churn: LiveBurnSample identified by its bucket date instead
of a fresh UUID per refresh, so Swift Charts diffs marks instead of rebuilding
the whole chart every ~3s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJPzcAQdxufCWMnap8vTTL
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@willwashburn, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 50 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ba23cf18-98a9-49fd-baaa-b3d017b17587

📥 Commits

Reviewing files that changed from the base of the PR and between 801e154 and 1c5cb3e.

📒 Files selected for processing (3)
  • apps/macos/Sources/Burn/BurnLedger.swift
  • apps/macos/Tests/BurnTests/PerformanceTests.swift
  • apps/macos/Tests/BurnTests/SubprocessSoakTests.swift
📝 Walkthrough

Walkthrough

SystemBurnRunner is extended to accept an injectable binary URL and capture timeout, with label propagation through subprocess capture and timeout enforcement. A new Signposts enum adds os_signpost instrumentation to refresh and load-spend flows. New PerformanceTests and SubprocessSoakTests validate benchmark timing and subprocess resource stability.

Changes

Injectable burn runner, signposts, and test coverage

Layer / File(s) Summary
Injectable binary/timeout in SystemBurnRunner
apps/macos/Sources/Burn/BurnLedger.swift
Adds explicitBinaryURL and captureTimeout to SystemBurnRunner init, bypasses tool resolution when an explicit binary is set, threads a label through loginShell/capture, and uses captureTimeout for wait enforcement instead of a fixed value.
Signpost instrumentation
apps/macos/Sources/Burn/Signposts.swift, apps/macos/Sources/Burn/LiveBurnViewModel.swift, apps/macos/Sources/Burn/UsageViewModel.swift
New Signposts enum defines refresh/subprocess OSSignposter categories; LiveBurnViewModel.refresh() and UsageViewModel.refresh(force:)/loadSpend(...) wrap execution with begin/end signpost intervals; LiveBurnSample.id changes from a random UUID to a stable Date-derived value.
Performance and soak tests
apps/macos/Tests/BurnTests/PerformanceTests.swift, apps/macos/Tests/BurnTests/SubprocessSoakTests.swift
New PerformanceTests benchmarks burndown build, timeseries parsing, and history rewrite; new SubprocessSoakTests verifies fd/memory stability across repeated, timed-out, and concurrent subprocess runs.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • AgentWorkforce/burn#478: Earlier addition of a timeout for BurnLedger.capture directly relates to this PR's configurable capture timeout changes.
  • AgentWorkforce/burn#481: Both PRs modify the subprocess capture/execution path in BurnLedger.swift, one fixing a leak and this one wiring in injectable binary/label/timeout.

Poem

A rabbit peeks behind the burn,
new signposts blink at every turn,
a timeout tamed, a binary swapped,
soak tests run till leaks are stopped.
🐰✨ Hop on, the ledger's sound and sworn!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main changes: macOS subprocess soak/perf tests, signposts, and a chart identity fix.
Description check ✅ Passed The description is directly related to the changeset and explains the soak tests, profiling hooks, and chart fix in detail.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/zealous-maxwell-2aienr-soak

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces performance profiling and testing enhancements for the macOS Burn application. Key changes include the addition of lightweight os_signpost instrumentation to profile hot paths, stability improvements to the live-burn chart by using stable dates as identifiers instead of dynamic UUIDs, and the introduction of performance and subprocess soak tests. Feedback focuses on avoiding blocking synchronous calls within Swift Concurrency's cooperative thread pool in SystemBurnRunner to prevent thread starvation, as well as updating deprecated Task.sleep(nanoseconds:) calls in tests to the modern Task.sleep(for:) API.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +105 to +110
private func capture(label: String, _ configure: (Process) -> Void) -> String? {
let signpostID = Signposts.subprocess.makeSignpostID()
let interval = Signposts.subprocess.beginInterval(
"capture", id: signpostID, "\(label, privacy: .public)"
)
defer { Signposts.subprocess.endInterval("capture", interval) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The capture method is synchronous and uses group.wait(timeout:) and usleep to block the calling thread until the subprocess exits or times out. Because capture is called from the async actor method run(_:), it executes on a thread from Swift Concurrency's cooperative thread pool.\n\nBlocking a cooperative thread violates the Swift Concurrency runtime contract (which requires that threads always make forward progress) and can lead to thread starvation, UI hangs, or deadlocks.\n\nConsider making capture asynchronous and wrapping the blocking Process execution inside withCheckedContinuation, running the blocking work on a non-cooperative background queue (e.g., DispatchQueue.global(qos: .utility)). To maintain the serialization of subprocesses without blocking the actor's thread, you can use a task-chaining pattern on the actor.


// The child is SIGTERM'd (then SIGKILL'd); give the background reader a
// beat to hit EOF and release the pipe fds.
try await Task.sleep(nanoseconds: 1_000_000_000)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Use the modern, non-deprecated Task.sleep(for:) API instead of Task.sleep(nanoseconds:) for better readability and type safety.

Suggested change
try await Task.sleep(nanoseconds: 1_000_000_000)
try await Task.sleep(for: .seconds(1))

XCTAssertTrue(results.allSatisfy { $0 != nil }, "every concurrent run should complete with output")

// Let any just-finished captures release their pipes before snapshotting.
try await Task.sleep(nanoseconds: 300_000_000)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Use the modern, non-deprecated Task.sleep(for:) API instead of Task.sleep(nanoseconds:) for better readability and type safety.

Suggested change
try await Task.sleep(nanoseconds: 300_000_000)
try await Task.sleep(for: .milliseconds(300))

@cubic-dev-ai cubic-dev-ai Bot 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.

2 issues found across 6 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread apps/macos/Sources/Burn/BurnLedger.swift Outdated
Comment thread apps/macos/Tests/BurnTests/PerformanceTests.swift Outdated
Base automatically changed from claude/zealous-maxwell-2aienr to main July 3, 2026 15:48
claude added 2 commits July 3, 2026 15:51
…r-spawn fd leak

Address review findings on the soak/perf PR:

- capture() no longer blocks a Swift Concurrency cooperative-pool thread for
  up to captureTimeout (30s prod). The blocking Process/Pipe body moves into a
  static blockingCapture(timeout:_:) that runs on a serial utility
  DispatchQueue; capture() awaits a continuation resumed from that queue. The
  serial queue preserves the one-subprocess-at-a-time invariant the blocking
  actor used to provide (guarded by testConcurrentCallersSerializeWithoutPileup)
  while freeing the pool during the child's run. loginShell/resolveTool become
  async accordingly; resolveTool's PATH probe is reentrancy-tolerant (two
  concurrent first calls converge on the same cached value).

- Plug the per-spawn fd leak the soak test caught in CI (~2 fds per run, 398
  over 200 runs): parent-side Pipe/FileHandle objects are Obj-C autoreleased
  and GCD pools drain lazily, so descriptors lingered long after each child
  exited. blockingCapture now explicitly close()s the read handles after each
  drain (and defensively on the timeout path, after a bounded re-wait) and
  wraps the whole body in autoreleasepool so remaining objects drain per call.
  With a spawn every 3s on the live tab this was a real production leak.
  Soak-test thresholds unchanged (<10 fds, <20 MB).

- SubprocessSoakTests: use Task.sleep(for:) Duration API.

- PerformanceTests: the history-record benchmark advances a deterministic
  clock 61s per iteration so no iteration hits the store's <1s duplicate-
  collapse path; reset moved further out so pruning never kicks in mid-measure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJPzcAQdxufCWMnap8vTTL

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (1)
apps/macos/Tests/BurnTests/SubprocessSoakTests.swift (1)

66-74: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Sleep script relies on undocumented shell exec-optimization; may defeat the timeout/kill test.

process.terminate()/SIGKILL in SystemBurnRunner.capture() only signal the immediate child's PID. This script assumes /bin/sh implicitly execs the trailing sleep 60 into the same process (no fork). That optimization exists in bash but is explicitly undocumented behavior, not a portable guarantee across shell implementations/versions.

If it doesn't apply in some environment, sh forks a child for sleep; killing the immediate sh PID leaves the orphaned sleep process holding the pipe's write end open, so the background readDataToEndOfFile() in capture() won't see EOF until the orphan exits ~60s later — well past the 1s buffer in testTimeoutPathReapsProcessAndFDs (Lines 118-121), risking a flaky/hanging fd measurement there.

Use exec to guarantee a single-PID replacement regardless of shell implementation.

🔧 Proposed fix
     private func makeSleepScript() throws -> URL {
         try makeScript(
             """
             #!/bin/sh
-            sleep 60
+            exec sleep 60
             """
         )
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/macos/Tests/BurnTests/SubprocessSoakTests.swift` around lines 66 - 74,
The hang test helper in makeSleepScript relies on shell-specific exec behavior,
which can leave an orphaned sleep process and break the timeout/kill path.
Update the script generated by makeSleepScript so the shell explicitly replaces
itself with the long-running command, ensuring SystemBurnRunner.capture() only
has one PID to terminate and testTimeoutPathReapsProcessAndFDs can reliably
observe EOF within the expected buffer.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/macos/Tests/BurnTests/SubprocessSoakTests.swift`:
- Around line 66-74: The hang test helper in makeSleepScript relies on
shell-specific exec behavior, which can leave an orphaned sleep process and
break the timeout/kill path. Update the script generated by makeSleepScript so
the shell explicitly replaces itself with the long-running command, ensuring
SystemBurnRunner.capture() only has one PID to terminate and
testTimeoutPathReapsProcessAndFDs can reliably observe EOF within the expected
buffer.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 69fc2de6-9a7b-4139-be93-f18fbff73f9a

📥 Commits

Reviewing files that changed from the base of the PR and between f78046c and 801e154.

📒 Files selected for processing (6)
  • apps/macos/Sources/Burn/BurnLedger.swift
  • apps/macos/Sources/Burn/LiveBurnViewModel.swift
  • apps/macos/Sources/Burn/Signposts.swift
  • apps/macos/Sources/Burn/UsageViewModel.swift
  • apps/macos/Tests/BurnTests/PerformanceTests.swift
  • apps/macos/Tests/BurnTests/SubprocessSoakTests.swift

Without exec, some shells fork for the trailing command; SIGTERM/SIGKILL
of the immediate child would leave an orphaned sleep holding the pipe
write end open past the test's fd-measurement window.
@willwashburn willwashburn merged commit f797cdc into main Jul 3, 2026
5 checks passed
@willwashburn willwashburn deleted the claude/zealous-maxwell-2aienr-soak branch July 3, 2026 19:35
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.

2 participants