Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
was still being sent no longer reaches the pane once you have left it, or left it and opened it
again, before the text's turn came.
([#576](https://github.com/devswha/herdr-web-ui/pull/576))
- The plugin's `start` keeps the app on its port when the app's own server holds it but cannot
reach herdr. It used to move the app to another port beside the running one and blame another
program; now it says that the app runs there without herdr and exits, and the app answers
again on its port once herdr is back.
([#577](https://github.com/devswha/herdr-web-ui/pull/577))

## [0.4.0] - 2026-10-08

Expand Down
49 changes: 48 additions & 1 deletion scripts/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ async function status(): Promise<{ out: string; err: string; exitCode: number }>
return run("status");
}

async function run(command: string): Promise<{ out: string; err: string; exitCode: number }> {
async function run(command: string, extra: Record<string, string> = {}): Promise<{ out: string; err: string; exitCode: number }> {
const bin = join(scratch, "bin");
const env: Record<string, string | undefined> = { ...process.env, PATH: `${bin}:${process.env["PATH"] ?? ""}`, HOME: scratch, HERDR_PLUGIN_STATE_DIR: join(scratch, "state") };
for (const key of ["HERDR_PLUGIN_CONFIG_DIR", "PORT", "HOST", "HERDR_WEB_TOKEN", "HERDR_WEB_STATE_DIR", "XDG_CONFIG_HOME"]) delete env[key];
Object.assign(env, extra);
const child = Bun.spawn(["bun", "scripts/plugin.ts", command], { cwd: ROOT, env, stdout: "pipe", stderr: "pipe" });
const [out, err, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
return { out, err, exitCode };
Expand Down Expand Up @@ -129,6 +130,52 @@ describe("port", () => {
expect(readFileSync(join(scratch, ".config", "herdr-web-ui", "plugin-port"), "utf8")).toBe(`${ours.port}\n`);
} finally { await Promise.all([ours.stop(true), stranger.stop(true)]); }
});

it("keeps the port of its own server that cannot reach herdr instead of moving the app (#428)", async () => {
// the app answers its bridge health, but the full one fails as it does while herdr is away
const ours = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: (request) => new URL(request.url).searchParams.get("scope") === "bridge"
? Response.json({ ok: true })
: Response.json({ error: { code: "connect_failed", message: "herdr socket unreachable" } }, { status: 502 }),
});
try {
keep(ours.port!);
mkdirSync(join(scratch, "state"), { recursive: true });
writeFileSync(join(scratch, "state", "server.pid"), `${process.pid}\n`); // the start that runs it, alive
// a start that moves anyway spawns no real server: there is no server/managed.ts under this root
const started = await run("start", { HERDR_PLUGIN_ROOT: scratch });
expect(started.out).not.toContain("instead");
expect(started.exitCode).toBe(1);
expect(started.err).toContain(`herdr web ui is running at http://127.0.0.1:${ours.port} (pid ${process.pid}) but cannot reach herdr: herdr socket unreachable`);
expect(readFileSync(join(scratch, ".config", "herdr-web-ui", "plugin-port"), "utf8")).toBe(`${ours.port}\n`);
expect(readFileSync(join(scratch, "state", "server.pid"), "utf8")).toBe(`${process.pid}\n`);
} finally { await ours.stop(true); }
}, 40_000);

it("reports its own server as running when herdr comes back while start asks why it cannot reach it", async () => {
// herdr is away through the wait and the check after the bridge probe, and back for the question after that
let afterBridge = -1;
const ours = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: (request) => {
if (new URL(request.url).searchParams.get("scope") === "bridge") { afterBridge = 0; return Response.json({ ok: true }); }
if (afterBridge >= 0 && afterBridge++ >= 1) return Response.json({ ok: true });
return Response.json({ error: { code: "connect_failed", message: "herdr socket unreachable" } }, { status: 502 });
},
});
try {
keep(ours.port!);
mkdirSync(join(scratch, "state"), { recursive: true });
writeFileSync(join(scratch, "state", "server.pid"), `${process.pid}\n`);
const started = await run("start", { HERDR_PLUGIN_ROOT: scratch });
expect(started.err).not.toContain("cannot reach herdr");
expect(started.exitCode).toBe(0);
expect(started.out).toContain(`herdr web ui already running at http://127.0.0.1:${ours.port}`);
} finally { await ours.stop(true); }
}, 40_000);
});

describe("stop", () => {
Expand Down
32 changes: 32 additions & 0 deletions scripts/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,29 @@ async function health(): Promise<boolean> {
}
}

/** Whether the app holds the port: its bridge health answers without asking herdr. */
async function appOnPort(): Promise<boolean> {
try {
const response = await fetch(`${origin}/api/health?scope=bridge`, { signal: AbortSignal.timeout(1500) });
return ((await response.json()) as { ok?: unknown }).ok === true;
} catch {
return false;
}
}

/** Why the app's full health fails, as it says: herdr's error while herdr is away; null when it answers now (herdr came back). */
async function healthError(): Promise<string | null> {
try {
// short, as health() is: the wait before this has already spent the start's deadline
const response = await fetch(`${origin}/api/health`, { signal: AbortSignal.timeout(1500) });
const body = (await response.json()) as { ok?: unknown; error?: { message?: unknown } };
if (response.ok && body.ok === true) return null;
return typeof body.error?.message === "string" ? body.error.message : `its health check answered ${response.status}`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch {
return "its health check did not answer";
}
}

function recordedPid(): number | null {
if (!existsSync(PID_FILE)) return null;
const pid = Number(readFileSync(PID_FILE, "utf8").trim());
Expand Down Expand Up @@ -193,6 +216,15 @@ async function settlePort(): Promise<"ready" | "running" | "failed"> {
if (await health()) return "running";
await Bun.sleep(250);
}
// the app holds the port but cannot reach herdr: another port would put a second server beside it (#428)
if (await appOnPort()) {
if (await health()) return "running";
const why = await healthError();
if (why === null) return "running";
const pid = recordedPid();
failStart(`herdr web ui is running at ${origin}${pid === null ? "" : ` (pid ${pid})`} but cannot reach herdr: ${why}. It keeps this port and answers again once herdr is back.`);
return "failed";
}
const settings = CONFIG_FILES.at(-1) ?? join(CONFIG_DIR, "env");
const blocked = platform() === "win32"
? `port ${port} on ${host} cannot be opened: another program has it, or Windows reserves it for Hyper-V, WSL2 or Docker (netsh interface ipv4 show excludedportrange protocol=tcp lists the reserved ranges).`
Expand Down