Skip to content

tart exec -i spins one CPU core at 100% after piped stdin reaches EOF (stdin readabilityHandler is never removed) #1280

Description

@NTillmann

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions