fix(virtdisplay): remove Xvfb socket/lock file on kill(), matching daijro/camoufox#652 - #323
Merged
Merged
Conversation
…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
approved these changes
Aug 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #322
Summary
VirtualDisplay.kill()only calledthis.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'skill()is still the pre-#652 shape even after #164/#168's-displayfdrewrite. 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 = Noneafter cleanup. I didn't port that:get()here only respawns Xvfb when!this.proc, so nulling it changes the observable behavior of callingget()again afterkill()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) readvd.procafterkill()expecting it to still reference the (now-exited) process, notnull. 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 setssignalCodeinstead of a numericexitCode. The two existing "kill terminates Xvfb" assertions invirtdisplay.test.tscheckedexitCodespecifically and would now fail (well, actually hang for the full 5swaitForExittimeout, then fail) against an Xvfb that did exit correctly, just via signal rather than a cleanexit()call. UpdatedwaitForExitand those two assertions to check either field via a smallhasExited()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.tschange 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)
```