Skip to content

fix(virtdisplay): remove Xvfb socket/lock file on kill(), matching daijro/camoufox#652 - #323

Merged
barjin merged 1 commit into
apify:masterfrom
e-Garcia:fix/virtdisplay-kill-cleanup
Aug 14, 2026
Merged

fix(virtdisplay): remove Xvfb socket/lock file on kill(), matching daijro/camoufox#652#323
barjin merged 1 commit into
apify:masterfrom
e-Garcia:fix/virtdisplay-kill-cleanup

Conversation

@e-Garcia

@e-Garcia e-Garcia commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #322

Summary

VirtualDisplay.kill() only called this.proc.kill() (SIGTERM). Nothing removed the Xvfb socket (/tmp/.X11-unix/X<n>) or lock file (/tmp/.X<n>-lock) it created. Under real-world restart churn these accumulate with no cap.

This is exactly what daijro/camoufox#652 fixed upstream: switched to SIGKILL and explicitly unlinks both files. This port never picked that fix up; src/virtdisplay.ts's kill() is still the pre-#652 shape even after #164/#168's -displayfd rewrite. This PR ports #652.

Hit this via camofox-browser, an HTTP wrapper around this package, which restarts the browser periodically under normal operation. jo-inc/camofox-browser#9252 is a compensating workaround shipped there in the meantime.

Why SIGKILL, not just adding cleanup after the existing SIGTERM

SIGTERM is catchable, so Xvfb can run its own signal handler and clean up its own socket/lock file, which (per my testing below) it apparently does reliably in a simple, idle, single-instance scenario. SIGKILL is not catchable, so switching to it guarantees Xvfb never gets a chance to run its own cleanup, making our explicit cleanup the only thing removing these files, deterministically. It's not dependent on Xvfb's graceful-shutdown path succeeding, which per #652's reporter's testimony and the downstream leak I hit, doesn't always happen under real load.

What I left out of the port

Python's #652 also does self.proc = None after cleanup. I didn't port that: get() here only respawns Xvfb when !this.proc, so nulling it changes the observable behavior of calling get() again after kill() on the same instance. That's a separate concern from the file-cleanup bug this PR is about, and the existing test suite's helpers (procOf) read vd.proc after kill() expecting it to still reference the (now-exited) process, not null. Happy to open that as a separate discussion if it's actually wanted, just didn't want to bundle an unrelated behavior change into this fix.

Test changes

Switching to SIGKILL changes what Node reports on the killed process: SIGKILL bypasses Xvfb's own exit() call entirely, so Node sets signalCode instead of a numeric exitCode. The two existing "kill terminates Xvfb" assertions in virtdisplay.test.ts checked exitCode specifically and would now fail (well, actually hang for the full 5s waitForExit timeout, then fail) against an Xvfb that did exit correctly, just via signal rather than a clean exit() call. Updated waitForExit and those two assertions to check either field via a small hasExited() helper.

Added a new test asserting the socket and lock file are both gone after kill() and confirmed exit.

Being upfront about a limitation of that test: it doesn't cleanly fail against the unpatched code in this environment. I reverted just the src/virtdisplay.ts change and reran. All 5 tests still passed, including the new one, because Xvfb apparently cleans up its own socket/lock file fine under a plain SIGTERM in a quick, idle, single-Xvfb test. So this specific test can't prove the old code was broken here, it can only regression-guard the new code's contract going forward. That matches #652's own history too: no automated test in that PR either, accepted on the reporter's testimony of eliminating "zombie Xvfb processes" under heavy load over months of production use, not a synthetic repro. I don't have a cleaner reproduction than that to offer.

Testing

```
$ npx vitest run
Test Files 6 passed (6)
Tests 51 passed (51)

$ npx tsc --noEmit
(clean)

$ npx biome check src/virtdisplay.ts test/virtdisplay.test.ts
(clean, after --write for formatting)
```

…ijro/camoufox#652

kill() only called this.proc.kill() (SIGTERM). Nothing removed the
Xvfb socket (/tmp/.X11-unix/X<n>) or lock file (/tmp/.X<n>-lock) it
created, and under real-world restart churn these accumulate with no
cap -- the exact symptom daijro/camoufox#652 fixed upstream (SIGKILL
+ explicit unlink of both files), which this JS port never picked up.

Ports that fix: switches to SIGKILL (uncatchable, so Xvfb can't run
its own signal handler -- deterministic instead of depending on
Xvfb's graceful-shutdown path succeeding), waits for the process to
actually exit, then removes both files. Left out the Python fix's
`self.proc = None` -- get() only respawns when !this.proc, so nulling
it changes observable kill()-then-get() behavior, which is outside
what this fix is about and isn't something the existing test suite's
helpers (which read vd.proc after kill()) expect.

Switching to SIGKILL means Node reports signalCode instead of a
numeric exitCode for the killed process (SIGKILL bypasses Xvfb's own
exit() call entirely) -- updated the two existing "kill terminates
Xvfb" assertions in virtdisplay.test.ts accordingly, they were
checking exitCode specifically and would now hang/fail on a correctly
-killed process.

Added a test asserting both files are gone after kill() + confirmed
exit. Being upfront that this test doesn't cleanly fail against the
unpatched code in this environment -- Xvfb apparently self-cleans
fine under a plain SIGTERM in a quick, single-instance, idle test, so
old and new code both leave a clean /tmp here. The real trigger seems
to need sustained load (matches daijro/camoufox#652's own reporter:
"heavy load... zombie Xvfb processes... eliminated the issue" after
months of production use, also without an automated repro). Test
still adds value as a regression guard on kill()'s own contract
either way.
@barjin
barjin merged commit abee34f into apify:master Aug 14, 2026
4 checks passed
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.

VirtualDisplay.kill() never removes its Xvfb socket/lock file (fixed upstream in daijro/camoufox#652, not ported here)

4 participants