Skip to content

[P0][BUG] Zombie Bifrost process leak: async shutdown silently dropped in process.on('exit') #2

Description

@pratik-saptarshi

Summary

src/index.ts registers a process.on('exit', ...) handler that calls shutdownBifrost() — an async function that terminates the Bifrost Go child process. However, the 'exit' event is synchronous; the Node.js event loop is already torn down, meaning the returned Promise is never awaited and the SIGTERM sent inside shutdownBifrost() is never delivered. The Bifrost subprocess continues running as an orphaned daemon after Probus exits.

Panel verdict: P0, VERIFIED, EXISTING_DEFECT, unanimous consensus.


Affected Files

File Lines Issue
src/index.ts L24 void shutdownBifrost() inside synchronous 'exit' handler
src/bifrost.ts L125–L133 shutdownBifrost() is async — calls await p internally

Root Cause — Code Evidence

src/index.ts, line 24:

process.on('exit', () => { void shutdownBifrost(); });
//                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^
// 'exit' fires synchronously; the Promise returned by shutdownBifrost()
// is created but the event loop is gone — the async body never executes.

src/bifrost.ts, lines 125–133:

export async function shutdownBifrost(): Promise<void> {
  const p = bifrostPromise;
  bifrostPromise = null;
  if (!p) return;
  try {
    const h = await p;   // ← this 'await' never resumes in 'exit' context
    h.close();           // ← h.close() / SIGTERM are never called
  } catch { /* ignore */ }
}

Contrast with the SIGINT/SIGTERM handler (lines 19–23) — which does work:

const shutdown = () => {
  shutdownBifrost().catch(() => {}).finally(() => process.exit(0));
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// ↑ Correct: the Promise chain runs before process.exit(0) is called.
// The 'exit' handler below is broken because the event loop is already dead.

Impact

  • Bifrost (a Go HTTP proxy binary) keeps running with the developer's OpenAI API key in memory after Probus exits.
  • If Probus is restarted the getFreePort() call may re-use the same port the orphan is listening on, causing a EADDRINUSE error or silent proxy routing to the stale process.
  • Over multiple restart cycles, multiple orphan Bifrost processes accumulate, consuming ports and memory.

Steps to Reproduce

  1. Start Probus with an OpenAI provider: OPENAI_API_KEY=sk-... npm run dev
  2. Initiate a scan so Bifrost is spawned.
  3. Kill Probus with Ctrl+C (SIGINT) or kill <pid> (SIGTERM).
  4. After Probus exits, run: ps aux | grep bifrost
  5. Observe the Bifrost process is still alive.

Remediation

Option A (preferred) — Store the child handle and kill synchronously:

In src/bifrost.ts, expose the child process handle:

let _child: ChildProcess | null = null;

// inside the spawn block:
_child = child;

export function shutdownBifrostSync(): void {
  if (_child) {
    try { _child.kill('SIGKILL'); } catch { /* ignore */ }
    _child = null;
  }
  bifrostPromise = null;
}

In src/index.ts, replace the async call with the sync version:

// Remove:
process.on('exit', () => { void shutdownBifrost(); });

// Add:
process.on('exit', () => { shutdownBifrostSync(); });

Option B — Remove the 'exit' handler entirely:

The SIGINT/SIGTERM handlers already call process.exit(0) inside a proper async chain. The synchronous 'exit' handler is redundant and broken — simply delete line 24.


References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions