Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions packages/webui/src/client/bindings/use-webui-shutdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<WebuiShutdownOutcome> {
return useCallback(async (): Promise<WebuiShutdownOutcome> => {
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: "关闭服务失败,请重试。" };
}
}, []);
}
15 changes: 9 additions & 6 deletions packages/webui/src/client/features/account/ui/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
60 changes: 54 additions & 6 deletions packages/webui/src/client/platform/webui-shutdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
): Promise<WebuiShutdownState> {
// 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.
Expand All @@ -39,15 +54,48 @@ 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
? "当前 WebUI 不支持关闭服务。"
: `关闭服务失败(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<boolean> {
try {
await doFetch(`${origin}/`, { method: "HEAD", cache: "no-store" });
return true;
} catch {
return false;
}
}
29 changes: 29 additions & 0 deletions packages/webui/test/browser/user-menu-shutdown.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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("关闭服务失败:本地服务仍在运行,请重试。");
});
Loading