diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4b0b98d6..f7438d4d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,9 +83,11 @@ jobs: - name: Restore DerivedData cache id: dd-cache - # Always restore so `cache-primary-key` is populated for the save - # step at the bottom (the wipe step below handles forced cold - # builds without preventing main from repopulating the cache). + # Restore only on main pushes / manual maintainer runs. Pull requests + # intentionally cold-build DerivedData: restore-key hits have produced + # stale Swift modules whose C-module dependencies are missing when + # Xcode later compiles EventSource. + if: ${{ github.event_name != 'pull_request' }} uses: actions/cache/restore@v5 with: path: ~/Library/Developer/Xcode/DerivedData @@ -97,13 +99,15 @@ jobs: restore-keys: | dd-${{ runner.os }}-${{ env.CACHE_SALT }}-xcode${{ env.XCODE_VERSION }}- - # Make "clear the build cache" a one-click operation. Two triggers: - # 1. `github.run_attempt != '1'` — i.e. a re-run. The default + # Make "clear the build cache" a one-click operation. Three triggers: + # 1. Pull requests — always cold-build DerivedData so PRs never trust + # a cached Xcode build product from another ref. + # 2. `github.run_attempt != '1'` — i.e. a re-run. The default # "Re-run failed jobs" button is the natural place for someone # who just saw a build failure to land, so we make that the # intuitive escape hatch for cache poison: the first attempt # uses the cache (fast); any re-run forces a cold compile. - # 2. `workflow_dispatch.clear_cache=true` — manual force-cold on + # 3. `workflow_dispatch.clear_cache=true` — manual force-cold on # a fresh run (e.g. validating a CACHE_SALT bump before PRs # start hitting it). # @@ -116,18 +120,18 @@ jobs: # every re-run cost ~2 min in PR #951 run 24937664669 — wasted # budget that contributed to the 30-min cold-build cancellation. # - # We wipe AFTER the restore step (rather than skipping the restore) - # so `steps.dd-cache.outputs.cache-primary-key` stays populated and - # the `Save DerivedData cache` step at the bottom can still - # repopulate the cache on a successful `main` run. - - name: Wipe restored DerivedData (re-run or workflow_dispatch clear_cache) - if: ${{ github.run_attempt != '1' || (github.event_name == 'workflow_dispatch' && inputs.clear_cache) }} + # On main/manual runs we wipe AFTER the restore step (rather than + # skipping the restore) so `steps.dd-cache.outputs.cache-primary-key` + # stays populated and the `Save DerivedData cache` step at the bottom + # can still repopulate the cache on a successful `main` run. + - name: Wipe restored DerivedData (PR, re-run, or workflow_dispatch clear_cache) + if: ${{ github.event_name == 'pull_request' || github.run_attempt != '1' || (github.event_name == 'workflow_dispatch' && inputs.clear_cache) }} run: | - REASON="run_attempt=${{ github.run_attempt }}" + REASON="event=${{ github.event_name }}, run_attempt=${{ github.run_attempt }}" if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.clear_cache }}" = "true" ]; then REASON="$REASON, workflow_dispatch clear_cache=true" fi - echo "::notice title=Cold build forced::Wiping restored DerivedData before build ($REASON). SPM cache preserved (it's source-only and pinned by Package.resolved). To re-run with the warm cache instead, push a new commit or trigger a fresh run." + echo "::notice title=Cold build forced::Wiping DerivedData before build ($REASON). SPM cache preserved (it's source-only and pinned by Package.resolved)." rm -rf "$HOME/Library/Developer/Xcode/DerivedData" - name: Resolve dependencies @@ -248,7 +252,7 @@ jobs: echo echo "**\`run_attempt > 1\` AND \`cache-hit: false\`?** That's the deliberate cold-rebuild path triggered by **Re-run failed jobs** — see the \`Wipe restored DerivedData\` step in this job. If the cold build is exhausting the 45-min budget on every re-run, the codebase has outgrown the budget; bump \`timeout-minutes\` and update its comment block, OR move warm-cache priming to a nightly \`main\` job so PRs always warm-start." echo - echo "**Suspect cache poisoning on a fresh attempt?** Click **Re-run failed jobs** — re-runs automatically wipe DerivedData (the SPM cache is preserved because it's pinned by \`Package.resolved\` and can't be poisoned)." + echo "**Suspect cache poisoning on a fresh attempt?** Pull requests already cold-build DerivedData; main/manual re-runs wipe DerivedData automatically while preserving the pinned SPM source cache." } >> "$GITHUB_STEP_SUMMARY" else # Mode B. diff --git a/Packages/OsaurusCore/Models/Chat/ChatSessionStore.swift b/Packages/OsaurusCore/Models/Chat/ChatSessionStore.swift index 1c1a054ba..2512fa539 100644 --- a/Packages/OsaurusCore/Models/Chat/ChatSessionStore.swift +++ b/Packages/OsaurusCore/Models/Chat/ChatSessionStore.swift @@ -74,4 +74,11 @@ enum ChatSessionStore { } LegacySessionImporter.runIfNeeded() } + + #if DEBUG + static func _resetForTesting() { + didOpen = false + ChatHistoryDatabase.shared.close() + } + #endif } diff --git a/Packages/OsaurusCore/Tests/Chat/ChatSessionResetForAgentTests.swift b/Packages/OsaurusCore/Tests/Chat/ChatSessionResetForAgentTests.swift index 3d358d947..8492af743 100644 --- a/Packages/OsaurusCore/Tests/Chat/ChatSessionResetForAgentTests.swift +++ b/Packages/OsaurusCore/Tests/Chat/ChatSessionResetForAgentTests.swift @@ -29,7 +29,6 @@ import Testing @Suite(.serialized) @MainActor struct ChatSessionResetForAgentTests { - /// Primary regression pin for #1005. Constructs a session in the same /// shape `ChatWindowState.loadSession` leaves it in (existing /// `sessionId`, populated `turns`, agent X), then calls @@ -39,62 +38,64 @@ struct ChatSessionResetForAgentTests { /// observed when users click "New Chat" after navigating to a /// conversation from a different agent. @Test("reset(for:) does not retag previous session via stop() side-effect save") - func resetForAgent_savesPreviousSessionUnderOldAgent() async { - let session = ChatSession() - let originalAgentId = UUID() - let originalSessionId = UUID() - let newAgentId = UUID() - - // Mimic a freshly-loaded conversation: existing sessionId + turns - // under an existing agent, just like `ChatSession.load(from:)` - // leaves things after `ChatWindowState.loadSession`. - session.agentId = originalAgentId - session.sessionId = originalSessionId - session.turns = [ - ChatTurn(role: .user, content: "Hello"), - ChatTurn(role: .assistant, content: "Hi"), - ] - session.title = "Existing conversation" - - // Capture session state at the moment a save fires. - // `onSessionChanged` is called inside `save()` AFTER - // `toSessionData()` has been encoded and passed to - // `ChatSessionsManager.shared.save`, so the captured snapshot - // reflects what was actually persisted (or attempted to be). - var capturedAgentIdAtSave: UUID? - var capturedSessionIdAtSave: UUID? - var capturedTurnCountAtSave: Int? - session.onSessionChanged = { - capturedAgentIdAtSave = session.agentId - capturedSessionIdAtSave = session.sessionId - capturedTurnCountAtSave = session.turns.count - } - - session.reset(for: newAgentId) - - // If a save fired during the reset chain, it MUST have targeted - // the original session id under the original agent. Pre-fix this - // would have captured `newAgentId` because `agentId` was - // overwritten before `reset()` ran. - if let capturedAgentId = capturedAgentIdAtSave { - #expect( - capturedSessionIdAtSave == originalSessionId, - "save during reset(for:) should target the original session id" - ) - #expect( - capturedAgentId == originalAgentId, - "reset(for:) re-tagged session \(originalSessionId) to agent \(capturedAgentId) instead of preserving \(originalAgentId) (#1005)" - ) - #expect( - (capturedTurnCountAtSave ?? 0) > 0, - "save during reset(for:) should still see the original turns" - ) + func resetForAgent_savesPreviousSessionUnderOldAgent() async throws { + try await ChatHistoryTestStorage.run { + let session = ChatSession() + let originalAgentId = UUID() + let originalSessionId = UUID() + let newAgentId = UUID() + + // Mimic a freshly-loaded conversation: existing sessionId + turns + // under an existing agent, just like `ChatSession.load(from:)` + // leaves things after `ChatWindowState.loadSession`. + session.agentId = originalAgentId + session.sessionId = originalSessionId + session.turns = [ + ChatTurn(role: .user, content: "Hello"), + ChatTurn(role: .assistant, content: "Hi"), + ] + session.title = "Existing conversation" + + // Capture session state at the moment a save fires. + // `onSessionChanged` is called inside `save()` AFTER + // `toSessionData()` has been encoded and passed to + // `ChatSessionsManager.shared.save`, so the captured snapshot + // reflects what was actually persisted (or attempted to be). + var capturedAgentIdAtSave: UUID? + var capturedSessionIdAtSave: UUID? + var capturedTurnCountAtSave: Int? + session.onSessionChanged = { + capturedAgentIdAtSave = session.agentId + capturedSessionIdAtSave = session.sessionId + capturedTurnCountAtSave = session.turns.count + } + + session.reset(for: newAgentId) + + // If a save fired during the reset chain, it MUST have targeted + // the original session id under the original agent. Pre-fix this + // would have captured `newAgentId` because `agentId` was + // overwritten before `reset()` ran. + if let capturedAgentId = capturedAgentIdAtSave { + #expect( + capturedSessionIdAtSave == originalSessionId, + "save during reset(for:) should target the original session id" + ) + #expect( + capturedAgentId == originalAgentId, + "reset(for:) re-tagged session \(originalSessionId) to agent \(capturedAgentId) instead of preserving \(originalAgentId) (#1005)" + ) + #expect( + (capturedTurnCountAtSave ?? 0) > 0, + "save during reset(for:) should still see the original turns" + ) + } + + // Final state: new agent applied, session cleared. + #expect(session.agentId == newAgentId) + #expect(session.sessionId == nil) + #expect(session.turns.isEmpty) } - - // Final state: new agent applied, session cleared. - #expect(session.agentId == newAgentId) - #expect(session.sessionId == nil) - #expect(session.turns.isEmpty) } /// Same shape as the regression test above, but also asserts that @@ -103,33 +104,35 @@ struct ChatSessionResetForAgentTests { /// onSessionChanged firing order without fixing the underlying /// `toSessionData()` payload. @Test("save() during reset(for:) encodes old agentId in ChatSessionData") - func resetForAgent_toSessionDataInsideSavePreservesOldAgent() async { - let session = ChatSession() - let originalAgentId = UUID() - let originalSessionId = UUID() - let newAgentId = UUID() - - session.agentId = originalAgentId - session.sessionId = originalSessionId - session.turns = [ChatTurn(role: .user, content: "single turn")] - session.title = "Loaded chat" - - var snapshot: ChatSessionData? - session.onSessionChanged = { - // Re-derive the persisted shape from the same source - // `save()` used. If the agent had already been swapped at - // this point, the snapshot would carry `newAgentId`. - snapshot = session.toSessionData() - } - - session.reset(for: newAgentId) - - if let snapshot { - #expect(snapshot.id == originalSessionId) - #expect( - snapshot.agentId == originalAgentId, - "ChatSessionData built during reset(for:) carries wrong agent id (#1005)" - ) + func resetForAgent_toSessionDataInsideSavePreservesOldAgent() async throws { + try await ChatHistoryTestStorage.run { + let session = ChatSession() + let originalAgentId = UUID() + let originalSessionId = UUID() + let newAgentId = UUID() + + session.agentId = originalAgentId + session.sessionId = originalSessionId + session.turns = [ChatTurn(role: .user, content: "single turn")] + session.title = "Loaded chat" + + var snapshot: ChatSessionData? + session.onSessionChanged = { + // Re-derive the persisted shape from the same source + // `save()` used. If the agent had already been swapped at + // this point, the snapshot would carry `newAgentId`. + snapshot = session.toSessionData() + } + + session.reset(for: newAgentId) + + if let snapshot { + #expect(snapshot.id == originalSessionId) + #expect( + snapshot.agentId == originalAgentId, + "ChatSessionData built during reset(for:) carries wrong agent id (#1005)" + ) + } } } diff --git a/Packages/OsaurusCore/Tests/Chat/ChatSessionStopTests.swift b/Packages/OsaurusCore/Tests/Chat/ChatSessionStopTests.swift index 83f119c0f..4f78cc0c5 100644 --- a/Packages/OsaurusCore/Tests/Chat/ChatSessionStopTests.swift +++ b/Packages/OsaurusCore/Tests/Chat/ChatSessionStopTests.swift @@ -7,35 +7,39 @@ import Testing @MainActor struct ChatSessionStopTests { @Test - func stop_trimsTrailingEmptyAssistantPlaceholder() { - let session = ChatSession() - session.turns = [ - ChatTurn(role: .user, content: "Hello"), - ChatTurn(role: .assistant, content: ""), - ] - - session.stop() - - #expect(session.turns.count == 1) - #expect(session.turns.last?.role == .user) + func stop_trimsTrailingEmptyAssistantPlaceholder() async throws { + try await ChatHistoryTestStorage.run { + let session = ChatSession() + session.turns = [ + ChatTurn(role: .user, content: "Hello"), + ChatTurn(role: .assistant, content: ""), + ] + + session.stop() + + #expect(session.turns.count == 1) + #expect(session.turns.last?.role == .user) + } } @Test func stop_ignoresLateResultsWhenEngineSetupIgnoresCancellation() async throws { - let session = ChatSession() - session.chatEngineFactory = { IgnoringCancellationChatEngine() } + try await ChatHistoryTestStorage.run { + let session = ChatSession() + session.chatEngineFactory = { IgnoringCancellationChatEngine() } - session.send("Hello") - try await Task.sleep(for: .milliseconds(20)) - session.stop() + session.send("Hello") + try await Task.sleep(for: .milliseconds(20)) + session.stop() - #expect(session.isStreaming == false) + #expect(session.isStreaming == false) - try await Task.sleep(for: .milliseconds(250)) + try await Task.sleep(for: .milliseconds(250)) - #expect(session.turns.count == 1) - #expect(session.turns.first?.role == .user) - #expect(session.turns.first?.content == "Hello") + #expect(session.turns.count == 1) + #expect(session.turns.first?.role == .user) + #expect(session.turns.first?.content == "Hello") + } } } diff --git a/Packages/OsaurusCore/Tests/Chat/ChatWindowStateAgentSyncTests.swift b/Packages/OsaurusCore/Tests/Chat/ChatWindowStateAgentSyncTests.swift index 610e3634b..8c2ef0bd7 100644 --- a/Packages/OsaurusCore/Tests/Chat/ChatWindowStateAgentSyncTests.swift +++ b/Packages/OsaurusCore/Tests/Chat/ChatWindowStateAgentSyncTests.swift @@ -20,9 +20,8 @@ // which covers the Default agent whose mutable settings live in // `ChatConfiguration` rather than the `Agent` struct. // -// Tests reuse the `SandboxTestLock.runWithStoragePaths` helper to -// serialize access to `AgentManager.shared`, mirroring the pattern in -// `ContextBudgetPreviewTests`. +// Tests reuse the `ChatHistoryTestStorage` helper to isolate chat history +// and agent persistence while serializing access to `AgentManager.shared`. // import Combine @@ -80,8 +79,8 @@ struct ChatWindowStateAgentSyncTests { /// agent dropdown while the chat window is open should reflect agents /// added afterwards (from AgentsView, onboarding, plugins, …). @Test("add → new agent appears in windowState.agents synchronously") - func add_propagatesToWindowAgents() async { - await SandboxTestLock.runWithStoragePaths { + func add_propagatesToWindowAgents() async throws { + try await ChatHistoryTestStorage.run { let window = makeWindow(for: Agent.defaultId) let countBefore = window.agents.count @@ -98,8 +97,8 @@ struct ChatWindowStateAgentSyncTests { // MARK: - 2. Active agent updates flow through the Combine sink @Test("rename active custom agent → cachedAgentDisplayName + cachedActiveAgent update") - func renameActive_updatesCachesSynchronously() async { - await SandboxTestLock.runWithStoragePaths { + func renameActive_updatesCachesSynchronously() async throws { + try await ChatHistoryTestStorage.run { let custom = makeCustomAgent(name: "RenameActive") AgentManager.shared.add(custom) @@ -121,8 +120,8 @@ struct ChatWindowStateAgentSyncTests { } @Test("active custom agent system prompt change → cachedSystemPrompt updates") - func activeCustomAgentSystemPromptChange_updatesCachedSystemPrompt() async { - await SandboxTestLock.runWithStoragePaths { + func activeCustomAgentSystemPromptChange_updatesCachedSystemPrompt() async throws { + try await ChatHistoryTestStorage.run { let custom = makeCustomAgent(name: "PromptTest", systemPrompt: "before") AgentManager.shared.add(custom) @@ -140,8 +139,8 @@ struct ChatWindowStateAgentSyncTests { } @Test("active custom agent tool selection change → cachedActiveAgent reflects new mode/allowlist") - func activeCustomAgentToolSelectionChange_updatesCachedActiveAgent() async { - await SandboxTestLock.runWithStoragePaths { + func activeCustomAgentToolSelectionChange_updatesCachedActiveAgent() async throws { + try await ChatHistoryTestStorage.run { let custom = makeCustomAgent(name: "ToolSelTest", toolSelectionMode: .auto) AgentManager.shared.add(custom) @@ -165,8 +164,8 @@ struct ChatWindowStateAgentSyncTests { /// settings live in `ChatConfiguration`, not the `Agent` struct, so /// they never trigger a `$agents` emission. @Test("Default agent system prompt change → cachedSystemPrompt updates via .appConfigurationChanged") - func defaultAgentSystemPromptChange_updatesCachedSystemPrompt() async { - await SandboxTestLock.runWithStoragePaths { + func defaultAgentSystemPromptChange_updatesCachedSystemPrompt() async throws { + try await ChatHistoryTestStorage.run { let window = makeWindow(for: Agent.defaultId) let originalConfig = ChatConfigurationStore.load() defer { ChatConfigurationStore.save(originalConfig) } @@ -190,8 +189,8 @@ struct ChatWindowStateAgentSyncTests { /// heavy `refreshAgentConfig()` path that invalidates the session /// token cache). @Test("rename non-active agent → list updates, active untouched") - func renameNonActive_updatesListButLeavesActiveUntouched() async { - await SandboxTestLock.runWithStoragePaths { + func renameNonActive_updatesListButLeavesActiveUntouched() async throws { + try await ChatHistoryTestStorage.run { let agentA = makeCustomAgent(name: "ActiveA") let agentB = makeCustomAgent(name: "OtherB") AgentManager.shared.add(agentA) @@ -219,8 +218,8 @@ struct ChatWindowStateAgentSyncTests { // MARK: - 4. Delete propagates @Test("delete non-active agent → disappears from windowState.agents") - func deleteNonActive_removesFromWindowAgents() async { - await SandboxTestLock.runWithStoragePaths { + func deleteNonActive_removesFromWindowAgents() async throws { + try await ChatHistoryTestStorage.run { let agentA = makeCustomAgent(name: "KeepA") let agentB = makeCustomAgent(name: "DeleteB") AgentManager.shared.add(agentA) @@ -243,8 +242,8 @@ struct ChatWindowStateAgentSyncTests { /// the window pointing at a dangling id. `applyAgentsUpdate` detects /// the missing agent and falls back to the Default agent. @Test("delete active agent → falls back to Default") - func deleteActive_fallsBackToDefault() async { - await SandboxTestLock.runWithStoragePaths { + func deleteActive_fallsBackToDefault() async throws { + try await ChatHistoryTestStorage.run { let custom = makeCustomAgent(name: "ToDeleteActive") AgentManager.shared.add(custom) @@ -290,13 +289,10 @@ struct ChatWindowStateAgentSyncTests { /// `ChatSession.reset(for:)` re-order keeps the old agent in /// scope while `stop()` runs). @Test("issue #1005: loadSession + startNewChat preserves conversation's agent") - func issue1005_loadSession_thenNewChat_preservesAgent() async { - await SandboxTestLock.runWithStoragePaths { + func issue1005_loadSession_thenNewChat_preservesAgent() async throws { + try await ChatHistoryTestStorage.run { let custom = makeCustomAgent(name: "Issue1005") AgentManager.shared.add(custom) - defer { - Task { _ = await AgentManager.shared.delete(id: custom.id) } - } // Window opens on Default — mirrors the user being in the // Default agent at the time they click on a custom-agent @@ -380,6 +376,8 @@ struct ChatWindowStateAgentSyncTests { #expect(window.session.agentId == custom.id) #expect(window.session.sessionId == nil) #expect(window.session.turns.isEmpty) + + _ = await AgentManager.shared.delete(id: custom.id) } } @@ -391,8 +389,8 @@ struct ChatWindowStateAgentSyncTests { /// notification-only model) would silently re-break the picker /// without this test. @Test("AgentManager.$agents emits on add and delete") - func agentManagerPublisher_emitsOnAddAndDelete() async { - await SandboxTestLock.runWithStoragePaths { + func agentManagerPublisher_emitsOnAddAndDelete() async throws { + try await ChatHistoryTestStorage.run { var emissionCount = 0 let cancellable = AgentManager.shared.$agents.sink { _ in emissionCount += 1 diff --git a/Packages/OsaurusCore/Tests/Helpers/ChatHistoryTestStorage.swift b/Packages/OsaurusCore/Tests/Helpers/ChatHistoryTestStorage.swift new file mode 100644 index 000000000..4254e8a10 --- /dev/null +++ b/Packages/OsaurusCore/Tests/Helpers/ChatHistoryTestStorage.swift @@ -0,0 +1,43 @@ +// +// ChatHistoryTestStorage.swift +// OsaurusCoreTests +// +// Isolates tests that exercise ChatSession save/reset paths from the +// real chat-history database and the user's Keychain-backed storage key. +// + +import CryptoKit +import Foundation + +@testable import OsaurusCore + +enum ChatHistoryTestStorage { + @MainActor + static func run( + _ body: @MainActor @Sendable () async throws -> T + ) async throws -> T { + try await SandboxTestLock.runWithStoragePaths { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "osaurus-chat-history-tests-\(UUID().uuidString)" + ) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + + let previousRoot = OsaurusPaths.overrideRoot + OsaurusPaths.overrideRoot = root + StorageKeyManager.shared._setKeyForTesting( + SymmetricKey(data: Data(repeating: 0x44, count: 32)) + ) + AgentManager.shared.refresh() + ChatSessionStore._resetForTesting() + defer { + ChatSessionStore._resetForTesting() + StorageKeyManager.shared.wipeCache() + OsaurusPaths.overrideRoot = previousRoot + AgentManager.shared.refresh() + try? FileManager.default.removeItem(at: root) + } + + return try await body() + } + } +} diff --git a/Packages/OsaurusCore/Tests/Helpers/HTTPServerTestLock.swift b/Packages/OsaurusCore/Tests/Helpers/HTTPServerTestLock.swift new file mode 100644 index 000000000..bd68ae62e --- /dev/null +++ b/Packages/OsaurusCore/Tests/Helpers/HTTPServerTestLock.swift @@ -0,0 +1,65 @@ +// +// HTTPServerTestLock.swift +// OsaurusCoreTests +// +// Process-wide serialization for tests that boot real loopback NIO servers. +// `@Suite(.serialized)` only serializes tests within one suite, while the +// networking suites all share URLSession, NIO loopback sockets, and the same +// host scheduler. Running many tiny servers at once can starve individual +// URLSession requests until they hit the 60s default timeout. +// + +import Foundation + +actor HTTPServerTestLock { + static let shared = HTTPServerTestLock() + + private var holder = false + private var activeLeaseIDs: Set = [] + private var waiters: [CheckedContinuation] = [] + + func acquire() async -> HTTPServerTestLease { + if !holder { + holder = true + return makeLease() + } + + await withCheckedContinuation { (cont: CheckedContinuation) in + waiters.append(cont) + } + return makeLease() + } + + fileprivate func release(id: UUID) { + guard activeLeaseIDs.remove(id) != nil else { + return + } + + if let next = waiters.first { + waiters.removeFirst() + next.resume() + } else { + holder = false + } + } + + private func makeLease() -> HTTPServerTestLease { + let id = UUID() + activeLeaseIDs.insert(id) + return HTTPServerTestLease(lock: self, id: id) + } +} + +final class HTTPServerTestLease: @unchecked Sendable { + private let lock: HTTPServerTestLock + private let id: UUID + + fileprivate init(lock: HTTPServerTestLock, id: UUID) { + self.lock = lock + self.id = id + } + + func release() async { + await lock.release(id: id) + } +} diff --git a/Packages/OsaurusCore/Tests/Networking/CORSHandlerTests.swift b/Packages/OsaurusCore/Tests/Networking/CORSHandlerTests.swift index baad0693f..8e202dff7 100644 --- a/Packages/OsaurusCore/Tests/Networking/CORSHandlerTests.swift +++ b/Packages/OsaurusCore/Tests/Networking/CORSHandlerTests.swift @@ -278,6 +278,7 @@ struct CORSHandlerTests { private struct CORSTestServer { let group: MultiThreadedEventLoopGroup let channel: Channel + let lease: HTTPServerTestLease let host: String let port: Int @@ -286,6 +287,7 @@ private struct CORSTestServer { await withCheckedContinuation { (cont: CheckedContinuation) in group.shutdownGracefully { _ in cont.resume() } } + await lease.release() } } @@ -298,28 +300,37 @@ private func startCORSTestServer( config: ServerConfiguration, trustLoopback: Bool = true ) async throws -> CORSTestServer { + let lease = await HTTPServerTestLock.shared.acquire() let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) - let bootstrap = ServerBootstrap(group: group) - .serverChannelOption(ChannelOptions.backlog, value: 256) - .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) - .childChannelInitializer { channel in - channel.pipeline.configureHTTPServerPipeline().flatMap { - channel.pipeline.addHandler( - HTTPHandler( - configuration: config, - apiKeyValidator: .empty, - eventLoop: channel.eventLoop, - trustLoopback: trustLoopback + do { + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.backlog, value: 256) + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelInitializer { channel in + channel.pipeline.configureHTTPServerPipeline().flatMap { + channel.pipeline.addHandler( + HTTPHandler( + configuration: config, + apiKeyValidator: .empty, + eventLoop: channel.eventLoop, + trustLoopback: trustLoopback + ) ) - ) + } } + .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelOption(ChannelOptions.socketOption(.tcp_nodelay), value: 1) + .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 16) + .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator()) + + let ch = try await bootstrap.bind(host: "127.0.0.1", port: 0).get() + let port = ch.localAddress?.port ?? 0 + return CORSTestServer(group: group, channel: ch, lease: lease, host: "127.0.0.1", port: port) + } catch { + await withCheckedContinuation { (cont: CheckedContinuation) in + group.shutdownGracefully { _ in cont.resume() } } - .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) - .childChannelOption(ChannelOptions.socketOption(.tcp_nodelay), value: 1) - .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 16) - .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator()) - - let ch = try await bootstrap.bind(host: "127.0.0.1", port: 0).get() - let port = ch.localAddress?.port ?? 0 - return CORSTestServer(group: group, channel: ch, host: "127.0.0.1", port: port) + await lease.release() + throw error + } } diff --git a/Packages/OsaurusCore/Tests/Networking/HTTPAuthGateTests.swift b/Packages/OsaurusCore/Tests/Networking/HTTPAuthGateTests.swift index e551c531f..53a8a59e3 100644 --- a/Packages/OsaurusCore/Tests/Networking/HTTPAuthGateTests.swift +++ b/Packages/OsaurusCore/Tests/Networking/HTTPAuthGateTests.swift @@ -182,6 +182,7 @@ struct HTTPAuthGateTests { private struct AuthTestServer { let group: MultiThreadedEventLoopGroup let channel: Channel + let lease: HTTPServerTestLease let host: String let port: Int @@ -190,6 +191,7 @@ private struct AuthTestServer { await withCheckedContinuation { (cont: CheckedContinuation) in group.shutdownGracefully { _ in cont.resume() } } + await lease.release() } } @@ -198,28 +200,37 @@ private func startAuthTestServer( ) async throws -> AuthTestServer { let config = ServerConfiguration.default + let lease = await HTTPServerTestLock.shared.acquire() let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) - let bootstrap = ServerBootstrap(group: group) - .serverChannelOption(ChannelOptions.backlog, value: 256) - .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) - .childChannelInitializer { channel in - channel.pipeline.configureHTTPServerPipeline().flatMap { - channel.pipeline.addHandler( - HTTPHandler( - configuration: config, - apiKeyValidator: validator, - eventLoop: channel.eventLoop, - trustLoopback: false + do { + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.backlog, value: 256) + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelInitializer { channel in + channel.pipeline.configureHTTPServerPipeline().flatMap { + channel.pipeline.addHandler( + HTTPHandler( + configuration: config, + apiKeyValidator: validator, + eventLoop: channel.eventLoop, + trustLoopback: false + ) ) - ) + } } + .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelOption(ChannelOptions.socketOption(.tcp_nodelay), value: 1) + .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 16) + .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator()) + + let ch = try await bootstrap.bind(host: "127.0.0.1", port: 0).get() + let port = ch.localAddress?.port ?? 0 + return AuthTestServer(group: group, channel: ch, lease: lease, host: "127.0.0.1", port: port) + } catch { + await withCheckedContinuation { (cont: CheckedContinuation) in + group.shutdownGracefully { _ in cont.resume() } } - .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) - .childChannelOption(ChannelOptions.socketOption(.tcp_nodelay), value: 1) - .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 16) - .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator()) - - let ch = try await bootstrap.bind(host: "127.0.0.1", port: 0).get() - let port = ch.localAddress?.port ?? 0 - return AuthTestServer(group: group, channel: ch, host: "127.0.0.1", port: port) + await lease.release() + throw error + } } diff --git a/Packages/OsaurusCore/Tests/Networking/HTTPBodySizeLimitTests.swift b/Packages/OsaurusCore/Tests/Networking/HTTPBodySizeLimitTests.swift index c956889c8..f53264fa5 100644 --- a/Packages/OsaurusCore/Tests/Networking/HTTPBodySizeLimitTests.swift +++ b/Packages/OsaurusCore/Tests/Networking/HTTPBodySizeLimitTests.swift @@ -90,6 +90,7 @@ struct HTTPBodySizeLimitTests { private struct BodyLimitTestServer { let group: MultiThreadedEventLoopGroup let channel: Channel + let lease: HTTPServerTestLease let host: String let port: Int @@ -98,34 +99,44 @@ private struct BodyLimitTestServer { await withCheckedContinuation { (cont: CheckedContinuation) in group.shutdownGracefully { _ in cont.resume() } } + await lease.release() } } private func startBodyLimitServer(config: ServerConfiguration) async throws -> BodyLimitTestServer { + let lease = await HTTPServerTestLock.shared.acquire() let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) - let bootstrap = ServerBootstrap(group: group) - .serverChannelOption(ChannelOptions.backlog, value: 256) - .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) - .childChannelInitializer { channel in - channel.pipeline.configureHTTPServerPipeline().flatMap { - channel.pipeline.addHandler( - HTTPHandler( - configuration: config, - apiKeyValidator: .empty, - eventLoop: channel.eventLoop, - // trustLoopback false so the auth gate would normally - // run — proves the size guard fires *before* it. - trustLoopback: false + do { + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.backlog, value: 256) + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelInitializer { channel in + channel.pipeline.configureHTTPServerPipeline().flatMap { + channel.pipeline.addHandler( + HTTPHandler( + configuration: config, + apiKeyValidator: .empty, + eventLoop: channel.eventLoop, + // trustLoopback false so the auth gate would normally + // run — proves the size guard fires *before* it. + trustLoopback: false + ) ) - ) + } } + .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelOption(ChannelOptions.socketOption(.tcp_nodelay), value: 1) + .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 16) + .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator()) + + let ch = try await bootstrap.bind(host: "127.0.0.1", port: 0).get() + let port = ch.localAddress?.port ?? 0 + return BodyLimitTestServer(group: group, channel: ch, lease: lease, host: "127.0.0.1", port: port) + } catch { + await withCheckedContinuation { (cont: CheckedContinuation) in + group.shutdownGracefully { _ in cont.resume() } } - .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) - .childChannelOption(ChannelOptions.socketOption(.tcp_nodelay), value: 1) - .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 16) - .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator()) - - let ch = try await bootstrap.bind(host: "127.0.0.1", port: 0).get() - let port = ch.localAddress?.port ?? 0 - return BodyLimitTestServer(group: group, channel: ch, host: "127.0.0.1", port: port) + await lease.release() + throw error + } } diff --git a/Packages/OsaurusCore/Tests/Networking/HTTPHandlerChatStreamingTests.swift b/Packages/OsaurusCore/Tests/Networking/HTTPHandlerChatStreamingTests.swift index cf5544f85..9ef6f5eb1 100644 --- a/Packages/OsaurusCore/Tests/Networking/HTTPHandlerChatStreamingTests.swift +++ b/Packages/OsaurusCore/Tests/Networking/HTTPHandlerChatStreamingTests.swift @@ -11,6 +11,12 @@ import Testing @testable import OsaurusCore +fileprivate extension URLRequest { + mutating func disablePersistenceForTests() { + setValue("false", forHTTPHeaderField: "X-Persist") + } +} + struct HTTPHandlerChatStreamingTests { @Test func sse_path_writes_role_content_finish_done() async throws { @@ -26,6 +32,7 @@ struct HTTPHandlerChatStreamingTests { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("text/event-stream", forHTTPHeaderField: "Accept") request.authenticate() + request.disablePersistenceForTests() let reqBody = ChatCompletionRequest( model: "fake", messages: [ChatMessage(role: "user", content: "hi")], @@ -64,6 +71,7 @@ struct HTTPHandlerChatStreamingTests { request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.authenticate() + request.disablePersistenceForTests() let reqBody = ChatCompletionRequest( model: "fake", messages: [ChatMessage(role: "user", content: "hi")], @@ -118,6 +126,7 @@ struct HTTPHandlerChatStreamingTests { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("text/event-stream", forHTTPHeaderField: "Accept") request.authenticate() + request.disablePersistenceForTests() let reqBody = ChatCompletionRequest( model: "fake", messages: [ChatMessage(role: "user", content: "hi")], @@ -182,6 +191,7 @@ struct HTTPHandlerChatStreamingTests { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("text/event-stream", forHTTPHeaderField: "Accept") request.authenticate() + request.disablePersistenceForTests() let reqBody = ChatCompletionRequest( model: "fake", messages: [ChatMessage(role: "user", content: "hi")], @@ -253,6 +263,7 @@ struct HTTPHandlerChatStreamingTests { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("text/event-stream", forHTTPHeaderField: "Accept") request.authenticate() + request.disablePersistenceForTests() let reqBody = ChatCompletionRequest( model: "fake", messages: [ChatMessage(role: "user", content: "hi")], @@ -327,6 +338,7 @@ struct HTTPHandlerChatStreamingTests { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("text/event-stream", forHTTPHeaderField: "Accept") request.authenticate() + request.disablePersistenceForTests() let bodyJSON = #""" {"model":"fake","max_tokens":16,"stream":true,"messages":[{"role":"user","content":"hi"}]} """# @@ -377,6 +389,7 @@ struct HTTPHandlerChatStreamingTests { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("text/event-stream", forHTTPHeaderField: "Accept") request.authenticate() + request.disablePersistenceForTests() let bodyJSON = #""" {"model":"fake","max_tokens":16,"stream":true,"messages":[{"role":"user","content":"hi"}]} """# @@ -418,6 +431,7 @@ struct HTTPHandlerChatStreamingTests { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("text/event-stream", forHTTPHeaderField: "Accept") request.authenticate() + request.disablePersistenceForTests() let bodyJSON = #""" {"model":"fake","stream":true,"input":"hi"} """# @@ -458,6 +472,7 @@ struct HTTPHandlerChatStreamingTests { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("text/event-stream", forHTTPHeaderField: "Accept") request.authenticate() + request.disablePersistenceForTests() let bodyJSON = #""" {"model":"fake","stream":true,"input":"hi"} """# @@ -508,6 +523,7 @@ struct HTTPHandlerChatStreamingTests { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("text/event-stream", forHTTPHeaderField: "Accept") request.authenticate() + request.disablePersistenceForTests() let bodyJSON = #""" {"model":"fake","stream":true,"input":"hi"} """# @@ -553,6 +569,7 @@ struct HTTPHandlerChatStreamingTests { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("text/event-stream", forHTTPHeaderField: "Accept") request.authenticate() + request.disablePersistenceForTests() let reqBody = ChatCompletionRequest( model: "fake", messages: [ChatMessage(role: "user", content: "hi")], @@ -587,6 +604,7 @@ struct HTTPHandlerChatStreamingTests { private struct TestServer { let group: MultiThreadedEventLoopGroup let channel: Channel + let lease: HTTPServerTestLease let host: String let port: Int @@ -595,35 +613,45 @@ private struct TestServer { await withCheckedContinuation { (cont: CheckedContinuation) in group.shutdownGracefully { _ in cont.resume() } } + await lease.release() } } @discardableResult private func startTestServer(with engine: ChatEngineProtocol) async throws -> TestServer { + let lease = await HTTPServerTestLock.shared.acquire() let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) - let bootstrap = ServerBootstrap(group: group) - .serverChannelOption(ChannelOptions.backlog, value: 256) - .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) - .childChannelInitializer { channel in - channel.pipeline.configureHTTPServerPipeline().flatMap { - channel.pipeline.addHandler( - HTTPHandler( - configuration: .default, - apiKeyValidator: TestAuth.validator, - eventLoop: channel.eventLoop, - chatEngine: engine, - trustLoopback: false + do { + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.backlog, value: 256) + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelInitializer { channel in + channel.pipeline.configureHTTPServerPipeline().flatMap { + channel.pipeline.addHandler( + HTTPHandler( + configuration: .default, + apiKeyValidator: TestAuth.validator, + eventLoop: channel.eventLoop, + chatEngine: engine, + trustLoopback: false + ) ) - ) + } } + .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelOption(ChannelOptions.socketOption(.tcp_nodelay), value: 1) + .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 16) + .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator()) + + let ch = try await bootstrap.bind(host: "127.0.0.1", port: 0).get() + let addr = ch.localAddress + let port = addr?.port ?? 0 + return TestServer(group: group, channel: ch, lease: lease, host: "127.0.0.1", port: port) + } catch { + await withCheckedContinuation { (cont: CheckedContinuation) in + group.shutdownGracefully { _ in cont.resume() } } - .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) - .childChannelOption(ChannelOptions.socketOption(.tcp_nodelay), value: 1) - .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 16) - .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator()) - - let ch = try await bootstrap.bind(host: "127.0.0.1", port: 0).get() - let addr = ch.localAddress - let port = addr?.port ?? 0 - return TestServer(group: group, channel: ch, host: "127.0.0.1", port: port) + await lease.release() + throw error + } } diff --git a/Packages/OsaurusCore/Tests/Networking/MCPHTTPHandlerTests.swift b/Packages/OsaurusCore/Tests/Networking/MCPHTTPHandlerTests.swift index 04c715e29..15f3005a6 100644 --- a/Packages/OsaurusCore/Tests/Networking/MCPHTTPHandlerTests.swift +++ b/Packages/OsaurusCore/Tests/Networking/MCPHTTPHandlerTests.swift @@ -163,6 +163,7 @@ private struct EchoTool: OsaurusTool { private struct TestServer { let group: MultiThreadedEventLoopGroup let channel: Channel + let lease: HTTPServerTestLease let host: String let port: Int @@ -171,34 +172,44 @@ private struct TestServer { await withCheckedContinuation { (cont: CheckedContinuation) in group.shutdownGracefully { _ in cont.resume() } } + await lease.release() } } @discardableResult private func startTestServer() async throws -> TestServer { + let lease = await HTTPServerTestLock.shared.acquire() let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) - let bootstrap = ServerBootstrap(group: group) - .serverChannelOption(ChannelOptions.backlog, value: 256) - .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) - .childChannelInitializer { channel in - channel.pipeline.configureHTTPServerPipeline().flatMap { - channel.pipeline.addHandler( - HTTPHandler( - configuration: .default, - apiKeyValidator: TestAuth.validator, - eventLoop: channel.eventLoop, - trustLoopback: false + do { + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.backlog, value: 256) + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelInitializer { channel in + channel.pipeline.configureHTTPServerPipeline().flatMap { + channel.pipeline.addHandler( + HTTPHandler( + configuration: .default, + apiKeyValidator: TestAuth.validator, + eventLoop: channel.eventLoop, + trustLoopback: false + ) ) - ) + } } + .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelOption(ChannelOptions.socketOption(.tcp_nodelay), value: 1) + .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 16) + .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator()) + + let ch = try await bootstrap.bind(host: "127.0.0.1", port: 0).get() + let addr = ch.localAddress + let port = addr?.port ?? 0 + return TestServer(group: group, channel: ch, lease: lease, host: "127.0.0.1", port: port) + } catch { + await withCheckedContinuation { (cont: CheckedContinuation) in + group.shutdownGracefully { _ in cont.resume() } } - .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) - .childChannelOption(ChannelOptions.socketOption(.tcp_nodelay), value: 1) - .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 16) - .childChannelOption(ChannelOptions.recvAllocator, value: AdaptiveRecvByteBufferAllocator()) - - let ch = try await bootstrap.bind(host: "127.0.0.1", port: 0).get() - let addr = ch.localAddress - let port = addr?.port ?? 0 - return TestServer(group: group, channel: ch, host: "127.0.0.1", port: port) + await lease.release() + throw error + } } diff --git a/Packages/OsaurusCore/Tests/Tool/ToolRegistryTimeoutTests.swift b/Packages/OsaurusCore/Tests/Tool/ToolRegistryTimeoutTests.swift index f5c1795f0..faf595421 100644 --- a/Packages/OsaurusCore/Tests/Tool/ToolRegistryTimeoutTests.swift +++ b/Packages/OsaurusCore/Tests/Tool/ToolRegistryTimeoutTests.swift @@ -21,12 +21,17 @@ struct ToolRegistryTimeoutTests { /// success envelope only if it somehow completes — that branch is /// the failure signal for the test. private struct SlowSleepTool: OsaurusTool { + static let sleepSeconds: TimeInterval = 8 + static let timeoutSeconds: TimeInterval = 0.5 + static let minimumTimeoutLeadSeconds: TimeInterval = 1 + private static let sleepNanoseconds = UInt64(sleepSeconds * 1_000_000_000) + let name: String = "test_slow_sleep" - let description: String = "Test fixture: sleeps 5 seconds, exceeding the test timeout." + let description: String = "Test fixture: sleeps 8 seconds, exceeding the test timeout." let parameters: JSONValue? = .object(["type": .string("object")]) func execute(argumentsJSON: String) async throws -> String { - try await Task.sleep(nanoseconds: 5_000_000_000) + try await Task.sleep(nanoseconds: Self.sleepNanoseconds) return ToolEnvelope.success(tool: name, text: "did not time out") } } @@ -51,7 +56,7 @@ struct ToolRegistryTimeoutTests { let result = try await ToolRegistry.runToolBody( tool, argumentsJSON: "{}", - timeoutSeconds: 0.5 + timeoutSeconds: SlowSleepTool.timeoutSeconds ) let elapsed = Date().timeIntervalSince(started) @@ -64,15 +69,17 @@ struct ToolRegistryTimeoutTests { #expect(parsed?["kind"] as? String == "timeout") #expect(parsed?["tool"] as? String == tool.name) #expect(parsed?["retryable"] as? Bool == true) - // Wall-clock budget: body sleeps 5s, so anything under 4s - // proves cancellation actually fired and we didn't accidentally - // wait for the body to finish. Looser than the previous <1s - // because xctest's parallel scheduler + Swift Concurrency - // cooperative pool can add seconds of latency under load — - // observed at ~3s under Xcode test runner vs ~0.2s on - // `swift test`. The race is the same; only the wake-up - // latency differs. - #expect(elapsed < 4.0, "took \(elapsed)s — expected <4s if timeout race fired") + + // Wall-clock budget: the envelope shape above proves the timeout + // branch won. Keep the elapsed assertion tied to the fixture's + // slow-body duration so loaded CI has room for scheduler latency, + // while still proving we returned materially before the slow tool + // could succeed. + let latestAcceptableTimeout = SlowSleepTool.sleepSeconds - SlowSleepTool.minimumTimeoutLeadSeconds + #expect( + elapsed < latestAcceptableTimeout, + "took \(elapsed)s — expected timeout at least \(SlowSleepTool.minimumTimeoutLeadSeconds)s before slow tool could finish" + ) } @Test