From f9235c6835bae6ef935943bb398afca3c685b524 Mon Sep 17 00:00:00 2001 From: stevenjj33 <75509501+stevenjj33@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:13:13 +0800 Subject: [PATCH] fix(webui): the shutdown request reports what it found, not what the browser said MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirmation put the browser's own wording in front of the person when the shutdown request got no answer -- "Load failed" in Safari, "Failed to fetch" in Chrome. That text describes the network, not what happened here, and it says nothing about the state the request was asking for. A request that gets no answer is ambiguous: the service may already be down, or it may have gone down before its answer arrived. The platform asks a second question when the first one is unanswered -- the listener that is still there answers, the one that is gone does not -- and reports one of three states: stopping, gone, or still-running. The binding turns those into the two answers the confirmation can show: stopped, or a failure with a message the person can act on. Nothing renders a raw error any more: a refusal keeps the message the server sent, and an error the platform did not name becomes a plain "关闭服务失败,请重试。". The browser spec covers both ends: a request with no answer closes the dialog when nothing is listening, and reports a failure in the page's own words when the service is still there. --- .../src/client/bindings/use-webui-shutdown.ts | 40 ++++++++++++- .../client/features/account/ui/UserMenu.tsx | 15 +++-- .../src/client/platform/webui-shutdown.ts | 60 +++++++++++++++++-- .../test/browser/user-menu-shutdown.spec.mjs | 29 +++++++++ 4 files changed, 129 insertions(+), 15 deletions(-) diff --git a/packages/webui/src/client/bindings/use-webui-shutdown.ts b/packages/webui/src/client/bindings/use-webui-shutdown.ts index ed8fef5a..f64e18d8 100644 --- a/packages/webui/src/client/bindings/use-webui-shutdown.ts +++ b/packages/webui/src/client/bindings/use-webui-shutdown.ts @@ -10,8 +10,42 @@ import { useCallback } from "react"; -import { requestWebuiShutdown } from "../platform/webui-shutdown.js"; +import { + requestWebuiShutdown, + WebuiShutdownRefused, +} from "../platform/webui-shutdown.js"; -export function useWebuiShutdown(): () => Promise { - return useCallback(() => requestWebuiShutdown(), []); +/** + * What the shutdown request found, in the terms the confirmation can show. + * + * `stopped` is the state the request was after: the service is not running, + * whether it said so or simply stopped answering. `failed` carries a + * message the person can act on -- never the browser's own wording, which + * describes the network rather than what happened here. + */ +export type WebuiShutdownOutcome = + | { readonly kind: "stopped" } + | { readonly kind: "failed"; readonly message: string }; + +export function useWebuiShutdown(): () => Promise { + return useCallback(async (): Promise => { + try { + const state = await requestWebuiShutdown(); + if (state === "still-running") { + return { + kind: "failed", + message: "关闭服务失败:本地服务仍在运行,请重试。", + }; + } + return { kind: "stopped" }; + } catch (error: unknown) { + if (error instanceof WebuiShutdownRefused) { + return { kind: "failed", message: error.message }; + } + // An error the platform did not name is a defect rather than an + // answer, and its wording is not something to put in front of a + // person: they are told the request did not go through. + return { kind: "failed", message: "关闭服务失败,请重试。" }; + } + }, []); } diff --git a/packages/webui/src/client/features/account/ui/UserMenu.tsx b/packages/webui/src/client/features/account/ui/UserMenu.tsx index c478a131..6b2f0de5 100644 --- a/packages/webui/src/client/features/account/ui/UserMenu.tsx +++ b/packages/webui/src/client/features/account/ui/UserMenu.tsx @@ -195,12 +195,15 @@ export function UserMenu({ setShutdownBusy(true); setShutdownError(undefined); try { - await shutdown(); - // The service answers before it stops, so the page is still alive here; - // the connection drop is what tells the person the server is gone. - setShutdownOpen(false); - } catch (error: unknown) { - setShutdownError(error instanceof Error ? error.message : String(error)); + const outcome = await shutdown(); + if (outcome.kind === "stopped") { + // The service is not running. It says so before it stops, and it + // says nothing at all when it was already down -- both leave the + // page here without a backend, which is what was asked for. + setShutdownOpen(false); + return; + } + setShutdownError(outcome.message); } finally { setShutdownBusy(false); } diff --git a/packages/webui/src/client/platform/webui-shutdown.ts b/packages/webui/src/client/platform/webui-shutdown.ts index 3155e8be..8ec0ec32 100644 --- a/packages/webui/src/client/platform/webui-shutdown.ts +++ b/packages/webui/src/client/platform/webui-shutdown.ts @@ -18,14 +18,29 @@ export interface WebuiShutdownOptions { readonly token?: string; } -/** The server refused the request, or the server could not be reached. */ +/** + * What the request found. + * + * - `stopping` -- the service took the request and is on its way down. + * - `gone` -- nothing is listening any more: the service is not running. + * - `still-running` -- the request got no answer, but the service is still + * there and still answering. + * + * The last two are the same question asked twice. Asking a service that is + * already down to stop is not a failure, and a request that never reached a + * service that is still up is not a success either; which of the two happened + * is what the person needs to be told. + */ +export type WebuiShutdownState = "stopping" | "gone" | "still-running"; + +/** The server answered, and it refused. */ export class WebuiShutdownRefused extends Error { override readonly name = "WebuiShutdownRefused"; } export async function requestWebuiShutdown( options: WebuiShutdownOptions = {}, -): Promise { +): Promise { // Presence is checked, not truthiness: an injected empty token is a valid // dev target, and falling back to the global for it would silently ignore // the caller's origin. @@ -39,10 +54,23 @@ export async function requestWebuiShutdown( const query = new URLSearchParams({ token: target.token }); const doFetch = options.fetchImpl ?? globalThis.fetch; - const response = await doFetch( - `${target.origin}/webui-shutdown?${query.toString()}`, - { method: "POST" }, - ); + let response: Response; + try { + response = await doFetch( + `${target.origin}/webui-shutdown?${query.toString()}`, + { method: "POST" }, + ); + } catch { + // The request got no answer. That is what a service that is already + // down looks like, and it is also what a service that went down before + // its answer arrived looks like: the answer and the teardown race, and + // a browser that reads the answer late loses that race. A second + // question settles which of the two it was -- the listener that is + // still there answers, the one that is gone does not. + return (await isListening(target.origin, doFetch)) + ? "still-running" + : "gone"; + } if (!response.ok) { throw new WebuiShutdownRefused( response.status === 404 @@ -50,4 +78,24 @@ export async function requestWebuiShutdown( : `关闭服务失败(HTTP ${response.status})。`, ); } + return "stopping"; +} + +/** + * Whether the service still answers on that address. + * + * Any HTTP answer counts, including a refusal: the question is whether the + * listener is there, and a listener that refuses is still a listener. A HEAD + * keeps the answer empty. + */ +async function isListening( + origin: string, + doFetch: typeof fetch, +): Promise { + try { + await doFetch(`${origin}/`, { method: "HEAD", cache: "no-store" }); + return true; + } catch { + return false; + } } diff --git a/packages/webui/test/browser/user-menu-shutdown.spec.mjs b/packages/webui/test/browser/user-menu-shutdown.spec.mjs index b5c85914..f608920d 100644 --- a/packages/webui/test/browser/user-menu-shutdown.spec.mjs +++ b/packages/webui/test/browser/user-menu-shutdown.spec.mjs @@ -71,3 +71,32 @@ test("a refused shutdown can be dismissed by clicking outside after the error", await page.locator('[data-webui-dialog-scrim="true"]').click({ position: { x: 8, y: 8 } }); await expect(dialog).toHaveCount(0); }); + +test("a shutdown request that gets no answer closes the dialog when the service is gone", async ({ page }) => { + await openApp(page); + // The service is not running any more: the page is still open, but + // nothing answers on the address it came from. That is the state the + // request was asking for, so it is not a failure to report -- and the + // browser's own wording for a request that got no answer says nothing + // about it either way. + await page.route("**/webui-shutdown?*", (route) => route.abort("connectionrefused")); + await page.route("**/", (route) => route.abort("connectionrefused")); + await page.locator('[data-testid="sidebar-user-menu-trigger"], [data-testid="sidebar-user-menu-trigger-rail"]').first().click(); + await page.getByTestId("user-menu-shutdown").click(); + const dialog = page.getByRole("alertdialog"); + await dialog.getByRole("button", { name: "关闭服务", exact: true }).click(); + await expect(dialog).toHaveCount(0); +}); + +test("a shutdown request that gets no answer while the service still answers reports the failure in the page's own words", async ({ page }) => { + await openApp(page); + // The request did not get through, but the service is there and still + // answering: the person is told to retry, in the page's language rather + // than the browser's. + await page.route("**/webui-shutdown?*", (route) => route.abort("connectionrefused")); + await page.locator('[data-testid="sidebar-user-menu-trigger"], [data-testid="sidebar-user-menu-trigger-rail"]').first().click(); + await page.getByTestId("user-menu-shutdown").click(); + const dialog = page.getByRole("alertdialog"); + await dialog.getByRole("button", { name: "关闭服务", exact: true }).click(); + await expect(dialog).toContainText("关闭服务失败:本地服务仍在运行,请重试。"); +});