From 3057ac50bf357707a9939ade7d119a678736e561 Mon Sep 17 00:00:00 2001 From: Tazintosh Date: Mon, 20 Jul 2026 00:12:47 +0200 Subject: [PATCH 1/5] Implement comprehensive application updates --- Orchard/OrchardApp.swift | 10 +- Orchard/OrchardAppDelegate.swift | 22 + Orchard/Services/AppServices.swift | 16 +- Orchard/Services/StartupSequenceService.swift | 497 ++++++++++++++++++ .../Features/MenuBar/MenuBarDashboard.swift | 3 + .../Features/Settings/SettingsView.swift | 30 +- .../Settings/StartupSettingsView.swift | 422 +++++++++++++++ Orchard/Views/Layout/Content.swift | 3 + .../StartupSequenceServiceTests.swift | 181 +++++++ 9 files changed, 1171 insertions(+), 13 deletions(-) create mode 100644 Orchard/OrchardAppDelegate.swift create mode 100644 Orchard/Services/StartupSequenceService.swift create mode 100644 Orchard/Views/Features/Settings/StartupSettingsView.swift create mode 100644 OrchardTests/StartupSequenceServiceTests.swift diff --git a/Orchard/OrchardApp.swift b/Orchard/OrchardApp.swift index 9cef0f6..cf0b1d4 100644 --- a/Orchard/OrchardApp.swift +++ b/Orchard/OrchardApp.swift @@ -2,10 +2,17 @@ import SwiftUI @main struct OrchardApp: App { - @StateObject private var services = AppServices.forLaunch() + @NSApplicationDelegateAdaptor(OrchardAppDelegate.self) private var appDelegate + @StateObject private var services: AppServices @StateObject private var menuBarManager = MenuBarManager() @StateObject private var updater = UpdaterService() + init() { + let services = AppServices.forLaunch() + _services = StateObject(wrappedValue: services) + (NSApplication.shared.delegate as? OrchardAppDelegate)?.startupSequenceService = services.startupSequenceService + } + var body: some Scene { WindowGroup(id: "main") { ContentView() @@ -70,6 +77,7 @@ extension View { .environmentObject(s.dnsService) .environmentObject(s.systemService) .environmentObject(s.containerListService) + .environmentObject(s.startupSequenceService) .environmentObject(s.machineService) .environmentObject(s.modelService) .environmentObject(s.modelServerService) diff --git a/Orchard/OrchardAppDelegate.swift b/Orchard/OrchardAppDelegate.swift new file mode 100644 index 0000000..dd1cb3a --- /dev/null +++ b/Orchard/OrchardAppDelegate.swift @@ -0,0 +1,22 @@ +import AppKit + +@MainActor +final class OrchardAppDelegate: NSObject, NSApplicationDelegate { + weak var startupSequenceService: StartupSequenceService? + private var terminationCleanupTask: Task? + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + if terminationCleanupTask != nil { return .terminateLater } + guard let startupSequenceService, + !startupSequenceService.sequenceOwnedContainerIDs.isEmpty || startupSequenceService.isRunning else { + return .terminateNow + } + + terminationCleanupTask = Task { @MainActor [weak self] in + await startupSequenceService.stopSequenceOwnedContainers() + sender.reply(toApplicationShouldTerminate: true) + self?.terminationCleanupTask = nil + } + return .terminateLater + } +} diff --git a/Orchard/Services/AppServices.swift b/Orchard/Services/AppServices.swift index 732cfca..b9a52f9 100644 --- a/Orchard/Services/AppServices.swift +++ b/Orchard/Services/AppServices.swift @@ -19,8 +19,9 @@ final class AppServices: ObservableObject { let imageService: ImageService let statsService: StatsService let dnsService: DNSService - let systemService: SystemService - let containerListService: ContainerListService + let systemService: SystemService + let containerListService: ContainerListService + let startupSequenceService: StartupSequenceService let machineService: MachineService let modelService: ModelService let modelServerService: ModelServerService @@ -63,9 +64,14 @@ final class AppServices: ObservableObject { let systemService = SystemService(backend: backend, runner: runner, settings: settings, alertCenter: alertCenter) self.systemService = systemService // ContainerListService is built before StatsService, which depends on it. - let containerListService = ContainerListService(backend: backend, alertCenter: alertCenter) - self.containerListService = containerListService - let statsService = StatsService(backend: backend, alertCenter: alertCenter, containerList: containerListService) + let containerListService = ContainerListService(backend: backend, alertCenter: alertCenter) + self.containerListService = containerListService + let startupRuntime = StartupSequenceRuntimeAdapter( + backend: backend, + systemService: systemService, + containerListService: containerListService) + self.startupSequenceService = StartupSequenceService(runtime: startupRuntime, defaults: defaults) + let statsService = StatsService(backend: backend, alertCenter: alertCenter, containerList: containerListService) self.statsService = statsService self.machineService = MachineService(backend: machineBackend, alertCenter: alertCenter) self.modelService = ModelService(backend: modelBackend) diff --git a/Orchard/Services/StartupSequenceService.swift b/Orchard/Services/StartupSequenceService.swift new file mode 100644 index 0000000..b4cc6f4 --- /dev/null +++ b/Orchard/Services/StartupSequenceService.swift @@ -0,0 +1,497 @@ +import Foundation + +struct StartupSequence: Codable, Equatable { + var isEnabled = false + var groups: [StartupGroup] = [] + + static let readinessTimeout: TimeInterval = 60 + + init(isEnabled: Bool = false, groups: [StartupGroup] = [], steps: [StartupStep]? = nil) { + self.isEnabled = isEnabled + if let steps { + self.groups = [StartupGroup(name: "Startup", containers: steps.map { + StartupGroupContainer(containerID: $0.containerID, waitForContainerIDs: $0.waitForContainerIDs) + })] + } else { + self.groups = groups + } + } + + private enum CodingKeys: String, CodingKey { + case isEnabled + case groups + case steps + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? false + if let groups = try container.decodeIfPresent([StartupGroup].self, forKey: .groups) { + self.groups = groups + } else if let steps = try container.decodeIfPresent([StartupStep].self, forKey: .steps) { + groups = [StartupGroup(name: "Startup", containers: steps.map { + StartupGroupContainer(containerID: $0.containerID, waitForContainerIDs: $0.waitForContainerIDs) + })] + } else { + groups = [] + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(isEnabled, forKey: .isEnabled) + try container.encode(groups, forKey: .groups) + } + + func validationError(availableContainerIDs: Set? = nil) -> String? { + var groupIDs = Set() + var containerGroups: [String: UUID] = [:] + + for (groupIndex, group) in groups.enumerated() { + guard !group.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return "Group \(groupIndex + 1) does not have a name." + } + + guard groupIDs.insert(group.id).inserted else { + return "Group \"\(group.name)\" appears more than once." + } + + for container in group.containers { + guard !container.containerID.isEmpty else { + return "Group \"\(group.name)\" has an empty container selection." + } + + if let availableContainerIDs, !availableContainerIDs.contains(container.containerID) { + return "Container \"\(container.containerID)\" is not available." + } + + if let existingGroupID = containerGroups[container.containerID] { + if existingGroupID == group.id { + return "Container \"\(container.containerID)\" appears more than once." + } + return "Container \"\(container.containerID)\" belongs to more than one group." + } + containerGroups[container.containerID] = group.id + } + } + + for group in groups { + var groupDependencies = Set() + for dependencyID in group.waitForGroupIDs { + guard dependencyID != group.id else { + return "Group \"\(group.name)\" cannot wait for itself." + } + guard groupIDs.contains(dependencyID) else { + return "Group \"\(group.name)\" waits for a missing group." + } + guard groupDependencies.insert(dependencyID).inserted else { + return "Group \"\(group.name)\" lists the same prerequisite more than once." + } + } + + for container in group.containers { + var dependencies = Set() + for dependencyID in container.waitForContainerIDs { + guard dependencyID != container.containerID else { + return "Container \"\(container.containerID)\" cannot wait for itself." + } + guard dependencies.insert(dependencyID).inserted else { + return "Dependency \"\(dependencyID)\" is listed more than once for \"\(container.containerID)\"." + } + guard containerGroups[dependencyID] != nil else { + return "Container \"\(container.containerID)\" waits for a missing container \"\(dependencyID)\"." + } + } + } + } + + if hasDependencyCycle(in: groups) { + return "Startup groups contain a dependency cycle." + } + return nil + } + + func dependencyCycleGroupIDs() -> Set { + Set(dependencyCycleNodes().compactMap { node in + if case .group(let id) = node { return id } + return nil + }) + } + + func dependencyCycleContainerIDs() -> Set { + Set(dependencyCycleNodes().compactMap { node in + if case .container(let id) = node { return id } + return nil + }) + } + + func wouldCreateGroupDependency(groupID: UUID, dependencyID: UUID) -> Bool { + var proposedGroups = groups + guard let index = proposedGroups.firstIndex(where: { $0.id == groupID }) else { return false } + if !proposedGroups[index].waitForGroupIDs.contains(dependencyID) { + proposedGroups[index].waitForGroupIDs.append(dependencyID) + } + return hasDependencyCycle(in: proposedGroups) + } + + func wouldCreateContainerDependency(containerID: String, dependencyID: String) -> Bool { + var proposedGroups = groups + for groupIndex in proposedGroups.indices { + guard let containerIndex = proposedGroups[groupIndex].containers.firstIndex(where: { $0.containerID == containerID }) else { continue } + if !proposedGroups[groupIndex].containers[containerIndex].waitForContainerIDs.contains(dependencyID) { + proposedGroups[groupIndex].containers[containerIndex].waitForContainerIDs.append(dependencyID) + } + return hasDependencyCycle(in: proposedGroups) + } + return false + } + + private enum DependencyNode: Hashable { + case group(UUID) + case container(String) + } + + private func hasDependencyCycle(in groups: [StartupGroup]) -> Bool { + !dependencyCycleNodes(in: groups).isEmpty + } + + private func dependencyCycleNodes(in groups: [StartupGroup] = []) -> Set { + let groups = groups.isEmpty ? self.groups : groups + var dependencies: [DependencyNode: Set] = [:] + for group in groups { + let groupNode = DependencyNode.group(group.id) + dependencies[groupNode, default: []].formUnion(group.waitForGroupIDs.map(DependencyNode.group)) + dependencies[groupNode, default: []].formUnion(group.containers.map { .container($0.containerID) }) + for container in group.containers { + dependencies[.container(container.containerID), default: []].formUnion( + container.waitForContainerIDs.map(DependencyNode.container)) + } + } + + var visited = Set() + var path: [DependencyNode] = [] + var cycleNodes = Set() + func visit(_ node: DependencyNode) -> Bool { + if let cycleStart = path.firstIndex(of: node) { + cycleNodes.formUnion(path[cycleStart...]) + return true + } + if visited.contains(node) { return false } + path.append(node) + for dependency in dependencies[node, default: []] where visit(dependency) { + break + } + path.removeLast() + visited.insert(node) + return false + } + + for node in dependencies.keys { + _ = visit(node) + } + return cycleNodes + } +} + +struct StartupGroup: Codable, Equatable, Identifiable { + let id: UUID + var name: String + var containers: [StartupGroupContainer] + var waitForGroupIDs: [UUID] + + init( + id: UUID = UUID(), + name: String, + containers: [StartupGroupContainer] = [], + waitForGroupIDs: [UUID] = []) { + self.id = id + self.name = name + self.containers = containers + self.waitForGroupIDs = waitForGroupIDs + } +} + +struct StartupGroupContainer: Codable, Equatable, Identifiable { + let id: UUID + var containerID: String + var waitForContainerIDs: [String] + + init(id: UUID = UUID(), containerID: String, waitForContainerIDs: [String] = []) { + self.id = id + self.containerID = containerID + self.waitForContainerIDs = waitForContainerIDs + } +} + +struct StartupStep: Codable, Equatable, Identifiable { + let id: UUID + var containerID: String + var waitForContainerIDs: [String] + + init(id: UUID = UUID(), containerID: String, waitForContainerID: String? = nil, waitForContainerIDs: [String]? = nil) { + self.id = id + self.containerID = containerID + self.waitForContainerIDs = waitForContainerIDs ?? waitForContainerID.map { [$0] } ?? [] + } +} + +enum StartupSequenceRunState: Equatable { + case idle + case startingSystem + case startingGroup(String) + case starting(String) + case waiting(String) + case completed + case stopping + case failed(String) + + var displayText: String { + switch self { + case .idle: + return "Not run" + case .startingSystem: + return "Starting container system…" + case .startingGroup(let name): + return "Starting group \(name)…" + case .starting(let containerID): + return "Starting \(containerID)…" + case .waiting(let containerID): + return "Waiting for \(containerID) to run…" + case .completed: + return "Sequence completed" + case .stopping: + return "Stopping sequence-owned containers…" + case .failed(let message): + return message + } + } +} + +enum StartupSequenceError: LocalizedError, Equatable { + case containerNotFound(String) + case readinessTimeout(String) + case runtimeFailure(String) + + var errorDescription: String? { + switch self { + case .containerNotFound(let message), + .readinessTimeout(let message), + .runtimeFailure(let message): + return message + } + } +} + +@MainActor +protocol StartupSequenceRuntime: AnyObject { + func isContainerSystemRunning() async -> Bool + func startContainerSystem() async throws + func containerStatuses() async throws -> [String: String] + func startStartupContainer(_ id: String) async throws + func stopStartupContainer(_ id: String) async throws +} + +@MainActor +final class StartupSequenceRuntimeAdapter: StartupSequenceRuntime { + private let backend: ContainerBackend + private let systemService: SystemService + private let containerListService: ContainerListService + + init(backend: ContainerBackend, systemService: SystemService, containerListService: ContainerListService) { + self.backend = backend + self.systemService = systemService + self.containerListService = containerListService + } + + func isContainerSystemRunning() async -> Bool { + if systemService.systemStatus == .running { return true } + await systemService.checkSystemStatus() + return systemService.systemStatus == .running + } + + func startContainerSystem() async throws { + await systemService.startSystem() + guard systemService.systemStatus == .running else { + throw StartupSequenceError.runtimeFailure("The container system could not be started.") + } + } + + func containerStatuses() async throws -> [String: String] { + let containers = try await backend.listContainers() + containerListService.containers = containers + return Dictionary(uniqueKeysWithValues: containers.map { ($0.configuration.id, $0.status) }) + } + + func startStartupContainer(_ id: String) async throws { + try await backend.bootstrapAndStart(id: id) + } + + func stopStartupContainer(_ id: String) async throws { + try await backend.stopContainer(id: id) + } +} + +@MainActor +final class StartupSequenceService: ObservableObject { + @Published private(set) var sequence: StartupSequence + @Published private(set) var state: StartupSequenceRunState = .idle + @Published private(set) var sequenceOwnedContainerIDs: [String] = [] + + private let runtime: StartupSequenceRuntime + private let defaults: UserDefaults + private let readinessTimeout: TimeInterval + private let sequenceKey = "OrchardStartupSequence" + private var hasAttemptedAutomaticRun = false + private var activeRunTask: Task? + + init(runtime: StartupSequenceRuntime, defaults: UserDefaults = .standard, readinessTimeout: TimeInterval = StartupSequence.readinessTimeout) { + self.runtime = runtime + self.defaults = defaults + self.readinessTimeout = readinessTimeout + if let data = defaults.data(forKey: sequenceKey), let savedSequence = try? JSONDecoder().decode(StartupSequence.self, from: data) { + sequence = savedSequence + } else { + sequence = StartupSequence() + } + } + + var isRunning: Bool { activeRunTask != nil } + + func updateSequence(_ sequence: StartupSequence) { + self.sequence = sequence + persistSequence() + } + + func persistSequence() { + guard let data = try? JSONEncoder().encode(sequence) else { return } + defaults.set(data, forKey: sequenceKey) + } + + func runIfEnabled(availableContainerIDs: Set) { + guard sequence.isEnabled, !hasAttemptedAutomaticRun else { return } + hasAttemptedAutomaticRun = true + run(availableContainerIDs: availableContainerIDs) + } + + func run(availableContainerIDs: Set) { + guard activeRunTask == nil else { return } + activeRunTask = Task { [weak self] in + guard let self else { return } + await self.execute(availableContainerIDs: availableContainerIDs) + self.activeRunTask = nil + } + } + + func stopSequenceOwnedContainers() async { + let runTask = activeRunTask + runTask?.cancel() + if let runTask { await runTask.value } + activeRunTask = nil + guard !sequenceOwnedContainerIDs.isEmpty else { return } + + state = .stopping + for id in sequenceOwnedContainerIDs.reversed() { + do { + try await runtime.stopStartupContainer(id) + } catch { + state = .failed("Failed to stop \(id): \(error.localizedDescription)") + } + } + sequenceOwnedContainerIDs.removeAll() + if case .stopping = state { state = .idle } + } + + private func execute(availableContainerIDs: Set) async { + if let validationError = sequence.validationError(availableContainerIDs: availableContainerIDs) { + state = .failed(validationError) + return + } + + do { + if !(await runtime.isContainerSystemRunning()) { + state = .startingSystem + try await runtime.startContainerSystem() + } + + var completedGroups = Set() + while completedGroups.count < sequence.groups.count { + try Task.checkCancellation() + let eligibleGroups = sequence.groups.filter { + !completedGroups.contains($0.id) && Set($0.waitForGroupIDs).isSubset(of: completedGroups) + } + guard !eligibleGroups.isEmpty else { + throw StartupSequenceError.runtimeFailure("Startup groups could not be ordered.") + } + + try await withThrowingTaskGroup(of: UUID.self) { groupTasks in + for group in eligibleGroups { + groupTasks.addTask { [weak self] in + guard let self else { throw CancellationError() } + try await self.execute(group) + return group.id + } + } + for try await groupID in groupTasks { + completedGroups.insert(groupID) + } + } + } + + state = .completed + } catch is CancellationError { + state = .idle + } catch { + state = .failed(error.localizedDescription) + } + } + + private func execute(_ group: StartupGroup) async throws { + state = .startingGroup(group.name) + try await withThrowingTaskGroup(of: String?.self) { containerTasks in + for container in group.containers { + containerTasks.addTask { [weak self] in + guard let self else { throw CancellationError() } + return try await self.execute(container) + } + } + for try await startedContainerID in containerTasks { + if let startedContainerID { + sequenceOwnedContainerIDs.append(startedContainerID) + } + } + } + } + + private func execute(_ container: StartupGroupContainer) async throws -> String? { + for dependency in container.waitForContainerIDs { + try await waitUntilRunning(dependency) + } + + let statuses = try await runtime.containerStatuses() + guard let status = statuses[container.containerID] else { + throw StartupSequenceError.containerNotFound(container.containerID) + } + + if status.lowercased() == "running" { + return nil + } + + state = .starting(container.containerID) + try await runtime.startStartupContainer(container.containerID) + try await waitUntilRunning(container.containerID) + return container.containerID + } + + private func waitUntilRunning(_ id: String) async throws { + let deadline = Date().addingTimeInterval(readinessTimeout) + while Date() < deadline { + try Task.checkCancellation() + let statuses = try await runtime.containerStatuses() + if statuses[id]?.lowercased() == "running" { return } + state = .waiting(id) + let remainingMilliseconds = Int(max(1, min(500, deadline.timeIntervalSinceNow * 1_000))) + try await Task.sleep(for: .milliseconds(remainingMilliseconds)) + } + throw StartupSequenceError.readinessTimeout("Timed out waiting for \(id) to become running.") + } +} diff --git a/Orchard/Views/Features/MenuBar/MenuBarDashboard.swift b/Orchard/Views/Features/MenuBar/MenuBarDashboard.swift index 20e4b8f..66ac846 100644 --- a/Orchard/Views/Features/MenuBar/MenuBarDashboard.swift +++ b/Orchard/Views/Features/MenuBar/MenuBarDashboard.swift @@ -7,6 +7,7 @@ struct MenuBarView: View { @EnvironmentObject var containerListService: ContainerListService @EnvironmentObject var builderService: BuilderService @EnvironmentObject var systemService: SystemService + @EnvironmentObject var startupSequenceService: StartupSequenceService @EnvironmentObject var dnsService: DNSService @EnvironmentObject var networkService: NetworkService @EnvironmentObject var statsService: StatsService @@ -190,6 +191,8 @@ struct MenuBarView: View { await dnsService.load(showLoading: true) await networkService.load(showLoading: true) await systemService.loadSystemDiskUsage(showLoading: false) + startupSequenceService.runIfEnabled( + availableContainerIDs: Set(containerListService.containers.map { $0.configuration.id })) } .onDisappear { stopRefreshTimer() diff --git a/Orchard/Views/Features/Settings/SettingsView.swift b/Orchard/Views/Features/Settings/SettingsView.swift index 0939e81..89744b3 100644 --- a/Orchard/Views/Features/Settings/SettingsView.swift +++ b/Orchard/Views/Features/Settings/SettingsView.swift @@ -7,22 +7,40 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var systemService: SystemService @EnvironmentObject var dnsService: DNSService + @EnvironmentObject var startupSequenceService: StartupSequenceService + @State private var selectedTab: SettingsTab = .general + + fileprivate enum SettingsTab: Hashable { + case general + case startup + case system + } var body: some View { - TabView { + TabView(selection: $selectedTab) { GeneralSettingsView() + .fixedSize(horizontal: false, vertical: true) .tabItem { Label("General", systemImage: "gearshape") } + .tag(SettingsTab.general) + + StartupSettingsView() + .fixedSize(horizontal: false, vertical: true) + .tabItem { + Label("Startup", systemImage: "arrow.clockwise.circle") + } + .tag(SettingsTab.startup) SystemSettingsView() + .fixedSize(horizontal: false, vertical: true) .tabItem { Label("System", systemImage: "cpu") } + .tag(SettingsTab.system) } - .frame(width: 540, height: 460) - // Loaded once here rather than per-tab: macOS TabView builds both tabs when the - // window opens, so a per-tab onAppear would fetch the property list twice. + .padding(20) + .frame(width: 540) .onAppear { Task { await systemService.loadSystemProperties(showLoading: true) @@ -32,8 +50,6 @@ struct SettingsView: View { } } -/// Genuine app preferences: which terminal to open shells in, which `container` binary to -/// drive, the default DNS domain, and software updates. struct GeneralSettingsView: View { @EnvironmentObject var settings: SettingsStore @EnvironmentObject var alertCenter: AlertCenter @@ -193,7 +209,7 @@ struct SystemSettingsView: View { description: String ) -> some View { let property = systemService.systemProperties.first(where: { $0.id == propertyId }) - let value = (useDisplayValue ? property?.displayValue : property?.value) ?? "Loading..." + let value = (useDisplayValue ? property?.displayValue : property?.value) ?? "Loading…" return Section { LabeledContent(title) { Text(value) diff --git a/Orchard/Views/Features/Settings/StartupSettingsView.swift b/Orchard/Views/Features/Settings/StartupSettingsView.swift new file mode 100644 index 0000000..eb4fc92 --- /dev/null +++ b/Orchard/Views/Features/Settings/StartupSettingsView.swift @@ -0,0 +1,422 @@ +import SwiftUI +struct StartupSettingsView: View { + @EnvironmentObject private var containerListService: ContainerListService + @EnvironmentObject private var startupSequenceService: StartupSequenceService + @State private var isShowingAddGroupPrompt = false + @State private var newGroupName = "" + @State private var showStatusMessage = true + + private var availableContainerIDs: [String] { + let configuredIDs = startupSequenceService.sequence.groups.flatMap { $0.containers.map(\.containerID) } + return Set(containerListService.containers.map { $0.configuration.id } + configuredIDs).sorted() + } + + private var validationMessage: String? { + startupSequenceService.sequence.validationError( + availableContainerIDs: Set(containerListService.containers.map { $0.configuration.id })) + } + + var body: some View { + Form { + Section { + Toggle( + "Run startup sequence when Orchard opens", + isOn: Binding( + get: { startupSequenceService.sequence.isEnabled }, + set: { isEnabled in + var sequence = startupSequenceService.sequence + sequence.isEnabled = isEnabled + startupSequenceService.updateSequence(sequence) + })) + + if let validationMessage { + Label(validationMessage, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + } + + if startupSequenceService.sequence.groups.isEmpty { + Section("Groups") { + ContentUnavailableView { + Label("No startup groups", systemImage: "square.stack.3d.up") + } description: { + Text("Add a group to build the launch graph.") + } actions: { + Button("Add Group", systemImage: "plus") { + newGroupName = "" + isShowingAddGroupPrompt = true + } + } + .frame(maxWidth: .infinity, minHeight: 180, alignment: .center) + } + } else { + ForEach(startupSequenceService.sequence.groups.indices, id: \.self) { index in + StartupGroupEditor( + group: startupSequenceService.sequence.groups[index], + allGroups: startupSequenceService.sequence.groups, + containerIDs: availableContainerIDs, + startupSequence: startupSequenceService.sequence, + onChange: { updateGroup(index, with: $0) }, + onMoveUp: { moveGroup(index, by: -1) }, + onMoveDown: { moveGroup(index, by: 1) }, + onDelete: { deleteGroup(index) }) + } + } + + if !startupSequenceService.sequence.groups.isEmpty { + Section { + HStack { + Spacer() + Button("Add Group", systemImage: "plus") { + newGroupName = "" + isShowingAddGroupPrompt = true + } + Spacer() + } + } + } + + Section { + HStack { + Button("Run Sequence", systemImage: "play.fill") { + startupSequenceService.run( + availableContainerIDs: Set(containerListService.containers.map { $0.configuration.id })) + } + .disabled(startupSequenceService.isRunning || validationMessage != nil) + + Button("Stop Owned Containers", systemImage: "stop.fill") { + Task { await startupSequenceService.stopSequenceOwnedContainers() } + } + .disabled(startupSequenceService.sequenceOwnedContainerIDs.isEmpty && !startupSequenceService.isRunning) + } + + if showStatusMessage { + Label(startupSequenceService.state.displayText, systemImage: "info.circle") + .foregroundStyle(.secondary) + } + } + } + .formStyle(.grouped) + .alert("Add Group", isPresented: $isShowingAddGroupPrompt) { + TextField("Group name", text: $newGroupName) + Button("Cancel", role: .cancel) { } + Button("Add") { + addGroup(named: newGroupName) + } + .disabled(newGroupName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } message: { + Text("Enter a name for the startup group.") + } + .task(id: startupSequenceService.state) { + if startupSequenceService.state == .completed { + showStatusMessage = true + try? await Task.sleep(for: .seconds(5)) + if !Task.isCancelled { + showStatusMessage = false + } + } else { + showStatusMessage = true + } + } + } + + private func addGroup(named name: String) { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return } + var sequence = startupSequenceService.sequence + sequence.groups.append(StartupGroup(name: trimmedName)) + startupSequenceService.updateSequence(sequence) + } + + private func updateGroup(_ index: Int, with group: StartupGroup) { + var sequence = startupSequenceService.sequence + guard sequence.groups.indices.contains(index) else { return } + sequence.groups[index] = group + startupSequenceService.updateSequence(sequence) + } + + private func moveGroup(_ index: Int, by offset: Int) { + var sequence = startupSequenceService.sequence + let destination = index + offset + guard sequence.groups.indices.contains(index), sequence.groups.indices.contains(destination) else { return } + sequence.groups.swapAt(index, destination) + startupSequenceService.updateSequence(sequence) + } + + private func deleteGroup(_ index: Int) { + var sequence = startupSequenceService.sequence + guard sequence.groups.indices.contains(index) else { return } + sequence.groups.remove(at: index) + startupSequenceService.updateSequence(sequence) + } +} + +private struct StartupGroupEditor: View { + let group: StartupGroup + let allGroups: [StartupGroup] + let containerIDs: [String] + let startupSequence: StartupSequence + let onChange: (StartupGroup) -> Void + let onMoveUp: () -> Void + let onMoveDown: () -> Void + let onDelete: () -> Void + + var body: some View { + let containersInOtherGroups = Set( + allGroups + .filter { $0.id != group.id } + .flatMap { $0.containers.map(\.containerID) }) + + Section { + if group.containers.isEmpty { + ContentUnavailableView { + Label("No containers", systemImage: "shippingbox") + } description: { + Text("Add a container to this startup group.") + } actions: { + Button("Add Container", systemImage: "plus", action: addContainer) + } + .frame(maxWidth: .infinity, minHeight: 120, alignment: .center) + } else { + ForEach(group.containers) { container in + StartupGroupContainerEditor( + container: container, + containerIDs: containerIDs, + allGroups: allGroups, + disabledContainerIDs: containersInOtherGroups, + startupSequence: startupSequence, + onChange: { updateContainer(container.id, with: $0) }, + onDelete: { deleteContainer(container.id) }) + } + } + + if !group.containers.isEmpty { + Button("Add Container", systemImage: "plus", action: addContainer) + .disabled(containerIDs.allSatisfy { id in + group.containers.contains { $0.containerID == id } || containersInOtherGroups.contains(id) + }) + .frame(maxWidth: .infinity, alignment: .center) + } + } header: { + HStack { + HStack { + Text(group.name) + Menu { + ForEach(allGroups.filter { $0.id != group.id }) { candidate in + Toggle(candidate.name, isOn: Binding( + get: { group.waitForGroupIDs.contains(candidate.id) }, + set: { selected in + var dependencies = group.waitForGroupIDs + if selected { + if !dependencies.contains(candidate.id) { dependencies.append(candidate.id) } + } else { + dependencies.removeAll { $0 == candidate.id } + } + onChange(StartupGroup(id: group.id, name: group.name, containers: group.containers, waitForGroupIDs: dependencies)) + })) + .disabled(startupSequence.wouldCreateGroupDependency(groupID: group.id, dependencyID: candidate.id) && !group.waitForGroupIDs.contains(candidate.id)) + } + } label: { + Label( + groupPrerequisiteLabel, + systemImage: "arrow.trianglehead.turn.up.right.diamond") + } + } + Spacer() + Button("Move up", systemImage: "chevron.up", action: onMoveUp) + .labelStyle(.iconOnly) + .buttonStyle(.plain) + Button("Move down", systemImage: "chevron.down", action: onMoveDown) + .labelStyle(.iconOnly) + .buttonStyle(.plain) + Button("Delete group", systemImage: "trash", action: onDelete) + .labelStyle(.iconOnly) + .foregroundStyle(.red) + .buttonStyle(.plain) + } + .controlSize(.small) + } + } + + private var groupPrerequisiteLabel: String { + switch group.waitForGroupIDs.count { + case 0: + return "No group prerequisites" + case 1: + return "Wait for \(allGroups.first { $0.id == group.waitForGroupIDs[0] }?.name ?? "group")" + default: + return "Wait for \(group.waitForGroupIDs.count) groups" + } + } + + private func addContainer() { + let containersInOtherGroups = Set( + allGroups + .filter { $0.id != group.id } + .flatMap { $0.containers.map(\.containerID) }) + guard let id = containerIDs.first(where: { id in + !group.containers.contains { $0.containerID == id } && !containersInOtherGroups.contains(id) + }) else { return } + var containers = group.containers + containers.append(StartupGroupContainer(containerID: id)) + onChange(StartupGroup(id: group.id, name: group.name, containers: containers, waitForGroupIDs: group.waitForGroupIDs)) + } + + private func updateContainer(_ id: UUID, with container: StartupGroupContainer) { + var containers = group.containers + guard let index = containers.firstIndex(where: { $0.id == id }) else { return } + containers[index] = container + onChange(StartupGroup(id: group.id, name: group.name, containers: containers, waitForGroupIDs: group.waitForGroupIDs)) + } + + private func deleteContainer(_ id: UUID) { + var containers = group.containers + containers.removeAll { $0.id == id } + onChange(StartupGroup(id: group.id, name: group.name, containers: containers, waitForGroupIDs: group.waitForGroupIDs)) + } +} + +private struct StartupGroupContainerEditor: View { + let container: StartupGroupContainer + let containerIDs: [String] + let allGroups: [StartupGroup] + let disabledContainerIDs: Set + let startupSequence: StartupSequence + let onChange: (StartupGroupContainer) -> Void + let onDelete: () -> Void + + var body: some View { + HStack { + HStack { + Menu { + ForEach(containerIDs, id: \.self) { id in + Button { + onChange(StartupGroupContainer(id: container.id, containerID: id, waitForContainerIDs: container.waitForContainerIDs)) + } label: { + if id == container.containerID { + Label(id, systemImage: "checkmark") + } else { + Text(id) + } + } + .disabled(disabledContainerIDs.contains(id) && id != container.containerID) + } + } label: { + Label(container.containerID, systemImage: "shippingbox") + } + + Menu { + ForEach(allGroups) { group in + let ids = group.containers.map(\.containerID).filter { $0 != container.containerID } + if !ids.isEmpty { + Section(group.name) { + ForEach(ids, id: \.self) { id in + prerequisiteToggle(for: id) + } + } + } + } + + let groupedIDs = Set(allGroups.flatMap { $0.containers.map(\.containerID) }) + let ungroupedIDs = containerIDs.filter { !$0.isEmpty && !groupedIDs.contains($0) && $0 != container.containerID } + if !ungroupedIDs.isEmpty { + Section("Available containers") { + ForEach(ungroupedIDs, id: \.self) { id in + prerequisiteToggle(for: id) + } + } + } + } label: { + Label( + containerPrerequisiteLabel, + systemImage: "arrow.trianglehead.turn.up.right.diamond") + } + } + Spacer() + Button("Delete container", systemImage: "trash", action: onDelete) + .labelStyle(.iconOnly) + .foregroundStyle(.red) + .buttonStyle(.plain) + } + .controlSize(.small) + } + + @ViewBuilder + private func prerequisiteToggle(for id: String) -> some View { + Toggle(id, isOn: Binding( + get: { container.waitForContainerIDs.contains(id) }, + set: { selected in + var dependencies = container.waitForContainerIDs + if selected { + if !dependencies.contains(id) { dependencies.append(id) } + } else { + dependencies.removeAll { $0 == id } + } + onChange(StartupGroupContainer(id: container.id, containerID: container.containerID, waitForContainerIDs: dependencies)) + })) + .disabled(startupSequence.wouldCreateContainerDependency(containerID: container.containerID, dependencyID: id) && !container.waitForContainerIDs.contains(id)) + } + + private var containerPrerequisiteLabel: String { + switch container.waitForContainerIDs.count { + case 0: + return "No prerequisites" + case 1: + return "Wait for \(container.waitForContainerIDs[0])" + default: + return "Wait for \(container.waitForContainerIDs.count) containers" + } + } +} + +#Preview("Startup Groups") { + let certbotID = UUID() + let websitesID = UUID() + let servicesID = UUID() + let groups = [ + StartupGroup( + id: certbotID, + name: "Certbot", + containers: [ + StartupGroupContainer(containerID: "certbot_aepornis"), + StartupGroupContainer(containerID: "certbot_pugnier"), + StartupGroupContainer(containerID: "certbot_tazintosh")]), + StartupGroup( + id: websitesID, + name: "Websites", + containers: [ + StartupGroupContainer(containerID: "mysql"), + StartupGroupContainer(containerID: "php"), + StartupGroupContainer(containerID: "php7"), + StartupGroupContainer(containerID: "nginx", waitForContainerIDs: ["mysql", "php", "php7"])], + waitForGroupIDs: [certbotID, servicesID]), + StartupGroup( + id: servicesID, + name: "Services", + containers: [ + StartupGroupContainer(containerID: "mosquitto", waitForContainerIDs: ["nodered"]), + StartupGroupContainer(containerID: "nodered", waitForContainerIDs: ["mosquitto", "mysql"]), + StartupGroupContainer(containerID: "emby"), + StartupGroupContainer(containerID: "hotline"), + StartupGroupContainer(containerID: "minecraft")], + waitForGroupIDs: [certbotID, websitesID]) + ] + let containerIDs = groups.flatMap { $0.containers.map(\.containerID) } + let sequence = StartupSequence(groups: groups) + + Form { + ForEach(groups) { group in + StartupGroupEditor( + group: group, + allGroups: groups, + containerIDs: containerIDs, + startupSequence: sequence, + onChange: { _ in }, + onMoveUp: { }, + onMoveDown: { }, + onDelete: { }) + } + } + .formStyle(.grouped) + .frame(width: 640, height: 760) +} diff --git a/Orchard/Views/Layout/Content.swift b/Orchard/Views/Layout/Content.swift index 3f84934..c2ccc02 100644 --- a/Orchard/Views/Layout/Content.swift +++ b/Orchard/Views/Layout/Content.swift @@ -7,6 +7,7 @@ struct ContentView: View { @EnvironmentObject var builderService: BuilderService @EnvironmentObject var statsService: StatsService @EnvironmentObject var systemService: SystemService + @EnvironmentObject var startupSequenceService: StartupSequenceService @EnvironmentObject var dnsService: DNSService @EnvironmentObject var networkService: NetworkService @EnvironmentObject var machineService: MachineService @@ -298,6 +299,8 @@ struct ContentView: View { await networkService.load(showLoading: true) await machineService.load(showLoading: true) await modelService.load(showLoading: true) + startupSequenceService.runIfEnabled( + availableContainerIDs: Set(containerListService.containers.map { $0.configuration.id })) } private func startRefreshTimer() { diff --git a/OrchardTests/StartupSequenceServiceTests.swift b/OrchardTests/StartupSequenceServiceTests.swift new file mode 100644 index 0000000..815449f --- /dev/null +++ b/OrchardTests/StartupSequenceServiceTests.swift @@ -0,0 +1,181 @@ +import Foundation +import Testing +@testable import Orchard + +@MainActor +private final class StartupRuntimeStub: StartupSequenceRuntime { + var systemRunning = false + var statuses: [String: String] + var startSystemCount = 0 + var startedContainerIDs: [String] = [] + var stoppedContainerIDs: [String] = [] + var containersBecomeRunning = true + + init(statuses: [String: String]) { + self.statuses = statuses + } + + func isContainerSystemRunning() async -> Bool { + systemRunning + } + + func startContainerSystem() async throws { + startSystemCount += 1 + systemRunning = true + } + + func containerStatuses() async throws -> [String: String] { + statuses + } + + func startStartupContainer(_ id: String) async throws { + startedContainerIDs.append(id) + if containersBecomeRunning { + statuses[id] = "running" + } + } + + func stopStartupContainer(_ id: String) async throws { + stoppedContainerIDs.append(id) + } +} + +@MainActor +struct StartupSequenceServiceTests { + @Test func persistsSequenceConfiguration() { + let suiteName = "OrchardStartupSequenceTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let runtime = StartupRuntimeStub(statuses: [:]) + let service = StartupSequenceService(runtime: runtime, defaults: defaults) + let sequence = StartupSequence( + isEnabled: true, + steps: [ + StartupStep(containerID: "a"), + StartupStep(containerID: "b", waitForContainerID: "a") + ]) + + service.updateSequence(sequence) + let reloaded = StartupSequenceService(runtime: runtime, defaults: defaults) + + #expect(reloaded.sequence == sequence) + } + + @Test func rejectsDuplicateAndOutOfOrderDependencies() { + let duplicate = StartupSequence(steps: [ + StartupStep(containerID: "a"), + StartupStep(containerID: "a") + ]) + #expect(duplicate.validationError() == "Container \"a\" appears more than once.") + + let outOfOrder = StartupSequence(steps: [ + StartupStep(containerID: "b", waitForContainerID: "a"), + StartupStep(containerID: "a") + ]) + #expect(outOfOrder.validationError() == nil) + } + + @Test func runsDependentGroupsAfterAllPrerequisiteContainersAreReady() async { + let backendID = UUID() + let frontendID = UUID() + let runtime = StartupRuntimeStub(statuses: ["mysql": "stopped", "php8": "stopped", "nginx": "stopped"]) + let service = StartupSequenceService(runtime: runtime, defaults: UserDefaults(suiteName: UUID().uuidString)!) + service.updateSequence(StartupSequence(groups: [ + StartupGroup( + id: backendID, + name: "Backend", + containers: [ + StartupGroupContainer(containerID: "mysql"), + StartupGroupContainer(containerID: "php8") + ]), + StartupGroup( + id: frontendID, + name: "Frontend", + containers: [StartupGroupContainer(containerID: "nginx")], + waitForGroupIDs: [backendID]) + ])) + + service.run(availableContainerIDs: ["mysql", "php8", "nginx"]) + await waitForRunToFinish(service) + + #expect(service.state == .completed) + #expect(Set(runtime.startedContainerIDs) == Set(["mysql", "php8", "nginx"])) + } + + @Test func allowsContainerToWaitForContainerInAnotherGroup() async { + let websitesID = UUID() + let servicesID = UUID() + let sequence = StartupSequence(groups: [ + StartupGroup( + id: websitesID, + name: "Websites", + containers: [StartupGroupContainer(containerID: "mysql")]), + StartupGroup( + id: servicesID, + name: "Services", + containers: [ + StartupGroupContainer(containerID: "mosquitto"), + StartupGroupContainer(containerID: "nodered", waitForContainerIDs: ["mosquitto", "mysql"])]), + ]) + #expect(sequence.validationError() == nil) + + let runtime = StartupRuntimeStub(statuses: ["mysql": "stopped", "mosquitto": "stopped", "nodered": "stopped"]) + let service = StartupSequenceService(runtime: runtime, defaults: UserDefaults(suiteName: UUID().uuidString)!) + service.updateSequence(sequence) + service.run(availableContainerIDs: ["mysql", "mosquitto", "nodered"]) + await waitForRunToFinish(service) + + #expect(service.state == .completed) + #expect(Set(runtime.startedContainerIDs) == Set(["mysql", "mosquitto", "nodered"])) + } + + @Test func startsInOrderAndStopsOnlyOwnedContainersInReverseOrder() async { + let runtime = StartupRuntimeStub(statuses: ["a": "stopped", "b": "running", "c": "stopped", "nginx": "stopped"]) + let service = StartupSequenceService(runtime: runtime, defaults: UserDefaults(suiteName: UUID().uuidString)!) + service.updateSequence(StartupSequence(steps: [ + StartupStep(containerID: "a"), + StartupStep(containerID: "b", waitForContainerID: "a"), + StartupStep(containerID: "c", waitForContainerID: "b"), + StartupStep(containerID: "nginx", waitForContainerIDs: ["a", "c"]) + ])) + + service.run(availableContainerIDs: ["a", "b", "c", "nginx"]) + await waitForRunToFinish(service) + + #expect(runtime.startSystemCount == 1) + #expect(Set(runtime.startedContainerIDs) == Set(["a", "c", "nginx"])) + #expect(service.state == .completed) + + let startedOrder = runtime.startedContainerIDs + await service.stopSequenceOwnedContainers() + + #expect(runtime.stoppedContainerIDs == Array(startedOrder.reversed())) + } + + @Test func abortsWhenStartedContainerNeverBecomesReady() async { + let runtime = StartupRuntimeStub(statuses: ["a": "stopped"]) + runtime.containersBecomeRunning = false + let service = StartupSequenceService( + runtime: runtime, + defaults: UserDefaults(suiteName: UUID().uuidString)!, + readinessTimeout: 0.02) + service.updateSequence(StartupSequence(steps: [StartupStep(containerID: "a")])) + + service.run(availableContainerIDs: ["a"]) + await waitForRunToFinish(service) + + guard case .failed(let message) = service.state else { + Issue.record("Expected a failed startup sequence") + return + } + #expect(message.contains("Timed out waiting for a")) + #expect(runtime.startedContainerIDs == ["a"]) + } + + private func waitForRunToFinish(_ service: StartupSequenceService) async { + while service.isRunning { + try? await Task.sleep(for: .milliseconds(10)) + } + } +} From 2df5e06d86e40b2ed839adfaf4416d04ad55bd1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20Puginier?= Date: Mon, 20 Jul 2026 01:11:00 +0200 Subject: [PATCH 2/5] Improve startup sequence dependencies and shutdown cleanup --- Orchard/OrchardApp.swift | 2 +- Orchard/OrchardAppDelegate.swift | 29 +++++++++++- Orchard/Services/AppServices.swift | 20 ++++---- Orchard/Services/StartupSequenceService.swift | 20 ++++---- .../Settings/StartupSettingsView.swift | 43 ++++++++++------- .../StartupSequenceServiceTests.swift | 47 ++++++++++++++++--- 6 files changed, 117 insertions(+), 44 deletions(-) diff --git a/Orchard/OrchardApp.swift b/Orchard/OrchardApp.swift index cf0b1d4..5ee3996 100644 --- a/Orchard/OrchardApp.swift +++ b/Orchard/OrchardApp.swift @@ -10,7 +10,7 @@ struct OrchardApp: App { init() { let services = AppServices.forLaunch() _services = StateObject(wrappedValue: services) - (NSApplication.shared.delegate as? OrchardAppDelegate)?.startupSequenceService = services.startupSequenceService + appDelegate.startupSequenceService = services.startupSequenceService } var body: some Scene { diff --git a/Orchard/OrchardAppDelegate.swift b/Orchard/OrchardAppDelegate.swift index dd1cb3a..492ab14 100644 --- a/Orchard/OrchardAppDelegate.swift +++ b/Orchard/OrchardAppDelegate.swift @@ -2,6 +2,11 @@ import AppKit @MainActor final class OrchardAppDelegate: NSObject, NSApplicationDelegate { + private enum CleanupOutcome { + case completed + case timedOut + } + weak var startupSequenceService: StartupSequenceService? private var terminationCleanupTask: Task? @@ -13,7 +18,29 @@ final class OrchardAppDelegate: NSObject, NSApplicationDelegate { } terminationCleanupTask = Task { @MainActor [weak self] in - await startupSequenceService.stopSequenceOwnedContainers() + let (completionStream, completionContinuation) = AsyncStream.makeStream() + let cleanupTask = Task { @MainActor in + await startupSequenceService.stopSequenceOwnedContainers() + completionContinuation.finish() + } + + let outcome = await withTaskGroup(of: CleanupOutcome.self) { tasks in + tasks.addTask { + for await _ in completionStream { } + return .completed + } + tasks.addTask { + try? await Task.sleep(for: .seconds(5)) + return .timedOut + } + let outcome = await tasks.next() ?? .timedOut + tasks.cancelAll() + return outcome + } + + if outcome == .timedOut { + cleanupTask.cancel() + } sender.reply(toApplicationShouldTerminate: true) self?.terminationCleanupTask = nil } diff --git a/Orchard/Services/AppServices.swift b/Orchard/Services/AppServices.swift index b9a52f9..204f0ea 100644 --- a/Orchard/Services/AppServices.swift +++ b/Orchard/Services/AppServices.swift @@ -19,9 +19,9 @@ final class AppServices: ObservableObject { let imageService: ImageService let statsService: StatsService let dnsService: DNSService - let systemService: SystemService - let containerListService: ContainerListService - let startupSequenceService: StartupSequenceService + let systemService: SystemService + let containerListService: ContainerListService + let startupSequenceService: StartupSequenceService let machineService: MachineService let modelService: ModelService let modelServerService: ModelServerService @@ -64,13 +64,13 @@ final class AppServices: ObservableObject { let systemService = SystemService(backend: backend, runner: runner, settings: settings, alertCenter: alertCenter) self.systemService = systemService // ContainerListService is built before StatsService, which depends on it. - let containerListService = ContainerListService(backend: backend, alertCenter: alertCenter) - self.containerListService = containerListService - let startupRuntime = StartupSequenceRuntimeAdapter( - backend: backend, - systemService: systemService, - containerListService: containerListService) - self.startupSequenceService = StartupSequenceService(runtime: startupRuntime, defaults: defaults) + let containerListService = ContainerListService(backend: backend, alertCenter: alertCenter) + self.containerListService = containerListService + let startupRuntime = StartupSequenceRuntimeAdapter( + backend: backend, + systemService: systemService, + containerListService: containerListService) + self.startupSequenceService = StartupSequenceService(runtime: startupRuntime, defaults: defaults) let statsService = StatsService(backend: backend, alertCenter: alertCenter, containerList: containerListService) self.statsService = statsService self.machineService = MachineService(backend: machineBackend, alertCenter: alertCenter) diff --git a/Orchard/Services/StartupSequenceService.swift b/Orchard/Services/StartupSequenceService.swift index b4cc6f4..0ff4e5f 100644 --- a/Orchard/Services/StartupSequenceService.swift +++ b/Orchard/Services/StartupSequenceService.swift @@ -165,6 +165,8 @@ struct StartupSequence: Codable, Equatable { for container in group.containers { dependencies[.container(container.containerID), default: []].formUnion( container.waitForContainerIDs.map(DependencyNode.container)) + dependencies[.container(container.containerID), default: []].formUnion( + group.waitForGroupIDs.map(DependencyNode.group)) } } @@ -447,22 +449,18 @@ final class StartupSequenceService: ObservableObject { private func execute(_ group: StartupGroup) async throws { state = .startingGroup(group.name) - try await withThrowingTaskGroup(of: String?.self) { containerTasks in + try await withThrowingTaskGroup(of: Void.self) { containerTasks in for container in group.containers { containerTasks.addTask { [weak self] in guard let self else { throw CancellationError() } - return try await self.execute(container) - } - } - for try await startedContainerID in containerTasks { - if let startedContainerID { - sequenceOwnedContainerIDs.append(startedContainerID) + try await self.execute(container) } } + for try await _ in containerTasks { } } } - private func execute(_ container: StartupGroupContainer) async throws -> String? { + private func execute(_ container: StartupGroupContainer) async throws { for dependency in container.waitForContainerIDs { try await waitUntilRunning(dependency) } @@ -473,13 +471,15 @@ final class StartupSequenceService: ObservableObject { } if status.lowercased() == "running" { - return nil + return } state = .starting(container.containerID) try await runtime.startStartupContainer(container.containerID) + if !sequenceOwnedContainerIDs.contains(container.containerID) { + sequenceOwnedContainerIDs.append(container.containerID) + } try await waitUntilRunning(container.containerID) - return container.containerID } private func waitUntilRunning(_ id: String) async throws { diff --git a/Orchard/Views/Features/Settings/StartupSettingsView.swift b/Orchard/Views/Features/Settings/StartupSettingsView.swift index eb4fc92..6b2ad7e 100644 --- a/Orchard/Views/Features/Settings/StartupSettingsView.swift +++ b/Orchard/Views/Features/Settings/StartupSettingsView.swift @@ -7,8 +7,12 @@ struct StartupSettingsView: View { @State private var showStatusMessage = true private var availableContainerIDs: [String] { + containerListService.containers.map { $0.configuration.id }.sorted() + } + + private var containerPickerIDs: [String] { let configuredIDs = startupSequenceService.sequence.groups.flatMap { $0.containers.map(\.containerID) } - return Set(containerListService.containers.map { $0.configuration.id } + configuredIDs).sorted() + return Set(availableContainerIDs + configuredIDs).sorted() } private var validationMessage: String? { @@ -50,16 +54,17 @@ struct StartupSettingsView: View { .frame(maxWidth: .infinity, minHeight: 180, alignment: .center) } } else { - ForEach(startupSequenceService.sequence.groups.indices, id: \.self) { index in + ForEach(startupSequenceService.sequence.groups) { group in StartupGroupEditor( - group: startupSequenceService.sequence.groups[index], + group: group, allGroups: startupSequenceService.sequence.groups, - containerIDs: availableContainerIDs, + containerIDs: containerPickerIDs, + availableContainerIDs: Set(availableContainerIDs), startupSequence: startupSequenceService.sequence, - onChange: { updateGroup(index, with: $0) }, - onMoveUp: { moveGroup(index, by: -1) }, - onMoveDown: { moveGroup(index, by: 1) }, - onDelete: { deleteGroup(index) }) + onChange: { updateGroup(group.id, with: $0) }, + onMoveUp: { moveGroup(group.id, by: -1) }, + onMoveDown: { moveGroup(group.id, by: 1) }, + onDelete: { deleteGroup(group.id) }) } } @@ -128,23 +133,25 @@ struct StartupSettingsView: View { startupSequenceService.updateSequence(sequence) } - private func updateGroup(_ index: Int, with group: StartupGroup) { + private func updateGroup(_ id: UUID, with group: StartupGroup) { var sequence = startupSequenceService.sequence - guard sequence.groups.indices.contains(index) else { return } + guard let index = sequence.groups.firstIndex(where: { $0.id == id }) else { return } sequence.groups[index] = group startupSequenceService.updateSequence(sequence) } - private func moveGroup(_ index: Int, by offset: Int) { + private func moveGroup(_ id: UUID, by offset: Int) { var sequence = startupSequenceService.sequence + guard let index = sequence.groups.firstIndex(where: { $0.id == id }) else { return } let destination = index + offset guard sequence.groups.indices.contains(index), sequence.groups.indices.contains(destination) else { return } sequence.groups.swapAt(index, destination) startupSequenceService.updateSequence(sequence) } - private func deleteGroup(_ index: Int) { + private func deleteGroup(_ id: UUID) { var sequence = startupSequenceService.sequence + guard let index = sequence.groups.firstIndex(where: { $0.id == id }) else { return } guard sequence.groups.indices.contains(index) else { return } sequence.groups.remove(at: index) startupSequenceService.updateSequence(sequence) @@ -155,6 +162,7 @@ private struct StartupGroupEditor: View { let group: StartupGroup let allGroups: [StartupGroup] let containerIDs: [String] + let availableContainerIDs: Set let startupSequence: StartupSequence let onChange: (StartupGroup) -> Void let onMoveUp: () -> Void @@ -182,6 +190,7 @@ private struct StartupGroupEditor: View { StartupGroupContainerEditor( container: container, containerIDs: containerIDs, + availableContainerIDs: availableContainerIDs, allGroups: allGroups, disabledContainerIDs: containersInOtherGroups, startupSequence: startupSequence, @@ -192,7 +201,7 @@ private struct StartupGroupEditor: View { if !group.containers.isEmpty { Button("Add Container", systemImage: "plus", action: addContainer) - .disabled(containerIDs.allSatisfy { id in + .disabled(availableContainerIDs.allSatisfy { id in group.containers.contains { $0.containerID == id } || containersInOtherGroups.contains(id) }) .frame(maxWidth: .infinity, alignment: .center) @@ -254,7 +263,7 @@ private struct StartupGroupEditor: View { allGroups .filter { $0.id != group.id } .flatMap { $0.containers.map(\.containerID) }) - guard let id = containerIDs.first(where: { id in + guard let id = availableContainerIDs.sorted().first(where: { id in !group.containers.contains { $0.containerID == id } && !containersInOtherGroups.contains(id) }) else { return } var containers = group.containers @@ -279,6 +288,7 @@ private struct StartupGroupEditor: View { private struct StartupGroupContainerEditor: View { let container: StartupGroupContainer let containerIDs: [String] + let availableContainerIDs: Set let allGroups: [StartupGroup] let disabledContainerIDs: Set let startupSequence: StartupSequence @@ -299,7 +309,7 @@ private struct StartupGroupContainerEditor: View { Text(id) } } - .disabled(disabledContainerIDs.contains(id) && id != container.containerID) + .disabled((!availableContainerIDs.contains(id) || disabledContainerIDs.contains(id)) && id != container.containerID) } } label: { Label(container.containerID, systemImage: "shippingbox") @@ -354,7 +364,7 @@ private struct StartupGroupContainerEditor: View { } onChange(StartupGroupContainer(id: container.id, containerID: container.containerID, waitForContainerIDs: dependencies)) })) - .disabled(startupSequence.wouldCreateContainerDependency(containerID: container.containerID, dependencyID: id) && !container.waitForContainerIDs.contains(id)) + .disabled((!availableContainerIDs.contains(id) || startupSequence.wouldCreateContainerDependency(containerID: container.containerID, dependencyID: id)) && !container.waitForContainerIDs.contains(id)) } private var containerPrerequisiteLabel: String { @@ -410,6 +420,7 @@ private struct StartupGroupContainerEditor: View { group: group, allGroups: groups, containerIDs: containerIDs, + availableContainerIDs: Set(containerIDs), startupSequence: sequence, onChange: { _ in }, onMoveUp: { }, diff --git a/OrchardTests/StartupSequenceServiceTests.swift b/OrchardTests/StartupSequenceServiceTests.swift index 815449f..3faa22f 100644 --- a/OrchardTests/StartupSequenceServiceTests.swift +++ b/OrchardTests/StartupSequenceServiceTests.swift @@ -62,7 +62,7 @@ struct StartupSequenceServiceTests { #expect(reloaded.sequence == sequence) } - @Test func rejectsDuplicateAndOutOfOrderDependencies() { + @Test func rejectsDuplicatesButAllowsOutOfOrderDependencies() { let duplicate = StartupSequence(steps: [ StartupStep(containerID: "a"), StartupStep(containerID: "a") @@ -80,7 +80,10 @@ struct StartupSequenceServiceTests { let backendID = UUID() let frontendID = UUID() let runtime = StartupRuntimeStub(statuses: ["mysql": "stopped", "php8": "stopped", "nginx": "stopped"]) - let service = StartupSequenceService(runtime: runtime, defaults: UserDefaults(suiteName: UUID().uuidString)!) + let suiteName = "OrchardStartupSequenceTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let service = StartupSequenceService(runtime: runtime, defaults: defaults) service.updateSequence(StartupSequence(groups: [ StartupGroup( id: backendID, @@ -121,7 +124,10 @@ struct StartupSequenceServiceTests { #expect(sequence.validationError() == nil) let runtime = StartupRuntimeStub(statuses: ["mysql": "stopped", "mosquitto": "stopped", "nodered": "stopped"]) - let service = StartupSequenceService(runtime: runtime, defaults: UserDefaults(suiteName: UUID().uuidString)!) + let suiteName = "OrchardStartupSequenceTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let service = StartupSequenceService(runtime: runtime, defaults: defaults) service.updateSequence(sequence) service.run(availableContainerIDs: ["mysql", "mosquitto", "nodered"]) await waitForRunToFinish(service) @@ -130,9 +136,30 @@ struct StartupSequenceServiceTests { #expect(Set(runtime.startedContainerIDs) == Set(["mysql", "mosquitto", "nodered"])) } + @Test func rejectsCrossGroupDependencyDeadlocks() { + let groupAID = UUID() + let groupBID = UUID() + let sequence = StartupSequence(groups: [ + StartupGroup( + id: groupAID, + name: "A", + containers: [StartupGroupContainer(containerID: "a")], + waitForGroupIDs: [groupBID]), + StartupGroup( + id: groupBID, + name: "B", + containers: [StartupGroupContainer(containerID: "b", waitForContainerIDs: ["a"])]) + ]) + + #expect(sequence.validationError() == "Startup groups contain a dependency cycle.") + } + @Test func startsInOrderAndStopsOnlyOwnedContainersInReverseOrder() async { let runtime = StartupRuntimeStub(statuses: ["a": "stopped", "b": "running", "c": "stopped", "nginx": "stopped"]) - let service = StartupSequenceService(runtime: runtime, defaults: UserDefaults(suiteName: UUID().uuidString)!) + let suiteName = "OrchardStartupSequenceTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let service = StartupSequenceService(runtime: runtime, defaults: defaults) service.updateSequence(StartupSequence(steps: [ StartupStep(containerID: "a"), StartupStep(containerID: "b", waitForContainerID: "a"), @@ -156,9 +183,12 @@ struct StartupSequenceServiceTests { @Test func abortsWhenStartedContainerNeverBecomesReady() async { let runtime = StartupRuntimeStub(statuses: ["a": "stopped"]) runtime.containersBecomeRunning = false + let suiteName = "OrchardStartupSequenceTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } let service = StartupSequenceService( runtime: runtime, - defaults: UserDefaults(suiteName: UUID().uuidString)!, + defaults: defaults, readinessTimeout: 0.02) service.updateSequence(StartupSequence(steps: [StartupStep(containerID: "a")])) @@ -171,11 +201,16 @@ struct StartupSequenceServiceTests { } #expect(message.contains("Timed out waiting for a")) #expect(runtime.startedContainerIDs == ["a"]) + #expect(service.sequenceOwnedContainerIDs == ["a"]) } private func waitForRunToFinish(_ service: StartupSequenceService) async { while service.isRunning { - try? await Task.sleep(for: .milliseconds(10)) + do { + try await Task.sleep(for: .milliseconds(10)) + } catch { + return + } } } } From 96e685ad2663b53848ab724af67b02e8a0ce5d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20Puginier?= Date: Mon, 20 Jul 2026 01:38:11 +0200 Subject: [PATCH 3/5] Make startup cleanup cancellation-aware --- Orchard/Services/StartupSequenceService.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Orchard/Services/StartupSequenceService.swift b/Orchard/Services/StartupSequenceService.swift index 0ff4e5f..70351ac 100644 --- a/Orchard/Services/StartupSequenceService.swift +++ b/Orchard/Services/StartupSequenceService.swift @@ -393,6 +393,7 @@ final class StartupSequenceService: ObservableObject { state = .stopping for id in sequenceOwnedContainerIDs.reversed() { + guard !Task.isCancelled else { break } do { try await runtime.stopStartupContainer(id) } catch { From cf9caa49dfa5c09d3f31d72cb01f58ca32ed6e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20Puginier?= Date: Tue, 21 Jul 2026 20:42:52 +0200 Subject: [PATCH 4/5] Start the container system before running startup sequences --- Orchard/Services/StartupSequenceService.swift | 14 +++++++++ Orchard/Services/SystemService.swift | 30 +++++++++++++++++-- .../Features/MenuBar/MenuBarDashboard.swift | 1 + .../Settings/StartupSettingsView.swift | 10 +++++-- Orchard/Views/Layout/Content.swift | 1 + .../StartupSequenceServiceTests.swift | 13 ++++++++ 6 files changed, 64 insertions(+), 5 deletions(-) diff --git a/Orchard/Services/StartupSequenceService.swift b/Orchard/Services/StartupSequenceService.swift index 70351ac..b831c7f 100644 --- a/Orchard/Services/StartupSequenceService.swift +++ b/Orchard/Services/StartupSequenceService.swift @@ -375,6 +375,20 @@ final class StartupSequenceService: ObservableObject { run(availableContainerIDs: availableContainerIDs) } + func prepareForAutomaticRun() async -> Bool { + guard sequence.isEnabled else { return true } + guard !(await runtime.isContainerSystemRunning()) else { return true } + + state = .startingSystem + do { + try await runtime.startContainerSystem() + return true + } catch { + state = .failed(error.localizedDescription) + return false + } + } + func run(availableContainerIDs: Set) { guard activeRunTask == nil else { return } activeRunTask = Task { [weak self] in diff --git a/Orchard/Services/SystemService.swift b/Orchard/Services/SystemService.swift index 613cd53..dfd6a81 100644 --- a/Orchard/Services/SystemService.swift +++ b/Orchard/Services/SystemService.swift @@ -49,6 +49,7 @@ final class SystemService: ObservableObject { private let runner: CommandRunner private let settings: SettingsStore private let alertCenter: AlertCenter + private let systemReadinessTimeout: TimeInterval = 60 /// Refresh the container list after the system starts. Set by the owner. var onSystemStarted: () async -> Void = {} @@ -98,13 +99,19 @@ final class SystemService: ObservableObject { let result = try await runner.run( program: settings.safeContainerBinaryPath(), arguments: ["system", "start"]) - isSystemLoading = false - if result.failed { + if result.failed { alertCenter.error(result.stderr ?? "Failed to start system") await checkSystemStatus() // don't assume .running — re-derive return } - systemStatus = .running + + guard await waitForSystemReadiness() else { + isSystemLoading = false + alertCenter.error("Container system started but its service is not ready yet.") + return + } + + isSystemLoading = false Log.containers.debug("Container system started successfully") await onSystemStarted() } catch { @@ -115,6 +122,23 @@ final class SystemService: ObservableObject { } } + private func waitForSystemReadiness() async -> Bool { + let deadline = Date().addingTimeInterval(systemReadinessTimeout) + while Date() < deadline { + await checkSystemStatus() + if systemStatus == .running { + return true + } + + do { + try await Task.sleep(for: .milliseconds(250)) + } catch { + return false + } + } + return false + } + func stopSystem() async { isSystemLoading = true alertCenter.dismiss() diff --git a/Orchard/Views/Features/MenuBar/MenuBarDashboard.swift b/Orchard/Views/Features/MenuBar/MenuBarDashboard.swift index 66ac846..14753b0 100644 --- a/Orchard/Views/Features/MenuBar/MenuBarDashboard.swift +++ b/Orchard/Views/Features/MenuBar/MenuBarDashboard.swift @@ -186,6 +186,7 @@ struct MenuBarView: View { startRefreshTimer() await systemService.checkSystemStatus() + guard await startupSequenceService.prepareForAutomaticRun() else { return } await containerListService.loadContainers(showLoading: true) await builderService.loadBuilders() await dnsService.load(showLoading: true) diff --git a/Orchard/Views/Features/Settings/StartupSettingsView.swift b/Orchard/Views/Features/Settings/StartupSettingsView.swift index 6b2ad7e..5fba01b 100644 --- a/Orchard/Views/Features/Settings/StartupSettingsView.swift +++ b/Orchard/Views/Features/Settings/StartupSettingsView.swift @@ -24,14 +24,20 @@ struct StartupSettingsView: View { Form { Section { Toggle( - "Run startup sequence when Orchard opens", isOn: Binding( get: { startupSequenceService.sequence.isEnabled }, set: { isEnabled in var sequence = startupSequenceService.sequence sequence.isEnabled = isEnabled startupSequenceService.updateSequence(sequence) - })) + })) { + VStack(alignment: .leading, spacing: 2) { + Text("Run startup sequence when Orchard opens") + Text("If the container system or a configured container is stopped, Orchard starts it automatically so the sequence can run.") + .font(.caption) + .foregroundStyle(.secondary) + } + } if let validationMessage { Label(validationMessage, systemImage: "exclamationmark.triangle") diff --git a/Orchard/Views/Layout/Content.swift b/Orchard/Views/Layout/Content.swift index c2ccc02..f9c8cc0 100644 --- a/Orchard/Views/Layout/Content.swift +++ b/Orchard/Views/Layout/Content.swift @@ -286,6 +286,7 @@ struct ContentView: View { private func performInitialLoad() async { await systemService.checkSystemStatus() + guard await startupSequenceService.prepareForAutomaticRun() else { return } // Load stats first for immediate display await statsService.load(showLoading: true) diff --git a/OrchardTests/StartupSequenceServiceTests.swift b/OrchardTests/StartupSequenceServiceTests.swift index 3faa22f..4fe576c 100644 --- a/OrchardTests/StartupSequenceServiceTests.swift +++ b/OrchardTests/StartupSequenceServiceTests.swift @@ -76,6 +76,19 @@ struct StartupSequenceServiceTests { #expect(outOfOrder.validationError() == nil) } + @Test func preparesAutomaticRunByStartingStoppedSystem() async { + let runtime = StartupRuntimeStub(statuses: [:]) + let suiteName = "OrchardStartupSequenceTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let service = StartupSequenceService(runtime: runtime, defaults: defaults) + service.updateSequence(StartupSequence(isEnabled: true)) + + #expect(await service.prepareForAutomaticRun()) + #expect(runtime.startSystemCount == 1) + #expect(runtime.systemRunning) + } + @Test func runsDependentGroupsAfterAllPrerequisiteContainersAreReady() async { let backendID = UUID() let frontendID = UUID() From c06e27fa3d0730fba936b471e68cb7f62002fc94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89douard=20Puginier?= Date: Tue, 21 Jul 2026 21:16:09 +0200 Subject: [PATCH 5/5] Coalesce automatic startup preparation and handle cancellation --- Orchard/OrchardApp.swift | 1 - Orchard/OrchardAppDelegate.swift | 47 +------------------ Orchard/Services/StartupSequenceService.swift | 29 ++++++++---- Orchard/Services/SystemService.swift | 27 +++++++---- .../StartupSequenceServiceTests.swift | 16 +++++++ 5 files changed, 55 insertions(+), 65 deletions(-) diff --git a/Orchard/OrchardApp.swift b/Orchard/OrchardApp.swift index 5ee3996..e5f0306 100644 --- a/Orchard/OrchardApp.swift +++ b/Orchard/OrchardApp.swift @@ -10,7 +10,6 @@ struct OrchardApp: App { init() { let services = AppServices.forLaunch() _services = StateObject(wrappedValue: services) - appDelegate.startupSequenceService = services.startupSequenceService } var body: some Scene { diff --git a/Orchard/OrchardAppDelegate.swift b/Orchard/OrchardAppDelegate.swift index 492ab14..1c393fc 100644 --- a/Orchard/OrchardAppDelegate.swift +++ b/Orchard/OrchardAppDelegate.swift @@ -1,49 +1,4 @@ import AppKit @MainActor -final class OrchardAppDelegate: NSObject, NSApplicationDelegate { - private enum CleanupOutcome { - case completed - case timedOut - } - - weak var startupSequenceService: StartupSequenceService? - private var terminationCleanupTask: Task? - - func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - if terminationCleanupTask != nil { return .terminateLater } - guard let startupSequenceService, - !startupSequenceService.sequenceOwnedContainerIDs.isEmpty || startupSequenceService.isRunning else { - return .terminateNow - } - - terminationCleanupTask = Task { @MainActor [weak self] in - let (completionStream, completionContinuation) = AsyncStream.makeStream() - let cleanupTask = Task { @MainActor in - await startupSequenceService.stopSequenceOwnedContainers() - completionContinuation.finish() - } - - let outcome = await withTaskGroup(of: CleanupOutcome.self) { tasks in - tasks.addTask { - for await _ in completionStream { } - return .completed - } - tasks.addTask { - try? await Task.sleep(for: .seconds(5)) - return .timedOut - } - let outcome = await tasks.next() ?? .timedOut - tasks.cancelAll() - return outcome - } - - if outcome == .timedOut { - cleanupTask.cancel() - } - sender.reply(toApplicationShouldTerminate: true) - self?.terminationCleanupTask = nil - } - return .terminateLater - } -} +final class OrchardAppDelegate: NSObject, NSApplicationDelegate {} diff --git a/Orchard/Services/StartupSequenceService.swift b/Orchard/Services/StartupSequenceService.swift index b831c7f..58814f3 100644 --- a/Orchard/Services/StartupSequenceService.swift +++ b/Orchard/Services/StartupSequenceService.swift @@ -345,6 +345,7 @@ final class StartupSequenceService: ObservableObject { private let sequenceKey = "OrchardStartupSequence" private var hasAttemptedAutomaticRun = false private var activeRunTask: Task? + private var automaticPreparationTask: Task? init(runtime: StartupSequenceRuntime, defaults: UserDefaults = .standard, readinessTimeout: TimeInterval = StartupSequence.readinessTimeout) { self.runtime = runtime @@ -377,16 +378,28 @@ final class StartupSequenceService: ObservableObject { func prepareForAutomaticRun() async -> Bool { guard sequence.isEnabled else { return true } - guard !(await runtime.isContainerSystemRunning()) else { return true } + if let automaticPreparationTask { + return await automaticPreparationTask.value + } - state = .startingSystem - do { - try await runtime.startContainerSystem() - return true - } catch { - state = .failed(error.localizedDescription) - return false + let task = Task { [weak self] in + guard let self else { return false } + guard !(await self.runtime.isContainerSystemRunning()) else { return true } + + self.state = .startingSystem + do { + try await self.runtime.startContainerSystem() + return true + } catch { + self.state = .failed(error.localizedDescription) + return false + } } + automaticPreparationTask = task + + let result = await task.value + automaticPreparationTask = nil + return result } func run(availableContainerIDs: Set) { diff --git a/Orchard/Services/SystemService.swift b/Orchard/Services/SystemService.swift index dfd6a81..92d425c 100644 --- a/Orchard/Services/SystemService.swift +++ b/Orchard/Services/SystemService.swift @@ -1,6 +1,8 @@ import Foundation import SwiftUI +private struct SystemReadinessTimeout: Error {} + enum SystemStatus { case unknown case stopped @@ -99,13 +101,19 @@ final class SystemService: ObservableObject { let result = try await runner.run( program: settings.safeContainerBinaryPath(), arguments: ["system", "start"]) - if result.failed { + if result.failed { + isSystemLoading = false alertCenter.error(result.stderr ?? "Failed to start system") await checkSystemStatus() // don't assume .running — re-derive return } - guard await waitForSystemReadiness() else { + do { + try await waitForSystemReadiness() + } catch is CancellationError { + isSystemLoading = false + return + } catch { isSystemLoading = false alertCenter.error("Container system started but its service is not ready yet.") return @@ -114,6 +122,8 @@ final class SystemService: ObservableObject { isSystemLoading = false Log.containers.debug("Container system started successfully") await onSystemStarted() + } catch is CancellationError { + isSystemLoading = false } catch { alertCenter.error("Failed to start system: \(error.localizedDescription)") isSystemLoading = false @@ -122,21 +132,18 @@ final class SystemService: ObservableObject { } } - private func waitForSystemReadiness() async -> Bool { + private func waitForSystemReadiness() async throws { let deadline = Date().addingTimeInterval(systemReadinessTimeout) while Date() < deadline { + try Task.checkCancellation() await checkSystemStatus() if systemStatus == .running { - return true + return } - do { - try await Task.sleep(for: .milliseconds(250)) - } catch { - return false - } + try await Task.sleep(for: .milliseconds(250)) } - return false + throw SystemReadinessTimeout() } func stopSystem() async { diff --git a/OrchardTests/StartupSequenceServiceTests.swift b/OrchardTests/StartupSequenceServiceTests.swift index 4fe576c..6e94b77 100644 --- a/OrchardTests/StartupSequenceServiceTests.swift +++ b/OrchardTests/StartupSequenceServiceTests.swift @@ -89,6 +89,22 @@ struct StartupSequenceServiceTests { #expect(runtime.systemRunning) } + @Test func coalescesConcurrentAutomaticPreparation() async { + let runtime = StartupRuntimeStub(statuses: [:]) + let suiteName = "OrchardStartupSequenceTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let service = StartupSequenceService(runtime: runtime, defaults: defaults) + service.updateSequence(StartupSequence(isEnabled: true)) + + async let first = service.prepareForAutomaticRun() + async let second = service.prepareForAutomaticRun() + + #expect(await first) + #expect(await second) + #expect(runtime.startSystemCount == 1) + } + @Test func runsDependentGroupsAfterAllPrerequisiteContainersAreReady() async { let backendID = UUID() let frontendID = UUID()