Skip to content

fix(mac_unified_logging): supervise log stream, shed on stall, fix shutdown#295

Draft
maximelb wants to merge 2 commits into
masterfrom
fix/mac-unified-logging-resilience
Draft

fix(mac_unified_logging): supervise log stream, shed on stall, fix shutdown#295
maximelb wants to merge 2 commits into
masterfrom
fix/mac-unified-logging-resilience

Conversation

@maximelb

Copy link
Copy Markdown
Contributor

Problem

The macOS Unified Logging adapter cannot recover from any subprocess-level disruption without a full process restart, and a prolonged downstream stall (USP output blocked for an extended period — e.g. a long server-side disruption) can leave it permanently degraded at a fraction of its real event rate while looking perfectly healthy (process up, websocket connected, keepalives flowing).

Specific defects in the previous implementation:

  1. Backpressure propagated into logd. The decoder fed an unbuffered channel; a stalled Ship() (which retries with an infinite timeout on ErrorBufferFull) froze the decoder, filled the 64KB stdout pipe, and blocked the log stream process itself for the duration of the stall. Besides guaranteed data loss, a long-lived log stream left in that state can remain degraded after the stall clears.
  2. No subprocess supervision. cmd.Start() with no Wait(), exit detection, or restart — a dead or degraded log stream silently meant fewer/no events forever.
  3. Decode errors spun forever. json.Decoder does not resync after garbage; the loop printed to stdout and retried the same error indefinitely.
  4. Broken stderr handling. Any stderr line (where log stream writes informational notices, including under pressure) tore down gathering and called panic() on a captured nil error; the channel was never closed, so the sender goroutine blocked on range forever — connected and mute.
  5. Broken shutdown. Close() called Done() on a never-Add()ed WaitGroup (panic), and the sender never released wgSenders, so the stopped-channel could never fire.

Fix

  • Shed, don't stall: buffered channel (4096) decouples the subprocess from the network; when the consumer stalls past the buffer, events are dropped and counted, with the shed count reported every minute. The logd pipe is always drained.
  • Supervision: a supervisor loop restarts log stream (5s backoff) on subprocess exit or decoder desync, reaping it correctly (Kill → drain stderr to EOF → Wait, respecting the os/exec pipe rules).
  • stderr surfaced as warnings, never fatal.
  • Deterministic shutdown: StopGathering is idempotent, kills the subprocess to unblock a parked decoder, closes the channel so the sender exits, and Close() waits on the correct WaitGroup.

Verification

  • GOOS=darwin go build ./mac_unified_logging/ and GOOS=darwin go vet ./mac_unified_logging/ clean (linux noop build unchanged).
  • No external callers of the package's internals (NewLogs/StartGathering are package-scoped usage only); the adapter's public constructor/Close signatures are unchanged.
  • Draft: would appreciate a sanity pass on the shed-vs-block policy (live tail semantics) and a real-Mac smoke test of restart-on-log stream-death before merge, since CI cannot exercise darwin subprocess behavior.

🤖 Generated with Claude Code

maximelb and others added 2 commits June 12, 2026 17:47
…utdown

The adapter could not recover from any subprocess-level disruption
without a full process restart, and a prolonged downstream stall (e.g.
the USP output blocked for a long time) could leave it permanently
degraded:

- The decoder fed an UNBUFFERED channel, so a stalled consumer backed
  the stall up through the stdout pipe into the `log stream` process
  itself (logd drops, and the long-lived subprocess can stay degraded
  after the stall clears).
- The `log stream` subprocess was never supervised: no Wait(), no exit
  detection, no restart. If it died or degraded, the adapter shipped
  nothing (or less) forever while keeping a healthy-looking connection.
- json.Decoder errors spun in a tight loop forever (a Decoder does not
  resync after garbage) instead of restarting the subprocess.
- Any stderr output tore down gathering and then called panic() on a
  nil error; the channel was never closed, leaving the sender goroutine
  blocked on range forever: process up, connection up, zero events.
- Close() called Done() on a WaitGroup that was never Add()ed (panic),
  and the sender goroutine never released wgSenders, so chStopped could
  never fire.

Rework:
- Buffered channel (4096) decouples the subprocess from the network;
  when the consumer stalls past the buffer, SHED and count, reporting
  the shed count every minute — never block the logd pipe silently.
- Supervisor loop restarts `log stream` (5s backoff) on exit or decode
  desync, reaping the subprocess properly (Kill, drain stderr, Wait).
- stderr lines are surfaced as warnings (log stream emits informational
  notices there) instead of killing gathering.
- Clean shutdown: StopGathering is idempotent, kills the subprocess to
  unblock a parked decoder, closes the channel so the sender exits, and
  Close() waits on the right WaitGroup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Self-review of the resilience rework found a deadlock and some rough
edges:

- Close() deadlock: it waited on the sender goroutine (wgSenders.Wait)
  BEFORE closing the uspClient. If handleEvents was parked in the
  infinite Ship(_, 0) retry (full buffer during an uplink stall — the
  exact condition this PR targets), nothing released it: the wait hung
  and uspClient.Close() was never reached. Reordered to stop the
  gatherer, Drain + Close the client (which unblocks a parked Ship),
  THEN wait — matching the bounded shutdown idiom used by the stdin and
  syslog adapters. A new isRunning flag makes handleEvents drain the
  channel without re-shipping (and without error spam) once closing.

- Quiet the spurious "decode failed, restarting subprocess" warning on a
  clean stop (the decode error there is just our own kill).

- Make the `log stream` command injectable (streamCommand var) and
  restartBackoff a var, so the supervisor/shed/stop/restart logic is
  testable without a real macOS `log` binary. log.go has no build
  constraint, so these tests run on Linux CI.

- Add log_test.go: header drop + ordered delivery, restart-on-exit,
  shed-when-consumer-stalls (with shed-count reporting), and
  idempotent StopGathering closing the channel. Passes under -race.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@maximelb

Copy link
Copy Markdown
Contributor Author

Self-review pass — found and fixed one real deadlock plus smaller issues (commit b15a204):

Deadlock in Close() (the significant one). Close() waited on the sender goroutine (wgSenders.Wait()) before closing the uspClient. If handleEvents was parked in the infinite Ship(_, 0) retry — buffer full during an uplink stall, i.e. the exact condition this PR exists to handle — nothing released it: the wait hung and uspClient.Close() (which unblocks the parked Ship) was never reached. Reachable via the config-update Close() path in containers/general/tool.go. Fixed by reordering to StopGathering → Drain(1m) + Close()Wait() (the bounded idiom stdin/syslog already use), plus an isRunning flag so handleEvents drains the channel without re-shipping/erroring once closing.

Smaller fixes:

  • Suppressed a spurious decode failed, restarting subprocess warning emitted on a clean stop (the decode error there is just our own subprocess kill).
  • Made the log stream command injectable and restartBackoff a var so the supervisor/shed/stop/restart logic is unit-testable without a real macOS log (log.go has no build constraint → runs on Linux CI).

Added log_test.go (was zero coverage): header-drop + ordered delivery, restart-on-subprocess-exit, shed-when-consumer-stalls incl. shed-count reporting, and idempotent StopGathering closing the channel. Passes under -race (~1s).

Verified: go build/go vet clean for the package and containers/general on both linux (noop) and darwin (real); race tests green.

One thing I deliberately did NOT add: process-group kill. cmd.Process.Kill() only kills the direct log child; if log stream ever spawned a helper holding the stdout/stderr pipe, the post-kill <-stderrDone wait could stall. Real log stream doesn't fork, and a portable pgroup kill needs unix-only SysProcAttr which would break the (unconstrained) log.go on the Windows build. Noting it as possible future hardening rather than risking the build.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants