diff --git a/PATCHES.md b/PATCHES.md index 158e2c1..449b33f 100644 --- a/PATCHES.md +++ b/PATCHES.md @@ -205,6 +205,36 @@ rebuild whenever a guest patch changes. Built locally, not in CI: the host frame into a never-accepted backlog. A failed pre-relay connection is also closed explicitly. Marked `[Nucleic vendored patch]`. +14. **Non-blocking, non-spinning guest I/O plane (`IOPair.swift`, `OSFile+Splice.swift`, + `VsockProxy.swift`) — the root cause of the "control plane unresponsive / all sessions stall" + wedge.** Every exec's stdio relay (`IOPair`) and every control-plane relay connection + (`VsockProxy`) share ONE thread: `ProcessSupervisor.default`'s epoll poller. Three defects let + a single slow peer freeze that thread — and with it every session's stdio AND the whole + container's control plane at once (probes then read "accepts connections but never answers"; + before the manager-side recovery rework the host restarted the container over this, SIGKILLing + every session): + - **`IOPair` blocking write:** only *registered* fds get `O_NONBLOCK` (set by `Epoll.add`), and + the relay registers only its READ fd — the write fd (e.g. the vsock socket carrying an exec's + stdout to the host) stayed blocking. One slow-drained stream parked the poller thread in + `write(2)`. The relay now sets the write fd non-blocking up front and implements real + backpressure: a full destination stashes the remainder in a `pending` backlog, registers the + write fd for EPOLLOUT, and suspends reads until the flush completes (throttling the producing + process via its own pipe, not the shared thread). The old close-on-short-write path — dead + while the fd was blocking, live and stdio-dropping once non-blocking — is subsumed by the + backlog; genuine write errors still close the pair. + - **`OSFile.splice` busy-spin:** when the destination was full (EAGAIN) and the source idle, + the outer loop had no exit — it spun the poller thread at 100% until the peer drained (or + forever if it never did). The flush leg's EAGAIN branch now returns partial progress; the + un-flushed bytes stay in the transfer pipe and the destination's EPOLLOUT edge resumes the + flush (both `VsockProxy` handlers already pump both directions on both events). + - **`VsockProxy` registration race:** the client fd's epoll handler can fire — and splice + toward the server fd — before the second registration makes that fd non-blocking; a full + destination made that a genuinely blocking splice on the poller thread. Both fds are now set + non-blocking before either is registered. + All three are guest-side and INERT until the initfs image is rebuilt (`make vminit-image`, tag + `0.34.0-nucleic4`) and `ContainerEngine.vminitReference` is bumped after runtime validation. + Marked `[Nucleic vendored patch]`. + ## Re-vendoring a newer upstream commit 1. `git clone` upstream (or copy `.build/checkouts/containerization` after bumping the URL pin @@ -222,9 +252,11 @@ rebuild whenever a guest patch changes. Built locally, not in CI: the host frame `Cgroup2Manager.swift` / `ManagedContainer.swift` / `ManagedProcess.swift`, patch #10 (`Socket.acceptStream` transient-error tolerance + `isTransientAcceptError`), patch #11 (the `UnixSocketRelay` per-connection containment + fail-fast closes), patch #12 (the `VsockProxy` - cleanup/`try!`/listener hardening in `vminitd/`), and patch #13 (the prompt-free + cleanup/`try!`/listener hardening in `vminitd/`), patch #13 (the prompt-free `KeychainQuery` reads: `withoutInteractiveUI` + the `errSecInteractionNotAllowed` handling + - the `save` duplicate retry). After re-applying any + the `save` duplicate retry), and patch #14 (the non-blocking/non-spinning guest I/O plane: + `IOPair` backpressure, the `OSFile.splice` EAGAIN return, and the `VsockProxy` pre-registration + non-blocking fds — all in `vminitd/`). After re-applying any `vminitd/` patch, rebuild + publish the custom init image with `make vminit-image` + `make vminit-image-push`, and bump `ContainerEngine.vminitReference`. 5. Update the commit hash above and in the root `Package.swift` comment. diff --git a/vminitd/Package.resolved b/vminitd/Package.resolved index e37bc3a..c1dc9ac 100644 --- a/vminitd/Package.resolved +++ b/vminitd/Package.resolved @@ -1,249 +1,258 @@ { - "originHash" : "6ccceb47b6a402e9ac07d23204ec7f4792823b22b96275cd67f8531787a60c04", - "pins" : [ + "originHash": "264b211a5ea74fa24ced86faade5901700722c925484a26379cc4a6b40083c6c", + "pins": [ { - "identity" : "async-http-client", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/async-http-client.git", - "state" : { - "revision" : "4b99975677236d13f0754339864e5360142ff5a1", - "version" : "1.30.3" + "identity": "async-http-client", + "kind": "remoteSourceControl", + "location": "https://github.com/swift-server/async-http-client.git", + "state": { + "revision": "4603a8036d921ea999fadb742931546c341f4bd7", + "version": "1.35.0" } }, { - "identity" : "grpc-swift-2", - "kind" : "remoteSourceControl", - "location" : "https://github.com/grpc/grpc-swift-2.git", - "state" : { - "revision" : "f28854bc760a116e053fdfc4a48a9428c34625c0", - "version" : "2.3.0" + "identity": "grpc-swift-2", + "kind": "remoteSourceControl", + "location": "https://github.com/grpc/grpc-swift-2.git", + "state": { + "revision": "28cdd63ef88583ddc67d7bb179eab46fab465ce9", + "version": "2.4.2" } }, { - "identity" : "grpc-swift-nio-transport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/grpc/grpc-swift-nio-transport.git", - "state" : { - "revision" : "f37e0c2d293cea668b11e10e1fb1c24cb40781ff", - "version" : "2.4.4" + "identity": "grpc-swift-nio-transport", + "kind": "remoteSourceControl", + "location": "https://github.com/grpc/grpc-swift-nio-transport.git", + "state": { + "revision": "2ca31f06658ed288a2560e23ad649acbb3d6b3a3", + "version": "2.9.0" } }, { - "identity" : "grpc-swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/grpc/grpc-swift-protobuf.git", - "state" : { - "revision" : "19153231a03c2fda1f4ea60da1b92a2cb9c011d8", - "version" : "2.2.0" + "identity": "grpc-swift-protobuf", + "kind": "remoteSourceControl", + "location": "https://github.com/grpc/grpc-swift-protobuf.git", + "state": { + "revision": "176c5a434fd76f6f479848d1a8f7d44967534168", + "version": "2.4.1" } }, { - "identity" : "swift-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-algorithms.git", - "state" : { - "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", - "version" : "1.2.1" + "identity": "swift-algorithms", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-algorithms.git", + "state": { + "revision": "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version": "1.2.1" } }, { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", - "state" : { - "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", - "version" : "1.7.0" + "identity": "swift-argument-parser", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-argument-parser.git", + "state": { + "revision": "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version": "1.8.2" } }, { - "identity" : "swift-asn1", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-asn1.git", - "state" : { - "revision" : "a54383ada6cecde007d374f58f864e29370ba5c3", - "version" : "1.3.2" + "identity": "swift-asn1", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-asn1.git", + "state": { + "revision": "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version": "1.7.1" } }, { - "identity" : "swift-async-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms.git", - "state" : { - "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", - "version" : "1.0.4" + "identity": "swift-async-algorithms", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-async-algorithms.git", + "state": { + "revision": "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version": "1.1.5" } }, { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", - "state" : { - "revision" : "cd142fd2f64be2100422d658e7411e39489da985", - "version" : "1.2.0" + "identity": "swift-atomics", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-atomics.git", + "state": { + "revision": "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version": "1.3.1" } }, { - "identity" : "swift-certificates", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-certificates.git", - "state" : { - "revision" : "f4cd9e78a1ec209b27e426a5f5c693675f95e75a", - "version" : "1.15.0" + "identity": "swift-certificates", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-certificates.git", + "state": { + "revision": "89fbc3714264cce8db8e4ec51b64e01c3e28c6c5", + "version": "1.19.3" } }, { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", - "version" : "1.2.0" + "identity": "swift-collections", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-collections.git", + "state": { + "revision": "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version": "1.6.0" } }, { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "revision" : "e8d6eba1fef23ae5b359c46b03f7d94be2f41fed", - "version" : "3.12.3" + "identity": "swift-configuration", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-configuration.git", + "state": { + "revision": "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version": "1.2.0" } }, { - "identity" : "swift-distributed-tracing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-distributed-tracing.git", - "state" : { - "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", - "version" : "1.4.1" + "identity": "swift-crypto", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-crypto.git", + "state": { + "revision": "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version": "3.15.1" } }, { - "identity" : "swift-http-structured-headers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-structured-headers.git", - "state" : { - "revision" : "db6eea3692638a65e2124990155cd220c2915903", - "version" : "1.3.0" + "identity": "swift-distributed-tracing", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-distributed-tracing.git", + "state": { + "revision": "dc4030184203ffafbb2ec614352487235d747fe0", + "version": "1.4.1" } }, { - "identity" : "swift-http-types", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-types.git", - "state" : { - "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", - "version" : "1.4.0" + "identity": "swift-http-structured-headers", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-http-structured-headers.git", + "state": { + "revision": "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version": "1.7.0" } }, { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", - "version" : "1.10.1" + "identity": "swift-http-types", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-http-types.git", + "state": { + "revision": "db774a277f60063a32d854f2980299caf06da041", + "version": "1.6.0" } }, { - "identity" : "swift-nio", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio.git", - "state" : { - "revision" : "4e8f4b1c9adaa59315c523540c1ff2b38adc20a9", - "version" : "2.87.0" + "identity": "swift-log", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-log.git", + "state": { + "revision": "a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a", + "version": "1.14.0" } }, { - "identity" : "swift-nio-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-extras.git", - "state" : { - "revision" : "145db1962f4f33a4ea07a32e751d5217602eea29", - "version" : "1.28.0" + "identity": "swift-nio", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-nio.git", + "state": { + "revision": "cd3e1152083706d77b223fb29110e590efcc70c0", + "version": "2.101.2" } }, { - "identity" : "swift-nio-http2", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-http2.git", - "state" : { - "revision" : "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5", - "version" : "1.38.0" + "identity": "swift-nio-extras", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-nio-extras.git", + "state": { + "revision": "88a51340f59cf181ebde888bd1b749296b3ec029", + "version": "1.34.3" } }, { - "identity" : "swift-nio-ssl", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-ssl.git", - "state" : { - "revision" : "173cc69a058623525a58ae6710e2f5727c663793", - "version" : "2.36.0" + "identity": "swift-nio-http2", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-nio-http2.git", + "state": { + "revision": "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", + "version": "1.44.0" } }, { - "identity" : "swift-nio-transport-services", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-transport-services.git", - "state" : { - "revision" : "cd1e89816d345d2523b11c55654570acd5cd4c56", - "version" : "1.24.0" + "identity": "swift-nio-ssl", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-nio-ssl.git", + "state": { + "revision": "407d82d5b6cc00e1c3fb83a81b1539b70c788c5e", + "version": "2.37.1" } }, { - "identity" : "swift-numerics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-numerics.git", - "state" : { - "revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8", - "version" : "1.0.3" + "identity": "swift-nio-transport-services", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-nio-transport-services.git", + "state": { + "revision": "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version": "1.28.0" } }, { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "86970144a0b86068c81ff48ee29b3f97cae0b879", - "version" : "1.36.0" + "identity": "swift-numerics", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-numerics.git", + "state": { + "revision": "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version": "1.1.1" } }, { - "identity" : "swift-service-context", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-service-context.git", - "state" : { - "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", - "version" : "1.3.0" + "identity": "swift-protobuf", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-protobuf.git", + "state": { + "revision": "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8", + "version": "1.38.1" } }, { - "identity" : "swift-service-lifecycle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-service-lifecycle.git", - "state" : { - "revision" : "e7187309187695115033536e8fc9b2eb87fd956d", - "version" : "2.8.0" + "identity": "swift-service-context", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-service-context.git", + "state": { + "revision": "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version": "1.3.0" } }, { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system.git", - "state" : { - "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", - "version" : "1.6.4" + "identity": "swift-service-lifecycle", + "kind": "remoteSourceControl", + "location": "https://github.com/swift-server/swift-service-lifecycle.git", + "state": { + "revision": "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version": "2.11.0" } }, { - "identity" : "zstd", - "kind" : "remoteSourceControl", - "location" : "https://github.com/facebook/zstd.git", - "state" : { - "revision" : "f8745da6ff1ad1e7bab384bd1f9d742439278e99", - "version" : "1.5.7" + "identity": "swift-system", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-system.git", + "state": { + "revision": "b5544ba79a70a0cb3563e75bf26dc198d6b40ed3", + "version": "1.7.4" + } + }, + { + "identity": "zstd", + "kind": "remoteSourceControl", + "location": "https://github.com/facebook/zstd.git", + "state": { + "revision": "f8745da6ff1ad1e7bab384bd1f9d742439278e99", + "version": "1.5.7" } } ], - "version" : 3 + "version": 3 } diff --git a/vminitd/Sources/VminitdCore/IOPair.swift b/vminitd/Sources/VminitdCore/IOPair.swift index 7d9df42..372554d 100644 --- a/vminitd/Sources/VminitdCore/IOPair.swift +++ b/vminitd/Sources/VminitdCore/IOPair.swift @@ -33,6 +33,16 @@ final class IOPair: Sendable { let buffer: UnsafeMutableBufferPointer var closed: Bool var registeredFd: Int32? + // [Nucleic vendored patch] Backpressure state: bytes read from `from` that `to` couldn't + // take yet (its buffer was full), plus whether `to` is currently registered for EPOLLOUT + // to flush them. While `pending` is non-empty the relay reads nothing more — the source + // pipe backs up and throttles the *producing process* instead of this thread. The write + // fd used to be BLOCKING (only registered fds get O_NONBLOCK, and it never was), so one + // slow-drained stream parked the shared ProcessSupervisor poller thread — freezing every + // exec's stdio and every control-plane relay in the container at once. + var pending: [UInt8] + var pendingOffset: Int + var writeFdRegistered: Bool func drain() { let readFrom = OSFile(fd: from.fileDescriptor) @@ -66,10 +76,27 @@ final class IOPair: Sendable { return } - // Try and drain IO first. - self.drain() + // [Nucleic vendored patch] Flush what we can IN ORDER: the pending backlog first, + // then (only if it fully flushed) a best-effort drain of the source. Draining with + // unsent pending bytes would reorder the stream. + let writeTo = OSFile(fd: to.fileDescriptor) + while pendingOffset < pending.count { + let offset = pendingOffset + let result = pending.withUnsafeMutableBufferPointer { buf in + writeTo.write( + UnsafeMutableBufferPointer( + start: buf.baseAddress!.advanced(by: offset), + count: buf.count - offset)) + } + if result.wrote > 0 { pendingOffset += result.wrote } + if result.action != .success { break } + } + if pendingOffset >= pending.count { + // Try and drain IO first. + self.drain() + } - // Remove the fd from our global epoll instance first. + // Remove the fds from our global epoll instance first. if let fd = self.registeredFd { do { try ProcessSupervisor.default.unregisterFd(fd) @@ -78,6 +105,15 @@ final class IOPair: Sendable { } self.registeredFd = nil } + // [Nucleic vendored patch] The write fd may be registered for backpressure flushing. + if self.writeFdRegistered { + do { + try ProcessSupervisor.default.unregisterFd(to.fileDescriptor) + } catch { + logger?.error("failed to delete write fd from epoll \(to.fileDescriptor): \(error)") + } + self.writeFdRegistered = false + } do { try self.from.close() @@ -108,7 +144,10 @@ final class IOPair: Sendable { to: writeTo, buffer: buffer, closed: false, - registeredFd: nil + registeredFd: nil, + pending: [], + pendingOffset: 0, + writeFdRegistered: false )) self.reason = reason self.logger = logger @@ -122,8 +161,16 @@ final class IOPair: Sendable { return (io.from.fileDescriptor, io.to.fileDescriptor) } - let readFrom = OSFile(fd: readFromFd) - let writeTo = OSFile(fd: writeToFd) + // [Nucleic vendored patch] The write fd must be non-blocking BEFORE the first relay write. + // `Epoll.add` only sets O_NONBLOCK on fds it registers, and the write fd is registered only + // on demand (EPOLLOUT backpressure below) — so without this, the very first full-buffer + // write blocked the shared poller thread. + let flags = fcntl(writeToFd, F_GETFL) + if flags == -1 || fcntl(writeToFd, F_SETFL, flags | O_NONBLOCK) == -1 { + self.logger?.error( + "failed to set relay write fd non-blocking", + metadata: ["fd": "\(writeToFd)", "errno": "\(errno)"]) + } try ProcessSupervisor.default.registerFd(readFromFd, mask: .input) { mask in self.io.withLock { io in @@ -139,42 +186,129 @@ final class IOPair: Sendable { return } - // Loop so we drain fully. - while true { - let r = readFrom.read(io.buffer) - if r.read > 0 { - let view = UnsafeMutableBufferPointer( - start: io.buffer.baseAddress, - count: r.read - ) + self.pump(&io, mask: mask, ignoreHup: ignoreHup) + } + } + } - let w = writeTo.write(view) - if w.wrote != r.read { - self.logger?.error("stopping relay: short write for stdio") - io.close(logger: self.logger) - return - } - } + /// [Nucleic vendored patch] One relay pass, non-blocking end to end: flush any pending + /// backlog toward `to`, then (only once it's empty) drain `from`. On a full destination the + /// remainder is stashed in `pending` and the write fd registered for EPOLLOUT, whose edge + /// re-enters this pump — so backpressure suspends the relay instead of blocking or spinning + /// the shared poller thread. Must be called with the `io` lock held. + private func pump(_ io: inout IO, mask: Epoll.Mask, ignoreHup: Bool) { + let readFrom = OSFile(fd: io.from.fileDescriptor) + let writeTo = OSFile(fd: io.to.fileDescriptor) - switch r.action { - case .error(let errno): - self.logger?.error("failed with errno \(errno) while reading for fd \(readFromFd)") - fallthrough - case .eof: - self.logger?.debug("closing relay for \(readFromFd)") - io.close(logger: self.logger) - return + // Flush the pending backlog first; reads stay suspended until it clears. + while io.pendingOffset < io.pending.count { + let offset = io.pendingOffset + let result = io.pending.withUnsafeMutableBufferPointer { buf in + writeTo.write( + UnsafeMutableBufferPointer( + start: buf.baseAddress!.advanced(by: offset), + count: buf.count - offset)) + } + if result.wrote > 0 { io.pendingOffset += result.wrote } + switch result.action { + case .success: + continue + case .again: + self.ensureWriteRegistered(&io) + return + default: + self.logger?.error("stopping relay: write failed during backlog flush") + io.close(logger: self.logger) + return + } + } + if !io.pending.isEmpty { + io.pending = [] + io.pendingOffset = 0 + self.unregisterWrite(&io) + } + + // Loop so we drain fully (edge-triggered epoll requires reading until EAGAIN). + while true { + let r = readFrom.read(io.buffer) + if r.read > 0 { + let view = UnsafeMutableBufferPointer( + start: io.buffer.baseAddress, + count: r.read + ) + + let w = writeTo.write(view) + if w.wrote != r.read { + switch w.action { case .again: - if mask.isHangup && !ignoreHup { - self.logger?.error("received EPOLLHUP and EAGAIN exiting") - self.close() - } + // Destination full: stash the remainder and suspend reads until its + // EPOLLOUT edge flushes it. (A later `read` re-reports EOF if this + // chunk was the stream's last, so no EOF is lost by returning here.) + io.pending = Array( + UnsafeBufferPointer( + start: io.buffer.baseAddress!.advanced(by: w.wrote), + count: r.read - w.wrote)) + io.pendingOffset = 0 + self.ensureWriteRegistered(&io) return default: - break + self.logger?.error("stopping relay: short write for stdio") + io.close(logger: self.logger) + return } } } + + switch r.action { + case .error(let errno): + self.logger?.error("failed with errno \(errno) while reading for fd \(io.from.fileDescriptor)") + fallthrough + case .eof: + self.logger?.debug("closing relay for \(io.from.fileDescriptor)") + io.close(logger: self.logger) + return + case .again: + if mask.isHangup && !ignoreHup { + self.logger?.error("received EPOLLHUP and EAGAIN exiting") + io.close(logger: self.logger) + } + return + default: + break + } + } + } + + /// [Nucleic vendored patch] Register the write fd for EPOLLOUT so the pending backlog is + /// flushed when the destination drains. Registration failure closes the pair — without the + /// flush wakeup the relay would hang with data stranded. Must be called with the lock held. + private func ensureWriteRegistered(_ io: inout IO) { + guard !io.writeFdRegistered else { return } + let writeToFd = io.to.fileDescriptor + do { + try ProcessSupervisor.default.registerFd(writeToFd, mask: .output) { _ in + self.io.withLock { io in + guard !io.closed else { return } + // An empty mask: HUP/EOF handling rides the read fd's own events. + self.pump(&io, mask: [], ignoreHup: true) + } + } + io.writeFdRegistered = true + } catch { + self.logger?.error("failed to register relay write fd for backpressure: \(error)") + io.close(logger: self.logger) + } + } + + /// [Nucleic vendored patch] Drop the EPOLLOUT registration once the backlog has flushed. + /// Must be called with the lock held. + private func unregisterWrite(_ io: inout IO) { + guard io.writeFdRegistered else { return } + io.writeFdRegistered = false + do { + try ProcessSupervisor.default.unregisterFd(io.to.fileDescriptor) + } catch { + self.logger?.error("failed to unregister relay write fd: \(error)") } } diff --git a/vminitd/Sources/VminitdCore/OSFile+Splice.swift b/vminitd/Sources/VminitdCore/OSFile+Splice.swift index 7101b43..9a6f652 100644 --- a/vminitd/Sources/VminitdCore/OSFile+Splice.swift +++ b/vminitd/Sources/VminitdCore/OSFile+Splice.swift @@ -89,7 +89,17 @@ extension OSFile { if errno != EAGAIN && errno != EIO { throw POSIXError(.init(rawValue: errno)!) } - break + // [Nucleic vendored patch] Destination full: RETURN, don't `break`. Breaking + // sent the outer `while true` straight back here — with the source idle and the + // destination still full, neither leg could progress and this spun the caller's + // thread at 100% until the peer drained. The caller is an epoll handler on + // vminitd's SINGLE ProcessSupervisor poller thread, so the spin froze every + // exec's stdio and every control-plane relay in the container at once (the + // all-sessions "produced no output within 60s" stall / dead control plane). + // The un-flushed bytes stay in the transfer pipe (`from.offset > to.offset` + // persists in the SpliceFiles); the destination fd is registered for EPOLLOUT, + // whose edge re-enters transferData and resumes the flush. + return (from.offset - fromOffset, to.offset - toOffset, .success) } to.offset += bytesWrote if bytesWrote == 0 { diff --git a/vminitd/Sources/VminitdCore/VsockProxy.swift b/vminitd/Sources/VminitdCore/VsockProxy.swift index e490a04..3e6059c 100644 --- a/vminitd/Sources/VminitdCore/VsockProxy.swift +++ b/vminitd/Sources/VminitdCore/VsockProxy.swift @@ -244,6 +244,22 @@ extension VsockProxy { try relayTo.connect() + // [Nucleic vendored patch] BOTH fds must be non-blocking BEFORE either is + // registered. `Epoll.add` sets O_NONBLOCK only at registration time, and the first + // (client) registration's handler can fire — and splice toward the server fd — + // before the second (server) registration has made that fd non-blocking. A full + // destination then turned the splice into a genuinely BLOCKING call on vminitd's + // single ProcessSupervisor poller thread, freezing every exec's stdio and every + // control-plane relay in the container until the peer drained. + for fd in [conn.fileDescriptor, relayTo.fileDescriptor] { + let flags = fcntl(fd, F_GETFL) + if flags == -1 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1 { + self.log?.error( + "failed to set proxy fd non-blocking", + metadata: ["fd": "\(fd)", "errno": "\(errno)"]) + } + } + // `clientFile` isn't used concurrently. nonisolated(unsafe) var clientFile = OSFile.SpliceFile(fd: conn.fileDescriptor) nonisolated(unsafe) var eofFromClient = false