What Problem This Solves
On Linux, killProcessOnPort() could report a port as released while the previous owner still held it. The backend then failed to bind at startup, with the port-kill step looking like it had succeeded.
The function sent one kill, slept a fixed 500ms, and returned whatever a single checkPortAvailable() call said. Process teardown is asynchronous, so a process that had not yet let go of the socket read as free.
Three distinct problems fed into that:
fuser -ksends SIGTERM. A process that does not act on it keeps the port bound, and nothing ever retried.fuseris not always installed. It ships withpsmisc, which minimal images frequently omit. The|| truemade a missing tool indistinguishable from a successful kill, so the port was reported free while the old owner still had it.- One check after 500ms leaves no room for a slow shutdown, and a failure produced no useful diagnostic.
Solution Details
- Escalate. SIGTERM first, then SIGKILL if the port is still held, then poll for release.
- Separate "missing tool" from "nothing to kill".
fuserexits 127 when it is not installed and 1 when no process holds the port. The first falls back tolsof— what the macOS path already relies on. The second is already the desired end state and must not trigger a second kill path. This is what|| truewas hiding. - Poll instead of assuming. Backoff delays of 100/200/400/800ms, then a second phase after SIGKILL.
falseis now returned only after the port genuinely could not be freed, and the reason is logged. - The Windows path is unchanged apart from moving; it already forced with
taskkill /F.
Why the helpers moved to electron/main/utils/port.ts
They had no test coverage and could not get any where they were. init.ts calls util.promisify at module load, and the jsdom test environment resolves util to a browser shim without promisify, so the module cannot be imported in a test at all. The extracted module depends only on child_process, net and electron-log, and avoids promisify. init.ts re-exports both helpers, so existing importers are unaffected.
Evidence
Eight tests in test/unit/electron/main/port.test.ts:
| Test | Pins |
|---|---|
escalates to SIGKILL when SIGTERM does not free the port | the core of #1668 |
falls back to lsof when fuser is not installed | the missing-tool case |
does not fall back to lsof when fuser simply found nothing | exit 1 is not exit 127 |
returns false when the port is never released | no false success |
logs why the port could not be freed | no silent failure |
checkPortAvailable (2) | free / occupied |
Reverting killProcessOnPort to its previous implementation fails 4 of the 8 and passes the rest, so the suite is pinned to this behaviour rather than to the new code shape.
vitest run test/unit/electron/main/port.test.ts— 8 passednpm run type-check— cleaneslinton the three touched files — clean (prettier and the repo's own pre-commit hooks also pass)
test/unit/electron/ has 22 pre-existing failures in buildConfig, install-deps and envUtil. Those fail identically on main without this branch, so they are unrelated.
User Impact
Eigent starts reliably when a previous backend instance left a port bound, including on minimal Linux installs without psmisc. When a port genuinely cannot be freed — usually a process owned by another user — the log now says so instead of reporting success.
Closes #1668
Rebased onto current
mainand re-verified while it waited for review:npm run type-checkandeslintare clean.mainbaseline.mainresolved by hand; the fix is now scoped to just the files it needs.The linked issue is still open, and this is ready for a look whenever you have time.