diff --git a/Packages/macOS/CmuxNext/Package.swift b/Packages/macOS/CmuxNext/Package.swift index eff61494830c..6f2ac887b951 100644 --- a/Packages/macOS/CmuxNext/Package.swift +++ b/Packages/macOS/CmuxNext/Package.swift @@ -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", diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/XCTestPhaseTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/XCTestPhaseTests.swift new file mode 100644 index 000000000000..5b431a7d51ee --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/XCTestPhaseTests.swift @@ -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.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 + } + // .xctest/Contents/MacOS/ + 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) + } +} diff --git a/Packages/macOS/CmuxNext/Tests/Support/CmuxNextTestExitTrace/CmuxNextTestRunLoopGuard.c b/Packages/macOS/CmuxNext/Tests/Support/CmuxNextTestExitTrace/CmuxNextTestRunLoopGuard.c index caf4ba77879f..e5c780bdf3be 100644 --- a/Packages/macOS/CmuxNext/Tests/Support/CmuxNextTestExitTrace/CmuxNextTestRunLoopGuard.c +++ b/Packages/macOS/CmuxNext/Tests/Support/CmuxNextTestExitTrace/CmuxNextTestRunLoopGuard.c @@ -27,7 +27,6 @@ #include "CmuxNextTestExitTrace.h" #include -#include #include #include #include @@ -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 `). 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( diff --git a/Packages/macOS/CmuxNext/Tests/Support/CmuxNextTestExitTrace/include/CmuxNextTestExitTrace.h b/Packages/macOS/CmuxNext/Tests/Support/CmuxNextTestExitTrace/include/CmuxNextTestExitTrace.h index 7b911a2e40ad..c54e5d3267c2 100644 --- a/Packages/macOS/CmuxNext/Tests/Support/CmuxNextTestExitTrace/include/CmuxNextTestExitTrace.h +++ b/Packages/macOS/CmuxNext/Tests/Support/CmuxNextTestExitTrace/include/CmuxNextTestExitTrace.h @@ -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