Skip to content

Network reader can block forever on nettop and stops updating until the module is toggled #3499

Description

@devjenjen

Describe the bug

If /usr/bin/nettop ever fails to exit, the Network reader blocks permanently and never produces another value. The module keeps displaying whatever it read last, and the only way back is disabling and re-enabling it or restarting Stats. A nettop process is left behind at the same time.

Two places spawn nettop and read its output with readDataToEndOfFile(), which has no bound:

  • UsageReader.readProcessBandwidth(), Modules/Net/readers.swift:357
  • ProcessReader.read(), Modules/Net/readers.swift:763

Both have a defer block that calls task.terminate() and task.waitUntilExit(). That cleanup only runs once the read returns, so in the one situation where it is needed, a nettop that never closes its pipes, it is never reached. The read blocks, the defer never runs, the child is never signalled.

The reader's Repeater fires on a serial queue (Kit/plugins/Repeater.swift:23), so a blocked read holds that queue for the rest of the process lifetime. Later ticks coalesce behind it and never run. pause() suspends the timer but does not touch a read that is already in flight, so closing and reopening the popup resumes the same dead queue. Only Reader.stop() clears repeatTask, which is why toggling the module off and on is what brings it back.

Who this affects

readProcessBandwidth() only runs when the reader type is set to process based, so that part is limited to those users.

ProcessReader is not. It is created for every Network module user (Modules/Net/main.swift:194) and started whenever the popup opens (Kit/module/module.swift:261), spawning one nettop per second for as long as the popup is visible. The reader type setting does not gate it.

Why I think nettop failing to exit is real

I could not reproduce a genuine nettop hang on demand, so I cannot show you the trigger. What I can point at is #536, #606, #744 and #3224, where people report nettop processes spawned by Stats that keep running, in a few cases for days at around 35% CPU, and that survive quitting Stats. That is the same precondition: if nettop does not exit, the read above it never returns.

About #3369

It might be this. The description there matches a blocked reader closely, a static value with the graph stopped, fixed only by toggling the module. But the reporter left the template empty, and Network_reader defaults to interface, which reads through sysctl and cannot block this way. It may be worth asking which reader type they had selected before treating the two as the same bug.

Suggested direction

Drain stdout and stderr on background queues while the child is still running, wait for the exit under a bound, and escalate to SIGKILL when the child ignores SIGTERM. The concurrent drain is the part that is easy to get wrong: waiting for the exit first and reading afterwards deadlocks as soon as the child writes past the 64KB pipe buffer, because the child then blocks in write() and never exits. systemProfilerAirport(timeout:) in the same file already does roughly this, so it does not need a new idiom.

I have a branch with that change and five tests covering it: https://github.com/devjenjen/stats/tree/fix/network-nettop-hang-timeout (diff against master: master...devjenjen:stats:fix/network-nettop-hang-timeout). Take from it whatever is useful, and no worries at all if you would rather write your own version. Thanks for maintaining Stats.

Details:

  • Device: MacBook Pro 18,3, Apple M1 Pro
  • macOS: 26.5.2 (25F84)
  • Application version: master at 9a750c7 (v3.0.9)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions