can1357/oh-my-pi · error · Error

Failed to kill ${holder.image} (PID ${holder.pid})

Error message

Failed to kill ${holder.image} (PID ${holder.pid})

What it means

If the holder survives the SIGTERM poll window, terminatePortHolder() escalates to SIGKILL. A non-ESRCH failure of that kill is wrapped as 'Failed to kill <image> (PID <pid>)'. As with the other stages this almost always means the OS refused the signal — normally EPERM against a process the current user does not own.

Source

Thrown at packages/stats/src/port-conflict.ts:216

		if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
		throw new Error(`Failed to stop ${holder.image} (PID ${holder.pid})`, { cause: error });
	}

	for (let attempt = 0; attempt < PROCESS_EXIT_POLLS; attempt++) {
		await Bun.sleep(PROCESS_EXIT_POLL_MS);
		try {
			process.kill(holder.pid, 0);
		} catch (error) {
			if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
			throw new Error(`Failed to inspect ${holder.image} (PID ${holder.pid})`, { cause: error });
		}
	}

	try {
		process.kill(holder.pid, "SIGKILL");
	} catch (error) {
		if (error instanceof Error && "code" in error && error.code === "ESRCH") return;
		throw new Error(`Failed to kill ${holder.image} (PID ${holder.pid})`, { cause: error });
	}
	await Bun.sleep(PROCESS_EXIT_POLL_MS);
}

async function reclaimStatsPort(port: number): Promise<"retry"> {
	const holder = await findPortHolder(port);
	if (!holder) {
		throw new Error(`Port ${port} is in use, but the listening process could not be identified.`);
	}
	if (holder.pid === process.pid) {
		throw new Error(`Port ${port} is held by the current process (${holder.image}, PID ${holder.pid}).`);
	}

	const normalizedImage = holder.image
		.toLowerCase()
		.replace(/\.exe$/, "")
		.replace(/ \(deleted\)$/, "");
	const normalizedCommand = holder.commandLine.toLowerCase().replaceAll("\\", "/");

View on GitHub (pinned to 9690622007)

Solutions

  1. Kill the process manually with sudo: sudo kill -9 <pid>
  2. Check ps -p <pid> -o user,stat to confirm ownership/state; if uninterruptible (D state), fix the kernel/IO hang first
  3. Run omp stats as the same user as the port holder, or reconfigure so the port belongs to the current user
  4. Choose a different port for the stats dashboard to avoid the conflict entirely

Example fix

// before
await prepareStatsPort(port); // throws 'Failed to kill omp (PID 1234)'
// after
try {
  await prepareStatsPort(port);
} catch (err) {
  if (err.message.includes('Failed to kill')) {
    console.error(`Manual intervention needed: sudo kill -9 <pid>`);
  } else throw err;
}
Defensive patterns

Strategy: try-catch

Validate before calling

const out = Bun.spawnSync(['ps', '-o', 'user=', '-p', String(pid)]);
if (out.stdout.toString().trim() !== process.env.USER) {
  console.error(`PID ${pid} owned by another user; SIGKILL will fail without sudo`);
}

Type guard

function isNodeErrno(err: unknown): err is NodeJS.ErrnoException {
  return err instanceof Error && typeof (err as NodeJS.ErrnoException).code === 'string';
}

Try / catch

try {
  await prepareStatsPort(port);
} catch (err) {
  if (err.message.startsWith('Failed to kill')) {
    console.error(`${err.message} — escalate: sudo kill -9 <pid>, or switch ports`);
  } else throw err;
}

Prevention

When it happens

Trigger: process.kill(holder.pid, 'SIGKILL') throwing EPERM/EINVAL after the process ignored SIGTERM for the full poll period, during reclaimStatsPort (called from prepareStatsPort/recoverStatsPort).

Common situations: A root-owned or another-user process squatting on the stats port and ignoring SIGTERM; zombies in uninterruptible sleep where even SIGKILL delivery fails; PID handed to a new process between polls with restrictive permissions.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/2370a92fdc4b70be. Report an issue: GitHub.