Fix first-run setup timing out on the kernel download

On a Mac without the Linux kernel, setup ran `container system start
--enable-kernel-install` with a 120 s limit. That command downloads a
697 MB archive without printing anything, so most connections hit the
limit and the window showed `timedOut([...])`.

- Start the service with --disable-kernel-install (10 min: a first
  start still pulls the 69 MB base filesystem).
- Download the kernel in its own step with progress, then hand the file
  to `container system kernel set --tar ... --digest ...`, which checks
  it against the runtime's digest.
- Opening the setup window no longer starts the download; Set Up does.
- Timeouts and runner errors read as sentences.
- A rebuild deletes the image it replaced, which frees about 2 GB each
  time, and deletes or stops the builder VM unless it was already
  running (it kept about 2.8 GB of memory).
- Setup asks for 10 GB free, measured on a real setup.
This commit is contained in:
aculix committed 2026-09-29 16:25:50 +05:30
1 parent 4b67728cce
commit 9fd654298a
13 files changed
+559 -128

No files matched your search

@@ -0,0 +1,163 @@
import Foundation
import Synchronization
import Testing
@testable import KnotchyCore
/// Runs `fix` against a fake `container` that logs its arguments.
struct ContainerCLIBackendSetupTests {
private struct FakeCLI {
let directory: URL
let runner: CLIRunner
var log: [String] {
let text = (try? String(contentsOf: directory.appendingPathComponent("log"), encoding: .utf8)) ?? ""
return text.split(separator: "\n").map(String.init)
}
}
/// `properties` is what `system property list --format json` prints.
private func fakeCLI(properties: String = "{}") throws -> FakeCLI {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("cli-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try properties.write(to: directory.appendingPathComponent("properties.json"), atomically: true, encoding: .utf8)
let tool = directory.appendingPathComponent("container")
let script = """
#!/bin/sh
echo "$*" >> '\(directory.path)/log'
# A test makes a command fail by creating fail-<first word> or fail-<first>-<second>.
if [ -f '\(directory.path)/fail-'"$1" ] || [ -f '\(directory.path)/fail-'"$1-$2" ]; then exit 1; fi
if [ "$1 $2 $3" = "system property list" ]; then cat '\(directory.path)/properties.json'; exit 0; fi
if [ "$1 $2" = "builder status" ]; then cat '\(directory.path)/builder.json' 2>/dev/null; exit 0; fi
# The archive must still be there when it's installed.
if [ "$1 $2 $3 $4" = "system kernel set --tar" ]; then [ -f "$5" ] || exit 3; fi
exit 0
"""
try script.write(to: tool, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tool.path)
return FakeCLI(directory: directory, runner: CLIRunner(searchPaths: [tool.path]))
}
private func fail(_ command: String, in cli: FakeCLI) throws {
try Data().write(to: cli.directory.appendingPathComponent("fail-\(command)"))
}
/// What `builder status --format json` prints; without it the state can't be read.
private func builder(_ json: String, in cli: FakeCLI) throws {
try json.write(to: cli.directory.appendingPathComponent("builder.json"), atomically: true, encoding: .utf8)
}
private func build(with cli: FakeCLI) async throws {
try await ContainerCLIBackend(vmSources: cli.directory, cli: cli.runner).fix(.imageMissing) { _ in }
}
private func kernelProperties(url: URL) -> String {
#"{"kernel":{"binaryPath":"opt/kata/vmlinux","digest":"sha256:abc123","url":"\#(url.absoluteString)"}}"#
}
@Test func startingTheServiceLeavesTheKernelToItsOwnStep() async throws {
let cli = try fakeCLI()
defer { try? FileManager.default.removeItem(at: cli.directory) }
try await ContainerCLIBackend(vmSources: nil, cli: cli.runner).fix(.serviceStopped) { _ in }
#expect(cli.log == ["system start --disable-kernel-install"])
}
@Test func theKernelIsDownloadedWithProgressThenInstalledFromTheFile() async throws {
let archive = FileManager.default.temporaryDirectory.appendingPathComponent("kata-\(UUID().uuidString).tar.zst")
try Data(count: 3_000_000).write(to: archive)
defer { try? FileManager.default.removeItem(at: archive) }
let cli = try fakeCLI(properties: kernelProperties(url: archive))
defer { try? FileManager.default.removeItem(at: cli.directory) }
let lines = LineLog()
try await ContainerCLIBackend(vmSources: nil, cli: cli.runner).fix(.kernelMissing) { lines.append($0) }
#expect(cli.log.count == 2)
#expect(cli.log.first == "system property list --format json")
let install = try #require(cli.log.last?.split(separator: " ").map(String.init))
#expect(Array(install.prefix(4)) == ["system", "kernel", "set", "--tar"])
#expect(Array(install.dropFirst(5)) == ["--binary", "opt/kata/vmlinux", "--digest", "sha256:abc123", "--force"])
// The download is deleted once the runtime has its copy.
let downloaded = URL(fileURLWithPath: install[4])
#expect(downloaded.deletingLastPathComponent().lastPathComponent.hasPrefix("knotchy-kernel-"))
#expect(!FileManager.default.fileExists(atPath: downloaded.deletingLastPathComponent().path))
#expect(lines.all.contains("Downloading the Linux kernel… 3 of 3 MB"))
#expect(lines.all.last == "Installing the Linux kernel…")
}
@Test func aFailedKernelDownloadInstallsNothing() async throws {
let missing = FileManager.default.temporaryDirectory.appendingPathComponent("missing-\(UUID().uuidString).tar.zst")
let cli = try fakeCLI(properties: kernelProperties(url: missing))
defer { try? FileManager.default.removeItem(at: cli.directory) }
await #expect(throws: VMBackendError.failed("Couldn't download the Linux kernel. Check your connection and try again.")) {
try await ContainerCLIBackend(vmSources: nil, cli: cli.runner).fix(.kernelMissing) { _ in }
}
#expect(cli.log == ["system property list --format json"])
}
@Test func aRebuildDeletesTheImageItReplaced() async throws {
let cli = try fakeCLI()
defer { try? FileManager.default.removeItem(at: cli.directory) }
try await build(with: cli)
#expect(cli.log.count == 4)
#expect(cli.log.first == "builder status --format json")
#expect(cli.log.dropFirst().first == "image tag local/knotchy-vm:latest local/knotchy-vm:previous")
#expect(cli.log.dropFirst(2).first?.hasPrefix("build ") == true)
#expect(cli.log.last == "image delete local/knotchy-vm:previous")
}
@Test func aFirstBuildHasNothingToDelete() async throws {
let cli = try fakeCLI()
defer { try? FileManager.default.removeItem(at: cli.directory) }
try fail("image-tag", in: cli)
try await build(with: cli)
#expect(!cli.log.contains { $0.hasPrefix("image delete") })
#expect(cli.log.last?.hasPrefix("build ") == true)
}
@Test func aFailedBuildKeepsTheImageButDropsTheExtraNameAndTheBuilder() async throws {
let cli = try fakeCLI()
defer { try? FileManager.default.removeItem(at: cli.directory) }
try builder("[]", in: cli)
try fail("build", in: cli)
await #expect(throws: VMBackendError.self) { try await build(with: cli) }
#expect(Array(cli.log.suffix(2)) == ["image delete local/knotchy-vm:previous", "builder delete --force"])
#expect(!cli.log.contains { $0.hasPrefix("image delete local/knotchy-vm:latest") })
}
@Test func aBuilderTheBuildCreatedIsDeletedAfterwards() async throws {
let cli = try fakeCLI()
defer { try? FileManager.default.removeItem(at: cli.directory) }
try builder("[]", in: cli)
try await build(with: cli)
#expect(cli.log.last == "builder delete --force")
}
@Test func aStoppedBuilderIsStoppedAgain() async throws {
let cli = try fakeCLI()
defer { try? FileManager.default.removeItem(at: cli.directory) }
try builder(#"[{"id":"buildkit","status":{"state":"stopped"}}]"#, in: cli)
try await build(with: cli)
#expect(cli.log.last == "builder stop")
}
@Test func aRunningBuilderIsLeftRunning() async throws {
let cli = try fakeCLI()
defer { try? FileManager.default.removeItem(at: cli.directory) }
try builder(#"[{"id":"buildkit","status":{"state":"running"}}]"#, in: cli)
try await build(with: cli)
#expect(cli.log.filter { $0.hasPrefix("builder") } == ["builder status --format json"])
}
@Test func aRuntimeThatDoesntNameItsKernelInstallsItsOwnRecommendation() async throws {
let cli = try fakeCLI(properties: #"{"kernel":{}}"#)
defer { try? FileManager.default.removeItem(at: cli.directory) }
try await ContainerCLIBackend(vmSources: nil, cli: cli.runner).fix(.kernelMissing) { _ in }
#expect(cli.log == ["system property list --format json", "system kernel set --recommended --force"])
}
}
private final class LineLog: Sendable {
private let lines = Mutex<[String]>([])
var all: [String] { lines.withLock { $0 } }
func append(_ line: String) { lines.withLock { $0.append(line) } }
}
@@ -13,6 +13,22 @@ struct SessionSetupRunTests {
#expect(h.backend.calls == [.sweep(keeping: []), .fix(.serviceStopped), .preflight, .fix(.imageMissing), .preflight])
}
@Test func aWindowRunFetchesTheKernelBetweenTheServiceAndTheBuild() async {
let h = Harness()
h.backend.queuePreflight(.issue(.kernelMissing), .issue(.imageMissing))
#expect(await h.controller.runSetup(.serviceStopped, continuing: true) == .ready)
#expect(h.backend.calls == [.sweep(keeping: []), .fix(.serviceStopped), .preflight, .fix(.kernelMissing), .preflight,
.fix(.imageMissing), .preflight])
}
@Test func aKernelThatStaysMissingIsReportedOnce() async {
let h = Harness()
h.backend.queuePreflight(.issue(.kernelMissing))
#expect(await h.controller.runSetup(.kernelMissing, continuing: true)
== .failed(.kernelMissing, SessionController.stillBrokenMessage))
#expect(h.backend.calls.filter { $0 == .fix(.kernelMissing) }.count == 1)
}
@Test func aWindowRunStartsFolded() async {
let h = Harness()
h.backend.hold(.fix)
@@ -79,6 +95,22 @@ struct SessionSetupRunTests {
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .fix(.serviceStopped), .preflight])
}
@Test func checkingNeverDownloadsTheKernel() async {
let h = Harness()
h.backend.queuePreflight(.issue(.serviceStopped), .issue(.kernelMissing))
#expect(await h.controller.checkSetup() == .needs(.kernelMissing))
#expect(h.state == .idle)
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .fix(.serviceStopped), .preflight])
}
@Test func setupErrorsReadAsSentences() {
let timedOut = SessionController.message(for: CLIRunner.Failure.timedOut(["system", "start"]))
#expect(timedOut == "The container runtime stopped responding. Try again.")
#expect(SessionController.message(for: CLIRunner.Failure.notInstalled) == "Apple's container runtime isn't installed.")
#expect(SessionController.message(for: CLIRunner.Failure.launchFailed("denied"))
== "Couldn't run the container runtime: denied")
}
@Test func checkingOpensALinkACardWasHolding() async {
let h = Harness()
h.backend.queuePreflight(.issue(.imageMissing))
@@ -270,6 +270,15 @@ struct SessionSetupTests {
.start("knotchy-00000002")])
}
@Test func aLinkFetchesAMissingKernelAfterStartingTheService() async {
let h = Harness()
h.backend.queuePreflight(.issue(.serviceStopped), .issue(.kernelMissing))
h.controller.open(Harness.url)
await waitUntil("loading") { h.state == .starting(.loading, folded: false) }
#expect(h.backend.calls == [.sweep(keeping: []), .preflight, .fix(.serviceStopped), .preflight,
.fix(.kernelMissing), .preflight, .start("knotchy-00000003")])
}
@Test func aServiceThatWontStayUpShowsItsCardOnce() async {
let h = Harness()
h.backend.queuePreflight(.issue(.serviceStopped), .issue(.serviceStopped))
+37 -2
View File
@@ -28,7 +28,7 @@ struct SetupFlowTests {
let flow = makeFlow()
await flow.refresh()
#expect(flow.runtimeAction == .install)
#expect(flow.runtime == .pending("Apple's container 1.4.1 · 118 MB · installs with Apple's Installer"))
#expect(flow.runtime == .pending("Apple's container 1.4.1 · 118 MB, then a 700 MB Linux kernel · installs with Apple's Installer"))
#expect(flow.vm == .pending("About 3 minutes · downloads about 225 MB"))
#expect(flow.canSetUp)
#expect(driver.calls == ["runtimeStatus"])
@@ -56,7 +56,7 @@ struct SetupFlowTests {
driver.runtimeStatuses = [.missing]
let flow = makeFlow()
await flow.refresh()
#expect(flow.mac == .failed("Setting up needs about 3 GB free. 2.5 GB is free.", details: []))
#expect(flow.mac == .failed("Setting up needs about 10 GB free. 2.5 GB is free.", details: []))
#expect(!flow.canSetUp)
driver.runtimeStatuses = [.current("1.4.1")]
@@ -190,6 +190,36 @@ struct SetupFlowTests {
#expect(!driver.calls.contains { $0.hasPrefix("runSetup") })
}
@Test func aMissingKernelWaitsForSetUp() async {
driver.checkResults = [.needs(.kernelMissing)]
let flow = makeFlow()
await flow.refresh()
#expect(flow.runtime == .pending("container 1.4.1 installed · still needs its Linux kernel (700 MB)"))
#expect(flow.vm == .pending("About 3 minutes · downloads about 225 MB"))
#expect(flow.canSetUp)
#expect(!driver.calls.contains { $0.hasPrefix("runSetup") })
}
@Test func setUpFetchesAMissingKernelThenBuilds() async {
driver.checkResults = [.needs(.kernelMissing), .needs(.kernelMissing)]
let flow = makeFlow()
await flow.setUp()
#expect(driver.calls.contains("runSetup(kernelMissing, continuing: true)"))
#expect(flow.runtime == .done("container 1.4.1 installed"))
#expect(flow.vm == .done("Ready"))
}
@Test func aFailedKernelDownloadShowsInTheRuntimeRow() async {
driver.checkResults = [.needs(.kernelMissing), .needs(.kernelMissing)]
driver.runResults = [.failed(.kernelMissing, "Couldn't download the Linux kernel. Check your connection and try again.")]
driver.setupOutput = ["Downloading the Linux kernel… 212 of 697 MB"]
let flow = makeFlow()
await flow.setUp()
#expect(flow.runtime == .failed("Couldn't download the Linux kernel. Check your connection and try again.",
details: ["Downloading the Linux kernel… 212 of 697 MB"]))
#expect(flow.vm == .pending("About 3 minutes · downloads about 225 MB"))
}
@Test func aBuildFailureShowsItsOutput() async {
driver.checkResults = [.needs(.imageMissing), .needs(.imageMissing)]
driver.runResults = [.failed(.imageMissing, "The image build failed: out of space")]
@@ -215,7 +245,12 @@ struct SetupFlowTests {
driver.setupLog = "Step 3/9"
#expect(flow.vm == .running("Step 3/9", fraction: nil))
driver.state = .settingUp(.serviceStopped)
driver.setupLog = nil
#expect(flow.runtime == .running("Starting the runtime…", fraction: nil))
driver.state = .settingUp(.kernelMissing)
#expect(flow.runtime == .running("Downloading the Linux kernel…", fraction: nil))
driver.setupLog = "Downloading the Linux kernel… 212 of 697 MB"
#expect(flow.runtime == .running("Downloading the Linux kernel… 212 of 697 MB", fraction: nil))
}
@Test func anOpenBrowserCountsAsAReadyVM() async {