Host (dominant): AppStore.reconcileLocks polls every 3s on the MainActor while any lock is held — effectively forever, since interrupted/errored sessions deliberately retain locks. Each pass ran heldPathDisposition's diverges() as three held×unmerged scans with two split-allocations per pathsOverlap call, pinning the main thread for tens of seconds per pass on a diverged trunk (hang-reports 2026-07-28: 100% of samples in reconcileLocks→heldPathDisposition→pathsOverlap). That froze running sessions' transcripts and starved the spawn path into the 60s "produced no output" watchdog. pathsOverlap is now allocation-free bytewise comparison with identical semantics, and divergentHeldPaths answers all three questions from one O((held+unmerged)·depth) set. Guest (persistence): VsockProxy threaded ONE offset pair through BOTH relay directions; once the EAGAIN-return backpressure patch let pending bytes persist, traffic in the other direction skewed the shared counters, made the write leg unreachable, and spun the single ProcessSupervisor poller thread forever — container-wide dead control plane until VM recreation, triggered by exactly the backpressure the host hang created. Each direction now owns its own pipe and counters (OSFile.RelayDirection), and source EOF is only surfaced after the pipe drains so SHUT_WR can't truncate a parked tail. Vendored patch docs updated (#15); inert until the initfs image is rebuilt+repointed. Co-Authored-By: Claude Fable 5 <[email protected]>
125 lines
5.9 KiB
Swift
125 lines
5.9 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// 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.
|
|
//===----------------------------------------------------------------------===//
|
|
|
|
#if os(Linux)
|
|
|
|
import Foundation
|
|
import LCShim
|
|
|
|
extension OSFile {
|
|
/// [Nucleic vendored patch] One direction of a bidirectional relay: `from` fd → transfer
|
|
/// pipe → `to` fd. Each direction owns its OWN pipe and byte counters.
|
|
///
|
|
/// The previous `SpliceFile` design threaded ONE offset pair through BOTH directions of
|
|
/// `VsockProxy`'s relay (a fd's struct served as read-counter in one direction and
|
|
/// write-counter in the other). That was survivable only while every splice call fully
|
|
/// drained its pipe before returning. Once the EAGAIN-return backpressure patch let pending
|
|
/// bytes persist across calls, one parked direction skewed the shared counters for the
|
|
/// other: its write leg's `to.offset < from.offset` guard went false with data still in the
|
|
/// pipe, and the outer loop then alternated read-EAGAIN/skip-write forever — a hard spin on
|
|
/// vminitd's single ProcessSupervisor poller thread that froze every exec's stdio and every
|
|
/// control-plane relay in the container until the VM was recreated (the persistent
|
|
/// "produced no output within 60s" / dead-control-plane state).
|
|
struct RelayDirection: Sendable {
|
|
let from: Int32
|
|
let to: Int32
|
|
private let pipe = Pipe()
|
|
/// Bytes spliced from `from` into the transfer pipe so far.
|
|
fileprivate var bytesIn = 0
|
|
/// Bytes spliced from the transfer pipe into `to` so far.
|
|
fileprivate var bytesOut = 0
|
|
/// The source reported EOF. The direction only FINISHES (`.eof`) once the pipe has
|
|
/// also drained, so the stream's tail is never dropped by an early SHUT_WR.
|
|
fileprivate var sawSourceEOF = false
|
|
|
|
/// Bytes read from the source that the destination hasn't accepted yet.
|
|
var pendingBytes: Int { bytesIn - bytesOut }
|
|
|
|
fileprivate var pipeReader: Int32 { pipe.fileHandleForReading.fileDescriptor }
|
|
fileprivate var pipeWriter: Int32 { pipe.fileHandleForWriting.fileDescriptor }
|
|
|
|
init(from: Int32, to: Int32) {
|
|
self.from = from
|
|
self.to = to
|
|
}
|
|
}
|
|
|
|
/// The terminal state of one `relay` pass over a direction.
|
|
enum RelayResult: Sendable {
|
|
/// No more progress possible right now: the source has no data (EAGAIN) or the
|
|
/// destination is full (its EPOLLOUT edge resumes the flush of `pendingBytes`).
|
|
case idle
|
|
/// Source EOF observed AND the pipe fully drained — the direction is complete; the
|
|
/// caller should SHUT_WR the destination.
|
|
case eof
|
|
/// The destination hung up mid-write; nothing further can be delivered.
|
|
case brokenPipe
|
|
}
|
|
|
|
/// Move as much data as possible along `direction` without blocking. `count` bounds the
|
|
/// bytes buffered in the transfer pipe (it matches the default pipe capacity).
|
|
static func relay(_ direction: inout RelayDirection, count: Int = 1 << 16) throws -> RelayResult {
|
|
let flags = UInt32(bitPattern: LCShim.SPLICE_F_MOVE | LCShim.SPLICE_F_NONBLOCK)
|
|
while true {
|
|
// Read leg: source → pipe, until the pipe is full, the source runs dry, or EOF.
|
|
var sourceDry = false
|
|
if !direction.sawSourceEOF {
|
|
while direction.pendingBytes < count {
|
|
let toRead = count - direction.pendingBytes
|
|
let n = LCShim.splice(direction.from, nil, direction.pipeWriter, nil, toRead, flags)
|
|
if n == -1 {
|
|
if errno != EAGAIN && errno != EIO {
|
|
throw POSIXError(.init(rawValue: errno)!)
|
|
}
|
|
sourceDry = true
|
|
break
|
|
}
|
|
if n == 0 {
|
|
direction.sawSourceEOF = true
|
|
break
|
|
}
|
|
direction.bytesIn += n
|
|
if n < toRead { break }
|
|
}
|
|
}
|
|
// Write leg: pipe → destination, until drained or the destination pushes back.
|
|
while direction.pendingBytes > 0 {
|
|
let n = LCShim.splice(direction.pipeReader, nil, direction.to, nil, direction.pendingBytes, flags)
|
|
if n == -1 {
|
|
if errno != EAGAIN && errno != EIO {
|
|
throw POSIXError(.init(rawValue: errno)!)
|
|
}
|
|
// Destination full: park with the remainder in the pipe. The destination
|
|
// fd's EPOLLOUT edge re-enters and resumes exactly here — never spin, and
|
|
// never block the shared poller thread.
|
|
return .idle
|
|
}
|
|
if n == 0 {
|
|
return .brokenPipe
|
|
}
|
|
direction.bytesOut += n
|
|
}
|
|
// Pipe is drained here.
|
|
if direction.sawSourceEOF { return .eof }
|
|
if sourceDry { return .idle }
|
|
// The read leg stopped only because the pipe filled (or read a full window):
|
|
// go around again — the source may still have data.
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|