From c6e8392a8c05b2a29a44d57fa595718a392f323f Mon Sep 17 00:00:00 2001 From: Stefan Hausotte Date: Tue, 9 Jun 2026 12:54:31 +0200 Subject: [PATCH] test: stream seed progress and add a docker exec timeout The integration seeder gave no visible progress and could hang indefinitely. A `docker compose exec` for the admin-user step wedged on a transient Docker-on-macOS flake, and `DockerExec.run` waited on it with no timeout, blocking the whole UI test suite forever with no output (stdout was block-buffered, so the existing phase prints never flushed). - Line-buffer the seeder's stdout so phase progress streams live instead of being dumped all at once when stdout is a pipe. - Add numbered "[n/7] (Ns elapsed)" headers for a clear progress and timing signal. - Add a 60s timeout to `docker compose exec` and retry the admin-user step, so a hung exec fails fast and recovers instead of wedging the suite. --- .../forgejo-seed/Sources/DockerExec.swift | 16 ++++++++++++++- integration/forgejo-seed/Sources/Main.swift | 4 ++++ .../forgejo-seed/Sources/SeedError.swift | 3 +++ integration/forgejo-seed/Sources/Seeder.swift | 20 +++++++++++++++++-- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/integration/forgejo-seed/Sources/DockerExec.swift b/integration/forgejo-seed/Sources/DockerExec.swift index 164760e..5b5e39c 100644 --- a/integration/forgejo-seed/Sources/DockerExec.swift +++ b/integration/forgejo-seed/Sources/DockerExec.swift @@ -4,6 +4,7 @@ enum DockerExec { static func run( composeFile: String, service: String, command: [String], + timeout: TimeInterval = 60, ) throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") @@ -16,8 +17,21 @@ enum DockerExec { process.standardError = stderrPipe process.standardOutput = FileHandle.nullDevice + let didExit = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in didExit.signal() } + try process.run() - process.waitUntilExit() + + // Enforce a timeout so a hung `docker compose exec` (a known Docker-on-macOS flake) + // fails fast and can be retried, instead of blocking the whole seed indefinitely. + if didExit.wait(timeout: .now() + timeout) == .timedOut { + process.terminate() + _ = didExit.wait(timeout: .now() + 5) + throw SeedError.dockerExecTimedOut( + command: command.joined(separator: " "), + seconds: timeout, + ) + } if process.terminationStatus != 0 { let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() diff --git a/integration/forgejo-seed/Sources/Main.swift b/integration/forgejo-seed/Sources/Main.swift index 0b26c76..4d0980c 100644 --- a/integration/forgejo-seed/Sources/Main.swift +++ b/integration/forgejo-seed/Sources/Main.swift @@ -3,6 +3,10 @@ import Foundation @main struct ForgejeSeed { static func main() async throws { + // Line-buffer stdout so seed progress streams live instead of being held in a + // block buffer (and dumped all at once) when stdout is a pipe rather than a TTY. + setvbuf(stdout, nil, _IOLBF, 0) + let args = CommandLine.arguments guard args.count >= 3 else { throw SeedError.missingArguments diff --git a/integration/forgejo-seed/Sources/SeedError.swift b/integration/forgejo-seed/Sources/SeedError.swift index 54a9c8e..d7043d6 100644 --- a/integration/forgejo-seed/Sources/SeedError.swift +++ b/integration/forgejo-seed/Sources/SeedError.swift @@ -3,6 +3,7 @@ import Foundation enum SeedError: LocalizedError { case missingArguments case dockerExecFailed(command: String, exitCode: Int32, stderr: String) + case dockerExecTimedOut(command: String, seconds: TimeInterval) case retryExhausted(operation: String, attempts: Int) var errorDescription: String? { @@ -11,6 +12,8 @@ enum SeedError: LocalizedError { "Usage: forgejo-seed [compose-file]" case let .dockerExecFailed(command, exitCode, stderr): "Docker exec failed (\(exitCode)): \(command)\n\(stderr)" + case let .dockerExecTimedOut(command, seconds): + "Docker exec timed out after \(Int(seconds))s: \(command)" case let .retryExhausted(operation, attempts): "\(operation) failed after \(attempts) attempts" } diff --git a/integration/forgejo-seed/Sources/Seeder.swift b/integration/forgejo-seed/Sources/Seeder.swift index ab01129..90f68c3 100644 --- a/integration/forgejo-seed/Sources/Seeder.swift +++ b/integration/forgejo-seed/Sources/Seeder.swift @@ -25,14 +25,30 @@ struct Seeder { allowSelfSignedCertificates: true, ) - try createAdminUser() + let start = Date() + let total = 7 + func step(_ number: Int, _ label: String) { + let elapsed = Int(Date().timeIntervalSince(start)) + print("\n[\(number)/\(total)] \(label) (\(elapsed)s elapsed)") + } + + step(1, "Creating admin user") + try await withRetry(maxAttempts: 3, delay: .seconds(2), operation: "create admin user") { + try createAdminUser() + } + step(2, "Creating API users") try await createAPIUsers(adminClient: adminClient) + step(3, "Creating repositories") try await createRepositories(adminClient: adminClient) + step(4, "Creating issues, files, and metadata") try await createIssuesFilesMetadata(adminClient: adminClient, testbotClient: testbotClient) + step(5, "Creating pull requests") try await createPullRequests(testbotClient: testbotClient) + step(6, "Setting pull request metadata") try await setPRMetadata(adminClient: adminClient, testbotClient: testbotClient) + step(7, "Creating API token") try await createAPIToken(adminClient: adminClient) - print("\nSeed data created successfully.") + print("\nSeed data created successfully (\(Int(Date().timeIntervalSince(start)))s total).") } // MARK: - Phase 1: Create users