Description
When stdin is a pipe that reaches EOF while the guest command is still running, tart exec -i busy-loops on the host at 100% of one CPU core for the remainder of the command's lifetime. The command otherwise works correctly (stdin is delivered, output and exit code are fine), so the spin is easy to miss — we found it via power/thermal monitoring of a CI host that was supposed to be idle.
Reproduction
printf 'ping\n' | tart exec -i <vm> sh -c 'read X; echo "got $X"; sleep 60' &
sleep 10; ps -o %cpu,time -p $!
# ~100.0 0:09.26 — one full core
Control — the identical command with the write end merely held open (no EOF):
mkfifo /tmp/f
tart exec -i <vm> sh -c 'read X; echo "got $X"; sleep 60' < /tmp/f &
exec 9<>/tmp/f; printf 'ping\n' >&9
sleep 10; ps -o %cpu,time -p $!
# 0.0 0:00.01
Root cause
In Sources/tart/Commands/Exec.swift, the non-regular-file stdin branch installs a readabilityHandler that is never unregistered:
handle.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
continuation.finish()
} else {
continuation.yield(data)
}
}
Once the pipe is closed, the fd is permanently "readable", so Foundation's fd-monitoring dispatch source re-invokes the handler in a tight loop — each pass doing an fstat + zero-byte read + a no-op finish() on the already-finished continuation.
This is the sibling of #1100 / #1106: that fix special-cased regular-file redirection, but the pipe/terminal branch kept the leaky handler.
sample of the spinning process (excerpts)
2157 Thread_4817799 DispatchQueue_1: com.apple.main-thread (serial)
... __CFRunLoopServiceMachPort → mach_msg (parked, idle)
1231 Thread_4817803 DispatchQueue_33: com.apple.NSFileHandle.fd_monitoring (serial)
+ 1231 start_wqthread
+ 1231 _pthread_wqthread
+ 1231 _dispatch_workloop_worker_thread
+ 1231 _dispatch_root_queue_drain_deferred_wlh
+ 1230 _dispatch_lane_invoke
+ ! 1228 _dispatch_lane_serial_drain
+ ! : 1219 _dispatch_source_invoke
+ ! : | 1216 _dispatch_source_latch_and_call
+ ! : | + 1173 _dispatch_continuation_pop
+ ! : | + 1171 _dispatch_client_callout
+ ! : | + ! 1163 __33-[NSConcreteFileHandle _monitor:]_block_invoke (in Foundation)
+ ! : | + ! : 1160 thunk for @escaping @callee_guaranteed @Sendable (@guaranteed NSFileHandle) -> () (in tart)
+ ! : | + ! : | 873 closure #1 in closure #1 in closure #2 in Exec.execute(_:) (in tart)
+ ! : | + ! : | + 460 -[NSConcreteFileHandle availableData] (in Foundation)
+ ! : | + ! : | + ! 456 _NSReadFromFileDescriptorWithProgress (in Foundation)
+ ! : | + ! : | + ! : 456 read (in libsystem_kernel.dylib)
+ ! : | + ! : | + 360 -[NSConcreteFileHandle availableData] (in Foundation)
+ ! : | + ! : | + ! 360 fstat (in libsystem_kernel.dylib)
Suggested fix
Unregister the handler on EOF (the standard remedy for this Foundation behavior, cf. Apple DevForums thread 669842):
if data.isEmpty {
handle.readabilityHandler = nil
continuation.finish()
} else {
continuation.yield(data)
}
PR incoming.
Workaround for anyone hitting this
Keep the pipe's write end open for the lifetime of the command (e.g. stdin from a FIFO held open on a spare fd, as in the control repro above), so EOF never occurs; tart exec still terminates normally via the guest agent's exit response.
Environment
- tart 2.32.1 (latest release), installed from the official package
- macOS Tahoe 26.5.2, Apple M4 (Mac16,10)
- Impact context: ephemeral GitHub Actions CI — piping a one-line JIT runner config into a long-lived
tart exec -i burns one host core per lane, continuously (for us: ~3 W extra draw and one of six vCPUs' worth of host CPU stolen from every build).
Description
When stdin is a pipe that reaches EOF while the guest command is still running,
tart exec -ibusy-loops on the host at 100% of one CPU core for the remainder of the command's lifetime. The command otherwise works correctly (stdin is delivered, output and exit code are fine), so the spin is easy to miss — we found it via power/thermal monitoring of a CI host that was supposed to be idle.Reproduction
Control — the identical command with the write end merely held open (no EOF):
Root cause
In
Sources/tart/Commands/Exec.swift, the non-regular-file stdin branch installs areadabilityHandlerthat is never unregistered:Once the pipe is closed, the fd is permanently "readable", so Foundation's fd-monitoring dispatch source re-invokes the handler in a tight loop — each pass doing an
fstat+ zero-byteread+ a no-opfinish()on the already-finished continuation.This is the sibling of #1100 / #1106: that fix special-cased regular-file redirection, but the pipe/terminal branch kept the leaky handler.
sampleof the spinning process (excerpts)Suggested fix
Unregister the handler on EOF (the standard remedy for this Foundation behavior, cf. Apple DevForums thread 669842):
PR incoming.
Workaround for anyone hitting this
Keep the pipe's write end open for the lifetime of the command (e.g. stdin from a FIFO held open on a spare fd, as in the control repro above), so EOF never occurs;
tart execstill terminates normally via the guest agent's exit response.Environment
tart exec -iburns one host core per lane, continuously (for us: ~3 W extra draw and one of six vCPUs' worth of host CPU stolen from every build).