Skip to content

Regression of #56: Chrome OS process survives pooled and on-demand shutdown on macOS (chromic_pdf 1.17.1) #349

Description

@mtarnovan

Environment

  • chromic_pdf 1.17.1
  • Elixir 1.19.4-otp-28 / Erlang/OTP 28.3
  • macOS 26.5.2 (arm64)
  • Google Chrome 150.0.7871.187 (system binary at /Applications/Google Chrome.app)
  • Default options (local Chrome, pipe connection, discard_stderr: true)

Summary

While investigating an accumulation of live orphaned Google Chrome --headless processes on developer machines (at peak we counted 24, some weeks old), we found what looks like one underlying shutdown regression with two entry paths, aggravated by unreliable pipe-EOF behavior in Chrome itself.

1. Graceful shutdown no longer terminates Chrome

With a pooled instance (on_demand: false) supervised in our app, a graceful stop tears down the ChromicPDF process tree and its ports, but the Chrome OS process keeps running:

# app running with {ChromicPDF, []} in the supervision tree; Chrome up for ~30s
:ok = Supervisor.terminate_child(MyApp.Supervisor, ChromicPDF)
# returns :ok in < 1ms; ChromicPDF ports are gone from Port.list()
# the Chrome OS process is still alive 10s later — and stays alive indefinitely

The :ok in under a millisecond suggests the Browser.close dispatched in Channel.terminate/2 never takes effect: it is sent via GenServer.cast to the Connection process and nothing awaits Chrome's exit before the supervisor tears down the Connection (and with it the port). This appears to be a regression relative to the behavior added for #56: the 0.6.0 CHANGELOG entry says "Graceful shutdown is now actually graceful in that it waits for Chrome to clean up the debugging sessions and close the pipe on its end" (#63), and that wait seems to have been lost in the connection refactor (#254).

2. On-demand cleanup hits the same failure

With on_demand: true, the temporary Browser supervisor is terminated after the print job, but its Chrome OS process remains alive:

{:ok, _blob} = ChromicPDF.print_to_pdf({:html, "<p>probe</p>"})
# 30s later (BEAM still running): the spawned Chrome is still alive — and stays alive

On-demand cleanup therefore exposes the same shutdown failure as pooled supervisor termination and can leave one orphaned Chrome process per print for the lifetime of the VM.

3. Pipe EOF is not a reliable fallback once Chrome is initialized

Chromium intends closure of the remote-debugging pipe to initiate browser shutdown. On our macOS/Chrome 150 setup, this worked when Chrome was terminated very shortly after launch (BEAM SIGKILLed within ~1–2s of Chrome spawning → Chrome exits within ~5s), but not after the browser had fully initialized (~30s+ → Chrome never exits; lsof on such an orphan shows fds 0/3 and 1/4 — the --remote-debugging-pipe pair mapped via 3<&0 4>&1 — with both peer endpoints in the BEAM gone, while the process idles for days). Similar orphaning was reported in 2020 (#56), although we found no Chromium issue establishing this particular age-dependent behavior as a known regression. Regardless of where the underlying failure lies, pipe EOF is not a reliable teardown guarantee on this setup. In our reproductions, SIGTERM to such a process also failed; SIGKILL succeeded.

The combination means there is currently no code path in the library that reliably terminates Chrome on this setup.

Impact

On macOS dev machines the leaked processes are instances of the real Google Chrome.app, so beyond resource usage they receive OS-level URL opens (e.g. clicking a link in Slack opens it in a headless orphan) and misbehave after Chrome updates (stale binary version still running). One orphan accumulates per ExUnit run / per on-demand print / per Ctrl+C'd iex session.

Reproduction

Any Mix app on macOS with the system Chrome:

  1. Finding 2 (simplest): on_demand: true, run ChromicPDF.print_to_pdf({:html, "<p>hi</p>"}) in iex -S mix, watch ps ax | grep 'Chrome --headless' — the spawned Chrome never goes away.
  2. Finding 1: pooled instance under your app supervisor, wait ~30s after boot, Supervisor.terminate_child/2, watch ps.
  3. Finding 3: pooled instance, wait ~30s, kill -9 the beam — Chrome survives with both pipe peers gone. Repeat killing the beam ~1s after boot — Chrome exits.

Suggested direction

After dispatching Browser.close, await Chrome's actual OS-process exit with a bounded timeout, then force-kill the exact owned process (or process group) as a fallback. As an observed practice (not claiming it's the only way), established CDP clients like puppeteer, Playwright and chromedp treat explicit process ownership with a force-kill fallback as the authoritative teardown rather than relying on connection loss.


Investigated and drafted with the help of an AI assistant (Claude); all reproductions were run and verified on the machine described above.

Metadata

Metadata

Assignees

No one assigned

    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