diff --git a/AGENTS.md b/AGENTS.md
index 275f6c3ff..fd354dde9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -424,6 +424,16 @@ so merge ranges from all mounted editors before replacing a registry entry.
after `send()` loses edits on disconnect.
- Rebuilds and reconnects must replay unacknowledged updates only when the epoch
is still compatible.
+- Shutdown closes every socket with 1012 (`OpenSockets.stopForRestart()`) before
+ `server.stop(true)`, whose own 1001 reads as a loss. The browser takes 1012 as a
+ quiet restart and a probe's 4xx as a refusal only with `x-chopin-refusal: 1`
+ (or a 401); answer any new refusal of `/ws` or `/ws/sidebar` through
+ `refused()` so a proxy's 4xx cannot be mistaken for it. A request waiting when
+ the 1012 arrives rejects as `WireLost` with `restarting` set; only a caller whose
+ request carries its own identity, as `chat:send` does, may send it again quietly.
+ For `RESTART_QUIET_MS` after a 1012 `Wire` raises no `onOutage`, so the document's one
+ offline notice stays away, and every other surface that speaks of a lost connection
+ waits `useConnectionNotice(lost, wire.restarting)`; a new one must honour both.
- Room opening and closing share the document lifecycle lock. Opening waits for
an active close; eviction forgets the room only after runtime shutdown and
persistence complete. Preserve that ordering.
diff --git a/apps/server/src/client.test.ts b/apps/server/src/client.test.ts
index 498040166..0259ca098 100644
--- a/apps/server/src/client.test.ts
+++ b/apps/server/src/client.test.ts
@@ -61,7 +61,7 @@ test("unfingerprinted public files are not cached as immutable", async () => {
test("HTML and SPA fallbacks are revalidated", async () => {
let client = await createClient({ "index.html": "app" });
try {
- for (let path of ["/index.html", "/rooms/room-123", "/assets/missing-AjK9nqi6.js"]) {
+ for (let path of ["/index.html", "/rooms/room-123", "/assets", "/assetsx/app.js"]) {
let response = await client.get(path);
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-cache");
@@ -73,6 +73,35 @@ test("HTML and SPA fallbacks are revalidated", async () => {
}
});
+test("a missing file under /assets answers 404, where the app's HTML would fail as a script", async () => {
+ let client = await createClient({
+ "index.html": "app",
+ "assets/index-AjK9nqi6.js": "console.log('build');",
+ });
+ try {
+ for (
+ let path of [
+ "/assets/old-chunk-Zy8wXq1p.js",
+ "/assets/old-style-Zy8wXq1p.css",
+ "/assets/unhashed.js",
+ "/assets/nested/dir/chunk-Zy8wXq1p.js",
+ "/assets/",
+ ]
+ ) {
+ let response = await client.get(path);
+ expect([path, response.status]).toEqual([path, 404]);
+ expect(response.headers.get("content-type")).not.toContain("text/html");
+ expect(response.headers.get("cache-control")).toBe("no-store");
+ expect(await response.text()).not.toContain("app");
+ }
+ let present = await client.get("/assets/index-AjK9nqi6.js");
+ expect(present.status).toBe(200);
+ expect(present.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
+ } finally {
+ await client.close();
+ }
+});
+
test("a missing built client keeps its existing 404 response", async () => {
let directory = await mkdtemp(join(tmpdir(), "chopin-client-"));
try {
diff --git a/apps/server/src/client.ts b/apps/server/src/client.ts
index cd71e7550..bccd71429 100644
--- a/apps/server/src/client.ts
+++ b/apps/server/src/client.ts
@@ -32,6 +32,7 @@ const PRIVATE = new Set(["authorization", "cookie"]);
/** Vite's default build names use an eight-character hash before the extension. */
const FINGERPRINTED_ASSET_PATH = /^\/assets\/.+-[A-Za-z0-9_-]{8}\.[^/]+$/;
+const ASSETS_PATH = "/assets/";
const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
function forwardable(headers: Headers): Headers {
@@ -76,7 +77,10 @@ export async function proxy(req: Request, url: URL, origin: string): Promise {
if (!existsSync(dir)) {
@@ -96,6 +100,12 @@ export async function serve(url: URL, dir: string): Promise {
}
return new Response(file, { headers });
}
+ if (url.pathname.startsWith(ASSETS_PATH)) {
+ return new Response("not found", {
+ status: 404,
+ headers: { "cache-control": "no-store", "content-type": "text/plain; charset=utf-8" },
+ });
+ }
return new Response(Bun.file(join(dir, "index.html")), {
headers: { "cache-control": "no-cache" },
});
diff --git a/apps/server/src/http/health.test.ts b/apps/server/src/http/health.test.ts
new file mode 100644
index 000000000..9f50be3b9
--- /dev/null
+++ b/apps/server/src/http/health.test.ts
@@ -0,0 +1,80 @@
+import { expect, test } from "bun:test";
+
+import { Readiness, registerHealthRoutes } from "./health";
+import { Router } from "./router";
+
+function service(holdsLease = () => true) {
+ let readiness = new Readiness(holdsLease);
+ let router = new Router();
+ registerHealthRoutes(router, readiness);
+ let get = async (path: string, method = "GET") => {
+ let response = (await router.handle(new Request(`http://chopin.test${path}`, { method })))!;
+ return { status: response.status, body: await response.text(), response };
+ };
+ return {
+ readiness,
+ live: () => get("/api/health/live"),
+ ready: () => get("/api/health/ready"),
+ get,
+ };
+}
+
+test("readiness is false until startup recovery has finished", async () => {
+ let { readiness, ready } = service();
+ expect(await ready()).toMatchObject({ status: 503, body: "starting" });
+ readiness.started();
+ expect(await ready()).toMatchObject({ status: 200, body: "ready" });
+});
+
+test("readiness needs the writer lease as well as finished recovery", async () => {
+ let held = false;
+ let { readiness, ready } = service(() => held);
+ readiness.started();
+ expect(await ready()).toMatchObject({ status: 503, body: "starting" });
+ held = true;
+ expect(await ready()).toMatchObject({ status: 200, body: "ready" });
+});
+
+test("readiness is false from the moment a drain starts, and stays false", async () => {
+ let { readiness, ready } = service();
+ readiness.started();
+ expect((await ready()).status).toBe(200);
+ readiness.draining();
+ expect(await ready()).toMatchObject({ status: 503, body: "draining" });
+ readiness.started();
+ expect(await ready()).toMatchObject({ status: 503, body: "draining" });
+});
+
+test("a drain that begins during startup keeps the process from ever being ready", async () => {
+ let { readiness, ready } = service();
+ readiness.draining();
+ readiness.started();
+ expect(await ready()).toMatchObject({ status: 503, body: "draining" });
+});
+
+test("liveness is true while the process runs, whatever readiness says", async () => {
+ let { readiness, live } = service(() => false);
+ expect(await live()).toMatchObject({ status: 200, body: "live" });
+ readiness.started();
+ readiness.draining();
+ expect(await live()).toMatchObject({ status: 200, body: "live" });
+});
+
+test("both answer one word to a caller with no credentials, and are never cached", async () => {
+ let { readiness, live, ready } = service();
+ readiness.started();
+ for (let { status, body, response } of [await live(), await ready()]) {
+ expect(status).toBe(200);
+ expect(body).toMatch(/^[a-z]+$/);
+ expect(response.headers.get("cache-control")).toBe("no-store");
+ expect(response.headers.get("set-cookie")).toBeNull();
+ }
+});
+
+test("a probe may ask with HEAD and gets the status without a body", async () => {
+ let { readiness, get } = service();
+ expect(await get("/api/health/ready", "HEAD")).toMatchObject({ status: 503, body: "" });
+ readiness.started();
+ expect(await get("/api/health/ready", "HEAD")).toMatchObject({ status: 200, body: "" });
+ expect(await get("/api/health/live", "HEAD")).toMatchObject({ status: 200, body: "" });
+});
diff --git a/apps/server/src/http/health.ts b/apps/server/src/http/health.ts
new file mode 100644
index 000000000..bf913766d
--- /dev/null
+++ b/apps/server/src/http/health.ts
@@ -0,0 +1,57 @@
+import type { Router } from "./router";
+
+export type ReadinessStatus = "starting" | "ready" | "draining";
+
+/**
+ * Whether this process may be sent traffic.
+ *
+ * Ready only once startup recovery has finished and the process holds the writer lease, and
+ * never again once a drain has begun: a replacement waits for exactly this process to let go.
+ */
+export class Readiness {
+ readonly #holdsLease: () => boolean;
+ #started = false;
+ #draining = false;
+
+ constructor(holdsLease: () => boolean) {
+ this.#holdsLease = holdsLease;
+ }
+
+ /** Startup recovery has finished. */
+ started(): void {
+ this.#started = true;
+ }
+
+ /** A drain has begun. Irreversible, because the process is on its way out. */
+ draining(): void {
+ this.#draining = true;
+ }
+
+ status(): ReadinessStatus {
+ if (this.#draining) return "draining";
+ return this.#started && this.#holdsLease() ? "ready" : "starting";
+ }
+}
+
+function answer(request: Request, status: number, word: string): Response {
+ return new Response(request.method === "HEAD" ? null : word, {
+ status,
+ headers: { "cache-control": "no-store", "content-type": "text/plain; charset=utf-8" },
+ });
+}
+
+/**
+ * `GET /api/health/live` answers while the process runs, and `GET /api/health/ready` says
+ * whether it may be sent traffic. Both are unauthenticated and say one word, nothing else.
+ */
+export function registerHealthRoutes(router: Router, readiness: Readiness): void {
+ let live = (request: Request) => answer(request, 200, "live");
+ let ready = (request: Request) => {
+ let status = readiness.status();
+ return answer(request, status === "ready" ? 200 : 503, status);
+ };
+ for (let method of ["GET", "HEAD"]) {
+ router.on(method, "/api/health/live", live);
+ router.on(method, "/api/health/ready", ready);
+ }
+}
diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
index cf8033695..86831c0b7 100644
--- a/apps/server/src/main.ts
+++ b/apps/server/src/main.ts
@@ -65,6 +65,7 @@ import { describe, load } from "./config";
import { GitHubClient, GitHubError } from "./github/client";
import { GitHubReferences } from "./github/references";
import { registerConnectorScript } from "./http/connector-script";
+import { Readiness, registerHealthRoutes } from "./http/health";
import { registerMachinesPath } from "./http/machines-path";
import { Router } from "./http/router";
import { JobExecutionError, JobRegistry } from "./jobs/registry";
@@ -111,7 +112,7 @@ import {
} from "./research/service";
import { placeResearchReference as placeResearch } from "./research/placement";
import * as Rooms from "./rooms";
-import { admit, admitSidebar } from "./socket/admission";
+import { admit, admitSidebar, refused } from "./socket/admission";
import { refreshAuthorization } from "./socket/authorization";
import {
recheckDecisionWatch,
@@ -122,6 +123,7 @@ import {
watchDecisions,
watchRequest,
} from "./socket/decision-watch";
+import { OpenSockets } from "./socket/open-sockets";
import { SessionSockets } from "./socket/session-sockets";
import { StorageError } from "./storage/errors";
import { createStorage } from "./storage/registry";
@@ -138,6 +140,7 @@ import {
} from "./wire";
import { AGENT_PATH, LINK_CLOSE } from "@chopin/agent-link";
+import { SERVICE_RESTART } from "@chopin/protocol/restart";
import type { Server, ServerWebSocket } from "bun";
import type { AgentSocketData } from "./harness/remote/endpoint";
@@ -192,6 +195,7 @@ const writerLease = new WriterLease(storage.leases, {
safetyMs: LEASE_SAFETY_MS,
retryMs: LEASE_RETRY_MS,
}, () => signal());
+const readiness = new Readiness(() => writerLease.held !== undefined);
let experiments: ExperimentRuntime | undefined;
let implementations: ReturnType | undefined;
let experimentTimer: ReturnType | undefined;
@@ -203,6 +207,7 @@ let plannerAttachments: PlannerAttachments | undefined;
let systemTurns = new SystemTurns();
/** Browser sockets by login, so a login that ends closes them before another broadcast. */
let sessionSockets = new SessionSockets(sessionId => hostedAuth.sessions.isLive(sessionId));
+const openSockets = new OpenSockets();
let jobRunner: JobRunner | undefined;
let researchService: ResearchWorkspaceService | undefined;
let researchRecoveryTimer: ReturnType | undefined;
@@ -1783,9 +1788,7 @@ function listen(): Server {
deleting: channelId => deletingChannels.has(channelId),
readOnly: channelId => archivingChannels.has(channelId),
});
- if ("status" in outcome) {
- return new Response(outcome.reason, { status: outcome.status });
- }
+ if ("status" in outcome) return refused(outcome);
return upgraded(req, self, outcome.data);
}
if (url.pathname === AGENT_PATH) {
@@ -1800,9 +1803,7 @@ function listen(): Server {
}
if (url.pathname === "/ws/sidebar") {
let outcome = await admitSidebar(req, hostedAuth);
- if ("status" in outcome) {
- return new Response(outcome.reason, { status: outcome.status });
- }
+ if ("status" in outcome) return refused(outcome);
return upgraded(req, self, outcome.data);
}
let routed = await router.handle(req, url);
@@ -1817,6 +1818,7 @@ function listen(): Server {
websocket: {
open(ws: AnySocket) {
+ openSockets.opened(ws);
if (isAgentSocket(ws)) {
if (!plannerAttachments) return ws.close(LINK_CLOSE.failed, "unavailable");
openAgentSocket(ws, hostedAuth, {
@@ -1852,6 +1854,7 @@ function listen(): Server {
},
close(ws: AnySocket) {
+ openSockets.closed(ws);
if (isAgentSocket(ws)) {
ws.data.alive?.stop();
return ws.data.link?.disconnected();
@@ -1882,9 +1885,10 @@ function drain(): Promise {
record(err);
}
};
+ readiness.draining();
// Before the sockets close, so no link withdraws its questions on the way out.
plannerAttachments?.shutdown();
- if (server) await attempt(() => server.stop(true));
+ if (server) await attempt(() => openSockets.stopForRestart(server));
if (researchRecoveryTimer) clearTimeout(researchRecoveryTimer);
if (experimentTimer) clearInterval(experimentTimer);
if (recoveringResearch) await attempt(() => recoveringResearch!);
@@ -2142,7 +2146,7 @@ async function deleteChannelLocked(channelId: string): Promise {
channel = await storage.channels.get(channelId);
} catch {
for (let ws of room?.members.values() ?? []) {
- ws.close(1012, "document state is temporarily unavailable");
+ ws.close(SERVICE_RESTART, "document state is temporarily unavailable");
}
throw err;
}
@@ -2153,7 +2157,7 @@ async function deleteChannelLocked(channelId: string): Promise {
await plan(survivingRoom, server);
} catch {
for (let ws of survivingRoom.members.values()) {
- ws.close(1012, "document reload required");
+ ws.close(SERVICE_RESTART, "document reload required");
}
}
}
@@ -2892,6 +2896,7 @@ registerConnectorScript(router, {
source: join(import.meta.dir, "../../connector/src/main.ts"),
});
registerMachinesPath(router);
+registerHealthRoutes(router, readiness);
try {
await storage.health();
@@ -2957,6 +2962,7 @@ try {
scheduleResearchRecovery(recovery.deferred);
await researchService.recoverTerminalPlannerInline();
jobRunner.start();
+ readiness.started();
} catch (err) {
if (listening) await listening.stop(true).catch(() => {});
if (researchRecoveryTimer) clearTimeout(researchRecoveryTimer);
diff --git a/apps/server/src/socket/admission.test.ts b/apps/server/src/socket/admission.test.ts
index 4ee957b46..51cf26628 100644
--- a/apps/server/src/socket/admission.test.ts
+++ b/apps/server/src/socket/admission.test.ts
@@ -1,3 +1,4 @@
+import { REFUSAL_HEADER } from "@chopin/protocol/restart";
import { Machines } from "../auth/machines";
import { describe, expect, it } from "bun:test";
@@ -5,7 +6,7 @@ import { Sessions } from "../auth/session";
import { Admission } from "../auth/admission";
import { GitHubError } from "../github/client";
import { MemoryStorage } from "../storage/memory/adapter";
-import { admit } from "./admission";
+import { admit, refused } from "./admission";
import type { HostedAuth } from "../auth/routes";
import type {
@@ -129,6 +130,13 @@ describe("socket admission", () => {
auth,
);
expect(wrongOrigin).toEqual({ status: 403, reason: "origin is not allowed" });
+ if (!("status" in wrongOrigin)) throw new Error("expected a refusal");
+ let answer = refused(wrongOrigin);
+ expect([answer.status, await answer.text(), answer.headers.get(REFUSAL_HEADER)]).toEqual([
+ 403,
+ "origin is not allowed",
+ "1",
+ ]);
// A page still open on the name the deployment moved from keeps its socket.
let previousOrigin = await admit(
new Request(url, {
diff --git a/apps/server/src/socket/admission.ts b/apps/server/src/socket/admission.ts
index 3b4c30d2a..262087dba 100644
--- a/apps/server/src/socket/admission.ts
+++ b/apps/server/src/socket/admission.ts
@@ -1,3 +1,5 @@
+import { REFUSAL_HEADER } from "@chopin/protocol/restart";
+
import { ownOrigin } from "../auth/config";
import { isChannelId } from "../channels/id";
import { GitHubError } from "../github/client";
@@ -13,6 +15,17 @@ export type Admission = { data: SocketData } | { status: number; reason: string
export type SidebarAdmission = { data: SidebarSocketData } | { status: number; reason: string };
+/**
+ * The HTTP answer to a socket handshake that admission refused, marked as Chopin's own so the
+ * client can tell it from a proxy's 4xx while Chopin is away.
+ */
+export function refused(outcome: { status: number; reason: string }): Response {
+ return new Response(outcome.reason, {
+ status: outcome.status,
+ headers: { [REFUSAL_HEADER]: "1" },
+ });
+}
+
function probing(request: Request): boolean {
return request.headers.get("x-chopin-socket-probe") === "1"
&& request.headers.get("upgrade")?.toLowerCase() !== "websocket";
diff --git a/apps/server/src/socket/open-sockets.test.ts b/apps/server/src/socket/open-sockets.test.ts
new file mode 100644
index 000000000..783d449a4
--- /dev/null
+++ b/apps/server/src/socket/open-sockets.test.ts
@@ -0,0 +1,74 @@
+import { SERVICE_RESTART } from "@chopin/protocol/restart";
+import { afterEach, expect, test } from "bun:test";
+
+import { OpenSockets } from "./open-sockets";
+
+import type { Server, ServerWebSocket } from "bun";
+
+let clients: WebSocket[] = [];
+
+afterEach(() => {
+ for (let client of clients) client.close();
+ clients = [];
+});
+
+function serve(sockets: OpenSockets) {
+ let server = Bun.serve({
+ port: 0,
+ hostname: "127.0.0.1",
+ fetch(request, self) {
+ if (self.upgrade(request)) return undefined;
+ return new Response("plain");
+ },
+ websocket: {
+ open(ws: ServerWebSocket) {
+ sockets.opened(ws);
+ },
+ close(ws: ServerWebSocket) {
+ sockets.closed(ws);
+ },
+ message() {},
+ },
+ });
+ return server;
+}
+
+async function connect(server: Server): Promise<{ closed: Promise }> {
+ let client = new WebSocket(`ws://127.0.0.1:${server.port}/`);
+ clients.push(client);
+ let closed = new Promise(resolve => client.addEventListener("close", resolve));
+ await new Promise(resolve => client.addEventListener("open", resolve, { once: true }));
+ return { closed };
+}
+
+test("every open socket is closed with 1012, where server.stop(true) alone says 1001", async () => {
+ let plain = serve(new OpenSockets());
+ let before = await connect(plain);
+ await plain.stop(true);
+ expect((await before.closed).code).toBe(1001);
+
+ let sockets = new OpenSockets();
+ let server = serve(sockets);
+ let open = await Promise.all([connect(server), connect(server), connect(server)]);
+ await sockets.stopForRestart(server);
+ for (let socket of open) {
+ let event = await socket.closed;
+ expect([event.code, event.reason, event.wasClean]).toEqual([
+ SERVICE_RESTART,
+ "server restarting",
+ true,
+ ]);
+ }
+});
+
+test("a socket that closed on its own is forgotten, and the listener stops taking new ones", async () => {
+ let sockets = new OpenSockets();
+ let server = serve(sockets);
+ let leaving = await connect(server);
+ clients.at(-1)!.close(1000, "done");
+ expect((await leaving.closed).code).toBe(1000);
+ let staying = await connect(server);
+ await sockets.stopForRestart(server);
+ expect((await staying.closed).code).toBe(SERVICE_RESTART);
+ await expect(fetch(`http://127.0.0.1:${server.port}/`)).rejects.toThrow();
+});
diff --git a/apps/server/src/socket/open-sockets.ts b/apps/server/src/socket/open-sockets.ts
new file mode 100644
index 000000000..b59536251
--- /dev/null
+++ b/apps/server/src/socket/open-sockets.ts
@@ -0,0 +1,31 @@
+import { SERVICE_RESTART } from "@chopin/protocol/restart";
+
+import type { ServerWebSocket } from "bun";
+
+type Listener = { stop(closeActiveConnections?: boolean): Promise };
+
+/**
+ * Every socket the server has open, so that stopping can end each with the code that says why.
+ *
+ * `server.stop(true)` closes every socket with 1001, "going away", which a browser cannot tell
+ * from a server it lost for good. A restart is closed with 1012 first.
+ */
+export class OpenSockets {
+ readonly #open = new Set>();
+
+ opened(ws: ServerWebSocket): void {
+ this.#open.add(ws);
+ }
+
+ closed(ws: ServerWebSocket): void {
+ this.#open.delete(ws);
+ }
+
+ /** Stop accepting connections, close every open socket with 1012, then drop what is left. */
+ async stopForRestart(server: Listener): Promise {
+ let stopping = server.stop();
+ for (let ws of this.#open) ws.close(SERVICE_RESTART, "server restarting");
+ await server.stop(true);
+ await stopping;
+ }
+}
diff --git a/apps/server/src/storage/postgres/startup.test.ts b/apps/server/src/storage/postgres/startup.test.ts
index 0ed8efabc..f08df5e76 100644
--- a/apps/server/src/storage/postgres/startup.test.ts
+++ b/apps/server/src/storage/postgres/startup.test.ts
@@ -88,6 +88,11 @@ if (database) {
await ready(9071);
let session = await fetch("http://127.0.0.1:9071/api/session");
expect(await session.json()).toEqual({ user: null, agent: false });
+ // Started means the lease is held and recovery has finished, for a caller with no login.
+ for (let [path, answer] of [["live", "live"], ["ready", "ready"]]) {
+ let health = await fetch(`http://127.0.0.1:9071/api/health/${path}`);
+ expect([health.status, await health.text()]).toEqual([200, answer]);
+ }
let storage = new PostgresStorage(database);
let now = new Date();
diff --git a/apps/web/bundle-budget.ts b/apps/web/bundle-budget.ts
index abd7901d6..ca927aff7 100644
--- a/apps/web/bundle-budget.ts
+++ b/apps/web/bundle-budget.ts
@@ -24,9 +24,12 @@ export type JavaScriptBudget = { gzip: number; raw: number };
// while retaining the raw limit and keeping the editor out of project-only navigation.
// A document's Machine, in the title menu every sidebar row shares, adds 500 B raw.
// The sidebar's Machines menu, on Base UI Menu, adds about 50 B gzip on Linux; allow 200 B.
+// Reloading for a chunk a deploy removed, with the registry of unsaved work it asks, and the
+// reconnect that reads a server restart, measure 258,947 B raw and 82,789 B gzip in the
+// container's Linux build; allow about 500 B raw and 200 B gzip above that.
export const INITIAL_JAVASCRIPT_BUDGET: JavaScriptBudget = {
- gzip: 82_328,
- raw: 258_000,
+ gzip: 83_000,
+ raw: 259_500,
};
export function enforceInitialJavaScript(
diff --git a/apps/web/src/build-view.tsx b/apps/web/src/build-view.tsx
index 88513ba8c..d57507c94 100644
--- a/apps/web/src/build-view.tsx
+++ b/apps/web/src/build-view.tsx
@@ -1,3 +1,4 @@
+import { usePendingWork } from "@chopin/editor";
import { Face } from "@chopin/editor/face";
import {
CheckIcon,
@@ -132,6 +133,7 @@ export function BuildView(
let [stopping, setStopping] = useState(false);
let [returning, setReturning] = useState(false);
let [reason, setReason] = useState("");
+ usePendingWork(returning && reason.trim() !== "");
let [open, setOpen] = useState>({});
let [linked, setLinked] = useState();
let [now, setNow] = useState(() => Date.now());
diff --git a/apps/web/src/chat/chat.tsx b/apps/web/src/chat/chat.tsx
index feb868b28..6a4c7efa2 100644
--- a/apps/web/src/chat/chat.tsx
+++ b/apps/web/src/chat/chat.tsx
@@ -18,13 +18,15 @@ import {
} from "react";
import {
- CONNECTION_GRACE,
+ connectionGrace,
isDemoHandle,
MAX_RESEARCH_BRIEF,
useConnectionNotice,
+ usePendingWork,
usePopoverDismissal,
} from "@chopin/editor";
import { chatDraftKey } from "../demo-actor";
+import { WireLost } from "../wire";
import { canWithdraw } from "./withdraw";
import type { DraftInputHandle } from "./draft-input";
import type { PlannerNotice } from "./planner-status";
@@ -77,7 +79,12 @@ import {
import { runCounts, RunStack } from "./run-card";
import { runStages } from "../api";
import { Transcript } from "./transcript";
-import { initialTranscript, transcriptReducer } from "./transcript-state";
+import {
+ initialTranscript,
+ progressOf,
+ silentRestart,
+ transcriptReducer,
+} from "./transcript-state";
import type { TranscriptDecisions } from "./transcript";
import type { CardLink } from "../conversation-plan/links";
import type { ExcerptCorrectionAction } from "../conversation-plan/analysis-overview";
@@ -270,6 +277,7 @@ export function Chat(
// A reload must not take an unsent message with it. Kept as it changes
// rather than on unload, which a browser does not promise to announce.
useEffect(() => keepDraft(room, handle, draft), [room, handle, draft]);
+ usePendingWork(!!draft.text.trim());
let pickerId = useId();
let mentionPickerId = useId();
let commandPickerId = useId();
@@ -290,18 +298,21 @@ export function Chat(
// A draft never waits on the connection; only sending does.
let draftable = !readonly && !archived;
// The document says when the connection is lost; Chat only holds back what it cannot send.
- let connectionNotice = useConnectionNotice(!connected);
+ let connectionNotice = useConnectionNotice(!connected, wire?.restarting);
let effectiveMode = !!agent && (mode || draft.submission?.to === "planner"
|| addressedOutsideReferences(draft.text, draft.references));
- let workingTurn = transcriptReady ? turn : undefined;
- let suspendedWork = !transcriptReady && turn && transcript.activeAnchorId
- ? {
- turnId: turn.id,
- entryOffset: turn.entryOffset,
- endOffset: entries.length,
- anchorId: transcript.activeAnchorId,
- }
- : undefined;
+ let quietRestart = useRef(false);
+ quietRestart.current = silentRestart(
+ quietRestart.current,
+ !!wire?.restarting,
+ transcriptReady,
+ connectionNotice !== "none",
+ );
+ let { working: workingTurn, suspended: suspendedWork } = progressOf(
+ transcript,
+ transcriptReady,
+ quietRestart.current,
+ );
let detected = referencesEnabled && composerReady && !submitting
? referenceTrigger(draft.text, selection.start, selection.end)
: undefined;
@@ -545,7 +556,10 @@ export function Chat(
void wire.ask("chat:send", payload).then(() => {
clearSubmittedDraft(submitted);
}, error => {
- setSendError(boundedChatError(error));
+ // A restart cut the send off. The draft keeps its request id, so it is made again once
+ // the server is back, and the server answers one it already holds with that message.
+ if (error instanceof WireLost && error.restarting) setHeld(true);
+ else setSendError(boundedChatError(error));
}).finally(() => {
if (submission.current !== token) return;
submission.current = undefined;
@@ -571,7 +585,7 @@ export function Chat(
// Never held longer than the grace period, however the wait ends.
useEffect(() => {
if (!held) return;
- let timer = setTimeout(() => setHeld(false), CONNECTION_GRACE);
+ let timer = setTimeout(() => setHeld(false), connectionGrace(wire?.restarting));
return () => clearTimeout(timer);
}, [held]);
useEffect(() => {
diff --git a/apps/web/src/chat/research-draft-controller.test.ts b/apps/web/src/chat/research-draft-controller.test.ts
index 7d9520594..d08cc5401 100644
--- a/apps/web/src/chat/research-draft-controller.test.ts
+++ b/apps/web/src/chat/research-draft-controller.test.ts
@@ -1,4 +1,5 @@
import { expect, test } from "bun:test";
+import { hasPendingWork } from "@chopin/protocol/pending-work";
import type { ConversationPlan } from "@chopin/protocol";
import { researchDraftHarness } from "../../../server/src/conversation-plan/research-draft.test-fixtures";
import { ResearchDraftController } from "./research-draft-controller";
@@ -36,3 +37,36 @@ test("lost edit acknowledgements retain patches for idempotent reconnect replay"
expect(controller.get().text).toBe(h.offer().brief);
expect(controller.get().syncing).toBe(false);
});
+
+test("a brief edit holds the page from reloading until the server has acknowledged it", async () => {
+ let h = researchDraftHarness();
+ let lose = false;
+ let wire = {
+ connected: true,
+ send() {},
+ async ask(
+ _kind: string,
+ payload: Omit,
+ ): Promise {
+ let result = await h.drafts.edit(payload.offerId, payload.operation, h.actor);
+ if (lose && payload.operation.kind === "patch") throw new Error("lost acknowledgement");
+ return result as T;
+ },
+ } as unknown as Pick;
+ let controller = new ResearchDraftController(h.offer());
+ controller.configure(wire, true, h.offer());
+ await controller.begin();
+ expect(controller.unsaved).toBe(false);
+
+ lose = true;
+ controller.change("Investigate Jev alternatives and costs.");
+ await expect(controller.flush()).rejects.toThrow("not synchronized");
+ expect(controller.unsaved).toBe(true);
+ expect(hasPendingWork()).toBe(true);
+
+ lose = false;
+ controller.configure(wire, false, h.offer());
+ controller.configure(wire, true, h.offer());
+ await controller.flush();
+ expect(controller.unsaved).toBe(false);
+});
diff --git a/apps/web/src/chat/research-draft-controller.ts b/apps/web/src/chat/research-draft-controller.ts
index d0cda6371..57f8208eb 100644
--- a/apps/web/src/chat/research-draft-controller.ts
+++ b/apps/web/src/chat/research-draft-controller.ts
@@ -1,4 +1,5 @@
import * as Draft from "@chopin/draft";
+import { trackPendingWork } from "@chopin/protocol/pending-work";
import type { ConversationPlan } from "@chopin/protocol";
import type { Wire } from "../wire";
@@ -33,6 +34,12 @@ export class ResearchDraftController {
syncing: false,
closed: offer.status !== "offered",
};
+ trackPendingWork(this);
+ }
+
+ /** Whether an edit of the brief has not reached the server, which a reload would lose. */
+ get unsaved(): boolean {
+ return this.#outbox.length > 0;
}
get = () => this.#snapshot;
subscribe = (listener: () => void) => {
diff --git a/apps/web/src/chat/transcript-state.test.ts b/apps/web/src/chat/transcript-state.test.ts
index 05323cd7f..877195f0a 100644
--- a/apps/web/src/chat/transcript-state.test.ts
+++ b/apps/web/src/chat/transcript-state.test.ts
@@ -1,7 +1,12 @@
import { describe, expect, it } from "bun:test";
import { group } from "./model";
-import { initialTranscript, transcriptReducer } from "./transcript-state";
+import {
+ initialTranscript,
+ progressOf,
+ silentRestart,
+ transcriptReducer,
+} from "./transcript-state";
import type { Chat } from "@chopin/protocol";
@@ -119,3 +124,70 @@ describe("transcript work retention", () => {
}]);
});
});
+
+describe("how a turn in progress is shown while its connection is lost", () => {
+ function working() {
+ let state = transcriptReducer(initialTranscript, {
+ kind: "history",
+ entries: [entry("prompt", { kind: "member", handle: "ana" })],
+ turn,
+ });
+ return transcriptReducer(state, {
+ kind: "message",
+ entry: entry("prose", { kind: "agent" }, "I will inspect this."),
+ });
+ }
+
+ it("goes on showing a turn as working while its connection is current", () => {
+ let shown = progressOf(working(), true, false);
+ expect(shown.working?.id).toBe("turn-1");
+ expect(shown.suspended).toBeUndefined();
+ });
+
+ it("goes on showing it as working while a restart is waited out in silence, so a restart leaves no trace", () => {
+ let shown = progressOf(working(), false, true);
+ expect(shown.working?.id).toBe("turn-1");
+ expect(shown.suspended).toBeUndefined();
+ });
+
+ it("marks it disconnected as soon as a connection that was not a restart is gone", () => {
+ let shown = progressOf(working(), false, false);
+ expect(shown.working).toBeUndefined();
+ expect(shown.suspended).toEqual({
+ turnId: "turn-1",
+ entryOffset: 1,
+ endOffset: 2,
+ anchorId: "prose",
+ });
+ });
+
+ it("has nothing to mark before the turn has written anything", () => {
+ let state = transcriptReducer(initialTranscript, {
+ kind: "history",
+ entries: [entry("prompt", { kind: "member", handle: "ana" })],
+ turn,
+ });
+ expect(progressOf(state, false, false)).toEqual({});
+ expect(progressOf(initialTranscript, false, false)).toEqual({});
+ });
+});
+
+describe("when a restart is waited out in silence", () => {
+ it("begins with a loss the server said was a restart, and with no other", () => {
+ expect(silentRestart(false, true, false, false)).toBe(true);
+ expect(silentRestart(false, false, false, false)).toBe(false);
+ });
+
+ it("lasts through the moment after the connection opens, until the transcript is current", () => {
+ // The connection is back, so it no longer says restarting, but the history has not come.
+ expect(silentRestart(true, false, false, false)).toBe(true);
+ expect(silentRestart(true, false, true, false)).toBe(false);
+ });
+
+ it("ends once the connection is spoken of, and does not begin again in that outage", () => {
+ expect(silentRestart(true, true, false, true)).toBe(false);
+ expect(silentRestart(false, true, false, true)).toBe(false);
+ // Back after a long outage: the next loss's marker is cleared, and nothing is silent.
+ expect(silentRestart(false, false, false, false)).toBe(false);
+ });
+});
diff --git a/apps/web/src/chat/transcript-state.ts b/apps/web/src/chat/transcript-state.ts
index 64f6b5461..ec29c0a40 100644
--- a/apps/web/src/chat/transcript-state.ts
+++ b/apps/web/src/chat/transcript-state.ts
@@ -16,6 +16,47 @@ export type TranscriptAction =
| { kind: "turn"; turn?: Chat.Turn }
| { kind: "responded" };
+/**
+ * How the turn in progress is shown. `current` says the transcript is this connection's own, and
+ * `quiet` that a restart is being waited out in silence (`silentRestart`).
+ *
+ * A turn whose transcript is not current is marked disconnected, as soon as the connection that
+ * carried it is gone. A restart is the exception: the page says nothing of it, so the turn goes on
+ * showing as working and a restart that ends in moments leaves no trace.
+ */
+export function progressOf(
+ state: TranscriptState,
+ current: boolean,
+ quiet: boolean,
+): { working?: Chat.Turn; suspended?: CompletedWork } {
+ if (current || quiet) return { working: state.turn };
+ if (!state.turn || !state.activeAnchorId) return {};
+ return {
+ suspended: {
+ turnId: state.turn.id,
+ entryOffset: state.turn.entryOffset,
+ endOffset: state.entries.length,
+ anchorId: state.activeAnchorId,
+ },
+ };
+}
+
+/**
+ * Whether a restart is being waited out in silence, given whether it was a moment ago. It begins
+ * with a loss the server said was a restart (`restarting`) and lasts through the moment after the
+ * connection opens again, until the transcript is current (`current`). It ends once the page speaks
+ * of the connection (`spoken`) and does not begin again for the rest of that outage. A loss that
+ * was not a restart never begins it.
+ */
+export function silentRestart(
+ was: boolean,
+ restarting: boolean,
+ current: boolean,
+ spoken: boolean,
+): boolean {
+ return !current && !spoken && (was || restarting);
+}
+
export let initialTranscript: TranscriptState = { entries: [], completedWork: [] };
function anchor(entries: Chat.Entry[], turn: Chat.Turn): string | undefined {
diff --git a/apps/web/src/document-rename.tsx b/apps/web/src/document-rename.tsx
index 6ae328457..aa772fcb9 100644
--- a/apps/web/src/document-rename.tsx
+++ b/apps/web/src/document-rename.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { boundDocumentTitle, normalizeDocumentTitle } from "@chopin/dialect";
+import { usePendingWork } from "@chopin/editor";
import * as Api from "./api";
import { heldTyping, holdTyping } from "./title-edit";
@@ -44,6 +45,7 @@ export function DocumentRename(
let [title, setTitle] = useState(channel.title);
let [error, setError] = useState();
let [saving, setSaving] = useState(false);
+ usePendingWork(saving || normalizeDocumentTitle(title) !== channel.title);
// Enter and the blur it can cause arrive before React re-renders `saving`.
let busy = useRef(false);
let cancelled = useRef(false);
diff --git a/apps/web/src/experiments/evidence.tsx b/apps/web/src/experiments/evidence.tsx
index c46b8fcd1..83737ba41 100644
--- a/apps/web/src/experiments/evidence.tsx
+++ b/apps/web/src/experiments/evidence.tsx
@@ -1,3 +1,4 @@
+import { usePendingWork } from "@chopin/editor";
import { useState, useSyncExternalStore } from "react";
import { ExperimentView } from "@chopin/experiment/react";
import type { PublishedInvestigation } from "@chopin/experiment/records";
@@ -19,6 +20,7 @@ export function EvidenceActions({ store, item, view, disabled }: {
let [busy, setBusy] = useState(false);
let [error, setError] = useState("");
let [operation] = useState(() => new RequestIdentity());
+ usePendingWork(open && (conclusion.trim() !== "" || rationale.trim() !== ""));
async function act(action: () => Promise) {
setBusy(true);
setError("");
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 1226d867a..aa74334e0 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -8,6 +8,7 @@ import { isDesignAuditRoute } from "./design-audit/route";
import { isDiagramGalleryRoute } from "./diagram-gallery/route";
import { useFocusInput } from "./focus-input";
import { useMotionInput } from "./motion-input";
+import { watchPreloadErrors } from "./preload-recovery";
import { watchScrollbarStyle } from "./scrollbars";
import { useVisualViewport } from "./viewport";
@@ -29,6 +30,7 @@ import "./favorite-star.css";
let root = document.getElementById("root");
if (!root) throw new Error("missing #root");
watchScrollbarStyle();
+watchPreloadErrors();
function Root() {
useFocusInput();
diff --git a/apps/web/src/preload-recovery.test.ts b/apps/web/src/preload-recovery.test.ts
new file mode 100644
index 000000000..89c06c8ae
--- /dev/null
+++ b/apps/web/src/preload-recovery.test.ts
@@ -0,0 +1,154 @@
+import { hasPendingWork } from "@chopin/protocol/pending-work";
+import { describe, expect, it } from "bun:test";
+
+import { ResearchDraftStore } from "../../../packages/editor/src/research-draft";
+import { entryOf, isNewerBuild, recoverFromPreloadError, RELOAD_GAP_MS } from "./preload-recovery";
+
+import type { Reloading } from "./preload-recovery";
+
+function page(
+ options: {
+ pending?: boolean | (() => boolean);
+ newer?: boolean;
+ storage?: "none" | "refusing";
+ } = {},
+) {
+ let values = new Map();
+ let reloads = 0;
+ let clock = 1_000_000;
+ let state = { pending: options.pending ?? false, newer: options.newer ?? true };
+ let pending = () => typeof state.pending === "function" ? state.pending() : state.pending;
+ let storage: Reloading["storage"] = options.storage === "none" ? undefined : {
+ getItem: key => {
+ if (options.storage === "refusing") throw new Error("storage is blocked");
+ return values.get(key) ?? null;
+ },
+ setItem: (key, value) => {
+ if (options.storage === "refusing") throw new Error("storage is blocked");
+ values.set(key, value);
+ },
+ };
+ let reloading: Reloading = {
+ pending,
+ newer: async () => {
+ await Promise.resolve();
+ return state.newer;
+ },
+ reload: () => {
+ reloads++;
+ },
+ now: () => clock,
+ storage,
+ };
+ return {
+ fail: () => recoverFromPreloadError(reloading),
+ state,
+ wait: (ms: number) => {
+ clock += ms;
+ },
+ reloads: () => reloads,
+ };
+}
+
+describe("recoverFromPreloadError", () => {
+ it("reloads for a chunk that is gone when the server answers with a new build and the page holds nothing", async () => {
+ let tab = page();
+ expect(await tab.fail()).toBe(true);
+ expect(tab.reloads()).toBe(1);
+ });
+
+ it("does not reload while the page holds unsaved work", async () => {
+ let tab = page({ pending: true });
+ expect(await tab.fail()).toBe(false);
+ expect(tab.reloads()).toBe(0);
+ });
+
+ it("does not reload while a question typed into the document has not been started", async () => {
+ let drafts = new ResearchDraftStore("investigation");
+ drafts.open({ top: 1, right: 2, bottom: 3, left: 4, width: 5, height: 6 });
+ let tab = page({ pending: hasPendingWork });
+ drafts.change("Which index is faster?");
+ expect(await tab.fail()).toBe(false);
+ expect(tab.reloads()).toBe(0);
+
+ // Nothing typed, or typed and put away, is nothing to lose.
+ drafts.change("");
+ expect(drafts.unsaved).toBe(false);
+ });
+
+ it("does not reload unless the server answers with a new build, which a chunk failing with the server away, or for no deploy at all, does not give", async () => {
+ let tab = page({ newer: false });
+ expect(await tab.fail()).toBe(false);
+ expect(tab.reloads()).toBe(0);
+ });
+
+ it("does not reload for work begun while the server was being asked", async () => {
+ let tab = page();
+ let failing = tab.fail();
+ tab.state.pending = true;
+ expect(await failing).toBe(false);
+ expect(tab.reloads()).toBe(0);
+ });
+
+ it("does not reload again for the next failure straight after one", async () => {
+ let tab = page();
+ expect(await tab.fail()).toBe(true);
+ tab.wait(RELOAD_GAP_MS - 1);
+ expect(await tab.fail()).toBe(false);
+ expect(tab.reloads()).toBe(1);
+ });
+
+ it("reloads again once the gap has passed, as for a second deploy in one session", async () => {
+ let tab = page();
+ await tab.fail();
+ tab.wait(RELOAD_GAP_MS);
+ expect(await tab.fail()).toBe(true);
+ expect(tab.reloads()).toBe(2);
+ });
+
+ it("reloads once for several chunks failing together", async () => {
+ let tab = page();
+ let results = await Promise.all([tab.fail(), tab.fail(), tab.fail()]);
+ expect(results.filter(Boolean)).toHaveLength(1);
+ expect(tab.reloads()).toBe(1);
+ });
+
+ it("makes no reload it could not remember, so that it cannot loop", async () => {
+ for (let storage of ["none", "refusing"] as const) {
+ let tab = page({ storage });
+ expect(await tab.fail()).toBe(false);
+ expect(tab.reloads()).toBe(0);
+ }
+ });
+});
+
+describe("entryOf", () => {
+ it("names the build by the entry script its page loads", () => {
+ let html = `
+
+ `;
+ expect(entryOf(html)).toBe("/assets/index-CS81MqW4.js");
+ expect(entryOf('')).toBe(
+ "/assets/index-CS81MqW4.js",
+ );
+ });
+
+ it("is undefined for a page that loads none, such as an error page", () => {
+ expect(entryOf("")).toBeUndefined();
+ expect(entryOf("Bad gateway")).toBeUndefined();
+ expect(entryOf(''))
+ .toBeUndefined();
+ });
+});
+
+describe("isNewerBuild", () => {
+ it("is the server's page being of another build than the one running", () => {
+ expect(isNewerBuild("/assets/index-AAAA.js", "/assets/index-BBBB.js")).toBe(true);
+ expect(isNewerBuild("/assets/index-AAAA.js", "/assets/index-AAAA.js")).toBe(false);
+ });
+
+ it("is never so while either build is unknown", () => {
+ expect(isNewerBuild(undefined, "/assets/index-BBBB.js")).toBe(false);
+ expect(isNewerBuild("/assets/index-AAAA.js", undefined)).toBe(false);
+ });
+});
diff --git a/apps/web/src/preload-recovery.ts b/apps/web/src/preload-recovery.ts
new file mode 100644
index 000000000..2b0716268
--- /dev/null
+++ b/apps/web/src/preload-recovery.ts
@@ -0,0 +1,97 @@
+/**
+ * What a page does when a chunk it needs cannot be loaded.
+ *
+ * A tab opened before a deploy asks for the chunks of its own build, and the server no longer has
+ * them. Reloading fetches the new build, but only if the server answers with one, and only if the
+ * page holds nothing a reload would throw away. A chunk also fails to load while the server is
+ * restarting, and a reload then lands on the browser's error page; and it fails for reasons that
+ * have nothing to do with a deploy, which the page's own recovery says what to do about, and a
+ * reload of the same build would not cure.
+ */
+
+import { hasPendingWork } from "@chopin/protocol/pending-work";
+
+const RELOADED_AT = "chopin:preload-reloaded-at";
+
+/**
+ * A reload that did not cure the failure is not repeated within this long, so a build that is
+ * really broken cannot reload the page for ever.
+ */
+export const RELOAD_GAP_MS = 10_000;
+
+/**
+ * The entry script a page's HTML loads, which names its build: any file of the build that changes
+ * changes the entry's hash. Undefined when the HTML loads none.
+ */
+export function entryOf(html: string): string | undefined {
+ for (let [tag] of html.matchAll(/