diff --git a/CHANGELOG.md b/CHANGELOG.md index b9fd7e866..ee2e6e6d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/plugin.test.ts b/scripts/plugin.test.ts index 93db30d04..f4c009c89 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,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", () => { diff --git a/scripts/plugin.ts b/scripts/plugin.ts index 8af09d343..952e8e8f1 100644 --- a/scripts/plugin.ts +++ b/scripts/plugin.ts @@ -141,6 +141,29 @@ 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; 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 { 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"; + } +} + function recordedPid(): number | null { if (!existsSync(PID_FILE)) return null; const pid = Number(readFileSync(PID_FILE, "utf8").trim()); @@ -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).`