Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,12 @@ let package = Package(
],
path: "Sources/Services/RuntimeLinux/Server"
),
.testTarget(
name: "ContainerRuntimeLinuxServerTests",
dependencies: [
"ContainerRuntimeLinuxServer"
]
),
.target(
name: "ContainerRuntimeClient",
dependencies: [
Expand Down
81 changes: 81 additions & 0 deletions Sources/Services/RuntimeLinux/Server/MultiWriter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 Containerization
import Foundation

/// Fans container stdout/stderr out to one or more file handles (typically the
/// attached client stdio handle plus the on-disk container log).
///
/// Individual handle failures are isolated so a dead attached client (EPIPE)
/// cannot stop writes to the remaining handles.
final class MultiWriter: Writer, @unchecked Sendable {
private let lock = NSLock()
private var handles: [FileHandle]

init(handles: [FileHandle]) {
self.handles = handles
}

/// Returns the currently live handles. Intended for tests.
var liveHandles: [FileHandle] {
lock.lock()
defer { lock.unlock() }
return handles
}

func close() throws {
lock.lock()
let current = handles
handles = []
lock.unlock()

var lastError: Error?
var failures = 0
for handle in current {
do {
try handle.close()
} catch {
failures += 1
lastError = error
}
}
if failures == current.count, let lastError {
throw lastError
}
}

func write(_ data: Data) throws {
lock.lock()
defer { lock.unlock() }

var surviving: [FileHandle] = []
surviving.reserveCapacity(handles.count)
var lastError: Error?
for handle in handles {
do {
try handle.write(contentsOf: data)
surviving.append(handle)
} catch {
lastError = error
}
}
handles = surviving
if surviving.isEmpty, let lastError {
throw lastError
}
}
}
20 changes: 0 additions & 20 deletions Sources/Services/RuntimeLinux/Server/RuntimeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1499,26 +1499,6 @@ extension Filesystem.SyncMode {
}
}

struct MultiWriter: Writer {
let handles: [FileHandle]

init(handles: [FileHandle]) {
self.handles = handles
}

func close() throws {
for handle in handles {
try handle.close()
}
}

func write(_ data: Data) throws {
for handle in handles {
try handle.write(contentsOf: data)
}
}
}

extension FileHandle: @retroactive ReaderStream, @retroactive Writer {
public func write(_ data: Data) throws {
try self.write(contentsOf: data)
Expand Down
71 changes: 71 additions & 0 deletions Tests/ContainerRuntimeLinuxServerTests/MultiWriterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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
import Testing

@testable import ContainerRuntimeLinuxServer

struct MultiWriterTests {
/// Match RuntimeLinuxHelper, which ignores SIGPIPE so broken-pipe writes
/// surface as EPIPE errors instead of terminating the process.
private static func ignoreSIGPIPE() {
signal(SIGPIPE, SIG_IGN)
}

@Test
func writeContinuesToLogAfterClientEPIPE() throws {
Self.ignoreSIGPIPE()

let clientPipe = Pipe()
// Closing the read end makes the next write to the write end fail with EPIPE,
// which is what happens when an attached client disappears.
try clientPipe.fileHandleForReading.close()
let clientHandle = clientPipe.fileHandleForWriting

let logURL = FileManager.default.temporaryDirectory
.appendingPathComponent("multiwriter-log-\(UUID().uuidString).log")
FileManager.default.createFile(atPath: logURL.path, contents: nil)
defer { try? FileManager.default.removeItem(at: logURL) }
let logHandle = try FileHandle(forWritingTo: logURL)

let writer = MultiWriter(handles: [clientHandle, logHandle])
let payload = Data("tick still logged\n".utf8)

try writer.write(payload)
try writer.write(Data("second line\n".utf8))

#expect(writer.liveHandles.count == 1)

try logHandle.synchronize()
let logged = try Data(contentsOf: logURL)
#expect(String(data: logged, encoding: .utf8) == "tick still logged\nsecond line\n")
}

@Test
func writeThrowsWhenEveryHandleFails() throws {
Self.ignoreSIGPIPE()

let pipe = Pipe()
try pipe.fileHandleForReading.close()
let writer = MultiWriter(handles: [pipe.fileHandleForWriting])

#expect(throws: (any Error).self) {
try writer.write(Data("no consumers\n".utf8))
}
#expect(writer.liveHandles.isEmpty)
}
}