Skip to content

Fix Process pipe-buffer deadlock in CommandExecutor#49

Merged
diogot merged 4 commits into
mainfrom
fix/command-executor-pipe-buffer-deadlock
May 26, 2026
Merged

Fix Process pipe-buffer deadlock in CommandExecutor#49
diogot merged 4 commits into
mainfrom
fix/command-executor-pipe-buffer-deadlock

Conversation

@diogot

@diogot diogot commented May 26, 2026

Copy link
Copy Markdown
Owner

Summary

Intermittent deadlocks in CommandExecutor traced to the classic macOS pipe-buffer anti-pattern: waitUntilExit() was called before readDataToEndOfFile(), so any command whose combined stdout+stderr exceeded the ~64KB pipe buffer would hang the child (blocked writing) and the parent (blocked waiting). This was hitting xcodebuild, agvtool, large find/git runs, and altool uploads.

What changed

  • Concurrent drain. New private runCollecting helper drains stdout and stderr in parallel via readabilityHandler + DispatchGroup, then waitUntilExit, then group.wait(). Modelled on the SwiftPM TSCBasic.Process pattern.
  • Consolidation. execute, executeReadOnly, executeWithArguments now share runCollecting instead of triplicating the unsafe sequential-read code.
  • EOF reliability. Switched availableDataread(upToCount:) to avoid the spurious zero-byte callbacks documented in SR-14669.
  • Async data-loss race fixed. Removed the fallback task that called continuation.finish() after waitUntilExit — this raced the final readabilityHandler dispatch and could drop tail bytes. The handler's empty-data callback is now the sole termination signal; waitUntilExit only collects the exit status after both consumer tasks complete.
  • Verbose tee. When verbose: true, sync execution now tees stdout/stderr to the parent process in real time (previously silent until completion).
  • Mutex<Data> (Swift 6 Synchronization) wrapped in a small LockedData class — avoids @unchecked Sendable which requires approval per CLAUDE.md.
  • CommandExecutor.swift: 553 → 360 lines.

Verification

Four regression tests added in CommandExecutorTests.swift covering 200KB each on stdout and stderr (well past 64KB):

  • Confirmed they hung indefinitely on the pre-fix code (killed by a 60s watchdog).
  • After the fix they pass in 48ms total, and an END_SENTINEL assertion in the streaming test confirms no data is dropped at EOF.
  • Full suite: 468 tests / 30 suites pass in 0.67s, no regressions.

Test plan

  • swift test — 468 tests pass
  • Four new tests pass within ms (would have hung pre-fix)
  • CommandExecutor suite (32 tests) still passes
  • Smoke test xp build / xp test against a real Xcode project to confirm the verbose tee behavior

- Drain stdout and stderr concurrently via readabilityHandler + DispatchGroup
  before waiting for process exit (the macOS pipe buffer is ~64KB; the old
  waitUntilExit-then-readDataToEndOfFile order deadlocked any command whose
  combined output exceeded that — xcodebuild, agvtool, large git/find runs)
- Consolidate execute, executeReadOnly, executeWithArguments onto a single
  runCollecting helper using the SwiftPM-style drain pattern
- Switch availableData → read(upToCount:) to avoid SR-14669 spurious
  zero-byte callbacks
- Remove the racy 'waitUntilExit then continuation.finish()' fallback in the
  async streaming paths — the readabilityHandler EOF callback is now the sole
  termination signal, so tail bytes are no longer dropped between the final
  pipe read and process termination
- Tee stdout/stderr to the parent process in real time when verbose: true
- Add four regression tests covering >64KB output on each sync path plus an
  EOF-sentinel test on the async streaming path
- Bump version to 1.0.6
Copilot AI review requested due to automatic review settings May 26, 2026 21:04

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

This PR fixes intermittent Process deadlocks in CommandExecutor caused by waiting for process exit before draining stdout/stderr (pipe-buffer fill). It refactors command execution to drain both pipes concurrently and adds regression tests that previously would hang with large output.

Changes:

  • Refactors synchronous execution to a shared runCollecting implementation that drains stdout/stderr concurrently.
  • Updates async streaming execution to rely on EOF from readabilityHandler (removing a race-prone fallback finisher).
  • Adds regression tests producing 200KB on stdout/stderr to ensure no deadlocks and no EOF tail-byte loss.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
VERSION Bumps package version to reflect the fix.
Tests/XprojectTests/Utilities/CommandExecutorTests.swift Adds large-output regression tests to prevent deadlock regressions and detect EOF truncation.
Sources/Xproject/Utilities/CommandExecutor.swift Implements concurrent pipe draining for sync execution and adjusts async streaming drain/termination behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Sources/Xproject/Utilities/CommandExecutor.swift Outdated
Comment thread Sources/Xproject/Utilities/CommandExecutor.swift
Comment thread Sources/Xproject/Utilities/CommandExecutor.swift
diogot added 3 commits May 26, 2026 18:22
- Cap read(upToCount:) at 64KB instead of Int.max — matches the macOS pipe
  buffer; readabilityHandler is re-invoked for any remaining bytes
- Log pipe read errors to stderr instead of silently treating them as EOF,
  so a non-EOF failure during drain is at least visible in CI logs
- Dispatch tee writes onto a serial DispatchQueue and flush it before
  runCollecting returns. Previously the synchronous FileHandle.write inside
  the readabilityHandler could block on a slow stdout consumer (e.g.
  'xp test --verbose | slow-consumer'), stalling the drain and recreating
  the pipe-buffer deadlock this PR set out to fix
- Add swiftlint:disable for type_body_length (struct is 314 lines; the
  process-execution surface area is cohesive and splitting would be artificial)
- Use thousand separator in readChunkSize: 64 * 1_024
- Wrap single-line guard return per conditional_returns_on_newline
Node.js 20 actions are deprecated on GitHub Actions runners (forced to
Node 24 from June 2nd, 2026; Node 20 removed September 16th, 2026).
Bumps every first-party action across workflows to the latest major:

- actions/checkout v4 → v6 (Node 24, separates creds storage)
- actions/cache v4 → v5 (Node 24 runtime only)
- actions/upload-artifact v4 → v7 (Node 24 + optional direct-file upload)

No API surface used by these workflows changed across the bumps.
@diogot diogot force-pushed the fix/command-executor-pipe-buffer-deadlock branch from 73855e5 to d2b9fd2 Compare May 26, 2026 21:50
@diogot diogot merged commit 321fdec into main May 26, 2026
1 of 2 checks passed
@diogot diogot deleted the fix/command-executor-pipe-buffer-deadlock branch May 26, 2026 21:55
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