Skip to content

Commit 985dac8

Browse files
authored
vminitd: Proper pty setup / mount /dev/console (#248)
1 parent 5a1975a commit 985dac8

File tree

12 files changed

+576
-201
lines changed

12 files changed

+576
-201
lines changed

Sources/Integration/Suite.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ struct IntegrationSuite: AsyncParsableCommand {
215215
"nested virt": testNestedVirtualizationEnabled,
216216
"container manager": testContainerManagerCreate,
217217
"container reuse": testContainerReuse,
218+
"container /dev/console": testContainerDevConsole,
218219
]
219220

220221
var passed = 0

Sources/Integration/VMTests.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,54 @@ extension IntegrationSuite {
257257
}
258258
}
259259

260+
func testContainerDevConsole() async throws {
261+
let id = "test-container-devconsole"
262+
263+
let bs = try await bootstrap()
264+
265+
let manager = try ContainerManager(vmm: bs.vmm)
266+
defer {
267+
try? manager.delete(id)
268+
}
269+
270+
let buffer = BufferWriter()
271+
let container = try await manager.create(
272+
id,
273+
image: bs.image,
274+
rootfs: bs.rootfs
275+
) { config in
276+
// We mount devtmpfs by default, and while this includes creating
277+
// /dev/console typically that'll be pointing to /dev/hvc0 (the
278+
// virtio serial console). This is just a character device, so a trivial
279+
// way to check that our bind mounted console setup worked is by just
280+
// parsing `mount`'s output and looking for /dev/console as it wouldn't
281+
// be there normally without our dance.
282+
config.process.arguments = ["mount"]
283+
config.process.terminal = true
284+
config.process.stdout = buffer
285+
}
286+
287+
try await container.create()
288+
try await container.start()
289+
290+
let status = try await container.wait()
291+
try await container.stop()
292+
guard status == 0 else {
293+
throw IntegrationError.assert(msg: "process status \(status) != 0")
294+
}
295+
296+
guard let str = String(data: buffer.data, encoding: .utf8) else {
297+
throw IntegrationError.assert(
298+
msg: "failed to convert standard output to a UTF8 string")
299+
}
300+
301+
let devConsole = "/dev/console"
302+
guard str.contains(devConsole) else {
303+
throw IntegrationError.assert(
304+
msg: "process should have \(devConsole) in `mount` output")
305+
}
306+
}
307+
260308
private func createMountDirectory() throws -> URL {
261309
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
262310
try "hello".write(to: dir.appendingPathComponent("hi.txt"), atomically: true, encoding: .utf8)

Sources/cctl/RunCommand.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ extension Application {
6868
@Option(name: .long, help: "Current working directory")
6969
var cwd: String = "/"
7070

71-
@Argument var arguments: [String] = ["/bin/sh"]
71+
@Argument(parsing: .captureForPassthrough)
72+
var arguments: [String] = ["/bin/sh"]
7273

7374
func run() async throws {
7475
let kernel = Kernel(

vminitd/Sources/vmexec/Console.swift

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the Containerization project authors. All rights reserved.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Foundation
18+
import Musl
19+
20+
class Console {
21+
let master: Int32
22+
let slavePath: String
23+
24+
init() throws {
25+
let masterFD = open("/dev/ptmx", O_RDWR | O_NOCTTY | O_CLOEXEC)
26+
guard masterFD != -1 else {
27+
throw App.Errno(stage: "open_ptmx")
28+
}
29+
30+
guard unlockpt(masterFD) == 0 else {
31+
throw App.Errno(stage: "unlockpt")
32+
}
33+
34+
guard let slavePath = ptsname(masterFD) else {
35+
throw App.Errno(stage: "ptsname")
36+
}
37+
38+
self.master = masterFD
39+
self.slavePath = String(cString: slavePath)
40+
}
41+
42+
func configureStdIO() throws {
43+
let path = self.slavePath
44+
let slaveFD = open(path, O_RDWR)
45+
guard slaveFD != -1 else {
46+
throw App.Errno(stage: "open_pts")
47+
}
48+
defer { Musl.close(slaveFD) }
49+
50+
for fd: Int32 in 0...2 {
51+
guard dup3(slaveFD, fd, 0) != -1 else {
52+
throw App.Errno(stage: "dup3")
53+
}
54+
}
55+
}
56+
57+
func close() throws {
58+
guard Musl.close(self.master) == 0 else {
59+
throw App.Errno(stage: "close")
60+
}
61+
}
62+
}

vminitd/Sources/vmexec/ExecCommand.swift

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -62,37 +62,68 @@ struct ExecCommand: ParsableCommand {
6262
process: ContainerizationOCI.Process,
6363
log: Logger
6464
) throws {
65-
// CLOEXEC the pipe fd that signals process readiness.
66-
let syncfd = FileHandle(fileDescriptor: 3)
67-
if fcntl(3, F_SETFD, FD_CLOEXEC) == -1 {
68-
throw App.Errno(stage: "cloexec(syncfd)")
69-
}
65+
let syncPipe = FileHandle(fileDescriptor: 3)
66+
let ackPipe = FileHandle(fileDescriptor: 4)
7067

7168
try Self.enterNS(path: "/proc/\(self.parentPid)/ns/cgroup", nsType: CLONE_NEWCGROUP)
7269
try Self.enterNS(path: "/proc/\(self.parentPid)/ns/pid", nsType: CLONE_NEWPID)
7370
try Self.enterNS(path: "/proc/\(self.parentPid)/ns/uts", nsType: CLONE_NEWUTS)
7471
try Self.enterNS(path: "/proc/\(self.parentPid)/ns/mnt", nsType: CLONE_NEWNS)
7572

76-
let childPipe = Pipe()
77-
try childPipe.setCloexec()
7873
let processID = fork()
7974

8075
guard processID != -1 else {
81-
try? childPipe.fileHandleForReading.close()
82-
try? childPipe.fileHandleForWriting.close()
83-
try? syncfd.close()
76+
try? syncPipe.close()
77+
try? ackPipe.close()
8478

8579
throw App.Errno(stage: "fork")
8680
}
8781

8882
if processID == 0 { // child
89-
try childPipe.fileHandleForReading.close()
90-
try syncfd.close()
83+
// Wait for the grandparent to tell us that they acked our pid.
84+
guard let data = try ackPipe.read(upToCount: App.ackPid.count) else {
85+
throw App.Failure(message: "read ack pipe")
86+
}
87+
guard let pidAckStr = String(data: data, encoding: .utf8) else {
88+
throw App.Failure(message: "convert ack pipe data to string")
89+
}
90+
91+
guard pidAckStr == App.ackPid else {
92+
throw App.Failure(message: "received invalid acknowledgement string: \(pidAckStr)")
93+
}
9194

9295
guard setsid() != -1 else {
9396
throw App.Errno(stage: "setsid()")
9497
}
9598

99+
if process.terminal {
100+
let pty = try Console()
101+
try pty.configureStdIO()
102+
var masterFD = pty.master
103+
104+
let data = Data(bytes: &masterFD, count: MemoryLayout.size(ofValue: masterFD))
105+
try syncPipe.write(contentsOf: data)
106+
try syncPipe.close()
107+
108+
// Wait for the grandparent to tell us that they acked our console.
109+
guard let data = try ackPipe.read(upToCount: App.ackConsole.count) else {
110+
throw App.Failure(message: "read ack pipe")
111+
}
112+
113+
guard let consoleAckStr = String(data: data, encoding: .utf8) else {
114+
throw App.Failure(message: "convert ack pipe data to string")
115+
}
116+
117+
guard consoleAckStr == App.ackConsole else {
118+
throw App.Failure(message: "received invalid acknowledgement string: \(consoleAckStr)")
119+
}
120+
121+
guard ioctl(0, UInt(TIOCSCTTY), 0) != -1 else {
122+
throw App.Errno(stage: "setctty(0)")
123+
}
124+
try pty.close()
125+
}
126+
96127
// Apply O_CLOEXEC to all file descriptors except stdio.
97128
// This ensures that all unwanted fds we may have accidentally
98129
// inherited are marked close-on-exec so they stay out of the
@@ -106,26 +137,13 @@ struct ExecCommand: ParsableCommand {
106137
// Set uid, gid, and supplementary groups
107138
try App.setPermissions(user: process.user)
108139

109-
if process.terminal {
110-
guard ioctl(0, UInt(TIOCSCTTY), 0) != -1 else {
111-
throw App.Errno(stage: "setctty()")
112-
}
113-
}
114-
115140
try App.exec(process: process)
116141
} else { // parent process
117-
try childPipe.fileHandleForWriting.close()
118-
119-
// wait until the pipe is closed then carry on.
120-
_ = try childPipe.fileHandleForReading.readToEnd()
121-
try childPipe.fileHandleForReading.close()
122-
123-
// send our child's pid to our parent before we exit.
142+
// Send our child's pid to our parent before we exit.
124143
var childPid = processID
125144
let data = Data(bytes: &childPid, count: MemoryLayout.size(ofValue: childPid))
126145

127-
try syncfd.write(contentsOf: data)
128-
try syncfd.close()
146+
try syncPipe.write(contentsOf: data)
129147
}
130148
}
131149
}

vminitd/Sources/vmexec/Mount.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ struct ContainerMount {
3636
}
3737

3838
func configureConsole() throws {
39-
let ptmx = self.rootfs.standardizingPath.appendingPathComponent("/dev/ptmx")
40-
39+
let ptmx = self.rootfs.standardizingPath.appendingPathComponent("dev/ptmx")
4140
guard remove(ptmx) == 0 else {
4241
throw App.Errno(stage: "remove(ptmx)")
4342
}

0 commit comments

Comments
 (0)