Skip to content

[Agent] macOS agent spawned 81,683 subprocesses in 2 days; each 'log show' burns ~3.8s CPU #2390

Description

@ToddHebebrand

Description

On an idle macOS device, the agent spawned 81,683 child processes over ~2 days — roughly one every 2 seconds, continuously. The processes are correctly reaped (no zombies), so this is not a fd/process leak — it is sustained, largely wasted work on every Mac in the fleet.

Evidence

The agent's stderr log was 17 MB, and 170,078 of its 170,464 lines were fork noise from these children:

breeze-agent(60647) MallocStackLogging: can't turn off malloc stack logging because it was not enabled.

(81,683 distinct child PIDs.) Sampling live children of the daemon shows the collector command fleet: log show x2, pmset -g log, networksetup -listallhardwareports, ipconfig getpacket, dscl, pwpolicy, spctl, socketfilterfw, defaults read, system_profiler.

The expensive one

agent/internal/collectors/eventlogs_darwin.go:121 runs log show --predicate ... --style json --last Nm every 5 minutes, via two parallel sub-collectors (security + hardware predicates), and again from ReliabilityCollector on its own EventLogCollector.

Timed on the affected host:

$ time log show --predicate '<hardware predicate>' --style json --last 5m
real 3.77s   user 1.46s   sys 0.51s
output: 2 bytes

3.8 seconds of CPU to produce 2 bytes. The source-side messageType >= error filter is working correctly (that part is fine and should stay) — but querying the macOS unified log is expensive regardless of how little it returns. We pay that cost every 5 minutes, several times over, on every Mac, to almost always get [].

Secondary: post-hoc output cap

agent/internal/collectors/command_limits.go:43-50:

cmd := exec.CommandContext(ctx, name, args...)
output, err := cmd.Output()                       // buffers EVERYTHING first
if len(output) > collectorCommandOutputLimit {    // 4 MiB — checked AFTER the fact
    return nil, fmt.Errorf("%s output too large", name)
}

The 4 MiB guard runs after the full output is already in memory, and cmd.Output() grows its buffer by doubling — so a 100 MB output costs ~200 MB peak before being rejected. runCollectorLimitedOutput (used by pmset just below) does this correctly with an io.LimitReader. The unified-log path should use the same.

Proposed Fix

  1. Back off the log show cadence, and/or collapse the two parallel predicates into one query.
  2. Use runCollectorLimitedOutput (streaming + io.LimitReader) for log show rather than runCollectorOutput, so the cap is enforced before the bytes are buffered.
  3. Consider whether the reliability collector needs its own independent EventLogCollector pass.

Affected Files

  • agent/internal/collectors/eventlogs_darwin.go (primary)
  • agent/internal/collectors/command_limits.go (runCollectorOutput post-hoc cap)
  • agent/internal/collectors/reliability_darwin.go

Found during the macOS agent memory audit (#2387).

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions