Skip to content

Conversation

@johnliedtke
Copy link
Contributor

@johnliedtke johnliedtke commented Nov 19, 2025

Description

I've been running into hangs with waitUntilExit() in my CLI, which is annoying since we use it in pre-commit hooks :D

The issue is that waitUntilExit() runs the current thread's runloop in NSDefaultRunLoopMode while waiting for the process to exit. This causes problems with Swift concurrency's cooperative thread pool because:

  1. You can't guarantee which thread your code runs on - it could be any thread in the pool
  2. Those threads may not have properly configured runloops (or any runloop at all)
  3. Even if they do, running the runloop in default mode can trigger unexpected timers and callbacks that weren't meant to fire in that context
  4. This leads to hangs, deadlocks, or other weird behavior

Basically, waitUntilExit() assumes you're on a specific thread with a well-behaved runloop (like the main thread), but Swift concurrency doesn't give you those guarantees.

The Fix

I replaced waitUntilExit() with a DispatchGroup:

  • Enter the group before starting the process
  • Leave it in the process's terminationHandler
  • Call wait() in close() to block until termination

This is a simple blocking wait that doesn't depend on runloop infrastructure, so it works reliably regardless of which thread it's called from.

Ironically, I hit this same issue in my own code and solved it with Eskimo's advice, but that approach would've required a bigger refactor here. This minimal change resolves the hanging issue.

See also: Mike Ash's article on dangerous Cocoa calls

Copy link
Contributor

@HT154 HT154 left a comment

Choose a reason for hiding this comment

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

Thanks for the fix. This is good to merge after a make swiftformat.

The forum thread is a great resource, thanks for linking it!

The waitUntilExit() call was causing hangs when invoked from Swift concurrency's
cooperative thread pool. This is because waitUntilExit() runs the current thread's
runloop in NSDefaultRunLoopMode while waiting, which can cause unexpected behavior
when called from arbitrary threads or thread pool contexts.

The issue occurs because:
1. waitUntilExit() runs the runloop in default mode, allowing timers and callbacks
   to fire unexpectedly (see Mike Ash's article on dangerous Cocoa calls)
2. Swift concurrency's cooperative thread pool doesn't guarantee which thread
   executes a given task, making runloop-dependent APIs unreliable
3. The runloop may not be properly configured on thread pool threads

The fix uses a DispatchGroup that is entered before starting the process and
left in the process's terminationHandler. The close() method then calls wait()
on the group, which is a simple blocking call without runloop involvement.

This approach is safe because:
- It doesn't depend on runloop infrastructure
- It works correctly regardless of which thread it's called from
- It provides clean synchronization with process termination

References:
- https://stackoverflow.com/questions/34996937/how-to-safely-use-nstask-waituntilexit-off-the-main-thread
- https://mikeash.com/pyblog/friday-qa-2009-11-13-dangerous-cocoa-calls.html
@johnliedtke johnliedtke force-pushed the john/bugfix/waitforexit-fix branch from fedcbc7 to b66032f Compare November 19, 2025 21:55
@johnliedtke
Copy link
Contributor Author

Contributor

Nice, thank you! Ran swiftformat

@HT154 HT154 merged commit 4225788 into apple:main Nov 19, 2025
11 checks passed
@johnliedtke johnliedtke deleted the john/bugfix/waitforexit-fix branch November 19, 2025 23:26
bioball pushed a commit that referenced this pull request Nov 20, 2025
…ngs (#86)

The waitUntilExit() call was causing hangs when invoked from Swift concurrency's
cooperative thread pool. This is because waitUntilExit() runs the current thread's
runloop in NSDefaultRunLoopMode while waiting, which can cause unexpected behavior
when called from arbitrary threads or thread pool contexts.

The issue occurs because:
1. waitUntilExit() runs the runloop in default mode, allowing timers and callbacks
   to fire unexpectedly (see Mike Ash's article on dangerous Cocoa calls)
2. Swift concurrency's cooperative thread pool doesn't guarantee which thread
   executes a given task, making runloop-dependent APIs unreliable
3. The runloop may not be properly configured on thread pool threads

The fix uses a DispatchGroup that is entered before starting the process and
left in the process's terminationHandler. The close() method then calls wait()
on the group, which is a simple blocking call without runloop involvement.

This approach is safe because:
- It doesn't depend on runloop infrastructure
- It works correctly regardless of which thread it's called from
- It provides clean synchronization with process termination

References:
- https://stackoverflow.com/questions/34996937/how-to-safely-use-nstask-waituntilexit-off-the-main-thread
- https://mikeash.com/pyblog/friday-qa-2009-11-13-dangerous-cocoa-calls.html
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