From a29469d0b3dd750ae63efc7abbb016ed730dfb60 Mon Sep 17 00:00:00 2001 From: Chris George Date: Sat, 2 May 2026 14:28:38 -0700 Subject: [PATCH] feat(api): wire ContainerEvent + events() streaming API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the placeholder 'XPCRoute.containerEvent' and 'XPCKeys.containerEvent' that already existed in main into a working lifecycle-event stream: the daemon now records create / start / stop / die / destroy events in a bounded ring buffer, and 'ContainerClient.events()' returns the buffered events to the client. Motivation ---------- External orchestrators that drive the API server (the canonical use case is a Compose-spec orchestrator implementing 'compose events') today have no daemon-side event signal — they have to poll 'ContainerClient.list' on a 1-second cadence and diff snapshots to synthesize lifecycle events. That has three problems: 1. 1s latency floor on event delivery. 2. Events that happen between polls (a quick start->exit->restart) are lost. 3. Polling cost grows linearly with project size. Wiring real events at the daemon side replaces the polling-fallback with first-party signal. What this PR changes -------------------- - Sources/ContainerResource/Container/ContainerEvent.swift (new): ContainerEvent { containerId, action: { create, start, stop, die, destroy }, timestamp } as a Codable Sendable Equatable struct. - Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift: a private 'eventBuffer: [ContainerEvent]' ring (capped at 1000) and a private 'recordEvent(_:action:)' helper. Lifecycle methods (handleCreate, handleStart, handleStop, handleDelete) record the matching action. A new public 'recentEvents(since:)' method returns the buffered events to the harness. - Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift: new 'events(_:)' XPC handler that JSON-encodes recentEvents() into the reply via the existing 'XPCKeys.containerEvent' payload key. - Sources/Services/ContainerAPIService/Client/ContainerClient.swift: new 'events()' method on ContainerClient — sends the 'XPCRoute.containerEvent' message and decodes '[ContainerEvent]' from the response. - Sources/APIServer/APIServer+Start.swift: register the harness' events handler for 'XPCRoute.containerEvent' (the route case already existed in main as an unwired placeholder). Note: 'XPCRoute.containerEvent' and 'XPCKeys.containerEvent' both already existed in main as reserved placeholders — no enum changes needed in this PR. This is purely the implementation that fills them in. Wire compatibility ------------------ Pure additive at the API surface. Older clients ignore the new events() method. Older servers receiving a containerEvent route respond with whatever the unwired placeholder did before (typically an empty reply, which decodes as 'no events'). Known limitations (intentional follow-ups) ------------------------------------------ - Snapshot-style API, not push. Clients call events() and get the current buffer; long-lived subscribers still have to poll. A push-style AsyncStream is a natural follow-up. - No per-client cursor. The buffer is global; clients deduplicate via 'recentEvents(since:)' on the daemon side. - No persistence across daemon restarts. The buffer is in-memory. - Buffer cap is hardcoded at 1000 events. Sufficient for typical Compose project sizes; large projects under chatty orchestration will see rollover. A configurable cap is a follow-up. Verification ------------ Full 'swift build' clean on macOS 26 / Apple silicon (release config, all targets including downstream consumers of ContainerClient). --- Sources/APIServer/APIServer+Start.swift | 1 + .../Container/ContainerEvent.swift | 45 +++++++++++++++++++ .../Client/ContainerClient.swift | 23 ++++++++++ .../Server/Containers/ContainersHarness.swift | 9 ++++ .../Server/Containers/ContainersService.swift | 21 +++++++++ 5 files changed, 99 insertions(+) create mode 100644 Sources/ContainerResource/Container/ContainerEvent.swift diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift index 936abd913..f5d9e7037 100644 --- a/Sources/APIServer/APIServer+Start.swift +++ b/Sources/APIServer/APIServer+Start.swift @@ -307,6 +307,7 @@ extension APIServer { routes[XPCRoute.containerCopyIn] = XPCServer.route(harness.copyIn) routes[XPCRoute.containerCopyOut] = XPCServer.route(harness.copyOut) routes[XPCRoute.containerExport] = XPCServer.route(harness.export) + routes[XPCRoute.containerEvent] = XPCServer.route(harness.events) return service } diff --git a/Sources/ContainerResource/Container/ContainerEvent.swift b/Sources/ContainerResource/Container/ContainerEvent.swift new file mode 100644 index 000000000..57ab7d21a --- /dev/null +++ b/Sources/ContainerResource/Container/ContainerEvent.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +/// A discrete container lifecycle event recorded by the daemon and returned +/// to clients via ``ContainerClient/events()``. +/// +/// Events are recorded in-process by `ContainersService` at the moment a +/// container transitions through `create` / `start` / `stop` / `die` / +/// `destroy`. The daemon retains a bounded ring buffer of the most recent +/// events; callers requesting events after the buffer rolls over will miss +/// the dropped frames. There is no persistence across daemon restarts. +public struct ContainerEvent: Codable, Sendable, Equatable { + public enum Action: String, Codable, Sendable, Equatable { + case create + case start + case stop + case die + case destroy + } + + public let containerId: String + public let action: Action + public let timestamp: Date + + public init(containerId: String, action: Action, timestamp: Date = Date()) { + self.containerId = containerId + self.action = action + self.timestamp = timestamp + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index 5a2b6d0d3..f9ba1aa99 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -390,4 +390,27 @@ public struct ContainerClient: Sendable { ) } } + + /// Fetch the daemon's recent container lifecycle events. + /// + /// Returns the events currently held in the daemon's bounded ring + /// buffer (`create` / `start` / `stop` / `die` / `destroy`). Buffer + /// rollover drops the oldest entries; events are not persisted across + /// daemon restarts. + public func events() async throws -> [ContainerEvent] { + do { + let request = XPCMessage(route: .containerEvent) + let response = try await xpcClient.send(request) + guard let data = response.dataNoCopy(key: .containerEvent) else { + return [] + } + return try JSONDecoder().decode([ContainerEvent].self, from: data) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get container events", + cause: error + ) + } + } } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index 1871cd149..2b054fe3b 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -404,4 +404,13 @@ public struct ContainersHarness: Sendable { try await service.exportRootfs(id: id, archive: archiveUrl) return message.reply() } + + @Sendable + public func events(_ message: XPCMessage) async throws -> XPCMessage { + let events = await service.recentEvents() + let data = try JSONEncoder().encode(events) + let reply = message.reply() + reply.set(key: .containerEvent, value: data) + return reply + } } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 81612495f..bafe66d4d 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -61,6 +61,8 @@ public actor ContainersService { private let lock: AsyncLock private var containers: [String: ContainerState] + private var eventBuffer: [ContainerEvent] = [] + private static let maxEventBufferSize = 1000 // FIXME: Find a better mechanism for services running on the APIServer to work with each other private weak var networksService: NetworksService? @@ -381,6 +383,7 @@ public actor ContainersService { startedDate: nil ) await self.setContainerState(configuration.id, ContainerState(snapshot: snapshot), context: context) + await self.recordEvent(configuration.id, action: .create) } catch { throw error } @@ -451,6 +454,7 @@ public actor ContainersService { state.client = runtimeClient await self.setContainerState(id, state, context: context) + await self.recordEvent(id, action: .start) } catch { let label = Self.fullLaunchdServiceLabel( runtimeName: config.runtimeHandler, @@ -643,6 +647,8 @@ public actor ContainersService { } } try await handleContainerExit(id: id) + recordEvent(id, action: .stop) + recordEvent(id, action: .die) } public func dial(id: String, port: UInt32) async throws -> FileHandle { @@ -861,6 +867,7 @@ public actor ContainersService { "id": "\(id)", ] ) + await self.recordEvent(id, action: .destroy) } case .stopping: throw ContainerizationError( @@ -870,10 +877,24 @@ public actor ContainersService { default: try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { context in try await self.cleanUp(id: id, context: context) + await self.recordEvent(id, action: .destroy) } } } + private func recordEvent(_ containerId: String, action: ContainerEvent.Action) { + let event = ContainerEvent(containerId: containerId, action: action) + eventBuffer.append(event) + if eventBuffer.count > Self.maxEventBufferSize { + eventBuffer.removeFirst(eventBuffer.count - Self.maxEventBufferSize) + } + } + + public func recentEvents(since: Date? = nil) -> [ContainerEvent] { + guard let since else { return eventBuffer } + return eventBuffer.filter { $0.timestamp >= since } + } + public func containerDiskUsage(id: String) async throws -> UInt64 { log.debug( "ContainersService: enter",