diff --git a/Sources/Containerization/IO/Terminal+ReaderStream.swift b/Sources/Containerization/IO/Terminal+ReaderStream.swift index 1bd91ee6..38b5f229 100644 --- a/Sources/Containerization/IO/Terminal+ReaderStream.swift +++ b/Sources/Containerization/IO/Terminal+ReaderStream.swift @@ -15,20 +15,41 @@ //===----------------------------------------------------------------------===// import ContainerizationOS +@preconcurrency import Dispatch import Foundation extension Terminal: ReaderStream { public func stream() -> AsyncStream { - .init { cont in - self.handle.readabilityHandler = { handle in - let data = handle.availableData - if data.isEmpty { - self.handle.readabilityHandler = nil - cont.finish() - return + let fd = self.fileDescriptor + guard fd >= 0 else { + return AsyncStream { $0.finish() } + } + + return AsyncStream { continuation in + let source = DispatchSource.makeReadSource( + fileDescriptor: fd, + queue: DispatchQueue(label: "com.apple.containerization.terminal.reader") + ) + + var buffer = [UInt8](repeating: 0, count: Int(getpagesize())) + source.setEventHandler { + let bytesRead = read(fd, &buffer, buffer.count) + if bytesRead > 0 { + continuation.yield(Data(buffer[.. Int32 { close(fd) } +private func sysWrite(_ fd: Int32, _ buf: UnsafeRawPointer, _ count: Int) -> Int { write(fd, buf, count) } /// `Terminal` provides a clean interface to deal with terminal interactions on Unix platforms. public struct Terminal: Sendable { private let initState: termios? - private var descriptor: Int32 { - handle.fileDescriptor - } - public let handle: FileHandle + /// The underlying file descriptor. + public let fileDescriptor: Int32 public init(descriptor: Int32, setInitState: Bool = true) throws { if setInitState { @@ -31,12 +46,25 @@ public struct Terminal: Sendable { } else { initState = nil } - self.handle = .init(fileDescriptor: descriptor, closeOnDealloc: false) + self.fileDescriptor = descriptor } /// Write the provided data to the tty device. public func write(_ data: Data) throws { - try handle.write(contentsOf: data) + try data.withUnsafeBytes { buffer in + guard let base = buffer.baseAddress, buffer.count > 0 else { return } + let fd = fileDescriptor + var offset = 0 + while offset < buffer.count { + let n = Syscall.retrying { + sysWrite(fd, base.advanced(by: offset), buffer.count - offset) + } + if n < 0 { + throw POSIXError(.init(rawValue: errno)!) + } + offset += n + } + } } /// The winsize for a pty. @@ -78,7 +106,7 @@ public struct Terminal: Sendable { public var size: Size { get throws { var ws = winsize() - try fromSyscall(ioctl(descriptor, UInt(TIOCGWINSZ), &ws)) + try fromSyscall(ioctl(fileDescriptor, UInt(TIOCGWINSZ), &ws)) return Size(ws) } } @@ -119,14 +147,14 @@ extension Terminal { /// - Parameter pty: A pty to resize from. public func resize(from pty: Terminal) throws { var ws = try pty.size - try fromSyscall(ioctl(descriptor, UInt(TIOCSWINSZ), &ws)) + try fromSyscall(ioctl(fileDescriptor, UInt(TIOCSWINSZ), &ws)) } /// Resize the pty to the provided window size. /// - Parameter size: A window size for a pty. public func resize(size: Size) throws { var ws = size.size - try fromSyscall(ioctl(descriptor, UInt(TIOCSWINSZ), &ws)) + try fromSyscall(ioctl(fileDescriptor, UInt(TIOCSWINSZ), &ws)) } /// Resize the pty to the provided window size. @@ -134,34 +162,34 @@ extension Terminal { /// - Parameter height: A height or rows of the terminal. public func resize(width: UInt16, height: UInt16) throws { var ws = Size(width: width, height: height) - try fromSyscall(ioctl(descriptor, UInt(TIOCSWINSZ), &ws)) + try fromSyscall(ioctl(fileDescriptor, UInt(TIOCSWINSZ), &ws)) } } extension Terminal { /// Enable raw mode for the pty. public func setraw() throws { - var attr = try Self.getattr(descriptor) + var attr = try Self.getattr(fileDescriptor) cfmakeraw(&attr) attr.c_oflag = attr.c_oflag | tcflag_t(OPOST) - try fromSyscall(tcsetattr(descriptor, TCSANOW, &attr)) + try fromSyscall(tcsetattr(fileDescriptor, TCSANOW, &attr)) } /// Enable echo support. /// Chars typed will be displayed to the terminal. public func enableEcho() throws { - var attr = try Self.getattr(descriptor) + var attr = try Self.getattr(fileDescriptor) attr.c_iflag &= ~tcflag_t(ICRNL) attr.c_lflag &= ~tcflag_t(ICANON | ECHO) - try fromSyscall(tcsetattr(descriptor, TCSANOW, &attr)) + try fromSyscall(tcsetattr(fileDescriptor, TCSANOW, &attr)) } /// Disable echo support. /// Chars typed will not be displayed back to the terminal. public func disableEcho() throws { - var attr = try Self.getattr(descriptor) + var attr = try Self.getattr(fileDescriptor) attr.c_lflag &= ~tcflag_t(ECHO) - try fromSyscall(tcsetattr(descriptor, TCSANOW, &attr)) + try fromSyscall(tcsetattr(fileDescriptor, TCSANOW, &attr)) } private static func getattr(_ fd: Int32) throws -> termios { @@ -176,22 +204,15 @@ extension Terminal { extension Terminal { /// Close this pty's file descriptor. public func close() throws { - do { - // Use FileHandle's close directly as it sets the underlying fd in the object - // to -1 for us. - try self.handle.close() - } catch { - if let error = error as NSError?, error.domain == NSPOSIXErrorDomain { - throw POSIXError(.init(rawValue: Int32(error.code))!) - } - throw error + guard sysClose(fileDescriptor) == 0 else { + throw POSIXError(.init(rawValue: errno)!) } } /// Reset the pty to its initial state. public func reset() throws { if var attr = initState { - try fromSyscall(tcsetattr(descriptor, TCSANOW, &attr)) + try fromSyscall(tcsetattr(fileDescriptor, TCSANOW, &attr)) } } diff --git a/Tests/ContainerizationOSTests/TerminalTests.swift b/Tests/ContainerizationOSTests/TerminalTests.swift new file mode 100644 index 00000000..0885440c --- /dev/null +++ b/Tests/ContainerizationOSTests/TerminalTests.swift @@ -0,0 +1,277 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization 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 ContainerizationOS + +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif + +@Suite("Terminal tests") +final class TerminalTests { + + @Suite("Size") + struct SizeTests { + @Test + func widthAndHeightAreStored() { + let size = Terminal.Size(width: 80, height: 24) + #expect(size.width == 80) + #expect(size.height == 24) + } + + @Test + func zeroSize() { + let size = Terminal.Size(width: 0, height: 0) + #expect(size.width == 0) + #expect(size.height == 0) + } + + @Test + func maxValues() { + let size = Terminal.Size(width: .max, height: .max) + #expect(size.width == UInt16.max) + #expect(size.height == UInt16.max) + } + } + + @Suite("PTY creation") + struct CreateTests { + @Test + func createReturnsPair() throws { + let (parent, child) = try Terminal.create() + defer { + try? parent.close() + try? child.close() + } + #expect(parent.fileDescriptor >= 0) + #expect(child.fileDescriptor >= 0) + #expect(parent.fileDescriptor != child.fileDescriptor) + } + + @Test + func createWithCustomSize() throws { + let size = Terminal.Size(width: 200, height: 50) + let (parent, child) = try Terminal.create(initialSize: size) + defer { + try? parent.close() + try? child.close() + } + let childSize = try child.size + #expect(childSize.width == 200) + #expect(childSize.height == 50) + } + + @Test + func createDefaultSize() throws { + let (parent, child) = try Terminal.create() + defer { + try? parent.close() + try? child.close() + } + let childSize = try child.size + #expect(childSize.width == 120) + #expect(childSize.height == 40) + } + } + + @Suite("Resize") + struct ResizeTests { + @Test + func resizeWithSize() throws { + let (parent, child) = try Terminal.create() + defer { + try? parent.close() + try? child.close() + } + + let newSize = Terminal.Size(width: 132, height: 43) + try child.resize(size: newSize) + + let actual = try child.size + #expect(actual.width == 132) + #expect(actual.height == 43) + } + + @Test + func resizeWithWidthAndHeight() throws { + let (parent, child) = try Terminal.create() + defer { + try? parent.close() + try? child.close() + } + + try child.resize(width: 100, height: 30) + let actual = try child.size + #expect(actual.width == 100) + #expect(actual.height == 30) + } + + @Test + func resizeFromAnotherPty() throws { + let (parent1, child1) = try Terminal.create( + initialSize: Terminal.Size(width: 160, height: 48) + ) + defer { + try? parent1.close() + try? child1.close() + } + + let (parent2, child2) = try Terminal.create( + initialSize: Terminal.Size(width: 80, height: 24) + ) + defer { + try? parent2.close() + try? child2.close() + } + + try child2.resize(from: child1) + let actual = try child2.size + #expect(actual.width == 160) + #expect(actual.height == 48) + } + } + + @Suite("Write") + struct WriteTests { + @Test + func writeDataToPty() throws { + let (parent, child) = try Terminal.create() + defer { + try? parent.close() + try? child.close() + } + + let message = "hello\n" + try child.write(Data(message.utf8)) + + let fd = parent.fileDescriptor + var buf = [UInt8](repeating: 0, count: 256) + let n = read(fd, &buf, buf.count) + #expect(n > 0) + } + } + + @Suite("Terminal modes") + struct ModeTests { + @Test + func setrawChangesAttributes() throws { + let (parent, child) = try Terminal.create() + defer { + try? parent.close() + try? child.close() + } + + try child.setraw() + + var attr = termios() + #expect(tcgetattr(child.fileDescriptor, &attr) == 0) + #expect(attr.c_lflag & tcflag_t(ECHO) == 0) + #expect(attr.c_lflag & tcflag_t(ICANON) == 0) + // setraw also re-enables OPOST. + #expect(attr.c_oflag & tcflag_t(OPOST) != 0) + } + + @Test + func disableEchoClearsFlag() throws { + let (parent, child) = try Terminal.create() + defer { + try? parent.close() + try? child.close() + } + + try child.disableEcho() + var attr = termios() + #expect(tcgetattr(child.fileDescriptor, &attr) == 0) + #expect(attr.c_lflag & tcflag_t(ECHO) == 0) + } + } + + @Suite("Close and reset") + struct LifecycleTests { + @Test + func closeSucceeds() throws { + let (parent, child) = try Terminal.create() + defer { + try? parent.close() + } + try child.close() + } + + @Test + func resetRestoresInitialState() throws { + let (parent, child) = try Terminal.create() + defer { + try? parent.close() + try? child.close() + } + + // The child pty was created via openpty (setInitState: false), + // so init it with setInitState: true to capture the original attrs. + let term = try Terminal(descriptor: child.fileDescriptor, setInitState: true) + + // Modify the terminal. + try term.setraw() + + // Reset and verify we get back the original state. + try term.reset() + + var attr = termios() + #expect(tcgetattr(child.fileDescriptor, &attr) == 0) + #expect(attr.c_lflag & tcflag_t(ECHO) != 0) + #expect(attr.c_lflag & tcflag_t(ICANON) != 0) + } + + @Test + func tryResetDoesNotThrow() throws { + let (parent, child) = try Terminal.create() + defer { + try? parent.close() + try? child.close() + } + + let term = try Terminal(descriptor: child.fileDescriptor, setInitState: true) + try term.setraw() + term.tryReset() + } + } + + @Suite("Error") + struct ErrorTests { + @Test + func notAPtyOnRegularFD() throws { + let fd = open("/dev/null", O_RDWR) + #expect(fd >= 0) + defer { close(fd) } + + #expect(throws: (any Swift.Error).self) { + try Terminal(descriptor: fd) + } + } + + @Test + func notAPtyErrorDescription() { + let error = Terminal.Error.notAPty + #expect(error.description == "the provided fd is not a pty") + } + } +} diff --git a/vminitd/Sources/vminitd/IOCloser+Extensions.swift b/vminitd/Sources/vminitd/IOCloser+Extensions.swift index 720ab8c3..5c379243 100644 --- a/vminitd/Sources/vminitd/IOCloser+Extensions.swift +++ b/vminitd/Sources/vminitd/IOCloser+Extensions.swift @@ -19,10 +19,6 @@ import Foundation extension Socket: IOCloser {} -extension Terminal: IOCloser { - var fileDescriptor: Int32 { - self.handle.fileDescriptor - } -} +extension Terminal: IOCloser {} extension FileHandle: IOCloser {}