From 7428a3b4e3ee040faf32466f283e223d9dc21202 Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 8 Oct 2026 04:19:35 +0900 Subject: [PATCH 1/4] fix(plugin): keep the port when the app holds it but cannot reach herdr start waited out its deadline with the recorded server alive, then moved the app to another port beside it. Before it picks another port, start now asks the bridge health, which answers without herdr: when the app answers, it keeps the port and says that herdr is unreachable. Fixes #428 --- CHANGELOG.md | 4 ++++ scripts/plugin.test.ts | 26 +++++++++++++++++++++++++- scripts/plugin.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49164817f..d430727a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa - Secret input and the Codex follow-up fallback validate the live screen, so a password prompt or collapsed question queue in scrollback cannot send input into the current program. ([#566](https://github.com/devswha/herdr-web-ui/pull/566)) +- 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. ## [0.4.0] - 2026-10-08 diff --git a/scripts/plugin.test.ts b/scripts/plugin.test.ts index 93db30d04..609bf4535 100644 --- a/scripts/plugin.test.ts +++ b/scripts/plugin.test.ts @@ -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 = {}): Promise<{ out: string; err: string; exitCode: number }> { const bin = join(scratch, "bin"); const env: Record = { ...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 }; @@ -129,6 +130,29 @@ 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); }); describe("stop", () => { diff --git a/scripts/plugin.ts b/scripts/plugin.ts index 8af09d343..f4dfb7b3a 100644 --- a/scripts/plugin.ts +++ b/scripts/plugin.ts @@ -141,6 +141,27 @@ async function health(): Promise { } } +/** Whether the app holds the port: its bridge health answers without asking herdr. */ +async function appOnPort(): Promise { + 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. */ +async function healthError(): Promise { + try { + const response = await fetch(`${origin}/api/health`, { signal: AbortSignal.timeout(READY_TIMEOUT_MS) }); + const body = (await response.json()) as { error?: { message?: unknown } }; + return typeof body.error?.message === "string" ? body.error.message : `its health check answered ${response.status}`; + } 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()); @@ -193,6 +214,13 @@ 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 pid = recordedPid(); + failStart(`herdr web ui is running at ${origin}${pid === null ? "" : ` (pid ${pid})`} but cannot reach herdr: ${await healthError()}. 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).` From 26009c57f8b142775433054feb1acd8dd65ecfce Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 8 Oct 2026 04:21:39 +0900 Subject: [PATCH 2/4] docs(changelog): link #577 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d430727a4..2c36e00e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa 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 From d9bc8247bad52a1bfe1617182ea6e2c6de1a6dcf Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 8 Oct 2026 04:27:36 +0900 Subject: [PATCH 3/4] fix(plugin): bound the herdr error lookup to a short timeout --- scripts/plugin.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/plugin.ts b/scripts/plugin.ts index f4dfb7b3a..9f7a40f08 100644 --- a/scripts/plugin.ts +++ b/scripts/plugin.ts @@ -154,7 +154,8 @@ async function appOnPort(): Promise { /** Why the app's full health fails, as it says: herdr's error while herdr is away. */ async function healthError(): Promise { try { - const response = await fetch(`${origin}/api/health`, { signal: AbortSignal.timeout(READY_TIMEOUT_MS) }); + // 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 { error?: { message?: unknown } }; return typeof body.error?.message === "string" ? body.error.message : `its health check answered ${response.status}`; } catch { From 1b4dc178c79c2dad717cde8ce1c66d54cc48f5c7 Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 8 Oct 2026 06:36:01 +0900 Subject: [PATCH 4/4] fix(plugin): a start that finds herdr back while it asks why reports the app running If herdr came back between the last health check and the question healthError() asks, start failed with "its health check answered 200" although the app served. healthError() now answers null for an app that answers, and settlePort() reports it running. --- scripts/plugin.test.ts | 23 +++++++++++++++++++++++ scripts/plugin.ts | 11 +++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/scripts/plugin.test.ts b/scripts/plugin.test.ts index 609bf4535..f4c009c89 100644 --- a/scripts/plugin.test.ts +++ b/scripts/plugin.test.ts @@ -153,6 +153,29 @@ describe("port", () => { 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", () => { diff --git a/scripts/plugin.ts b/scripts/plugin.ts index 9f7a40f08..952e8e8f1 100644 --- a/scripts/plugin.ts +++ b/scripts/plugin.ts @@ -151,12 +151,13 @@ async function appOnPort(): Promise { } } -/** Why the app's full health fails, as it says: herdr's error while herdr is away. */ -async function healthError(): Promise { +/** 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 { 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 { error?: { message?: unknown } }; + 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}`; } catch { return "its health check did not answer"; @@ -218,8 +219,10 @@ async function settlePort(): Promise<"ready" | "running" | "failed"> { // 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: ${await healthError()}. It keeps this port and answers again once herdr is back.`); + 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");