The previous post ended on a question: what does a process own that a restart cannot rebuild? In the same app I ran into a heavier answer. My agent drives a headless browser, and it does that through a Node process held by a Port, with Chromium sitting underneath.
Crossing that boundary means leaving the actor model. Two things I never think about in Elixir stop working there. One of them cost me a genuine bug. The other cost me a careful fix for a disaster that was never going to happen, which took me longer to admit.
The setup
The Elixir side owns a Node process and talks to it in newline-delimited JSON, one object per line, calls correlated by id:
def encode(id, cmd, args) do
[Jason.encode_to_iodata!(%{id: id, cmd: cmd, args: args}), "\n"]
end
The port itself is opened once, when the GenServer starts:
port =
Port.open({:spawn_executable, node_executable()}, [
:binary,
:exit_status,
args: [script_path()],
line: 1_000_000
])
{:ok, %{port: port, next_id: 1, pending: %{}, buffer: ""}}
That is the whole bridge. Playwright primitives cross it, nothing else.
Supervision does not cross
The GenServer is supervised. The Node process is not a BEAM process, so the
supervisor has no idea it exists. If I want anything to happen on the way out,
I have to write it myself in terminate/2.
There is a catch worth getting exactly right, so I ran a small experiment
instead of trusting my memory of it. A supervisor stops a child by sending it
an exit signal. A child that does not trap exits dies on the spot, and its
terminate/2 never runs:
--- child WITHOUT trap_exit ---
(nothing above means terminate/2 was not called)
--- child WITH trap_exit ---
Trapping.terminate/2 CALLED
So the cleanup hangs off one line in init/1, and the rest follows:
Process.flag(:trap_exit, true)
def terminate(_reason, state) do
Port.close(state.port)
:ok
rescue
ArgumentError -> :ok
end
Closing the port sends EOF on the sidecar’s stdin. The Node side listens for exactly that, and closes the browser before it goes:
process.stdin.on("end", async () => {
try {
await dispatch("browser.close", {});
} catch {
// best-effort: exit regardless of whether the browser was already closed or gone
} finally {
process.exit(0);
}
});
The leak I could not reproduce
I wrote all of that to stop a specific disaster. The commit message says it plainly: a supervised stop or restart could orphan Chromium, 300 to 500 MB of it, without ever closing it.
While writing this post I went back to reproduce that leak, on the commit before the fix. It did not happen. So I tried harder.
Orderly supervisor stop on the old code, no page open: the Node process was
reclaimed. Same thing with a context and a page open, four live
chrome-headless-shell descendants confirmed by walking the process tree, and
all four went away. Then the unfair one, on the current code, kill -9
straight at the Node process so its exit handlers could not possibly run:
node os_pid = 97004, chrome-headless-shell descendants: ["97005", "97006", "97029", "97030"]
-> kill -9 on the Node process
RESULT: no orphan
Three attempts, no leak. That is worth understanding rather than shrugging at, because the answer is that three separate mechanisms were covering for me.
The VM closes a port when its owning process dies, whether I asked for it or
not. Playwright installs its own exit handlers on the Node process. And the
third one is the reason even kill -9 stays clean, which I found by reading
the command line Playwright actually uses:
--remote-debugging-pipe
Chromium is driven over that pipe rather than a TCP debugging port, on file descriptors 3 and 4. Kill the parent and those descriptors go with it, which is the whole appeal of pipe-based CDP: the browser does not outlive the process that was talking to it. Nothing left to orphan.

I kept terminate/2 anyway, for two reasons that survive the result. Closing
the browser through browser.close is not the same event as having it die
because a pipe went quiet, and the difference shows up the moment you care
about persisted storage state. And the clean shutdown I was relying on turns
out to depend on a third-party library’s exit handlers plus a browser flag,
which is somebody else’s implementation detail, not a contract I was given.
Being wrong about the failure mode did not make the code wrong. It made my reason for it wrong, which is worth knowing.
Serialization does not cross either
The second guarantee is subtler, and this bug was real.
On the BEAM I get ordering for free. A GenServer handles one message at a time, so two commands for the same page cannot interleave. I had quietly assumed that property extended across the Port. It does not.
Node reads stdin in chunks, and a single chunk can carry two complete lines.
The line reader then calls the dispatcher twice without waiting, async
functions being what they are, and now two commands are in flight against the
same shared maps and the same live Playwright objects. A page.close and a
page.text for one page id, racing.
The fix chains commands per resource key, so same-key commands run in arrival order while different keys still overlap:
function keyFor(args) {
if (args.page_id) return args.page_id;
if (args.context_id) return args.context_id;
return GLOBAL_KEY;
}
function enqueue(key, task) {
const priorTail = tails.get(key) ?? Promise.resolve();
const result = Promise.all([priorTail, barrier]).then(task, task);
const tail = result.catch(() => {});
tails.set(key, tail);
tail.finally(() => {
if (tails.get(key) === tail) tails.delete(key);
});
return result;
}
The detail worth pausing on is .then(task, task), the same function handed
in as both the success and the failure handler. A command that blows up must
not wedge every later command for that page, so the queue advances either way.
browser.close gets a stronger treatment: it waits on every key, and anything
dispatched after it queues behind. The three tests that pin this down read
like the specification they are:
✔ two commands on the same page_id execute in arrival order
✔ two commands on different page_ids overlap
✔ browser.close waits for an in-flight page command to finish
A surprise in the framing
Once Node orders per key, the Elixir side can keep several calls in flight and
correlate the answers by id, parking each caller’s from in a map until its
line comes back. That part is ordinary GenServer work.
What caught me out is the framing. The port is opened with line: 1_000_000,
and anything longer does not arrive as a line at all: it shows up as :noeol
fragments I have to stitch back together in the state. There is a test that
round-trips a 1.5 MB payload, and it exists because that limit is otherwise
the kind of thing you meet in production, on the one page that happened to be
big.
Where that leaves me
One boundary, two lost guarantees, and an honest scoreboard: a disaster I guarded against for reasons that turned out to be wrong, and a race I caused myself. The race is the one that stings, because I never reasoned about the Node side at all. I had extended a BEAM habit across a boundary where it does not apply, which is easier to do than it sounds when one side of your app is that pleasant to reason about.
The next thing worth writing up sits one layer higher: the pool that lends pages out, shuts the browser down when nobody has wanted one for a while, and stamps every checkout with a generation so a caller cannot hand back a page that belonged to a browser which no longer exists.
Have comments or want to discuss this topic?
Send an email to ~bounga/bounga.org-discuss@lists.sr.ht