#1971

fix(backend): verify the port is actually free after killing its owner

Opencarlosjarenomwants to merge intofrom

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 -k sends SIGTERM. A process that does not act on it keeps the port bound, and nothing ever retried.
  • fuser is not always installed. It ships with psmisc, which minimal images frequently omit. The || true made 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". fuser exits 127 when it is not installed and 1 when no process holds the port. The first falls back to lsof — 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 || true was hiding.
  • Poll instead of assuming. Backoff delays of 100/200/400/800ms, then a second phase after SIGKILL. false is 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:

TestPins
escalates to SIGKILL when SIGTERM does not free the portthe core of #1668
falls back to lsof when fuser is not installedthe missing-tool case
does not fall back to lsof when fuser simply found nothingexit 1 is not exit 127
returns false when the port is never releasedno false success
logs why the port could not be freedno 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 passed
  • npm run type-check — clean
  • eslint on 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

Activity

2
carlosjarenom1mo ago

Rebased onto current main and re-verified while it waited for review:

  • npm run type-check and eslint are clean.
  • Full backend suite: 3696 passed, 12 skipped — identical to the main baseline.
  • Conflicts with main resolved 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.