Skip to content
Merged
7 changes: 4 additions & 3 deletions Packages/macOS/CmuxNext/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1690,9 +1690,10 @@ let packageTargets: [Target] = uiLayerPreviewTargets + [
dependencies: ["CmuxNextActions", "CmuxNextCompat", "CmuxNextDaemon", "CmuxNextDaemonService", "CmuxNextPages", "CmuxNextSettings"],
swiftSettings: uiSwiftSettings
),
// Test-only: every exit() of the test process prints its stack, and the main run loop
// survives being stopped at its outermost run, whose return would end the whole test run
// through swift_task_asyncMainDrainQueue's exit(0) (TestProcessExitTests).
// Test-only: every exit() of the test process prints its stack, and in swiftpm-testing-helper
// the main run loop survives being stopped at its outermost run, whose return would end the
// whole test run through swift_task_asyncMainDrainQueue's exit(0) (TestProcessExitTests).
// Never in xctest, which runs its own main (XCTestPhaseTests).
.target(
name: "CmuxNextTestExitTrace",
path: "Tests/Support/CmuxNextTestExitTrace",
Expand Down
118 changes: 118 additions & 0 deletions Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/XCTestPhaseTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import CmuxNextTestExitTrace
import Darwin
import Foundation
import Testing
import XCTest

/// `swift test` runs this bundle twice: its Swift Testing tests in `swiftpm-testing-helper`, then
/// its XCTest phase in Xcode's `xctest` tool, which loads the same bundle. From 2026-11-02 the
/// XCTest phase of CmuxNext suite steps hung for about 10 minutes and then died on signal 6, after
/// every Swift Testing test had passed (aws-m4pro-1, -3, -4, -6, -9, Blacksmith macOS 26). The stack
/// of the hung xctest (aws-m4pro-6, feat 305434be55a1):
///
/// _XCTestMain -> -[XCTestDriver _runTests] -> -[XCTFuture value]
/// -> +[XCTWaiter _synchronouslyWaitForTimeInterval:] -> CFRunLoopRun -> __CFRunLoopDoBlocks
/// -> the run-loop guard's block -> guard_hold_the_main_loop -> CFRunLoopRun (for good)
///
/// XCTest waits for its start-up work with a turn of the main run loop whenever that work is not
/// done yet (there, a Contacts store lookup in xctest failed for 22 s first; on aws-m4pro-1 the
/// same hang came with no Contacts line at all). The run-loop guard
/// (Tests/Support/CmuxNextTestExitTrace) had queued a block that never returns, so that one turn
/// took xctest's main thread and XCTest never ran its suite.
nonisolated struct XCTestPhaseTests {
@Test func theRunLoopGuardHoldsOnlyTheSwiftTestingHelper() {
#expect(cmux_next_test_runloop_guard_holds_in("swiftpm-testing-helper") == 1)
#expect(cmux_next_test_runloop_guard_holds_in("xctest") == 0)
#expect(cmux_next_test_runloop_guard_holds_in(nil) == 0)
}

/// An XCTest wait in `xctest` must come back: the probe below waits the way XCTest's own
/// start-up does, in a child `xctest` of this same bundle started as SwiftPM starts its XCTest
/// phase (xctest itself, not through the xcrun shim, which is SIP-protected and drops the
/// DYLD_* paths; SWIFT_TESTING_ENABLED=0, so XCTest runs no Swift Testing test in it). On the
/// base the guard held the child's main thread, and it was killed here at the limit.
@Test(.timeLimit(.minutes(3)))
func anXCTestWaitInTheXCTestPhaseReturns() async throws {
let bundle = try #require(Self.testBundle(), "the test bundle that holds this test")
let find = await Self.run("/usr/bin/xcrun", ["--find", "xctest"], environment: ProcessInfo.processInfo.environment,
limit: .seconds(60))
let xctest = find.output.trimmingCharacters(in: .whitespacesAndNewlines)
try #require(find.status == 0 && xctest.hasPrefix("/"), "xcrun --find xctest: \(find.output)")

var environment = ProcessInfo.processInfo.environment
environment["SWIFT_TESTING_ENABLED"] = "0"
let phase = await Self.run(xctest, ["-XCTest", "CmuxNextAppTests.XCTestPhaseRunLoopProbe", bundle.path],
environment: environment, limit: .seconds(120))
let tail = String(phase.output.suffix(4000))
#expect(phase.status == 0, "xctest: \(phase.status.map { "exit \($0)" } ?? "still running after 120 s, killed"); its output ends: \(tail)")
#expect(phase.output.contains("Executed 1 test, with 0 failures"), "the probe did not run and pass; output ends: \(tail)")
#expect(!phase.output.contains("holding the main run loop open"), "the run-loop guard held xctest's main thread")
}

/// Runs a child to its end or to `limit` (then SIGKILL, and a nil status). Its output goes to a
/// file, not a pipe, so a child that prints a lot can never block on a full pipe.
private static func run(_ executable: String, _ arguments: [String], environment: [String: String],
limit: Duration) async -> (status: Int32?, output: String) {
let output = FileManager.default.temporaryDirectory.appendingPathComponent("xctest-phase-\(UUID().uuidString).log")
guard FileManager.default.createFile(atPath: output.path, contents: nil),
let log = try? FileHandle(forWritingTo: output) else { return (nil, "cannot create \(output.path)") }
defer {
try? log.close()
try? FileManager.default.removeItem(at: output)
}
let child = Process()
child.executableURL = URL(fileURLWithPath: executable)
child.arguments = arguments
child.environment = environment
child.standardInput = FileHandle.nullDevice
child.standardOutput = log
child.standardError = log
let ended = AsyncStream<Int32>.makeStream()
child.terminationHandler = { finished in
ended.continuation.yield(finished.terminationStatus)
ended.continuation.finish()
}
do { try child.run() } catch { return (nil, "\(executable): \(error)") }
let pid = child.processIdentifier
let status: Int32? = await withTaskGroup(of: Int32?.self) { group in
group.addTask {
for await status in ended.stream { return status }
return nil
}
group.addTask {
try? await Task.sleep(for: limit)
return nil
}
let first = await group.next() ?? nil
group.cancelAll()
return first
}
if status == nil { kill(pid, SIGKILL) }
return (status, (try? String(contentsOf: output, encoding: .utf8)) ?? "")
}

/// `…/CmuxNextPackageTests.xctest`, from the image that holds the guard's own code.
private static func testBundle() -> URL? {
let installed: @convention(c) () -> Int32 = cmux_next_test_runloop_guard_installed
var info = Dl_info()
guard dladdr(unsafeBitCast(installed, to: UnsafeRawPointer.self), &info) != 0, let name = info.dli_fname else {
return nil
}
// <bundle>.xctest/Contents/MacOS/<binary>
let bundle = URL(fileURLWithPath: String(cString: name))
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
return bundle.pathExtension == "xctest" ? bundle : nil
}
}

/// Run by ``XCTestPhaseTests`` in a child `xctest`, and by the XCTest phase of a whole-package
/// `swift test`, where it passes in about 50 ms. It waits on an expectation the way XCTest's
/// start-up does: the wait turns the main run loop until the expectation is met. `nonisolated`:
/// this target defaults to the main actor, and XCTestCase's initializers are not isolated.
nonisolated final class XCTestPhaseRunLoopProbe: XCTestCase {
func testAnXCTestWaitReturns() {
let turned = expectation(description: "the main run loop turned")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { turned.fulfill() }
wait(for: [turned], timeout: 10)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
#include "CmuxNextTestExitTrace.h"

#include <CoreFoundation/CoreFoundation.h>
#include <crt_externs.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
Expand Down Expand Up @@ -147,28 +146,29 @@ static void probe_fire(CFRunLoopTimerRef timer, void *info) {
/// Defined beside this file: a run that lists its tests rather than running any.
int cmux_next_test_lists_tests(void);

/// The XCTest leg of `swift test` (`xctest -XCTest <filter> <bundle>`). Its main thread is XCTest's own
/// synchronous runner, not `swift_task_asyncMainDrainQueue`, so there is no drain-queue run for a stray
/// stop to end, and this package has no XCTest cases. Holding its loop can only hang it: on some fleet
/// Macs the leg turns the main run loop once while it loads (a Contacts store XPC wait, the
/// "Error obtaining permanent ID" line), the queued block below takes the main thread for good, and
/// the leg dies on signal 6 about 650 s later with no test started (cmux#32898's suite runs; the same
/// leg passed on Macs where nothing turned the loop).
static int runs_xctest_leg(void) {
int count = *_NSGetArgc();
char **arguments = *_NSGetArgv();
for (int index = 1; index < count; index++) {
if (strcmp(arguments[index], "-XCTest") == 0) return 1;
}
return 0;
/// Only `swift test`'s Swift Testing helper has the main thread this guard protects: its `main` is
/// `swift_task_asyncMainDrainQueue`, which exits when its run of the main run loop returns. Every
/// other host of this bundle runs its own main, and the block below would take that main thread for
/// good the first time anything turns the main run loop. That is how `swift test`'s XCTest phase
/// (the `xctest` tool, which loads the same bundle) hung for about 10 minutes and then died on
/// signal 6 after every Swift Testing test had passed (2026-11-02, five fleet Macs and Blacksmith):
/// `-[XCTestDriver _runTests]` waits on its start-up future with a turn of the main run loop
/// (`+[XCTWaiter _synchronouslyWaitForTimeInterval:]`) whenever that work is not done yet, the
/// guard's block ran in that turn, and XCTest never ran its suite (XCTestPhaseTests). #33023 first
/// stopped the hold in a process whose argv has `-XCTest`; this name check covers that leg and every
/// other host (an Xcode test host, `xctest` started without `-XCTest`), and installs nothing there.
int cmux_next_test_runloop_guard_holds_in(const char *process_name) {
return process_name != NULL && strcmp(process_name, "swiftpm-testing-helper") == 0;
}

__attribute__((constructor)) static void cmux_next_test_runloop_guard_install(void) {
// A listing run reports its tests and ends; holding its run loop open could only hang it, and
// nothing in a listing run needs the depth count either.
if (cmux_next_test_lists_tests()) return;
// Nothing at all in another host (xctest, an Xcode test host): no observer, no source, no block.
if (!cmux_next_test_runloop_guard_holds_in(getprogname())) return;
const char *setting = getenv("CMUX_NEXT_TEST_RUNLOOP_GUARD");
int hold = !(setting && strcmp(setting, "0") == 0) && !runs_xctest_leg();
int hold = !(setting && strcmp(setting, "0") == 0);
CFRunLoopRef main_loop = CFRunLoopGetMain();
if (!main_loop) return;
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@ int cmux_next_test_exit_trace_installed(void);
/// this process keeps its backtracer and its children (exit tests, privileged tools) do not get one.
int cmux_next_test_backtrace_kept_from_children(void);

/// 1 once the main run loop survives being stopped at its outermost run (installed with the trace).
/// 1 once the main run loop survives being stopped at its outermost run (installed when the bundle
/// loads, in swiftpm-testing-helper only).
int cmux_next_test_runloop_guard_installed(void);

/// 1 when the guard holds the main run loop in a process of this name: only `swift test`'s Swift
/// Testing helper (`swiftpm-testing-helper`), never `xctest` or another test host.
int cmux_next_test_runloop_guard_holds_in(const char *process_name);

/// How many times the guard has entered the main run loop again after it was stopped.
int cmux_next_test_runloop_guard_reentries(void);

/// Runs of the main run loop in the common modes, outermost included: 1 when no nested pass is open.
/// Counted in swiftpm-testing-helper only; 0 in any other process.
int cmux_next_test_runloop_guard_depth(void);

/// Stops the run a stray stop would be fatal to, `times` times, each on a turn when that run is
Expand Down
Loading