diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1f217f4..a50001ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,14 +7,51 @@ on: jobs: test: - runs-on: macos-15 + # `macos-latest`, not a pinned older image. Measured 2026-10-29: that + # label is macOS 26 Arm64 with Xcode 26.6 (17F113) as its default, and it + # is what release.yml's build job has used since that workflow was + # written (`c2453cf7`, 2026-07-27). It was never pinned to the older + # image; from the repository root, this prints nothing: + # + # git log -S'macos-15' -- .github/workflows/release.yml + # + # So the app that + # ships is already compiled there, tests and all. Pinned to `macos-15` + # this gate compiled with Xcode 16.4 (16F6) / Swift 6.1.2, a toolchain + # that neither the release builder nor the development machine (Xcode + # 27.1 / Swift 6.4) uses. That is how run 40913494898 came to fail the + # build on a file which had been green locally for weeks, and run + # 40928759131 to fail the test target on five more sites in both test + # targets: the only gate on the project was reading a compiler nobody + # here builds with. + # + # A moving label can move under us. The trade taken knowingly: both + # workflows now move together, so this gate meets a new image before a + # release does, which is the ordering that catches the move instead of + # shipping it. The toolchain step below prints `xcodebuild -version` AND + # `swift --version`, so from now on the figures travel with the run + # rather than with a comment. That is how the wrong figure survived + # from 2026-09-16, the date on the toolchain step's own measurement + # below, to 2026-10-29. + runs-on: macos-latest # The test runner has hung intermittently since M20: the process sits at # 0% CPU and never finishes, so the job would burn the runner's default - # six-hour budget instead of failing. Green runs take three to four - # minutes, so 20 leaves ample room for a slow runner while turning a hang - # into a red build. This is the net, not the fix -- the cause is still - # open. - timeout-minutes: 20 + # six-hour budget instead of failing. This is the net, not the fix -- + # the cause is still open. + # + # 35, raised from 20 on 2026-10-29 together with the Unit-Tests step + # budget below; the derivation is there. This comment also read "Green + # runs take three to four minutes, so 20 leaves ample room for a slow + # runner", which had stopped being true of the job long before the image + # moved: the last green run on `macos-15` (38225383495, 2026-10-12, + # `c0762fac`) took 9 m 52 s end to end (08:44:12Z to 08:54:04Z), + # measured with + # + # gh run view 38225383495 --repo NoiXdev/macSCP --json jobs + # + # reading the job's own startedAt and completedAt. Three to four minutes + # was the figure for the TEST STEP in September, not for the job. + timeout-minutes: 35 env: # --------------------------------------------------------------------- # Compiler-warning budget. Raising it accepts a warning forever; read @@ -72,6 +109,11 @@ jobs: run: | set -euo pipefail xcodebuild -version + # Printed because inferring it from the Xcode version is what went + # wrong: Xcode 16.4 ships Swift 6.1.2, Xcode 27.1 ships 6.4, and a + # comment claiming one while the runner had the other cost two red + # runs on 2026-10-29. + swift --version if xcrun metal --version; then echo "Metal toolchain already present." exit 0 @@ -120,20 +162,57 @@ jobs: # by name (run 33698102652 lost 60 suite verdicts the same way). The # step below reads the file back; the artifact keeps it. # - # `timeout-minutes: 8` (docs/BACKLOG.md, "A test parked on a bare + # `timeout-minutes: 16` (docs/BACKLOG.md, "A test parked on a bare # continuation outlives its time limit"): a test parked on a bare # continuation ignores Swift Testing's own `.timeLimit` cancellation - # and would otherwise run on to the JOB's 20-minute budget above - # instead of failing near its own stated limit. Measured from the - # last three green `develop` runs before this step timeout was - # added (`gh run list --branch develop --limit 5 --json - # databaseId,conclusion`, then `gh run view --json jobs --jq - # '.jobs[].steps[] | select(.name|test("Unit-Tests")) | - # "\(.startedAt) \(.completedAt)"'`): runs 33972030162, 33959532085 - # and 33955366191 took 181 s, 230 s and 184 s — a measured maximum - # of 230 s (3 m 50 s). 8 minutes (480 s) is 2.09x that maximum, - # comfortably above the "at least 2x" floor. - timeout-minutes: 8 + # and would otherwise run on to the JOB's budget above instead of + # failing near its own stated limit. + # + # It read `timeout-minutes: 8` until 2026-10-29, derived -- the + # withdrawn wording -- from "the last three green `develop` runs + # before this step timeout was added ... runs 33972030162, + # 33959532085 and 33955366191 took 181 s, 230 s and 184 s — a + # measured maximum of 230 s (3 m 50 s). 8 minutes (480 s) is 2.09x + # that maximum". That derivation was correct on 2026-09-05 and is + # correct arithmetic still; what expired is the maximum it rests on. + # Two runs measure it, each read with + # + # gh run view --repo NoiXdev/macSCP --log + # + # which prints the two `Build complete! (s)` lines (the Build + # step's, then `swift test`'s own compile of the test targets) and, + # on a run that reaches the end, the `Test run with tests passed + # after seconds` line -- which the killed run below never + # printed, hence its missing run figure: + # + # 38225383495, 2026-10-12, `c0762fac`, macos-15, GREEN: + # 240.40s build, 244.42s test-target compile, + # 6821 tests in 78.397 s -- whole step 329 s, i.e. 1.46x inside + # the 480 s budget, not the 2.09x above. + # 40933631584, 2026-10-29, `9c8d3673`, macos-latest, KILLED here: + # 230.46s build, 392.46s test-target compile. The first test did + # not start until 397 s (6 m 37 s) into the step, leaving 83 s + # of the 480 s for the whole suite; the kill landed 96 s after + # the first test started. How many tests the current tree has is + # not measured -- the run never reached a total. + # + # The two compile figures are NOT a toolchain comparison and must + # not be read as one. 28,721 lines of test code and 17,144 lines of + # source landed between those two commits: + # + # git diff --shortstat c0762fac..dcb5a414 -- Tests/ + # git diff --shortstat c0762fac..dcb5a414 -- Sources/ + # + # Note which way that cuts on the build half: Sources grew by those + # 17,144 lines and the Build step still came back 240.40s -> 230.46s. + # + # 16 minutes (960 s) is 2.04x of 392 s + 78 s = 470 s -- this + # image's measured compile plus the OTHER image's measured run, + # because this image's run figure does not exist yet: the step was + # killed before the suite finished. The first green run on this + # image replaces that addend and this number with its own + # measurement. + timeout-minutes: 16 run: | set -o pipefail swift test --event-stream-output-path "$RUNNER_TEMP/test-events.jsonl" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 83e0803d..1d970069 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,6 +126,11 @@ jobs: run: | set -euo pipefail xcodebuild -version + # Printed because inferring it from the Xcode version is what went + # wrong: Xcode 16.4 ships Swift 6.1.2, Xcode 27.1 ships 6.4, and a + # comment claiming one while the runner had the other cost two red + # runs on 2026-10-29. + swift --version if xcrun metal --version; then echo "Metal toolchain already present." exit 0 diff --git a/CLAUDE.md b/CLAUDE.md index 75071196..e153a9d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,10 +200,58 @@ cooperative pool never gave the probe's body a thread, and a body that never began did not overrun anything. The case asserts that the deadline settled the row, which is the property it always existed for. +**2026-10-30 update: `.timeLimit` is a wall-clock ceiling too, and on a +saturated runner it fires at random.** Two runs of the gate on +`macos-latest`, two commits apart, same image, the suites these three +cases live in still bounded at `.minutes(1)`: + +| case, bound 60 s | run 40965600726 | run 41030496532 | +|---|---|---| +| `cancelAllWithParallelRunners()` | passed after 70.097 s | failed after 100.325 s | +| `cancelAllCancelsQueuedAndRunning()` | passed after 72.676 s | failed after 100.335 s | +| `sendPreservesFIFOOrderUnderVaryingLatency()` | passed after 89.332 s | failed after 100.360 s | + +All three ran past their own 60 s bound in BOTH runs and reddened only in +the second. Run 41030496532 recorded 148 `Time limit was exceeded: 60.000 +seconds` issues across 73 tests in 11 suites; run 40965600726, which +reached the end of the suite, recorded none. Of the 74 tests that recorded +any issue in the later run, 73 recorded that one, so there was no second +cause — the one that did not is `ConnectMainActorLivenessTests`' own +warning-severity report, and that test passed. + +Why the figures read like that: Swift Testing runs the suite in parallel +with no concurrency limit, the cooperative pool is as wide as the machine +has cores (three here), and all 7975 tests are in flight at once. So +`Test X passed after N seconds` is the run's wall clock as seen from X, +not X's own work. In run 41030496532, of 6911 cases carrying a duration +line, 6843 reported over 10 s, 6335 over 30 s and 1153 over 60 s, with 45 +under 1 s, against a run total of 131.530 s. A bound below the run's own +duration therefore does not measure the test at all, and whether it fires +depends on whether the timer enforcing it gets a thread — the same +starvation the 2026-09-19 update above measured on `DetachedProbe`'s +`DispatchQueue.global()` timer. + +So a `.timeLimit` has to sit ABOVE the whole suite's wall clock to be a +hang bound, and below the job's step budget to be worth having at all: +past the step budget the step is killed and the test's name is lost, which +is the `docs/BACKLOG.md` row "A test parked on a bare continuation +outlives its time limit". The 110 `.minutes(1)` bounds in `Tests/` were +raised to `.minutes(5)` on 2026-10-30 — 2.3x the 131.530 s run against +0.31x the 960 s step budget. + +Two limits of that fix, both unmeasured and left alone deliberately. It +couples the bound to the suite's size: a run twice as long makes five +minutes marginal again, so the figure above is a measurement with a shelf +life, not a constant. And the 27 `.minutes(2)` bounds were not touched — +no run has recorded one firing, though 120 s is 0.91x this run's total, +which makes them the next candidates rather than a cleared set. + Rule: assert the outcome and the ordering a bound is supposed to produce, not how long producing it took. A floor (`elapsed >= …`, "this did not return early") is fine — a slow machine cannot defeat it, only a -broken one. A ceiling always can. +broken one. A ceiling always can — including the one a `.timeLimit` +states, which is a ceiling on a clock no assertion chose, and which the +2026-10-30 update above measures firing on healthy tests. ## Architecture invariants @@ -435,6 +483,29 @@ own: the thing it scans is there at all. Without one it is not a guard, it is a comment that runs. + **This binds a check run once at a keyboard exactly as it binds one in + the suite**, measured 2026-10-30 on the CI warning gate. The gate's own + pattern was run against a saved `gh run view --log` to predict whether + `MAX_WARNINGS=0` would pass, which needs the log's + `\t\t ` prefix stripped first, and the strip was + written `sed -E 's/^[a-z]*\t[^\t]*\t[0-9T:.Z-]+ //'`. Outside a bracket + expression `\t` is a tab; INSIDE one it is a backslash and the letter + `t`, so `[^\t]*` excluded `t` and could not cross a step named + `Unit-Tests`. Nothing was stripped, the following `^`-anchored grep + matched nothing, and nothing-matched was reported as no-warnings. The + log held the warning 25 times. One line separates the two readings, and + it is the positive control this rule asks for: + + printf 'x\tUnit\tY\n' | sed -E 's/^x\t[^\t]*\tY$/STRIPPED/' + printf 'x\tUnit\tY\n' | sed -E 's/^x\t[^[:space:]]*\tY$/STRIPPED/' + + The first prints the line unchanged, the second prints `STRIPPED`. Same + family as the `grep`/ugrep `$` case under point 3 below — a pattern that + silently matches nothing turns a check into an attestation — and the + same remedy: before believing a zero, feed the pattern something it MUST + match and watch it match. The corrected strip reproduces the figure the + gate itself printed, 1 unique location, where the original reported 0. + 2. **A guard that spells a symbol it could read instead is waiting for a rename.** Walk the name back from a call site, derive a catalogue key from the type that owns it. A literal is a second copy of a name, and diff --git a/Tests/macSCPAppKitTests/AlreadyOpenSessionTests.swift b/Tests/macSCPAppKitTests/AlreadyOpenSessionTests.swift index 07e2862f..f2ea881d 100644 --- a/Tests/macSCPAppKitTests/AlreadyOpenSessionTests.swift +++ b/Tests/macSCPAppKitTests/AlreadyOpenSessionTests.swift @@ -31,7 +31,7 @@ import Testing /// these tests leave in the strip is one of their own, built around /// `StartRecorder`, so a start that DOES go ahead is observed instead of /// dialed for real. -@Suite("Session already open", .timeLimit(.minutes(1))) +@Suite("Session already open", .timeLimit(.minutes(5))) @MainActor struct AlreadyOpenSessionTests { private func makeTempDirectory(_ label: String) -> URL { diff --git a/Tests/macSCPAppKitTests/ArchivePresentationTests.swift b/Tests/macSCPAppKitTests/ArchivePresentationTests.swift index 3fd76ce3..06edefd0 100644 --- a/Tests/macSCPAppKitTests/ArchivePresentationTests.swift +++ b/Tests/macSCPAppKitTests/ArchivePresentationTests.swift @@ -6,7 +6,7 @@ import Testing /// What the pane SAYS about an archive operation: one sentence per ending, /// one per refusal, and none of them built from a tool's output. -@Suite(.timeLimit(.minutes(1))) +@Suite(.timeLimit(.minutes(5))) struct ArchivePresentationTests { @Test func aCancelAndASuccessSayNothing() { #expect(ArchivePresentation.message(for: ArchiveActivity.Ending.finished) == nil) diff --git a/Tests/macSCPAppKitTests/BucketObjectLockSheetTests.swift b/Tests/macSCPAppKitTests/BucketObjectLockSheetTests.swift index cd3b061c..2f2a82e4 100644 --- a/Tests/macSCPAppKitTests/BucketObjectLockSheetTests.swift +++ b/Tests/macSCPAppKitTests/BucketObjectLockSheetTests.swift @@ -371,7 +371,7 @@ struct BucketObjectLockSheetTests { /// With a hang bound, for the reason given on the per-file sheet's twin of /// this test (`ObjectLockSheetTests.aSecondRunWhileOneIsInFlightSendsNothing`). - @MainActor @Test(.timeLimit(.minutes(1))) func aSecondSaveWhileOneIsInFlightSendsNothing() async { + @MainActor @Test(.timeLimit(.minutes(5))) func aSecondSaveWhileOneIsInFlightSendsNothing() async { let entered = AsyncSignal() let release = AsyncSignal() defer { release.signal() } diff --git a/Tests/macSCPAppKitTests/BucketVersioningSheetTests.swift b/Tests/macSCPAppKitTests/BucketVersioningSheetTests.swift index 90b9f921..cb6c291c 100644 --- a/Tests/macSCPAppKitTests/BucketVersioningSheetTests.swift +++ b/Tests/macSCPAppKitTests/BucketVersioningSheetTests.swift @@ -293,7 +293,7 @@ struct BucketVersioningSheetTests { /// while the versioning read has already returned; in that window the sheet /// must still be `.reading`, not a settled sheet with no button. Only when /// the lock answer is in does the load settle, with both. - @MainActor @Test(.timeLimit(.minutes(1))) + @MainActor @Test(.timeLimit(.minutes(5))) func theVersioningAnswerDoesNotSettleBeforeTheLockAnswer() async { let entered = AsyncSignal() let release = AsyncSignal() diff --git a/Tests/macSCPAppKitTests/ComparisonFetchTests.swift b/Tests/macSCPAppKitTests/ComparisonFetchTests.swift index d8396151..828b5b65 100644 --- a/Tests/macSCPAppKitTests/ComparisonFetchTests.swift +++ b/Tests/macSCPAppKitTests/ComparisonFetchTests.swift @@ -639,7 +639,7 @@ struct ComparisonFetchTests { /// an arrow key. Built from a real queue and a real cancel; every /// postcondition is read BEFORE the parked read is let go, because the /// read ending would make the queue look tidy whatever the cancel did. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func aDiffWhoseTaskIsCancelledMidFetchLeavesNoTransferRunning() async throws { let root = try makeTemporaryRoot() defer { try? FileManager.default.removeItem(at: root) } @@ -725,7 +725,7 @@ struct ComparisonFetchTests { /// /// Every postcondition is read BEFORE the parked read is let go: the read /// ending would make the queue tidy whatever the cancel did. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func aSettleInFlightIsCancelledByTheComparisonsOwnCancel() async throws { let root = try makeTemporaryRoot() defer { try? FileManager.default.removeItem(at: root) } @@ -784,7 +784,7 @@ struct ComparisonFetchTests { /// started the same way and NOT cancelled, settles the row and reads both /// sides. Without this, a `startTrackedWork` that cancelled every task it /// was handed would pass the test above. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func aSettleNobodyCancelsSettlesTheRowAndReadsBothSides() async throws { let root = try makeTemporaryRoot() defer { try? FileManager.default.removeItem(at: root) } @@ -812,7 +812,7 @@ struct ComparisonFetchTests { /// cannot be satisfied by the other. Here the left file arrives and /// finishes, the right read is parked, and the task is cancelled — which /// is what selecting a row, waiting a moment and arrowing on does. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func aDiffWhoseTaskIsCancelledDuringTheRightDownloadLeavesNoTransferRunning() async throws { let root = try makeTemporaryRoot() defer { try? FileManager.default.removeItem(at: root) } @@ -849,7 +849,7 @@ struct ComparisonFetchTests { } /// The click route to the same fetch. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func aSettleWhoseTaskIsCancelledMidFetchLeavesNoTransferRunning() async throws { let root = try makeTemporaryRoot() defer { try? FileManager.default.removeItem(at: root) } diff --git a/Tests/macSCPAppKitTests/ComparisonOverviewTests.swift b/Tests/macSCPAppKitTests/ComparisonOverviewTests.swift index 9572b3bc..59446d52 100644 --- a/Tests/macSCPAppKitTests/ComparisonOverviewTests.swift +++ b/Tests/macSCPAppKitTests/ComparisonOverviewTests.swift @@ -24,7 +24,7 @@ import macSCPCore /// `en.lproj` separately, by reading the file. /// /// No test asserts how long anything took. -@Suite("Comparison overview", .timeLimit(.minutes(1))) +@Suite("Comparison overview", .timeLimit(.minutes(5))) @MainActor struct ComparisonOverviewTests { private typealias Presentation = ComparisonOverviewPresentation diff --git a/Tests/macSCPAppKitTests/ComparisonOwnershipTests.swift b/Tests/macSCPAppKitTests/ComparisonOwnershipTests.swift index 722f2fa3..0d1c10ff 100644 --- a/Tests/macSCPAppKitTests/ComparisonOwnershipTests.swift +++ b/Tests/macSCPAppKitTests/ComparisonOwnershipTests.swift @@ -25,7 +25,7 @@ import Testing /// No test here asserts how long anything took. A walk parked on a peer is /// modelled by a call that waits until it is cancelled; the suite's hang /// bound is `.timeLimit`. -@Suite("Comparison ownership", .timeLimit(.minutes(1))) +@Suite("Comparison ownership", .timeLimit(.minutes(5))) @MainActor struct ComparisonOwnershipTests { diff --git a/Tests/macSCPAppKitTests/ComparisonWindowModelTests.swift b/Tests/macSCPAppKitTests/ComparisonWindowModelTests.swift index 95a70655..74d4d61a 100644 --- a/Tests/macSCPAppKitTests/ComparisonWindowModelTests.swift +++ b/Tests/macSCPAppKitTests/ComparisonWindowModelTests.swift @@ -22,7 +22,7 @@ import Testing /// Both sides are the real local file system over temporary directories, so /// a walk finishes without a network. No test asserts how long anything took; /// the suite's hang bound is `.timeLimit`. -@Suite("Comparison window model", .timeLimit(.minutes(1))) +@Suite("Comparison window model", .timeLimit(.minutes(5))) @MainActor struct ComparisonWindowModelTests { diff --git a/Tests/macSCPAppKitTests/ConnectAttemptHandoffTests.swift b/Tests/macSCPAppKitTests/ConnectAttemptHandoffTests.swift index 1ea37947..608945f8 100644 --- a/Tests/macSCPAppKitTests/ConnectAttemptHandoffTests.swift +++ b/Tests/macSCPAppKitTests/ConnectAttemptHandoffTests.swift @@ -63,7 +63,7 @@ import Testing /// from the byte comparison — parsed via `SessionStore`'s own real reader, /// which is a READ, not a write, and therefore safe regardless of what a /// broken seam did. -@Suite("Connect attempt hand-off", .timeLimit(.minutes(1))) +@Suite("Connect attempt hand-off", .timeLimit(.minutes(5))) @MainActor struct ConnectAttemptHandoffTests { /// The ONE real, on-disk file this whole suite must never write — diff --git a/Tests/macSCPAppKitTests/DetailSurfacePlanTests.swift b/Tests/macSCPAppKitTests/DetailSurfacePlanTests.swift index 2325266b..a6181633 100644 --- a/Tests/macSCPAppKitTests/DetailSurfacePlanTests.swift +++ b/Tests/macSCPAppKitTests/DetailSurfacePlanTests.swift @@ -26,7 +26,7 @@ import Testing /// raises the text is `FormFailureAlertPlanTests`' claim; that the view /// switches on this answer and on nothing else is /// `DetailSurfaceWiringGuardTests`'. -@Suite("Detail surface plan", .timeLimit(.minutes(1))) +@Suite("Detail surface plan", .timeLimit(.minutes(5))) @MainActor struct DetailSurfacePlanTests { private static let selected = StoredSession( diff --git a/Tests/macSCPAppKitTests/EmptyContainerListTests.swift b/Tests/macSCPAppKitTests/EmptyContainerListTests.swift index e2945e90..fc107fde 100644 --- a/Tests/macSCPAppKitTests/EmptyContainerListTests.swift +++ b/Tests/macSCPAppKitTests/EmptyContainerListTests.swift @@ -121,7 +121,7 @@ struct EmptyContainerListTests { /// a key added to the view and not to `allLines` is red rather than /// unmeasured. Read from comment-free source, so the list in the doc /// comment cannot stand in for the code. - @Test func everyKeyTheViewUsesIsOneTheCatalogueChecksCover() throws { + @MainActor @Test func everyKeyTheViewUsesIsOneTheCatalogueChecksCover() throws { let source = try SourceCorpus.commentFree( of: SourceCorpus.url(of: .sources) .appendingPathComponent("MacSCPAppKit/EmptyContainerListView.swift")) diff --git a/Tests/macSCPAppKitTests/ErrorNotificationTests.swift b/Tests/macSCPAppKitTests/ErrorNotificationTests.swift index b8a4cc17..db6de6cb 100644 --- a/Tests/macSCPAppKitTests/ErrorNotificationTests.swift +++ b/Tests/macSCPAppKitTests/ErrorNotificationTests.swift @@ -16,7 +16,7 @@ import UserNotifications /// are driven through the real functions that own them /// (`ContentView.handleLivenessGiveUp(_:)`, /// `ContentView.notifyTransferFailures()`, `TunnelManager`'s state mirror). -@Suite("Error notifications", .timeLimit(.minutes(1))) +@Suite("Error notifications", .timeLimit(.minutes(5))) @MainActor struct ErrorNotificationTests { diff --git a/Tests/macSCPAppKitTests/HostKeyMismatchHintGuardTests.swift b/Tests/macSCPAppKitTests/HostKeyMismatchHintGuardTests.swift index e497e7a8..adb306db 100644 --- a/Tests/macSCPAppKitTests/HostKeyMismatchHintGuardTests.swift +++ b/Tests/macSCPAppKitTests/HostKeyMismatchHintGuardTests.swift @@ -13,7 +13,7 @@ import Testing /// The negative direction (the message contains the label) is paired with /// positive checks that the label is non-empty and that the message is longer /// than the label, so the test cannot pass by matching an empty string. -@Suite("Host-key mismatch hint names real menu items", .timeLimit(.minutes(1))) +@Suite("Host-key mismatch hint names real menu items", .timeLimit(.minutes(5))) struct HostKeyMismatchHintGuardTests { private static let repoRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() diff --git a/Tests/macSCPAppKitTests/HostResolveModelTests.swift b/Tests/macSCPAppKitTests/HostResolveModelTests.swift index fea82171..98c7b071 100644 --- a/Tests/macSCPAppKitTests/HostResolveModelTests.swift +++ b/Tests/macSCPAppKitTests/HostResolveModelTests.swift @@ -22,7 +22,7 @@ import Testing /// that nothing on this path writes a known host, is /// `HostResolveWiringGuardTests`' claim, read from source. @MainActor -@Suite("Resolving the host field", .timeLimit(.minutes(1))) +@Suite("Resolving the host field", .timeLimit(.minutes(5))) struct HostResolveModelTests { private static func makeForm() -> ConnectionViewModel { ConnectionViewModel(connector: { _, _ in diff --git a/Tests/macSCPAppKitTests/JumpTwoTabsTests.swift b/Tests/macSCPAppKitTests/JumpTwoTabsTests.swift index 14bbc79a..71f5eb92 100644 --- a/Tests/macSCPAppKitTests/JumpTwoTabsTests.swift +++ b/Tests/macSCPAppKitTests/JumpTwoTabsTests.swift @@ -55,7 +55,7 @@ import Testing /// That case makes a real loopback TCP dial on purpose, and the port is /// asked for rather than spelled so that no listener of the machine's can /// change its verdict. -@Suite("Two tabs through one jump", .timeLimit(.minutes(1))) +@Suite("Two tabs through one jump", .timeLimit(.minutes(5))) @MainActor struct JumpTwoTabsTests { // MARK: - Fixtures diff --git a/Tests/macSCPAppKitTests/KeyPassphraseActionsTests.swift b/Tests/macSCPAppKitTests/KeyPassphraseActionsTests.swift index f890873b..cd22ed06 100644 --- a/Tests/macSCPAppKitTests/KeyPassphraseActionsTests.swift +++ b/Tests/macSCPAppKitTests/KeyPassphraseActionsTests.swift @@ -24,7 +24,7 @@ import Testing /// members of a `View` and inherit its isolation; calling them from a /// nonisolated suite compiles with an `#ActorIsolatedCall` warning, and the /// warning budget here is zero. -@Suite("Managed key passphrase actions", .timeLimit(.minutes(1))) +@Suite("Managed key passphrase actions", .timeLimit(.minutes(5))) @MainActor struct KeyPassphraseActionsTests { private static let sourceFile = SourceCorpus.url(of: .sources) diff --git a/Tests/macSCPAppKitTests/LivenessProbeCancellationTests.swift b/Tests/macSCPAppKitTests/LivenessProbeCancellationTests.swift index 7a73cb48..fa0b1a39 100644 --- a/Tests/macSCPAppKitTests/LivenessProbeCancellationTests.swift +++ b/Tests/macSCPAppKitTests/LivenessProbeCancellationTests.swift @@ -34,7 +34,7 @@ import Testing /// file system this suite hands its sessions never answers and never touches /// disk, and the deadline is the shortest `probeTimeout(forInterval:)` can /// produce. -@Suite("Liveness probe cancellation", .timeLimit(.minutes(1))) +@Suite("Liveness probe cancellation", .timeLimit(.minutes(5))) @MainActor struct LivenessProbeCancellationTests { /// The shortest deadline the shipped policy can produce diff --git a/Tests/macSCPAppKitTests/LivenessProbeCauseTests.swift b/Tests/macSCPAppKitTests/LivenessProbeCauseTests.swift index 7e9cfc9f..eaa047e2 100644 --- a/Tests/macSCPAppKitTests/LivenessProbeCauseTests.swift +++ b/Tests/macSCPAppKitTests/LivenessProbeCauseTests.swift @@ -11,7 +11,7 @@ import Testing /// /// Isolation as in `LivenessProbeCancellationTests`: no `ContentView`, no /// store, no network. -@Suite("Liveness probe cause", .timeLimit(.minutes(1))) +@Suite("Liveness probe cause", .timeLimit(.minutes(5))) @MainActor struct LivenessProbeCauseTests { @Test func aThrownConnectionLossIsRecordedAsAClosedConnection() async { @@ -166,7 +166,7 @@ private final class ScriptedStatFileSystem: RemoteFileSystem, @unchecked Sendabl /// place every disconnect goes through, and it is where the App stops /// listening — so a close arriving afterwards cannot write a line naming a /// tab whose session is gone. -@Suite("Close report ends at teardown", .timeLimit(.minutes(1))) +@Suite("Close report ends at teardown", .timeLimit(.minutes(5))) @MainActor struct CloseReportTeardownTests { @Test func teardownStopsTheCloseReport() async { diff --git a/Tests/macSCPAppKitTests/LivenessProbeRaceTests.swift b/Tests/macSCPAppKitTests/LivenessProbeRaceTests.swift index 2e5865d5..783d10bb 100644 --- a/Tests/macSCPAppKitTests/LivenessProbeRaceTests.swift +++ b/Tests/macSCPAppKitTests/LivenessProbeRaceTests.swift @@ -90,7 +90,7 @@ struct LivenessProbeRaceTests { // whole suite, not this race. Raising the number would only move the // hostage line; `.timeLimit` states the actual claim — this must not // hang — and lets the harness enforce it independently of contention. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func aStatThatNeverRespondsStillReportsFailureWithinTheDeadline() async { let fs = NeverRespondingFileSystem() let start = ContinuousClock.now diff --git a/Tests/macSCPAppKitTests/ObjectLockSheetTests.swift b/Tests/macSCPAppKitTests/ObjectLockSheetTests.swift index 2c9d2669..f68ce1b0 100644 --- a/Tests/macSCPAppKitTests/ObjectLockSheetTests.swift +++ b/Tests/macSCPAppKitTests/ObjectLockSheetTests.swift @@ -733,7 +733,7 @@ struct ObjectLockSheetTests { /// 2026-10-21 by planting out `guard canRun` in `run`, which left the run /// parked for ten minutes with no verdict. The limit turns that into a red; /// the `defer` lets go of anything still held when the test ends either way. - @MainActor @Test(.timeLimit(.minutes(1))) func aSecondRunWhileOneIsInFlightSendsNothing() async { + @MainActor @Test(.timeLimit(.minutes(5))) func aSecondRunWhileOneIsInFlightSendsNothing() async { let entered = AsyncSignal() let release = AsyncSignal() defer { release.signal() } diff --git a/Tests/macSCPAppKitTests/QuitSequenceTests.swift b/Tests/macSCPAppKitTests/QuitSequenceTests.swift index 225d88b6..b24e32c5 100644 --- a/Tests/macSCPAppKitTests/QuitSequenceTests.swift +++ b/Tests/macSCPAppKitTests/QuitSequenceTests.swift @@ -23,7 +23,8 @@ import Testing /// wiring guards read a view body. Every positional pin below is also a /// presence check: a needle that has moved out of the body fails on "not /// found" before any ordering claim is made. -/// `.timeLimit(.minutes(1))` since fix round 2 of the port-forwarding +/// `.timeLimit(.minutes(5))` — raised from one minute on 2026-10-30, and a +/// bound here at all since fix round 2 of the port-forwarding /// plan's Task 6: this suite now drives `BoundedStep` against work that /// parks and cannot be cancelled, and it reads the outcome through a /// `pollUntil` — which carries no deadline of its own, deliberately, so what @@ -31,7 +32,7 @@ import Testing /// .everyCallerOfPollUntilDeclaresATimeLimit` requires it, and without it a /// version that does not bound HANGS the run instead of failing it — /// measured, by planting exactly that). -@Suite("Quit sequence", .timeLimit(.minutes(1))) +@Suite("Quit sequence", .timeLimit(.minutes(5))) @MainActor struct QuitSequenceTests { diff --git a/Tests/macSCPAppKitTests/ReconnectPathTests.swift b/Tests/macSCPAppKitTests/ReconnectPathTests.swift index 7d79f16b..1d5e0aa7 100644 --- a/Tests/macSCPAppKitTests/ReconnectPathTests.swift +++ b/Tests/macSCPAppKitTests/ReconnectPathTests.swift @@ -22,7 +22,7 @@ import Testing /// see that file's own "Isolation, round 3" — and /// `theRealSessionsFileIsNeverTouched` proves the isolation empirically /// rather than by reading the code. -@Suite("Reconnect path", .timeLimit(.minutes(1))) +@Suite("Reconnect path", .timeLimit(.minutes(5))) @MainActor struct ReconnectPathTests { private static var realSessionsFileURL: URL { diff --git a/Tests/macSCPAppKitTests/SSHTerminalViewSizingTests.swift b/Tests/macSCPAppKitTests/SSHTerminalViewSizingTests.swift index e1293deb..aa45473d 100644 --- a/Tests/macSCPAppKitTests/SSHTerminalViewSizingTests.swift +++ b/Tests/macSCPAppKitTests/SSHTerminalViewSizingTests.swift @@ -23,7 +23,7 @@ import macSCPCore /// /// The three tests below take the three hops apart, so a red run names the /// hop rather than the symptom. -@Suite("Terminal resize measurement", .serialized, .timeLimit(.minutes(1))) +@Suite("Terminal resize measurement", .serialized, .timeLimit(.minutes(5))) @MainActor struct SSHTerminalViewSizingTests { diff --git a/Tests/macSCPAppKitTests/SnippetAfterConnectSequenceTests.swift b/Tests/macSCPAppKitTests/SnippetAfterConnectSequenceTests.swift index 66bde2fc..4801eb66 100644 --- a/Tests/macSCPAppKitTests/SnippetAfterConnectSequenceTests.swift +++ b/Tests/macSCPAppKitTests/SnippetAfterConnectSequenceTests.swift @@ -35,7 +35,7 @@ import Testing /// the maintainer's real session store and real Keychain. The tab the window /// starts with is replaced by one of this suite's own, so the only connector /// reachable from `tabsModel` is the fake. -@Suite("Snippet after connect", .timeLimit(.minutes(1))) +@Suite("Snippet after connect", .timeLimit(.minutes(5))) @MainActor struct SnippetAfterConnectSequenceTests { // MARK: - Harness diff --git a/Tests/macSCPAppKitTests/StoreReloadOnActivationGuardTests.swift b/Tests/macSCPAppKitTests/StoreReloadOnActivationGuardTests.swift index 540d4ea1..887f5db1 100644 --- a/Tests/macSCPAppKitTests/StoreReloadOnActivationGuardTests.swift +++ b/Tests/macSCPAppKitTests/StoreReloadOnActivationGuardTests.swift @@ -477,7 +477,7 @@ struct StoreReloadOnActivationGuardTests { /// `TabRegistry()` instances of their own, never `.shared` — the rule that /// type's own doc comment states, so a registration here can never leak into /// another test. -@Suite("Every open window's session list", .timeLimit(.minutes(1))) +@Suite("Every open window's session list", .timeLimit(.minutes(5))) @MainActor struct SessionListRegistrationTests { diff --git a/Tests/macSCPAppKitTests/TabRegistryTests.swift b/Tests/macSCPAppKitTests/TabRegistryTests.swift index 92813cc8..fab5d8e9 100644 --- a/Tests/macSCPAppKitTests/TabRegistryTests.swift +++ b/Tests/macSCPAppKitTests/TabRegistryTests.swift @@ -321,7 +321,7 @@ private final class UnreachedSecretStore: SecretStore, @unchecked Sendable { /// /// `TabRegistry()` instances of their own, never `.shared` — the rule that /// type's own doc comment states. -@Suite("A tab's terminal type follows the tab", .timeLimit(.minutes(1))) +@Suite("A tab's terminal type follows the tab", .timeLimit(.minutes(5))) @MainActor struct TerminalTypeWindowScopeTests { diff --git a/Tests/macSCPAppKitTests/TabTitlePlanTests.swift b/Tests/macSCPAppKitTests/TabTitlePlanTests.swift index 7b2c5c87..d15e3a39 100644 --- a/Tests/macSCPAppKitTests/TabTitlePlanTests.swift +++ b/Tests/macSCPAppKitTests/TabTitlePlanTests.swift @@ -13,7 +13,7 @@ import Testing /// describing a session said "New Connection". That the strip and the /// window title draw this plan's answer is `TabTitleWiringGuardTests`' /// claim, not this suite's. -@Suite("Tab title plan", .timeLimit(.minutes(1))) +@Suite("Tab title plan", .timeLimit(.minutes(5))) @MainActor struct TabTitlePlanTests { private static let target = StoredSession( diff --git a/Tests/macSCPAppKitTests/TeardownStageTests.swift b/Tests/macSCPAppKitTests/TeardownStageTests.swift index 49c1a262..89bd94f8 100644 --- a/Tests/macSCPAppKitTests/TeardownStageTests.swift +++ b/Tests/macSCPAppKitTests/TeardownStageTests.swift @@ -50,7 +50,7 @@ struct TeardownStageTests { func raise() { lock.withLock { raised = true } } } - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func aStageThatNeverReturnsIsAbandoned() async { // `boundSeconds: 0` rather than the stage's own five: this asserts // the abandonment BRANCH, and spending the real bound to reach it @@ -79,7 +79,7 @@ struct TeardownStageTests { /// /// A bound forwarded as zero is caught by `finished` itself rather than /// by a clock: the bound would win the race and this would be `false`. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func aStageThatReturnsIsNotAbandonedAndDoesNotWaitOutItsBound() async { let finished = await TeardownStage.stopEditWatchers .runBounded(boundSeconds: 3600) {} @@ -89,7 +89,7 @@ struct TeardownStageTests { /// The property the whole per-stage design exists for, and the one a /// single bound around all of `teardown` would not have: giving up on /// one stage costs that stage only. Teardown runs to its end. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func abandoningOneStageStillLetsTheNextOneRun() async { let ran = Flag() let abandoned = await TeardownStage.stopEditWatchers diff --git a/Tests/macSCPAppKitTests/TunnelDockPresenceTests.swift b/Tests/macSCPAppKitTests/TunnelDockPresenceTests.swift index ee411581..9865dd6c 100644 --- a/Tests/macSCPAppKitTests/TunnelDockPresenceTests.swift +++ b/Tests/macSCPAppKitTests/TunnelDockPresenceTests.swift @@ -21,7 +21,7 @@ import Testing /// The manager runs on `TunnelManagerTests.Rig` — the same fake runners, the /// same real `TunnelStore` on a temporary directory — reused rather than /// copied. -@Suite("Tunnel Dock presence", .timeLimit(.minutes(1))) +@Suite("Tunnel Dock presence", .timeLimit(.minutes(5))) @MainActor struct TunnelDockPresenceTests { diff --git a/Tests/macSCPAppKitTests/TunnelHostKeyPromptBridgeTests.swift b/Tests/macSCPAppKitTests/TunnelHostKeyPromptBridgeTests.swift index c2736194..b51c0f4b 100644 --- a/Tests/macSCPAppKitTests/TunnelHostKeyPromptBridgeTests.swift +++ b/Tests/macSCPAppKitTests/TunnelHostKeyPromptBridgeTests.swift @@ -28,7 +28,7 @@ import Testing /// of these tests hang until the probe was killed by hand, with no verdict /// at all. Polling for the recorded answer is cancellable, so the same plant /// is red instead. -@Suite("Tunnel host-key prompt bridge", .timeLimit(.minutes(1))) +@Suite("Tunnel host-key prompt bridge", .timeLimit(.minutes(5))) @MainActor struct TunnelHostKeyPromptBridgeTests { diff --git a/Tests/macSCPAppKitTests/TunnelManagerTests.swift b/Tests/macSCPAppKitTests/TunnelManagerTests.swift index ccc8a3b7..a2b28876 100644 --- a/Tests/macSCPAppKitTests/TunnelManagerTests.swift +++ b/Tests/macSCPAppKitTests/TunnelManagerTests.swift @@ -18,7 +18,7 @@ import Testing /// The store is a real `TunnelStore` on a temporary directory: the profiles /// the manager hands out come back through JSON, so a save that does not /// persist fails here rather than in a dev build. -@Suite("Tunnel manager", .timeLimit(.minutes(1))) +@Suite("Tunnel manager", .timeLimit(.minutes(5))) @MainActor struct TunnelManagerTests { diff --git a/Tests/macSCPCoreTests/AgentAuthTests.swift b/Tests/macSCPCoreTests/AgentAuthTests.swift index e2ed01bd..e5d567a9 100644 --- a/Tests/macSCPCoreTests/AgentAuthTests.swift +++ b/Tests/macSCPCoreTests/AgentAuthTests.swift @@ -31,11 +31,18 @@ import Testing /// (restored in each test) — serializing avoids any cross-test interleaving /// on that shared, process-global state. /// -/// `.timeLimit(.minutes(1))`: nothing here generates a key or touches a +/// `.timeLimit(.minutes(5))`: nothing here generates a key or touches a /// real network — the mock transport is the slowest thing any test drives, /// and `signTimesOutWithProtocolError` bounds its own wait with a 0.05 s -/// `signTimeout` regardless. The project default is enough. -@Suite("AgentAuth", .serialized, .timeLimit(.minutes(1))) +/// `signTimeout` regardless. That is an argument for not needing a LONG +/// bound and it still holds; it was written as "The project default is +/// enough" against a default of one minute — the withdrawn wording. One +/// minute was not above what an awaiting case here reports on a three-core +/// runner, because what it reports is the whole run's wall clock rather +/// than its own work (CLAUDE.md, "A wall-clock ceiling in a test measures +/// the runner", 2026-10-30). Five minutes now, like the rest of the tree, +/// and nothing in this suite is expected to approach it. +@Suite("AgentAuth", .serialized, .timeLimit(.minutes(5))) struct AgentAuthTests { // MARK: - Fixtures diff --git a/Tests/macSCPCoreTests/AgentEnvLockTests.swift b/Tests/macSCPCoreTests/AgentEnvLockTests.swift index 555ba97e..7e10fcc6 100644 --- a/Tests/macSCPCoreTests/AgentEnvLockTests.swift +++ b/Tests/macSCPCoreTests/AgentEnvLockTests.swift @@ -17,7 +17,7 @@ import Testing /// suites use concurrently for their own, unrelated `SSH_AUTH_SOCK` /// critical sections — sharing it here would risk this test interfering /// with (or being interfered with by) them. -@Suite("AgentEnvLock", .timeLimit(.minutes(1))) +@Suite("AgentEnvLock", .timeLimit(.minutes(5))) struct AgentEnvLockTests { /// The scenario the bug produced: A holds the lock, B queues behind /// it, B is cancelled while still queued, A releases — and a fresh C diff --git a/Tests/macSCPCoreTests/ArchiveActivityTests.swift b/Tests/macSCPCoreTests/ArchiveActivityTests.swift index 16e45936..3be843bd 100644 --- a/Tests/macSCPCoreTests/ArchiveActivityTests.swift +++ b/Tests/macSCPCoreTests/ArchiveActivityTests.swift @@ -6,7 +6,7 @@ import Foundation /// the pane reads it from SwiftUI. Nothing in here blocks the main actor -- /// every wait is an `await` on a continuation. @MainActor -@Suite(.timeLimit(.minutes(1))) +@Suite(.timeLimit(.minutes(5))) struct ArchiveActivityTests { /// A runner that parks until the test releases it, so the running state /// can be read WHILE it is running. Parked on `AsyncSignal`, awaited -- diff --git a/Tests/macSCPCoreTests/ArchiveCommandChannelTests.swift b/Tests/macSCPCoreTests/ArchiveCommandChannelTests.swift index b51ef1b0..01f8aedc 100644 --- a/Tests/macSCPCoreTests/ArchiveCommandChannelTests.swift +++ b/Tests/macSCPCoreTests/ArchiveCommandChannelTests.swift @@ -47,7 +47,7 @@ final actor FailingArchiveChannel: ArchiveCommandChannel { } } -@Suite(.timeLimit(.minutes(1))) +@Suite(.timeLimit(.minutes(5))) struct ArchiveCommandChannelTests { @Test func aChannelIsHandedTheLineAndTheBytesSeparately() async throws { let channel = RecordingArchiveChannel() diff --git a/Tests/macSCPCoreTests/ArchiveCommandLineTests.swift b/Tests/macSCPCoreTests/ArchiveCommandLineTests.swift index 872a1b92..6d903677 100644 --- a/Tests/macSCPCoreTests/ArchiveCommandLineTests.swift +++ b/Tests/macSCPCoreTests/ArchiveCommandLineTests.swift @@ -2,7 +2,7 @@ import Testing import Foundation @testable import macSCPCore -@Suite(.timeLimit(.minutes(1))) +@Suite(.timeLimit(.minutes(5))) struct ArchiveCommandLineTests { private func file(_ name: String) -> RemoteFileItem { RemoteFileItem(name: name, path: "/d/" + name, kind: .file) diff --git a/Tests/macSCPCoreTests/ArchiveFormatTests.swift b/Tests/macSCPCoreTests/ArchiveFormatTests.swift index aff68388..95fa0638 100644 --- a/Tests/macSCPCoreTests/ArchiveFormatTests.swift +++ b/Tests/macSCPCoreTests/ArchiveFormatTests.swift @@ -2,7 +2,7 @@ import Testing import Foundation @testable import macSCPCore -@Suite(.timeLimit(.minutes(1))) +@Suite(.timeLimit(.minutes(5))) struct ArchiveFormatTests { private func file(_ name: String) -> RemoteFileItem { RemoteFileItem(name: name, path: "/d/" + name, kind: .file) diff --git a/Tests/macSCPCoreTests/ArchiveMenuEntriesTests.swift b/Tests/macSCPCoreTests/ArchiveMenuEntriesTests.swift index e8ab292b..48f44db4 100644 --- a/Tests/macSCPCoreTests/ArchiveMenuEntriesTests.swift +++ b/Tests/macSCPCoreTests/ArchiveMenuEntriesTests.swift @@ -1,7 +1,7 @@ import Testing @testable import macSCPCore -@Suite(.timeLimit(.minutes(1))) +@Suite(.timeLimit(.minutes(5))) struct ArchiveMenuEntriesTests { private func file(_ name: String) -> RemoteFileItem { RemoteFileItem(name: name, path: "/d/" + name, kind: .file) diff --git a/Tests/macSCPCoreTests/ArchivePlanTests.swift b/Tests/macSCPCoreTests/ArchivePlanTests.swift index 4898ef2f..679ffdb5 100644 --- a/Tests/macSCPCoreTests/ArchivePlanTests.swift +++ b/Tests/macSCPCoreTests/ArchivePlanTests.swift @@ -2,7 +2,7 @@ import Testing import Foundation @testable import macSCPCore -@Suite(.timeLimit(.minutes(1))) +@Suite(.timeLimit(.minutes(5))) struct ArchivePlanTests { private func file(_ name: String) -> RemoteFileItem { RemoteFileItem(name: name, path: "/d/" + name, kind: .file) diff --git a/Tests/macSCPCoreTests/AsyncSignalTests.swift b/Tests/macSCPCoreTests/AsyncSignalTests.swift index 846ee33b..50a019c7 100644 --- a/Tests/macSCPCoreTests/AsyncSignalTests.swift +++ b/Tests/macSCPCoreTests/AsyncSignalTests.swift @@ -11,7 +11,7 @@ import Testing /// under cancellation the `Task.sleep` branch of `race` throws at once and /// wins the race before the collapsed `wait()` can answer. A property that /// another path happens to cover today is not a property that is guarded. -@Suite("AsyncSignal", .timeLimit(.minutes(1))) +@Suite("AsyncSignal", .timeLimit(.minutes(5))) struct AsyncSignalTests { /// The positive half. Without it the cancellation test below would go on /// passing against a `wait()` that answers `.cancelled` for everything. diff --git a/Tests/macSCPCoreTests/AwaitResumptionTests.swift b/Tests/macSCPCoreTests/AwaitResumptionTests.swift index 941bb1fb..3297cc6b 100644 --- a/Tests/macSCPCoreTests/AwaitResumptionTests.swift +++ b/Tests/macSCPCoreTests/AwaitResumptionTests.swift @@ -13,7 +13,7 @@ import MacSCPTestSupport /// no clock of any kind, per CLAUDE.md "A wall-clock ceiling in a test /// measures the runner": the only thing that ends a wait here is either the /// body resuming, or `.timeLimit` cancelling the suite. -@Suite("awaitResumption", .timeLimit(.minutes(1))) +@Suite("awaitResumption", .timeLimit(.minutes(5))) struct AwaitResumptionTests { private enum ProbeError: Error, Equatable { case thrown } diff --git a/Tests/macSCPCoreTests/BoundedCloseTests.swift b/Tests/macSCPCoreTests/BoundedCloseTests.swift index 573ad812..5e874813 100644 --- a/Tests/macSCPCoreTests/BoundedCloseTests.swift +++ b/Tests/macSCPCoreTests/BoundedCloseTests.swift @@ -42,7 +42,7 @@ struct BoundedCloseTests { /// contention as much as it measures this race, and a number large /// enough to be safe is too large to mean anything. "This must not /// hang" is the actual claim. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func anOperationThatNeverReturnsIsAbandonedAtTheBound() async { let startedAt = ContinuousClock.now let finished = await BoundedClose.run(boundSeconds: 1) { await self.neverReturns() } @@ -72,7 +72,7 @@ struct BoundedCloseTests { /// stall `ConnectMainActorLivenessTests` records at up to 14.67 s — so /// the ceiling was measuring the runner, exactly as CLAUDE.md's "A /// wall-clock ceiling in a test measures the runner" says. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func anOperationThatFinishesInsideTheBoundIsNotAbandoned() async { let finished = await BoundedClose.run(boundSeconds: 120) {} #expect(finished == true) diff --git a/Tests/macSCPCoreTests/BytePumpTests.swift b/Tests/macSCPCoreTests/BytePumpTests.swift index 84f5c58c..ee88cf30 100644 --- a/Tests/macSCPCoreTests/BytePumpTests.swift +++ b/Tests/macSCPCoreTests/BytePumpTests.swift @@ -24,7 +24,7 @@ import Testing /// gets its own test, and that test asserts the two channels really are /// on different loops before it asserts anything else — without that a /// same-loop run would pass while proving nothing. -@Suite("BytePump", .timeLimit(.minutes(1))) +@Suite("BytePump", .timeLimit(.minutes(5))) struct BytePumpTests { // MARK: - Same loop: EmbeddedChannel pairs diff --git a/Tests/macSCPCoreTests/CLIDiagnoseForegroundRunTests.swift b/Tests/macSCPCoreTests/CLIDiagnoseForegroundRunTests.swift index c6d93d2c..7fc8b747 100644 --- a/Tests/macSCPCoreTests/CLIDiagnoseForegroundRunTests.swift +++ b/Tests/macSCPCoreTests/CLIDiagnoseForegroundRunTests.swift @@ -12,7 +12,7 @@ import Testing /// The fake diagnoses park on an `AsyncSignal` — until they are cancelled, /// or until the case releases them — and never on a clock. The time limit /// is a hang bound. -@Suite("CLI diagnose foreground run", .timeLimit(.minutes(1))) +@Suite("CLI diagnose foreground run", .timeLimit(.minutes(5))) struct CLIDiagnoseForegroundRunTests { /// The first signal cancels the diagnosis and WAITS for it: the walk's /// cleanup — the throughput test's removal — runs, and its report is diff --git a/Tests/macSCPCoreTests/CLIMatrixITests.swift b/Tests/macSCPCoreTests/CLIMatrixITests.swift index 04dbe66a..36826a6b 100644 --- a/Tests/macSCPCoreTests/CLIMatrixITests.swift +++ b/Tests/macSCPCoreTests/CLIMatrixITests.swift @@ -3001,7 +3001,7 @@ struct CLIMatrixCoverageTests { /// cancellation, so a generous limit would hide the very regression it /// exists for. The limit is still a net rather than a measurement — nothing /// below asserts on elapsed time. -@Suite("CLIMatrixRunUntilLine", .timeLimit(.minutes(1))) +@Suite("CLIMatrixRunUntilLine", .timeLimit(.minutes(5))) struct CLIMatrixRunUntilLineTests { private static let shell = URL(fileURLWithPath: "/bin/sh") diff --git a/Tests/macSCPCoreTests/CLISecretSourcesTests.swift b/Tests/macSCPCoreTests/CLISecretSourcesTests.swift index 0e29ed05..06cc2a18 100644 --- a/Tests/macSCPCoreTests/CLISecretSourcesTests.swift +++ b/Tests/macSCPCoreTests/CLISecretSourcesTests.swift @@ -2,7 +2,7 @@ import Foundation import Testing @testable import macSCPCore -@Suite("PasswordCommandSecretSource", .timeLimit(.minutes(1))) +@Suite("PasswordCommandSecretSource", .timeLimit(.minutes(5))) struct PasswordCommandSecretSourceTests { @Test func returnsTheCommandsOutputWithTrailingNewlineStripped() throws { let source = PasswordCommandSecretSource(command: "echo hunter2") diff --git a/Tests/macSCPCoreTests/CLITunnelForegroundRunTests.swift b/Tests/macSCPCoreTests/CLITunnelForegroundRunTests.swift index 311b2533..961e9a4b 100644 --- a/Tests/macSCPCoreTests/CLITunnelForegroundRunTests.swift +++ b/Tests/macSCPCoreTests/CLITunnelForegroundRunTests.swift @@ -17,7 +17,7 @@ import Testing /// /// Every wait is an `await` on `pollUntil` (CLAUDE.md, "Tests never block /// the cooperative pool"), and nothing here asserts an elapsed time. -@Suite("CLI tunnels start foreground loop", .timeLimit(.minutes(1))) +@Suite("CLI tunnels start foreground loop", .timeLimit(.minutes(5))) struct CLITunnelForegroundRunTests { // MARK: - The clean sequence diff --git a/Tests/macSCPCoreTests/ConnectFailureSecrecyTests.swift b/Tests/macSCPCoreTests/ConnectFailureSecrecyTests.swift index 530ad03b..9e28bec7 100644 --- a/Tests/macSCPCoreTests/ConnectFailureSecrecyTests.swift +++ b/Tests/macSCPCoreTests/ConnectFailureSecrecyTests.swift @@ -22,7 +22,7 @@ import Testing /// `jumpAwareFailedState` and adds nothing to the text. Driving `connect()` /// itself would additionally require a resolved form, and resolving a form /// means a secret store; these tests deliberately touch none. -@Suite("Connect failure secrecy", .timeLimit(.minutes(1))) +@Suite("Connect failure secrecy", .timeLimit(.minutes(5))) struct ConnectFailureSecrecyTests { /// Values that must never appear in a failure text. Each one is handed /// to the code that throws, in the field that really carries it. diff --git a/Tests/macSCPCoreTests/ConnectMainActorLivenessTests.swift b/Tests/macSCPCoreTests/ConnectMainActorLivenessTests.swift index 849ad8ba..196e55f5 100644 --- a/Tests/macSCPCoreTests/ConnectMainActorLivenessTests.swift +++ b/Tests/macSCPCoreTests/ConnectMainActorLivenessTests.swift @@ -219,14 +219,173 @@ struct ConnectMainActorLivenessTests { /// /// What this does NOT bound is the instrument's own floor as measured /// on 2026-08-28 and recorded at the top of this file: 4.7 s with a - /// bare ticker. That is above the cap, so a run in that regime is a - /// false red rather than a blind pass — the direction this suite - /// prefers, since a false red is visible and a blind pass is not. No - /// run of the rebuilt assertion has reached it (2026-09-02). + /// bare ticker. That is above the cap, so a run in that regime cannot + /// return a verdict either way: a gap under the ceiling is unreachable + /// and a gap over it separates nothing. This paragraph went on to read + /// "so a run in that regime is a false red rather than a blind pass — + /// the direction this suite prefers, since a false red is visible and a + /// blind pass is not. No run of the rebuilt assertion has reached it + /// (2026-09-02)" — the withdrawn wording. A run has now reached it, and + /// what happens there is no longer a false red: see + /// `stateGapAgainstCeiling` below, which reports such a run instead of + /// reddening it. private func ceiling(forAmbient ambient: Duration) -> Duration { min(max(ambient * 3, .seconds(2)), .seconds(4)) } + /// The ambient control a run must come in under before its gap is + /// asserted on at all. Not a tuning knob: the figure is the gap between + /// two measured populations. On this development machine the control + /// reads 0.030269209 s, 0.030088625 s and 0.0310155 s (three runs, + /// 2026-10-30); across + /// three CI runs on `macos-latest` its six samples are 23.317136667, + /// 13.706875625, 5.95172925, 0.909875959, 5.696799124999999 and + /// 1.004113042 s. Half a second sits an order of magnitude above the + /// first population and below all of the second, and leaves a 5 s block + /// — the smallest this suite plants — ten times the control it would + /// have to be distinguished from. + static let resolutionFloor = Duration.milliseconds(500) + + /// States a measured gap against the ceiling — and declines to assert + /// unless the run has shown the instrument has the resolution to + /// separate a block from its own scheduling noise. + /// + /// This gate read `ambient <= bar` until 2026-10-30, explained as + /// "`ceiling(forAmbient:)` is capped at four seconds, so an ambient + /// control ABOVE that cap describes a run in which the noise alone + /// already clears the bar a block would have to clear" — the withdrawn + /// wording and the withdrawn condition. `bar` is derived FROM `ambient`, + /// so that test reduced to `ambient > 4 s`, and it let a control of one + /// second stand as adequate resolution for a three-second ceiling. + /// Run 41040277938 (2026-10-30, `c8041d19`) is where that broke: test 2 + /// printed `ambient=1.004113042 seconds gap=21.006908125 seconds + /// ceiling=3.012339126 seconds` and FAILED on the gap. Nothing was + /// blocking — the run's other 7974 tests passed. One sample of 1 s does + /// not bound the next six seconds of a runner whose noise arrives in + /// bursts, and the control and the gap are drawn at different times, so + /// a low control is not evidence of a quiet window. + /// + /// Six healthy samples over three runs, every one of them with no block + /// present, read with + /// + /// gh run view --repo NoiXdev/macSCP --log + /// + /// 40965600726 test 1 ambient 23.317 gap 4.924 ceiling 4.0 + /// 40965600726 test 2 ambient 13.707 gap 0.449 ceiling 4.0 + /// 41030496532 test 1 ambient 5.952 gap 17.047 ceiling 4.0 + /// 41030496532 test 2 ambient 0.910 gap 0.180 ceiling 2.730 + /// 41040277938 test 1 ambient 5.697 gap 8.925 ceiling 4.0 + /// 41040277938 test 2 ambient 1.004 gap 21.007 ceiling 3.012 + /// + /// The gaps span 0.180 s to 21.007 s with nothing under test — a factor + /// of 117 — against a signal of 5 s in test 1 and 6 s in test 2. Test + /// 1's control was over the 4 s cap in all three runs, so its timing + /// half has never once been in a position to give a verdict on this + /// image; the one time the old gate did assert, it was wrong. A bound + /// that both detects a 6 s block and survives 21 s of noise does not + /// exist, which is why this is a resolution gate and not a wider + /// ceiling. + /// + /// One alternative was considered and rejected for want of evidence: + /// bracketing the window with a second ambient sample afterwards and + /// taking the larger. It would have to be shown that the trailing + /// sample catches a burst the leading one missed, and run 41040277938 + /// measured no trailing sample, so adopting it would be a guess wearing + /// a measurement's clothes. + /// + /// Reported, then, rather than asserted on, following the house pattern + /// of `FileDiffCostTests.report(_:)`: `print` puts the figures in the + /// build log, which is how a CI run is read, and a warning-severity + /// issue puts them in the test report, which is how a local run is + /// read. Neither fails the test. + /// + /// What keeps this from being a check that quietly stopped checking — + /// the failure mode CLAUDE.md names under "Guards that name what they + /// watch", where a negative check that matches nothing reads exactly + /// like one that is satisfied: + /// + /// - the skip is loud. It names the case and prints both figures, so a + /// run that measured nothing says so rather than passing silently. + /// - test 1's `ranOnMainThread == false` is deterministic, not a timing + /// measurement, and still asserts. It is the half that catches the + /// connector body inheriting main-actor isolation. + /// - test 3 blocks the main actor on purpose and asserts a FLOOR on the + /// gap (`> 250 ms`). A floor cannot be defeated by a slow machine, + /// only by a broken instrument — so the ticker's own sensitivity + /// stays pinned by an assertion no load can lift, and it is the + /// positive check standing beside this negative one. + /// + /// What is NOT covered while the control is out of range: a + /// caller-side blocking bridge of the dial's full duration, the shape + /// test 3 measures directly. On such a run that shape is proven only + /// where test 3 proves it, from the main actor, and not through either + /// awaited connect. + /// + /// Measured with `scripts/mutation-probe` on 2026-10-30, two plants + /// that differ only in whether the control reads out of range: + /// + /// A. `blockThisThread(forMilliseconds: 5000)` inserted before + /// `_ = await vm.connect()` — a 5 s block of the main actor + /// inside the dial window. RESULT: RED, caught by this test. + /// D. that same insertion, PLUS one before + /// `try? await Task.sleep(for: window)` in `ambientGap(over:)`, + /// which lifts the control to ~5 s and so past the 4 s cap. + /// RESULT: GREEN. + /// + /// Same defect, same assertion; the one difference between them is the + /// control. A third plant, the ambient block on its own, is GREEN as + /// well and proves nothing either way — it never touches the dial + /// window the gap is measured over, so the assertion was never asked + /// to see it. Both probes ran `--filter + /// awaitingTheConnectorDoesNotKeepTheMainActor`; test 2 was not probed. + /// Both probes ran on this development machine, where the control reads + /// 0.03 s and so clears `resolutionFloor`, so the gate change above is + /// not expected to move either outcome, and plant A was re-run against + /// the new gate to check that rather than assume it. Measured + /// 2026-10-30, with + /// + /// scripts/mutation-probe --filter \ + /// awaitingTheConnectorDoesNotKeepTheMainActor --apply + /// + /// RESULT: RED — 1 test ran and the plant was caught by + /// `awaitingTheConnectorDoesNotKeepTheMainActor()`. Plant D was not + /// re-run: its control of ~5 s is above the old 4 s cap and the new + /// 500 ms floor alike, so nothing measured there would tell the two + /// gates apart. + /// + /// Corrected 2026-10-30: this paragraph said "test 2 is gated behind + /// `MACSCP_ITEST=1`". It is not — it carries + /// `.enabled(if: StalledLoopbackEndpoint.isAvailable)`, which is why it + /// is skipped here and runs on CI. + @MainActor + private func stateGapAgainstCeiling( + _ log: TickLog, + ambient: Duration, + test: String, + sourceLocation: SourceLocation = #_sourceLocation + ) { + let bar = ceiling(forAmbient: ambient) + let figures = "\(test) ambient=\(ambient) gap=\(log.largestGap) ceiling=\(bar)" + print("liveness: " + figures) + guard ambient <= Self.resolutionFloor else { + let text = + "liveness: \(figures) — NOT ASSERTED. The ambient control is " + + "above \(Self.resolutionFloor), so this run has not shown " + + "that the instrument can tell a block from its own " + + "scheduling noise, and the gap has no verdict to give. " + + "Measured over \(log.stamps.count) ticks." + print(text) + Issue.record( + Comment(rawValue: text), severity: .warning, + sourceLocation: sourceLocation) + return + } + #expect( + log.largestGap <= bar, + "gap \(log.largestGap) over \(log.stamps.count) ticks, ambient \(ambient)", + sourceLocation: sourceLocation) + } + // MARK: - 1. The boundary itself /// The load-bearing structural claim: `ConnectionViewModel.connect()` is @@ -282,12 +441,7 @@ struct ConnectMainActorLivenessTests { #expect(ranOnMainThread.value == false) // The dial really lasted what the ceiling is calibrated against. #expect(elapsed > .seconds(4), "the dial was only \(elapsed)") - print( - "liveness: test1 ambient=\(ambient) gap=\(log.largestGap) " - + "ceiling=\(ceiling(forAmbient: ambient))") - #expect( - log.largestGap <= ceiling(forAmbient: ambient), - "gap \(log.largestGap) over \(log.stamps.count) ticks, ambient \(ambient)") + stateGapAgainstCeiling(log, ambient: ambient, test: "test1") } // MARK: - 2. The real dial @@ -344,12 +498,7 @@ struct ConnectMainActorLivenessTests { #expect(thrown != nil) #expect(elapsed > .seconds(5)) // And the main actor ran the whole time it was stalling. - print( - "liveness: test2 ambient=\(ambient) gap=\(log.largestGap) " - + "ceiling=\(ceiling(forAmbient: ambient))") - #expect( - log.largestGap <= ceiling(forAmbient: ambient), - "gap \(log.largestGap) over \(log.stamps.count) ticks, ambient \(ambient)") + stateGapAgainstCeiling(log, ambient: ambient, test: "test2") } // MARK: - 3. The agent's blocking wait diff --git a/Tests/macSCPCoreTests/ConnectionDiagnosticsTests.swift b/Tests/macSCPCoreTests/ConnectionDiagnosticsTests.swift index 9d68778f..46d36ea8 100644 --- a/Tests/macSCPCoreTests/ConnectionDiagnosticsTests.swift +++ b/Tests/macSCPCoreTests/ConnectionDiagnosticsTests.swift @@ -176,7 +176,7 @@ struct ConnectionDiagnosticsTests { /// runner"). @Test( .enabled(if: ProcessInfo.processInfo.environment["MACSCP_NETSPIKE"] == "1"), - .timeLimit(.minutes(1))) + .timeLimit(.minutes(5))) func tcpPingToAnUnroutableAddressTimesOutInsideTheStepTimeout() async throws { let outcome = await HostResolver.resolve(host: "192.0.2.1", port: 22, timeout: .seconds(2)) guard case .resolved(let addresses) = outcome, let address = addresses.first else { @@ -1509,7 +1509,7 @@ struct ConnectionDiagnosticsTests { /// into a red rather than a hang: a step that waited for its probe /// would never return, because the gate it waits on is opened only /// after the step has returned. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func aProbeThatIgnoresCancellationDoesNotHoldTheStepPastItsDeadline() async throws { let port = try #require(LoopbackSocket.closedPort()) let release = Gate() diff --git a/Tests/macSCPCoreTests/ConnectionViewModelFailureAcknowledgementTests.swift b/Tests/macSCPCoreTests/ConnectionViewModelFailureAcknowledgementTests.swift index e178c518..3bf8d382 100644 --- a/Tests/macSCPCoreTests/ConnectionViewModelFailureAcknowledgementTests.swift +++ b/Tests/macSCPCoreTests/ConnectionViewModelFailureAcknowledgementTests.swift @@ -18,7 +18,7 @@ import Testing /// Driven through the real `connect()` and `showFailure`, as the /// `lastFailureKind` tests in `ConnectionViewModelTests` are: what matters /// is that the marker reaches the property on the path the App reads. -@Suite("Connection failure acknowledgement", .timeLimit(.minutes(1))) +@Suite("Connection failure acknowledgement", .timeLimit(.minutes(5))) @MainActor struct ConnectionViewModelFailureAcknowledgementTests { private func makeVM( diff --git a/Tests/macSCPCoreTests/ConnectionViewModelManagedKeyStoreTests.swift b/Tests/macSCPCoreTests/ConnectionViewModelManagedKeyStoreTests.swift index c46f16b3..492a434d 100644 --- a/Tests/macSCPCoreTests/ConnectionViewModelManagedKeyStoreTests.swift +++ b/Tests/macSCPCoreTests/ConnectionViewModelManagedKeyStoreTests.swift @@ -21,7 +21,7 @@ import Testing /// "the key loaded with the passphrase the form held". The key is generated /// at run time by `ssh-keygen`; the store is a temporary directory /// (`CorruptManagedKeyStoreRig`). -@Suite("ConnectionViewModel over an unreadable managed key store", .timeLimit(.minutes(1))) +@Suite("ConnectionViewModel over an unreadable managed key store", .timeLimit(.minutes(5))) @MainActor struct ConnectionViewModelManagedKeyStoreTests { private static let loadingConnector: ConnectionViewModel.Connector = { config, _ in diff --git a/Tests/macSCPCoreTests/ConnectionViewModelTests.swift b/Tests/macSCPCoreTests/ConnectionViewModelTests.swift index 31fbc2e8..59fa44f1 100644 --- a/Tests/macSCPCoreTests/ConnectionViewModelTests.swift +++ b/Tests/macSCPCoreTests/ConnectionViewModelTests.swift @@ -4,7 +4,7 @@ import MacSCPTestSupport import Testing @testable import macSCPCore -@Suite("ConnectionViewModel", .timeLimit(.minutes(1))) +@Suite("ConnectionViewModel", .timeLimit(.minutes(5))) @MainActor struct ConnectionViewModelTests { private func makeVM( diff --git a/Tests/macSCPCoreTests/DiagnosticLogSharedSinkTests.swift b/Tests/macSCPCoreTests/DiagnosticLogSharedSinkTests.swift index 207c5d11..d3576fc5 100644 --- a/Tests/macSCPCoreTests/DiagnosticLogSharedSinkTests.swift +++ b/Tests/macSCPCoreTests/DiagnosticLogSharedSinkTests.swift @@ -57,7 +57,7 @@ import Testing /// `DiagnosticLog`'s own private `yyyy-MM-dd` formatting, documented on /// its class-level doc comment as the file-naming contract) — never by /// reading the live singleton's state back. -@Suite("DiagnosticLog shared sink", .serialized, .timeLimit(.minutes(1))) +@Suite("DiagnosticLog shared sink", .serialized, .timeLimit(.minutes(5))) struct DiagnosticLogSharedSinkTests { private func makeTempDirectory() -> URL { let url = FileManager.default.temporaryDirectory diff --git a/Tests/macSCPCoreTests/DiagnosticLogTests.swift b/Tests/macSCPCoreTests/DiagnosticLogTests.swift index 8b19187d..930f72a1 100644 --- a/Tests/macSCPCoreTests/DiagnosticLogTests.swift +++ b/Tests/macSCPCoreTests/DiagnosticLogTests.swift @@ -32,12 +32,17 @@ import Testing /// file `DiagnosticLogSharedSinkIsolationGuardTests` lets mention /// `DiagnosticLog.shared`. /// -/// `.timeLimit(.minutes(1))`: nothing here waits on a wall clock — every +/// `.timeLimit(.minutes(5))`: nothing here waits on a wall clock — every /// wait is either an `await flush()` (which the writer resolves through a /// sequence number, never a timer) or a synchronous read — so the limit is -/// a backstop against a genuine hang, not a duration this suite is expected -/// to approach. -@Suite("DiagnosticLog", .timeLimit(.minutes(1))) +/// a backstop against a genuine hang. The sentence ended "not a duration +/// this suite is expected to approach", which was true of the suite's work +/// and false of what the harness reports: an awaiting case on a three-core +/// runner reports the whole run's wall clock, so at one minute this suite +/// would have approached its bound without anything here being slow +/// (CLAUDE.md, "A wall-clock ceiling in a test measures the runner", +/// 2026-10-30). +@Suite("DiagnosticLog", .timeLimit(.minutes(5))) struct DiagnosticLogTests { /// A fresh, empty directory under the system temp directory, removed by /// the caller in a `defer` — never `~/Library/Logs/macSCP` itself, so a diff --git a/Tests/macSCPCoreTests/DiagnosticsNoDescribingGuardTests.swift b/Tests/macSCPCoreTests/DiagnosticsNoDescribingGuardTests.swift index 00a57609..7f110ad0 100644 --- a/Tests/macSCPCoreTests/DiagnosticsNoDescribingGuardTests.swift +++ b/Tests/macSCPCoreTests/DiagnosticsNoDescribingGuardTests.swift @@ -84,7 +84,7 @@ import Testing /// (a path separator, a query marker, a fragment marker, and whitespace as /// the fourth test). It is not an opening raw-string delimiter, so both /// stripping modes read every file here without throwing. -@Suite("Diagnostics no String(describing:) guard", .timeLimit(.minutes(1))) +@Suite("Diagnostics no String(describing:) guard", .timeLimit(.minutes(5))) struct DiagnosticsNoDescribingGuardTests { /// `#filePath` here is /// `/Tests/macSCPCoreTests/DiagnosticsNoDescribingGuardTests.swift`; diff --git a/Tests/macSCPCoreTests/DialFailureGroupReleaseTests.swift b/Tests/macSCPCoreTests/DialFailureGroupReleaseTests.swift index 03f0fdd1..38ccb778 100644 --- a/Tests/macSCPCoreTests/DialFailureGroupReleaseTests.swift +++ b/Tests/macSCPCoreTests/DialFailureGroupReleaseTests.swift @@ -36,7 +36,7 @@ import Testing /// is not a call of the delayed release, so the spy sees nothing for it; the /// source guard `DialGroupReleaseGuardTests` pins that no such shutdown is /// written in the dial's files at all. -@Suite("Dial failure group release", .timeLimit(.minutes(1))) +@Suite("Dial failure group release", .timeLimit(.minutes(5))) struct DialFailureGroupReleaseTests { enum Path: String, CaseIterable, Sendable, CustomTestStringConvertible { case forwarding diff --git a/Tests/macSCPCoreTests/EmbeddedKeyPorterTests.swift b/Tests/macSCPCoreTests/EmbeddedKeyPorterTests.swift index bbaf432e..1904a4dd 100644 --- a/Tests/macSCPCoreTests/EmbeddedKeyPorterTests.swift +++ b/Tests/macSCPCoreTests/EmbeddedKeyPorterTests.swift @@ -3,7 +3,7 @@ import MacSCPTestSupport import Testing @testable import macSCPCore -@Suite("EmbeddedKeyPorter", .timeLimit(.minutes(1))) +@Suite("EmbeddedKeyPorter", .timeLimit(.minutes(5))) struct EmbeddedKeyPorterTests { private func tempDir() -> URL { let dir = URL(fileURLWithPath: NSTemporaryDirectory()) diff --git a/Tests/macSCPCoreTests/ExtractPreviewTests.swift b/Tests/macSCPCoreTests/ExtractPreviewTests.swift index a3db49b6..72fee89e 100644 --- a/Tests/macSCPCoreTests/ExtractPreviewTests.swift +++ b/Tests/macSCPCoreTests/ExtractPreviewTests.swift @@ -2,7 +2,7 @@ import Testing import Foundation @testable import macSCPCore -@Suite(.timeLimit(.minutes(1))) +@Suite(.timeLimit(.minutes(5))) struct ExtractPreviewTests { @Test func theListingCommandForAZipAsksUnzipForNamesOnly() throws { let plan = try ArchivePlan.listing( diff --git a/Tests/macSCPCoreTests/FileActionActivityTests.swift b/Tests/macSCPCoreTests/FileActionActivityTests.swift index 2d3396db..fa9768c9 100644 --- a/Tests/macSCPCoreTests/FileActionActivityTests.swift +++ b/Tests/macSCPCoreTests/FileActionActivityTests.swift @@ -10,7 +10,7 @@ import Testing /// injected, and the one real runner's pure half is tested through its /// `resolve` seam. @MainActor -@Suite(.timeLimit(.minutes(1))) +@Suite(.timeLimit(.minutes(5))) struct FileActionActivityTests { private static let first = FileActionInvocation(executable: "wc", arguments: ["-l", "/d/a"]) private static let second = FileActionInvocation(executable: "wc", arguments: ["-l", "/d/b"]) @@ -110,7 +110,7 @@ struct FileActionActivityTests { /// The runner here ignores the cancel, so what stops the second /// invocation is the activity's own check between the two. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func cancellingDuringARunStartsNoFurtherInvocation() async { let activity = FileActionActivity() let runner = RecordingRunner(parksFirst: true) diff --git a/Tests/macSCPCoreTests/FileDiffTests.swift b/Tests/macSCPCoreTests/FileDiffTests.swift index 5727fba5..e4e82b93 100644 --- a/Tests/macSCPCoreTests/FileDiffTests.swift +++ b/Tests/macSCPCoreTests/FileDiffTests.swift @@ -357,7 +357,7 @@ import Testing /// context and additions, and it spends no more edits than the /// shortest edit script does. Run over a two-letter alphabet, which is /// where the ties live. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func aWholeFileHunkReconstructsBothSidesWithAMinimalScript() { var rng = Lcg(state: 20_261_013) var differing = 0 @@ -393,7 +393,7 @@ import Testing /// it was, and the hunks are in order and do not overlap. And nothing is /// lost between them: `expectEveryChangeIsShownOnce` checks, against the /// whole-file script, that no change falls outside the hunks. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func everyHunkPointsAtItsOwnLinesOnBothSides() { var rng = Lcg(state: 20_261_014) var multiHunk = 0 @@ -571,7 +571,7 @@ import Testing /// Swapping the sides swaps the kinds: what one direction removes the /// other adds, and the same number of lines is touched. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func swappingTheSidesSwapsAddedAndRemoved() { var rng = Lcg(state: 20_261_015) var touched = 0 diff --git a/Tests/macSCPCoreTests/GenerateKeyFormTests.swift b/Tests/macSCPCoreTests/GenerateKeyFormTests.swift index d0300ab9..6e7f7e01 100644 --- a/Tests/macSCPCoreTests/GenerateKeyFormTests.swift +++ b/Tests/macSCPCoreTests/GenerateKeyFormTests.swift @@ -11,7 +11,7 @@ import Testing /// fields changed during the run" is the test's own sequence, not a race. /// Nothing here runs `ssh-keygen`: the generator writes two placeholder files /// where the real one would, so the tests can see whether they are removed. -@Suite("GenerateKeyForm", .timeLimit(.minutes(1))) +@Suite("GenerateKeyForm", .timeLimit(.minutes(5))) @MainActor struct GenerateKeyFormTests { /// Never written into an `#expect` expression's source text (CLAUDE.md, diff --git a/Tests/macSCPCoreTests/HostAddressLookupTests.swift b/Tests/macSCPCoreTests/HostAddressLookupTests.swift index 66dd77ee..a2044d0d 100644 --- a/Tests/macSCPCoreTests/HostAddressLookupTests.swift +++ b/Tests/macSCPCoreTests/HostAddressLookupTests.swift @@ -23,7 +23,7 @@ import Testing /// depend on how fast the runner is. The suite's time limit is the /// harness's, for a regression that stops cutting it: it turns a hang into /// a red, and asserts nothing about how long the cut took. -@Suite("Host address lookup for the connection form", .timeLimit(.minutes(1))) +@Suite("Host address lookup for the connection form", .timeLimit(.minutes(5))) struct HostAddressLookupTests { private static let roomy: Duration = .seconds(600) private static let tight: Duration = .milliseconds(200) diff --git a/Tests/macSCPCoreTests/ITermColorsImportTests.swift b/Tests/macSCPCoreTests/ITermColorsImportTests.swift index 4404253f..dd678fe4 100644 --- a/Tests/macSCPCoreTests/ITermColorsImportTests.swift +++ b/Tests/macSCPCoreTests/ITermColorsImportTests.swift @@ -21,7 +21,7 @@ import Testing /// read here. What is verified is the SHAPE described above, against files /// this suite authored and `plutil -lint` accepted — not that iTerm2's own /// export is byte-identical to them. -@Suite("iTerm2 colour import", .timeLimit(.minutes(1))) +@Suite("iTerm2 colour import", .timeLimit(.minutes(5))) struct ITermColorsImportTests { private static func fixture(_ name: String) throws -> Data { let url = URL(fileURLWithPath: #filePath) diff --git a/Tests/macSCPCoreTests/InternetSpeedLiveTransportTests.swift b/Tests/macSCPCoreTests/InternetSpeedLiveTransportTests.swift index b17a7f00..f3b2d400 100644 --- a/Tests/macSCPCoreTests/InternetSpeedLiveTransportTests.swift +++ b/Tests/macSCPCoreTests/InternetSpeedLiveTransportTests.swift @@ -24,7 +24,7 @@ import Testing /// Caching cannot cross a run here the way it can in the S3 suite: the live /// transport's session is ephemeral with `urlCache = nil`, and every /// request carries `.reloadIgnoringLocalAndRemoteCacheData`. -@Suite("The internet speed test's live transport", .timeLimit(.minutes(1))) +@Suite("The internet speed test's live transport", .timeLimit(.minutes(5))) struct InternetSpeedLiveTransportTests { /// A 307, which is the dangerous one: 301/302/303 rewrite the request /// to a GET and drop the body, while 307 and 308 preserve the method diff --git a/Tests/macSCPCoreTests/KeyPassphraseFormsTests.swift b/Tests/macSCPCoreTests/KeyPassphraseFormsTests.swift index abf62404..17949fa9 100644 --- a/Tests/macSCPCoreTests/KeyPassphraseFormsTests.swift +++ b/Tests/macSCPCoreTests/KeyPassphraseFormsTests.swift @@ -16,7 +16,7 @@ import Testing /// `Bool` computed first, so neither a value nor its spelling can reach a /// failure message (CLAUDE.md, "A value a test must not leak has two exits, /// not one"). That rule is the whole subject matter here. -@Suite("Managed key passphrase forms", .timeLimit(.minutes(1))) +@Suite("Managed key passphrase forms", .timeLimit(.minutes(5))) @MainActor struct KeyPassphraseFormsTests { /// What the key file actually opens with, before anything in a test runs. diff --git a/Tests/macSCPCoreTests/LocalFileSystemTests.swift b/Tests/macSCPCoreTests/LocalFileSystemTests.swift index e511a704..5db3e0b3 100644 --- a/Tests/macSCPCoreTests/LocalFileSystemTests.swift +++ b/Tests/macSCPCoreTests/LocalFileSystemTests.swift @@ -4,12 +4,12 @@ import MacSCPTestSupport import Testing @testable import macSCPCore -/// `.timeLimit(.minutes(1))`: the parked-entry tests below abandon a child +/// `.timeLimit(.minutes(5))`: the parked-entry tests below abandon a child /// `Task` on a `Gate` the test never opens — the ONLY clock that ends such a /// test is cancellation from this trait, never a wall-clock bound of the /// test's own (CLAUDE.md, "A wall-clock ceiling in a test measures the /// runner"). -@Suite("LocalFileSystem", .timeLimit(.minutes(1))) +@Suite("LocalFileSystem", .timeLimit(.minutes(5))) struct LocalFileSystemTests { /// Creates a throwaway tree: /unterordner/ and /datei.txt (5 bytes). private func makeTempTree() throws -> URL { diff --git a/Tests/macSCPCoreTests/LocalForwardListenerTests.swift b/Tests/macSCPCoreTests/LocalForwardListenerTests.swift index eda2578d..f460cf11 100644 --- a/Tests/macSCPCoreTests/LocalForwardListenerTests.swift +++ b/Tests/macSCPCoreTests/LocalForwardListenerTests.swift @@ -17,7 +17,7 @@ import Testing /// Every listener binds port 0 and every wait is an `await`; nothing here /// holds a fixed port or a wall-clock bound of its own (the suite's /// `.timeLimit` is the only deadline). -@Suite("LocalForwardListener", .timeLimit(.minutes(1))) +@Suite("LocalForwardListener", .timeLimit(.minutes(5))) struct LocalForwardListenerTests { @Test func bytesTravelToTheFactorysChannelAndBack() async throws { diff --git a/Tests/macSCPCoreTests/ManagedKeyStoreUnreadableTests.swift b/Tests/macSCPCoreTests/ManagedKeyStoreUnreadableTests.swift index 5059c497..87052f7c 100644 --- a/Tests/macSCPCoreTests/ManagedKeyStoreUnreadableTests.swift +++ b/Tests/macSCPCoreTests/ManagedKeyStoreUnreadableTests.swift @@ -21,7 +21,7 @@ import Testing /// named constant that no expectation spells (CLAUDE.md, "A value a test /// must not leak has two exits"): every check that the content does not /// leak computes its `Bool` first. -@Suite("An unreadable managed key store", .timeLimit(.minutes(1))) +@Suite("An unreadable managed key store", .timeLimit(.minutes(5))) struct ManagedKeyStoreUnreadableTests { // MARK: - The link remembers what it saw diff --git a/Tests/macSCPCoreTests/NetworkTraceTests.swift b/Tests/macSCPCoreTests/NetworkTraceTests.swift index 9fc10142..25a2d6ee 100644 --- a/Tests/macSCPCoreTests/NetworkTraceTests.swift +++ b/Tests/macSCPCoreTests/NetworkTraceTests.swift @@ -530,7 +530,7 @@ struct NetworkTraceTests { /// asserted before — the hops survive, and the ending names the budget — /// with no duration compared against any other duration anywhere in the /// case. - @Test(.timeLimit(.minutes(1))) + @Test(.timeLimit(.minutes(5))) func anOuterMarginOverrunKeepsTheHopsTheWalkHadMeasured() async { let measured = NetworkTraceHop( ttl: 1, outcome: .forwarded(address: "10.0.0.1", rtt: .milliseconds(2))) diff --git a/Tests/macSCPCoreTests/OneAtATimeTests.swift b/Tests/macSCPCoreTests/OneAtATimeTests.swift index 1d36990e..6ca9f8c6 100644 --- a/Tests/macSCPCoreTests/OneAtATimeTests.swift +++ b/Tests/macSCPCoreTests/OneAtATimeTests.swift @@ -5,7 +5,7 @@ import Testing /// `OneAtATime`, the latch login-set import runs through. The first run is /// held open on a signal the test raises, so "the second call arrived while /// the first was in flight" is the test's own sequence, not a race. -@Suite("OneAtATime", .timeLimit(.minutes(1))) +@Suite("OneAtATime", .timeLimit(.minutes(5))) @MainActor struct OneAtATimeTests { @Test func anOverlappingRunIsRefusedWithoutRunning() async { diff --git a/Tests/macSCPCoreTests/PKCS12ImportIsolationGuardTests.swift b/Tests/macSCPCoreTests/PKCS12ImportIsolationGuardTests.swift index 77d29a47..9465e9f6 100644 --- a/Tests/macSCPCoreTests/PKCS12ImportIsolationGuardTests.swift +++ b/Tests/macSCPCoreTests/PKCS12ImportIsolationGuardTests.swift @@ -112,7 +112,7 @@ import Testing /// stops the scan. Both cost a false alarm, neither a silent pass — and /// this paragraph is the one to distrust first, since it is the claim that /// was wrong the last time it was written. -@Suite("PKCS#12 import isolation", .timeLimit(.minutes(1))) +@Suite("PKCS#12 import isolation", .timeLimit(.minutes(5))) struct PKCS12ImportIsolationGuardTests { /// `#filePath` is /// `/Tests/macSCPCoreTests/PKCS12ImportIsolationGuardTests.swift`. diff --git a/Tests/macSCPCoreTests/PTYSubprocessTests.swift b/Tests/macSCPCoreTests/PTYSubprocessTests.swift index 49fe07d1..65e69fdb 100644 --- a/Tests/macSCPCoreTests/PTYSubprocessTests.swift +++ b/Tests/macSCPCoreTests/PTYSubprocessTests.swift @@ -9,7 +9,7 @@ import Testing /// long a case takes (CLAUDE.md, "A wall-clock ceiling in a test measures /// the runner") — every wait in here is an `await` on `PTYSubprocess.run` /// or on its `Handle.output`, never a sleep guessed to be "long enough". -@Suite("PTYSubprocess", .timeLimit(.minutes(1))) +@Suite("PTYSubprocess", .timeLimit(.minutes(5))) struct PTYSubprocessTests { /// The positive this whole file exists to establish: `/usr/bin/tty` /// prints a real `/dev/ttysNNN` path, which it does if and only if its diff --git a/Tests/macSCPCoreTests/PollUntilTests.swift b/Tests/macSCPCoreTests/PollUntilTests.swift index a5854a9a..d4b3774f 100644 --- a/Tests/macSCPCoreTests/PollUntilTests.swift +++ b/Tests/macSCPCoreTests/PollUntilTests.swift @@ -1,7 +1,7 @@ import Testing import MacSCPTestSupport -@Suite("pollUntil", .timeLimit(.minutes(1))) +@Suite("pollUntil", .timeLimit(.minutes(5))) struct PollUntilTests { /// A condition that holds on the third evaluation returns after /// exactly three evaluations — the helper polls, it does not race. diff --git a/Tests/macSCPCoreTests/PollingGuardTests.swift b/Tests/macSCPCoreTests/PollingGuardTests.swift index a2e59436..f93218fb 100644 --- a/Tests/macSCPCoreTests/PollingGuardTests.swift +++ b/Tests/macSCPCoreTests/PollingGuardTests.swift @@ -16,13 +16,13 @@ import Testing /// `.wait(until: Date(...)` (CLAUDE.md, "A wall-clock ceiling in a test /// measures the runner"). /// -/// `.timeLimit(.minutes(1))` because every check here scans every Swift +/// `.timeLimit(.minutes(5))` because every check here scans every Swift /// file under `Tests/`. The files, their text and their blanked view come /// from `SourceCorpus`, each file read and blanked at most once per test /// process; the patterns are compiled once per process (`CompiledPattern`) /// rather than on every call; and the while-block scan two checks share is /// remembered per file (`sleepingWhileBlocksByFile`). -@Suite("Polling guard", .timeLimit(.minutes(1))) +@Suite("Polling guard", .timeLimit(.minutes(5))) struct PollingGuardTests { private static var testsRoot: URL { URL(fileURLWithPath: #filePath) diff --git a/Tests/macSCPCoreTests/ProbeDeadlineTests.swift b/Tests/macSCPCoreTests/ProbeDeadlineTests.swift index 72f5ea65..ecf328f6 100644 --- a/Tests/macSCPCoreTests/ProbeDeadlineTests.swift +++ b/Tests/macSCPCoreTests/ProbeDeadlineTests.swift @@ -30,7 +30,7 @@ import Testing /// `ProbeDeadlineSaturationTests` holds it to the same contract with the /// pool full, and is gated, because a test that fills the pool ends every /// other test sharing the process. -@Suite("Probe deadlines", .timeLimit(.minutes(1))) +@Suite("Probe deadlines", .timeLimit(.minutes(5))) struct ProbeDeadlineTests { /// A scheduled deadline fires; a cancelled one does not, even when its /// own moment has passed. @@ -210,7 +210,7 @@ struct ProbeDeadlineTests { /// at all — the probe never returns while the pool is parked, and the suite's /// time limit is what ends the test. Measured 2026-09-25, both cases red /// that way before the timer was written. -@Suite("Probe deadlines under a saturated Dispatch pool", .serialized, .timeLimit(.minutes(1))) +@Suite("Probe deadlines under a saturated Dispatch pool", .serialized, .timeLimit(.minutes(5))) struct ProbeDeadlineSaturationTests { private static let gate = ProcessInfo.processInfo.environment["MACSCP_SATURATION"] == "1" private static let gateReason: Comment = diff --git a/Tests/macSCPCoreTests/ProbeStartTests.swift b/Tests/macSCPCoreTests/ProbeStartTests.swift index 9f924a90..a085d919 100644 --- a/Tests/macSCPCoreTests/ProbeStartTests.swift +++ b/Tests/macSCPCoreTests/ProbeStartTests.swift @@ -21,7 +21,7 @@ import Testing /// has not begun, whatever the machine is doing. What the cases assert is an /// outcome and an ordering; the one duration any of them reads is a FLOOR, /// which a slow machine cannot defeat. -@Suite("A probe that never began", .timeLimit(.minutes(1))) +@Suite("A probe that never began", .timeLimit(.minutes(5))) struct ProbeStartTests { /// Short, because nothing here waits for it on a clock: each case waits /// for the probe to return, however late that is. diff --git a/Tests/macSCPCoreTests/RemoteBrowserViewModelMetadataMergeTests.swift b/Tests/macSCPCoreTests/RemoteBrowserViewModelMetadataMergeTests.swift index 9e7994e1..6e483bcd 100644 --- a/Tests/macSCPCoreTests/RemoteBrowserViewModelMetadataMergeTests.swift +++ b/Tests/macSCPCoreTests/RemoteBrowserViewModelMetadataMergeTests.swift @@ -12,14 +12,73 @@ import Testing /// mirroring `LocalFileSystemTests`' own `metadataAbandonsAStuckEntry…` /// tests, but one layer up, at the view model. /// -/// `.timeLimit(.minutes(1))` in its own suite rather than added to the much -/// larger `RemoteBrowserViewModelTests` suite: only these tests poll via -/// `pollUntil` for a background merge Task to catch up, so only they need a -/// stated ceiling on how long that poll may run before the harness itself +/// `.timeLimit(.minutes(5))` in its own suite rather than added to the much +/// larger `RemoteBrowserViewModelTests` suite: these tests poll via +/// `pollUntil` for a background merge Task to catch up, so they need a +/// stated bound on how long that poll may run before the harness itself /// calls it a hang (CLAUDE.md: "Tests never block the cooperative pool" — /// every wait here is `pollUntil`'s `await Task.sleep`, never a blocking /// wait, and the `.timeLimit` is the only clock any assertion reads). -@Suite("RemoteBrowserViewModel metadata merge", .timeLimit(.minutes(1))) +/// +/// That sentence read "only these tests poll via `pollUntil` for a +/// background merge Task to catch up, so only they need a stated ceiling" +/// until 2026-10-30 — the withdrawn wording, and a claim about the rest of +/// the tree that nobody counted while writing it. Counted on 2026-10-30: +/// 127 bounds of this kind stand in `Tests/`, 110 of them raised from one +/// minute in the commit that corrects this paragraph. +/// +/// grep -rnE '\.timeLimit\(\.minutes\(5\)\)' Tests/ | grep -vc '///' +/// +/// A line count, not an occurrence count, and that is deliberate: a bound +/// is one trait on one line, while the same spelling also appears in nine +/// doc comments that describe their own suite's bound, and those must stay +/// out of the figure. Writing this paragraph changed its own answer twice +/// before the count stopped moving — 128 matches, then 136 — which is the +/// `grep -c` distinction in CLAUDE.md arriving from the other side: here +/// lines ARE the unit, so `-c` is the right tool and `-o | wc -l` is the +/// wrong one. The pattern is written escaped so the command does not match +/// its own text, and `grep -vc '///'` would drop that line regardless. +/// +/// The bound read `.minutes(1)` until 2026-10-29 — the paragraph above +/// opened "`.timeLimit(.minutes(1))` in its own suite" — when all seven +/// polling cases here came back `Time limit was exceeded: 60.000 seconds` +/// on CI run 40933631584, the first run of the gate on `macos-latest`. On +/// the development machine +/// +/// swift test --filter RemoteBrowserViewModelMetadataMergeTests +/// +/// reports the whole suite in 0.087 s, the slowest of the seven +/// (`twoHundredEntriesArrivingInReverseOrderMergeUnderSizeSort`) in that +/// same 0.087 s, and the other six in 0.011 s to 0.013 s. +/// +/// Two of the conclusions drawn from those figures are withdrawn as of +/// 2026-10-30. The first attributed the red to this suite: "One minute was +/// not a hang bound for them; it was a wall-clock ceiling, which is the +/// thing CLAUDE.md has a rule against". The second derived the new value +/// by ratio: "Sixty seconds is 690x the slowest case measured here and +/// still lost to a loaded three-core runner. Five minutes is 3448x it". +/// The direction was right; the attribution and the arithmetic were not. +/// One minute was not too tight for these seven cases, it was too tight +/// for any awaiting test on that runner — and a ratio against the local +/// figure measures nothing, because the runner figure it divides into is +/// the whole run's duration rather than the case's own work. +/// +/// What survives from that sentence is the half that never rested on a +/// ratio: five minutes "turns a poll that never completes into a red just +/// as well, which is all this bound is for", and `ConnectionDiagnostics` +/// carried `.minutes(5)` before any of this. The runs that show the rest, +/// and the mechanism behind them, are in CLAUDE.md, "A wall-clock ceiling +/// in a test measures the runner", 2026-10-30 update; what belongs here is +/// only that this suite needs a bound at all, which the paragraph above +/// says. +/// +/// One cross-reference checked while changing this, since it is a claim +/// about other code: `twoHundredEntries…`'s own comment below cites +/// `mergePathIndex`'s doc comment for a hang. Read 2026-10-29, that doc +/// comment records the cost of the `firstIndex(where:)` scan it replaced +/// — O(n) per arrival, O(n²) over a listing, "roughly 12.5 million +/// compares" for 5000 entries — and does not mention a hang. +@Suite("RemoteBrowserViewModel metadata merge", .timeLimit(.minutes(5))) @MainActor struct RemoteBrowserViewModelMetadataMergeTests { /// A `RemoteFileSystem` that ALSO conforms to `LocalMetadataSource`, diff --git a/Tests/macSCPCoreTests/RemoteForwardTests.swift b/Tests/macSCPCoreTests/RemoteForwardTests.swift index 5f1bc242..436d0c87 100644 --- a/Tests/macSCPCoreTests/RemoteForwardTests.swift +++ b/Tests/macSCPCoreTests/RemoteForwardTests.swift @@ -29,7 +29,7 @@ import Testing /// its own. The remote port is named, and the fake answers with a DIFFERENT /// number, so `start` is measured to return what `onOpen` said rather than /// what it was asked for. -@Suite("RemoteForward", .timeLimit(.minutes(1))) +@Suite("RemoteForward", .timeLimit(.minutes(5))) struct RemoteForwardTests { /// The whole path: the server names a port, an inbound connection is diff --git a/Tests/macSCPCoreTests/S3RedirectAuthorizationMeasurementTests.swift b/Tests/macSCPCoreTests/S3RedirectAuthorizationMeasurementTests.swift index 18471c52..2988e085 100644 --- a/Tests/macSCPCoreTests/S3RedirectAuthorizationMeasurementTests.swift +++ b/Tests/macSCPCoreTests/S3RedirectAuthorizationMeasurementTests.swift @@ -50,7 +50,7 @@ import Testing /// What this cannot see: what a REAL S3 provider does on a redirect. This /// measures Foundation against a controlled loopback stub, which is what the /// question is about, and nothing beyond it. -@Suite("S3 redirect Authorization measurement", .timeLimit(.minutes(1))) +@Suite("S3 redirect Authorization measurement", .timeLimit(.minutes(5))) struct S3RedirectAuthorizationMeasurementTests { // MARK: - Canned responses diff --git a/Tests/macSCPCoreTests/S3RedirectControlTests.swift b/Tests/macSCPCoreTests/S3RedirectControlTests.swift index ecc2672a..1b42cd26 100644 --- a/Tests/macSCPCoreTests/S3RedirectControlTests.swift +++ b/Tests/macSCPCoreTests/S3RedirectControlTests.swift @@ -22,7 +22,7 @@ import Testing /// /// What this cannot see: what a REAL S3 provider does on a redirect. It /// measures this project's policy against a controlled stub. -@Suite("S3 redirect control", .timeLimit(.minutes(1))) +@Suite("S3 redirect control", .timeLimit(.minutes(5))) struct S3RedirectControlTests { private static func freshBucket() -> String { "bucket-\(UUID().uuidString)" } diff --git a/Tests/macSCPCoreTests/S3SessionIsolationTests.swift b/Tests/macSCPCoreTests/S3SessionIsolationTests.swift index 16446027..ac4122f8 100644 --- a/Tests/macSCPCoreTests/S3SessionIsolationTests.swift +++ b/Tests/macSCPCoreTests/S3SessionIsolationTests.swift @@ -24,7 +24,7 @@ import Testing /// Neither test clears `URLCache.shared`: it belongs to whoever is running /// the suite. They only read it, and each dial uses a bucket name nothing /// has used before so the key they look up is unrepeatable. -@Suite("S3 session isolation", .timeLimit(.minutes(1))) +@Suite("S3 session isolation", .timeLimit(.minutes(5))) struct S3SessionIsolationTests { /// A well-formed, EXPLICITLY cacheable empty `ListObjectsV2` answer. diff --git a/Tests/macSCPCoreTests/SFTPStartBoundTests.swift b/Tests/macSCPCoreTests/SFTPStartBoundTests.swift index d327b986..30597b11 100644 --- a/Tests/macSCPCoreTests/SFTPStartBoundTests.swift +++ b/Tests/macSCPCoreTests/SFTPStartBoundTests.swift @@ -20,7 +20,7 @@ import Testing /// Every deadline here is fired BY HAND through an injected sleeper: nothing /// in this suite waits for the connect timeout, or for any wall-clock time. /// `.timeLimit` is a hang bound only. -@Suite("SFTP start bound", .timeLimit(.minutes(1))) +@Suite("SFTP start bound", .timeLimit(.minutes(5))) struct SFTPStartBoundTests { // MARK: - The seam diff --git a/Tests/macSCPCoreTests/SOCKS5HandshakeTests.swift b/Tests/macSCPCoreTests/SOCKS5HandshakeTests.swift index a1ecbb0a..6ab8b113 100644 --- a/Tests/macSCPCoreTests/SOCKS5HandshakeTests.swift +++ b/Tests/macSCPCoreTests/SOCKS5HandshakeTests.swift @@ -22,7 +22,7 @@ import Testing /// `BytePumpTests` gives: `EmbeddedEventLoop.checkCorrectThread` only /// tolerates the thread it was created on, and an `await` in a Swift /// Testing body can resume elsewhere. -@Suite("SOCKS5 handshake", .timeLimit(.minutes(1))) +@Suite("SOCKS5 handshake", .timeLimit(.minutes(5))) struct SOCKS5HandshakeTests { // MARK: - Turning reading on diff --git a/Tests/macSCPCoreTests/SOCKS5ListenerTests.swift b/Tests/macSCPCoreTests/SOCKS5ListenerTests.swift index e9883189..0cc06933 100644 --- a/Tests/macSCPCoreTests/SOCKS5ListenerTests.swift +++ b/Tests/macSCPCoreTests/SOCKS5ListenerTests.swift @@ -16,7 +16,7 @@ import Testing /// /// Every listener binds port 0 and every wait is an `await`; nothing here /// holds a fixed port or a wall-clock bound of its own. -@Suite("SOCKS5Listener", .timeLimit(.minutes(1))) +@Suite("SOCKS5Listener", .timeLimit(.minutes(5))) struct SOCKS5ListenerTests { /// Greeting, CONNECT, the two replies, then ordinary bytes through the @@ -399,7 +399,7 @@ private struct FactoryRefusedTheChannel: Error {} /// The handover box on its own: a waiter whose task is cancelled is released /// with the cancellation, and the box stays resolved exactly once — a later /// resolve is dropped. -@Suite("SOCKS5RequestBox", .timeLimit(.minutes(1))) +@Suite("SOCKS5RequestBox", .timeLimit(.minutes(5))) struct SOCKS5RequestBoxTests { @Test func cancellingTheWaitingTaskResolvesTheBoxOnce() async throws { let box = SOCKS5RequestBox() diff --git a/Tests/macSCPCoreTests/SSHForwardingConnectionTests.swift b/Tests/macSCPCoreTests/SSHForwardingConnectionTests.swift index b2a7f438..8200d765 100644 --- a/Tests/macSCPCoreTests/SSHForwardingConnectionTests.swift +++ b/Tests/macSCPCoreTests/SSHForwardingConnectionTests.swift @@ -32,7 +32,7 @@ import Testing /// /// The gated twin, against a real OpenSSH without the SFTP subsystem, is /// `ForwardingWithoutSFTPITests`. -@Suite("SSHForwardingConnection", .timeLimit(.minutes(1))) +@Suite("SSHForwardingConnection", .timeLimit(.minutes(5))) struct SSHForwardingConnectionTests { enum Path: String, CaseIterable, Sendable, CustomTestStringConvertible { case forwarding diff --git a/Tests/macSCPCoreTests/SSHPrivateKeyLoaderTests.swift b/Tests/macSCPCoreTests/SSHPrivateKeyLoaderTests.swift index 88d20a60..0cbaa63a 100644 --- a/Tests/macSCPCoreTests/SSHPrivateKeyLoaderTests.swift +++ b/Tests/macSCPCoreTests/SSHPrivateKeyLoaderTests.swift @@ -33,11 +33,17 @@ import Testing /// Test keys are generated at RUNTIME (ssh-keygen) — never checked in. /// -/// `.timeLimit(.minutes(1))`: `makeKey` shells out to `ssh-keygen` once per -/// call, well under a second even on a loaded runner — a single invocation -/// taking a full minute would itself be the defect. The project default is -/// enough; nothing here needed the wider limit. -@Suite("SSHPrivateKeyLoader", .timeLimit(.minutes(1))) +/// `.timeLimit(.minutes(5))`: `makeKey` shells out to `ssh-keygen` once per +/// call, well under a second even on a loaded runner. This paragraph went +/// on to read "a single invocation taking a full minute would itself be the +/// defect. The project default is enough; nothing here needed the wider +/// limit" — the withdrawn wording, and its reasoning does not hold: a +/// minute of time reported against a case on a saturated runner is not a +/// minute of `ssh-keygen`, it is the run's own wall clock seen from that +/// case (CLAUDE.md, "A wall-clock ceiling in a test measures the runner", +/// 2026-10-30). There is no wider limit left to decline; five minutes is +/// the figure the tree carries. +@Suite("SSHPrivateKeyLoader", .timeLimit(.minutes(5))) struct SSHPrivateKeyLoaderTests { /// Generates a key of `type` in the temp directory; passphrase "" = unencrypted. /// `extra` is appended to the ssh-keygen argument list (e.g. `["-b", "384"]`, diff --git a/Tests/macSCPCoreTests/TerminalPanelViewModelTests.swift b/Tests/macSCPCoreTests/TerminalPanelViewModelTests.swift index cf4fcd04..4ab7f537 100644 --- a/Tests/macSCPCoreTests/TerminalPanelViewModelTests.swift +++ b/Tests/macSCPCoreTests/TerminalPanelViewModelTests.swift @@ -28,7 +28,7 @@ final class MockShell: RemoteShell, Sendable { func close() async { state.withLock { $0.closed = true }; continuation.finish() } } -@Suite("TerminalPanelViewModel", .timeLimit(.minutes(1))) +@Suite("TerminalPanelViewModel", .timeLimit(.minutes(5))) @MainActor struct TerminalPanelViewModelTests { @Test func toggleOpensShellAndForwardsOutput() async throws { @@ -943,7 +943,7 @@ actor ShellFactory { /// out. The audit log's snippet entry hangs on it: before P5 the entry was /// written right after the send CALL, so a shell that failed to open left an /// entry claiming a snippet ran when its bytes were buffered and discarded. -@Suite("TerminalPanelViewModel delivery callback", .timeLimit(.minutes(1))) +@Suite("TerminalPanelViewModel delivery callback", .timeLimit(.minutes(5))) @MainActor struct TerminalPanelViewModelDeliveryTests { /// Lets the test decide WHEN the shell finishes opening, instead of @@ -1007,7 +1007,7 @@ struct TerminalPanelViewModelDeliveryTests { /// trades a flaky test for a hung suite, in a repository whose backlog /// already carries a suite-hang incident. The ending that measurement /// bought was a private ceiling task of thirty seconds; it is now the - /// suite's `.timeLimit(.minutes(1))`, which cancels the test — and + /// suite's `.timeLimit(.minutes(5))`, which cancels the test — and /// `waitForFirstDelivery` resumes its parked waiter from the /// cancellation handler, so the caller's assertion and its message still /// run. A callback that will NEVER fire is still a red test with a diff --git a/Tests/macSCPCoreTests/TerminalThemeTests.swift b/Tests/macSCPCoreTests/TerminalThemeTests.swift index c6ba9d13..c2f06aab 100644 --- a/Tests/macSCPCoreTests/TerminalThemeTests.swift +++ b/Tests/macSCPCoreTests/TerminalThemeTests.swift @@ -10,7 +10,7 @@ import Testing /// Known blind spot: nothing here renders anything. That a theme reaches /// the emulator is `TerminalThemeWiringGuardTests`' (App, source-scanning); /// that SwiftTerm paints the sixteen colours it is handed is SwiftTerm's. -@Suite("Terminal themes", .timeLimit(.minutes(1))) +@Suite("Terminal themes", .timeLimit(.minutes(5))) struct TerminalThemeTests { // MARK: - Colour diff --git a/Tests/macSCPCoreTests/TerminalTypeTests.swift b/Tests/macSCPCoreTests/TerminalTypeTests.swift index 2b1f0064..1b1ad313 100644 --- a/Tests/macSCPCoreTests/TerminalTypeTests.swift +++ b/Tests/macSCPCoreTests/TerminalTypeTests.swift @@ -14,7 +14,7 @@ import Testing /// terminfo entries (the task report); the gated /// `CitadelShellIntegrationTests.theChosenTerminalTypeReachesTheRemoteShell` /// proves only that the name reaches the server. -@Suite("Terminal type", .timeLimit(.minutes(1))) +@Suite("Terminal type", .timeLimit(.minutes(5))) @MainActor struct TerminalTypeTests { // MARK: - What is offered diff --git a/Tests/macSCPCoreTests/TransferEngineTests.swift b/Tests/macSCPCoreTests/TransferEngineTests.swift index 4ac61b09..ceecd185 100644 --- a/Tests/macSCPCoreTests/TransferEngineTests.swift +++ b/Tests/macSCPCoreTests/TransferEngineTests.swift @@ -3,7 +3,7 @@ import MacSCPTestSupport import Testing @testable import macSCPCore -@Suite("TransferEngine", .timeLimit(.minutes(1))) +@Suite("TransferEngine", .timeLimit(.minutes(5))) struct TransferEngineTests { private func makeSource(content: Data) -> MockRemoteFileSystem { MockRemoteFileSystem( diff --git a/Tests/macSCPCoreTests/TransferQueueViewModelTests.swift b/Tests/macSCPCoreTests/TransferQueueViewModelTests.swift index 7bee4554..da952d4b 100644 --- a/Tests/macSCPCoreTests/TransferQueueViewModelTests.swift +++ b/Tests/macSCPCoreTests/TransferQueueViewModelTests.swift @@ -4,7 +4,7 @@ import Synchronization import Testing @testable import macSCPCore -@Suite("TransferQueueViewModel", .timeLimit(.minutes(1))) +@Suite("TransferQueueViewModel", .timeLimit(.minutes(5))) @MainActor struct TransferQueueViewModelTests { diff --git a/Tests/macSCPCoreTests/TunnelLatchTests.swift b/Tests/macSCPCoreTests/TunnelLatchTests.swift index 56f00c7d..54353bb5 100644 --- a/Tests/macSCPCoreTests/TunnelLatchTests.swift +++ b/Tests/macSCPCoreTests/TunnelLatchTests.swift @@ -16,12 +16,12 @@ import Testing /// is the direct measurement, so a future change to `TunnelLatch` is red /// here rather than red as a puzzling ordering failure two files away. /// -/// `.timeLimit(.minutes(1))` per `PollingGuardTests +/// `.timeLimit(.minutes(5))` per `PollingGuardTests /// .everyCallerOfPollUntilDeclaresATimeLimit` — and, honestly, it would /// not save this suite if the latch regressed: a wait that ignores /// cancellation ignores the time limit's cancellation too. Every case /// releases its latch on the way out. -@Suite("Tunnel latch", .timeLimit(.minutes(1))) +@Suite("Tunnel latch", .timeLimit(.minutes(5))) struct TunnelLatchTests { /// A wait entered from an ALREADY-CANCELLED task stays parked, and the /// release is what ends it — not the cancellation. diff --git a/Tests/macSCPCoreTests/TunnelRunnerTests.swift b/Tests/macSCPCoreTests/TunnelRunnerTests.swift index cd01379d..f0908cc7 100644 --- a/Tests/macSCPCoreTests/TunnelRunnerTests.swift +++ b/Tests/macSCPCoreTests/TunnelRunnerTests.swift @@ -14,7 +14,7 @@ import Testing /// block the cooperative pool"), and no assertion carries a wall-clock /// ceiling: the backoff is measured by what the injected sleeper was ASKED /// for, not by how long anything took. -@Suite("Tunnel runner", .timeLimit(.minutes(1))) +@Suite("Tunnel runner", .timeLimit(.minutes(5))) struct TunnelRunnerTests { // MARK: - The clean sequence diff --git a/Tests/macSCPCoreTests/TunnelRuntimeFactoryWiringTests.swift b/Tests/macSCPCoreTests/TunnelRuntimeFactoryWiringTests.swift index e25d5d84..f11e861d 100644 --- a/Tests/macSCPCoreTests/TunnelRuntimeFactoryWiringTests.swift +++ b/Tests/macSCPCoreTests/TunnelRuntimeFactoryWiringTests.swift @@ -18,7 +18,7 @@ import Testing /// listeners on port 0, a connection double standing in for the SSH server /// and refusing every channel, no rig. The SSH half is measured against the /// rig in `TunnelRigITests.aFailedConnectionIsCountedWhileTheForwardStaysUp`. -@Suite("Tunnel runtime factory wiring", .timeLimit(.minutes(1))) +@Suite("Tunnel runtime factory wiring", .timeLimit(.minutes(5))) struct TunnelRuntimeFactoryWiringTests { /// End to end through the runner: a `-L` whose server refuses the diff --git a/Tests/macSCPCoreTests/TunnelRuntimeTests.swift b/Tests/macSCPCoreTests/TunnelRuntimeTests.swift index bca537f0..dd7435e2 100644 --- a/Tests/macSCPCoreTests/TunnelRuntimeTests.swift +++ b/Tests/macSCPCoreTests/TunnelRuntimeTests.swift @@ -19,7 +19,7 @@ import Testing /// Written after a mutation probe on the cancellation gate came back GREEN /// (2026-09-06): nothing reached this type, because a `.remote` runtime is /// otherwise only built over a live SSH connection. -@Suite("Tunnel runtime", .timeLimit(.minutes(1))) +@Suite("Tunnel runtime", .timeLimit(.minutes(5))) struct TunnelRuntimeTests { @Test func aForwardThatEndsAfterTheServerConfirmedItIsReported() async throws { diff --git a/Tests/macSCPCoreTests/WebDAVFileSystemIntegrationTests.swift b/Tests/macSCPCoreTests/WebDAVFileSystemIntegrationTests.swift index d77b88ee..f2cdbd56 100644 --- a/Tests/macSCPCoreTests/WebDAVFileSystemIntegrationTests.swift +++ b/Tests/macSCPCoreTests/WebDAVFileSystemIntegrationTests.swift @@ -23,7 +23,7 @@ import Testing "WebDAVFileSystem against Docker Apache", .enabled(if: ProcessInfo.processInfo.environment["MACSCP_ITEST"] == "1"), .serialized, - .timeLimit(.minutes(1)) + .timeLimit(.minutes(5)) ) struct WebDAVFileSystemIntegrationTests { diff --git a/Tests/macSCPCoreTests/WebDAVFileSystemWriteTests.swift b/Tests/macSCPCoreTests/WebDAVFileSystemWriteTests.swift index 5b47bcba..11ebfe57 100644 --- a/Tests/macSCPCoreTests/WebDAVFileSystemWriteTests.swift +++ b/Tests/macSCPCoreTests/WebDAVFileSystemWriteTests.swift @@ -95,7 +95,7 @@ private struct WriteOutcome: Sendable { /// `.timeLimit` is the one clock these tests carry: the three `abandonable` /// waits below end through the cancellation it performs, never through a /// timer of their own. -@Suite("WebDAVFileSystem writes", .timeLimit(.minutes(1))) +@Suite("WebDAVFileSystem writes", .timeLimit(.minutes(5))) struct WebDAVFileSystemWriteTests { private let config = WebDAVConnectionConfig( baseURL: "https://dav.example.com/dav", username: "u", useNextcloudPath: false,