Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b5d5cd8
cmux-next swift test: a truncated run can no longer read as a pass
teamleaderleo Oct 26, 2026
a4757a2
Read the markers wherever they are, and a known-issue pass as finished
teamleaderleo Oct 26, 2026
a4119f6
Read the reporter lines anchored, and say why that is the trade
teamleaderleo Oct 26, 2026
51d9eea
A known-issue finish carries "━", not ✘
teamleaderleo Oct 26, 2026
81fd382
The cause: Harness.click posts and dequeues events outside a nested pass
teamleaderleo Oct 26, 2026
bd30611
One definition of "finished", because the watchdog had its own
teamleaderleo Oct 26, 2026
3cd20f9
Count runs, not names: a repeated test name hid a truncation
teamleaderleo Oct 26, 2026
f856fed
Match each finish to a start, in order, and time each run separately
teamleaderleo Oct 26, 2026
f9b9209
A run key joins with a byte no test name can contain
teamleaderleo Oct 26, 2026
4aa861d
Stuck means no test finished, not one test open a long time
teamleaderleo Oct 26, 2026
57f83c6
cmux-next: the swift test run survives a stray stop of the main run loop
teamleaderleo Oct 26, 2026
a08e2f1
cmux-next: hold the main run loop open from a block, not from an obse…
teamleaderleo Oct 26, 2026
9304f35
cmux-next: the guard's own test can fail for only one reason
teamleaderleo Oct 26, 2026
5dc60d8
cmux-next: a test helper's crash report is found, and silence is neve…
teamleaderleo Oct 26, 2026
4983a86
cmux-next: the crash prints its own stack, instead of hoping for a re…
teamleaderleo Oct 26, 2026
b9d88c0
Merge remote-tracking branch 'origin/feat-cmux-next' into ci-speed-2/…
Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Packages/macOS/CmuxNext/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1609,10 +1609,13 @@ let packageTargets: [Target] = uiLayerPreviewTargets + [
dependencies: ["CmuxNextActions", "CmuxNextCompat", "CmuxNextDaemon", "CmuxNextDaemonService", "CmuxNextPages", "CmuxNextSettings"],
swiftSettings: uiSwiftSettings
),
// Test-only: every exit() of the test process prints its stack (TestProcessExitTests).
// 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).
.target(
name: "CmuxNextTestExitTrace",
path: "Tests/Support/CmuxNextTestExitTrace"
path: "Tests/Support/CmuxNextTestExitTrace",
linkerSettings: [.linkedFramework("CoreFoundation")]
),
.testTarget(
name: "CmuxNextAppTests",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,24 +38,42 @@ import Testing
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber,
context: nil, eventNumber: 0, clickCount: 1, pressure: 1))
}
Self.post(try event(.leftMouseUp))
document.mouseDown(with: try event(.leftMouseDown))
// A list that did not track the press leaves its mouse-up queued: drop it.
while NSApp.nextEvent(matching: .leftMouseUp, until: Date(), inMode: .default, dequeue: true) != nil {}
let up = try event(.leftMouseUp), down = try event(.leftMouseDown)
// The whole click, every step of it, inside one nested pass of the main run loop. Any
// CFRunLoopStop AppKit leaves behind then ends that pass instead of the test process's
// outermost loop. Isolating only the postEvent was not enough: the nextEvent drain
// below is the other half of the shape the repository already blames for ending the
// process (KeyDownDispatchTests.swift:18, ActionRunTimingTests.swift:324, "postEvent,
// then nextEvent, in any run loop mode"), and mouseDown runs the list's own
// press-and-drag tracking, which posts and dequeues events too.
//
// In the helper, not left to each caller. The caller that forgot was
// openingAConversationFromTheListPutsTheKeyboardInItsMessageBox below, which calls
// this seven times: on 2026-10-26 it started at 09:02:16.217 in the swift test job of
// cmux#25391 and the process exited 0 twenty-eight log lines later, with 2303 tests
// started and unfinished and no summary. aClickLeavesTheRunLoopItRanInRunning, which
// wrapped its own call, had passed on the line before.
Self.inANestedPass {
NSApp.postEvent(up, atStart: false)
self.document.mouseDown(with: down)
// A list that did not track the press leaves its mouse-up queued: drop it.
while NSApp.nextEvent(matching: .leftMouseUp, until: Date(), inMode: .default, dequeue: true) != nil {}
}
}

/// Queues `event` for the list's press-and-drag loop. `NSApp.postEvent` stops the run loop
/// running when it is called, to wake an event wait; from a test's main-actor job that is
/// the test process's outermost loop, whose return ends the process. Posted from a pass of
/// a mode of its own, the stop ends that pass, and the pass runs nothing else. The wake
/// ends the pass as soon as the event is queued, should AppKit not stop it.
private static func post(_ event: NSEvent) {
let mode = CFRunLoopMode("com.cmuxterm.next.tests.post-event" as CFString)
CFRunLoopPerformBlock(CFRunLoopGetMain(), mode.rawValue) {
MainActor.assumeIsolated { NSApp.postEvent(event, atStart: false) }
CFRunLoopWakeUp(CFRunLoopGetMain())
}
CFRunLoopRunInMode(mode, 1, true)
/// One nested pass of the main run loop around `body`, discarding how it ended: the point
/// is only that a stop `body` leaves behind ends THIS pass. `NSApp.postEvent` stops the run
/// loop running when it is called, to wake an event wait (`-[NSEvent _postAtStart:]` calls
/// `CFRunLoopStop`), and so does AppKit's own event tracking; from a test's main-actor job
/// the loop running is the test process's outermost one, swift-testing's async-main
/// `CFRunLoopRun`, whose return drops `main` through to `exit(0)`. The pass runs in a mode
/// of its own, so it runs `body` and nothing else (a default-mode pass would also run other
/// suites' main-actor jobs, and end as handled when it drained them), and the wake after
/// `body` ends the pass should AppKit not stop it.
///
/// The suite's own `inANestedPass` reports the result instead, to assert this very property.
fileprivate static func inANestedPass(_ body: @escaping () -> Void) {
_ = HomeOpenFocusesComposerTests.inANestedPass(named: "harness-input", body)
}

/// A key-down on the list's document view (`keyCode` for arrows, `characters` for Return).
Expand All @@ -64,7 +82,9 @@ import Testing
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber,
context: nil, characters: characters, charactersIgnoringModifiers: characters,
isARepeat: false, keyCode: keyCode))
document.keyDown(with: event)
// In a nested pass for the same reason as `click`: a key-down runs the list's own
// handling, which can post and dequeue events.
Self.inANestedPass { self.document.keyDown(with: event) }
}

/// The first responder's class name (never its `description`: AppKit views build it from
Expand Down Expand Up @@ -132,8 +152,14 @@ import Testing
/// running loop to wake an event wait (`-[NSEvent _postAtStart:]` calls `CFRunLoopStop`); the
/// stop stayed on the outermost loop, which returned at the test's next suspension. The whole
/// CmuxNextAppTests process then ended with exit 0 and no summary, hundreds of tests unfinished
/// (the PR job's "no Test run with summary line", 2026-10-21). Here the click runs in a nested
/// pass of the main run loop, which a stop left by the click ends as stopped.
/// (the PR job's "no Test run with summary line", 2026-10-21).
///
/// This asserted the property while leaving it to each caller to hold, and the other test in
/// this suite did not: it called `click` seven times bare and ended the process again on
/// 2026-10-26, 2303 tests unfinished. `click` and `key` now run their whole body in a nested
/// pass themselves, so this reads as it always should have: the pass around a click ends
/// FINISHED, because the click absorbed its own stop rather than because this caller wrapped
/// it. A regression that takes the pass back out of the helper fails here.
@Test func aClickLeavesTheRunLoopItRanInRunning() async throws {
let h = try await Self.harness()
defer { h.close() }
Expand All @@ -147,8 +173,13 @@ import Testing
/// `.stopped` when `body` stopped it, else `.finished`. The pass runs in a mode of its own, so
/// it runs `body` and nothing else (a default-mode pass would also run other suites' main-actor
/// jobs, and end as handled when it drained them), and the wake after `body` ends it.
private static func inANestedPass(_ body: @escaping () -> Void) -> CFRunLoopRunResult {
let mode = CFRunLoopMode("com.cmuxterm.next.tests.nested-pass" as CFString)
///
/// `named` so the harness's own pass is a different mode from the one a test puts around it:
/// `aClickLeavesTheRunLoopItRanInRunning` nests this around a click that now nests it again,
/// and a block left in one mode must never be run by the other's activation.
private static func inANestedPass(named name: String = "nested-pass",
_ body: @escaping () -> Void) -> CFRunLoopRunResult {
let mode = CFRunLoopMode("com.cmuxterm.next.tests.\(name)" as CFString)
CFRunLoopPerformBlock(CFRunLoopGetMain(), mode.rawValue) {
MainActor.assumeIsolated { body() }
CFRunLoopWakeUp(CFRunLoopGetMain())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,41 @@ struct TestProcessExitTests {
@Test func anExitOfTheTestProcessPrintsItsStack() {
#expect(cmux_next_test_exit_trace_installed() == 1)
}

/// The other way a test run ends in the middle with status 0: something stops the main run
/// loop while its outermost run is the current one. `swift_task_asyncMainDrainQueue` calls
/// `exit(0)` the moment that run returns, so the tests in flight never report and there is no
/// summary line (run 40317841968: 2793 tests started and never finished). This test takes that
/// stop for real, from a timer on the main run loop, and the assertions below run at all only
/// because the process is still alive. With `CMUX_NEXT_TEST_RUNLOOP_GUARD=0` the same binary
/// ends here instead.
@Test func aStopOfTheOutermostRunLoopDoesNotEndTheTestRun() async throws {
// Deliberately NOT a #require on the guard being installed. With
// CMUX_NEXT_TEST_RUNLOOP_GUARD=0 this test must reach the stop and the process must die of
// it, because that is the only red that proves anything: a red that only says "the guard
// was switched off" would be the switch testing itself (cmux#25391 review, 2026-10-26).
let guarded = cmux_next_test_runloop_guard_installed() == 1
// The whole design rests on the drain queue's run not being the innermost one, so that is
// checked rather than assumed: its run, plus the one the guard holds inside it. A
// toolchain that stopped running the main loop here, or ran it in a mode outside the
// common modes where the counting observer cannot see it, fails on this line and says so.
if guarded { #expect(cmux_next_test_runloop_guard_depth() >= 2) }

// Three stops, not one: the first shape of this fix survived exactly one (run 40321954380)
// and one stop would have called that a pass.
let wanted = 3
let before = cmux_next_test_runloop_guard_reentries()
defer { cmux_next_test_runloop_guard_stop_probing() }
cmux_next_test_runloop_guard_stop_outermost(Int32(wanted))
let deadline = Date().addingTimeInterval(60)
while Date() < deadline,
cmux_next_test_runloop_guard_stopped_outermost() < Int32(wanted)
|| cmux_next_test_runloop_guard_reentries() - before < Int32(wanted) {
try await Task.sleep(for: .milliseconds(20))
}
// The stops landed at all only because the process is still running to count them, and
// each was followed by a restart: survived, not merely attempted.
#expect(cmux_next_test_runloop_guard_stopped_outermost() == Int32(wanted))
#expect(cmux_next_test_runloop_guard_reentries() - before >= Int32(wanted))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ static void cmux_next_test_exit_trace(void) {
}

/// A run that lists or dumps its tests (`--list-tests`, `--dump-tests-json`) instead of running them.
static int lists_tests(void) {
/// Shared with the run-loop guard beside this file, so both read one answer.
int cmux_next_test_lists_tests(void) {
int count = *_NSGetArgc();
char **arguments = *_NSGetArgv();
for (int index = 1; index < count; index++) {
Expand All @@ -34,7 +35,7 @@ static int lists_tests(void) {
}

__attribute__((constructor)) static void cmux_next_test_exit_trace_install(void) {
installed = lists_tests() || atexit(cmux_next_test_exit_trace) == 0;
installed = cmux_next_test_lists_tests() || atexit(cmux_next_test_exit_trace) == 0;
}

int cmux_next_test_exit_trace_installed(void) {
Expand Down
Loading
Loading