Merge branch 'claude/zen-ptolemy-01vsx1': never leave a page stuck on its loading bar
7 files+296−100/7 viewed
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | DeadlineExceeded, | |
| 6 | + | LONG_DEADLINE_MS, | |
| 7 | + | READ_DEADLINE_MS, | |
| 8 | + | SLOW_READ_DEADLINE_MS, | |
| 9 | + | WRITE_DEADLINE_MS, | |
| 10 | + | deadlineFor, | |
| 11 | + | withDeadline, | |
| 12 | + | } from "./deadline.ts"; | |
| 13 | + | import { STUCK_NAVIGATION_MS } from "./stuck.ts"; | |
| 14 | + | ||
| 15 | + | test("an answer in time is passed through", async () => { | |
| 16 | + | assert.equal(await withDeadline("repos get_repo", 1000, async () => "ok"), "ok"); | |
| 17 | + | }); | |
| 18 | + | ||
| 19 | + | test("a call that never answers fails at its deadline, and its signal aborts", async () => { | |
| 20 | + | let seen: AbortSignal | undefined; | |
| 21 | + | const never = withDeadline("repos get_repo", 20, (signal) => { | |
| 22 | + | seen = signal; | |
| 23 | + | return new Promise<never>(() => {}); | |
| 24 | + | }); | |
| 25 | + | await assert.rejects(never, (error: unknown) => error instanceof DeadlineExceeded && /repos get_repo did not answer within 20ms/.test(error.message)); | |
| 26 | + | assert.equal(seen?.aborted, true); | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | test("a call that ignores its signal is still let go", async () => { | |
| 30 | + | await assert.rejects(withDeadline("chat sidebar", 10, () => new Promise<never>(() => {})), DeadlineExceeded); | |
| 31 | + | }); | |
| 32 | + | ||
| 33 | + | test("a failure in time is the call's own, not a deadline", async () => { | |
| 34 | + | await assert.rejects( | |
| 35 | + | withDeadline("work get_pull", 1000, async () => { | |
| 36 | + | throw new Error("get_pull failed with status 500"); | |
| 37 | + | }), | |
| 38 | + | /status 500/, | |
| 39 | + | ); | |
| 40 | + | }); | |
| 41 | + | ||
| 42 | + | test("the caller's own signal aborts the call", async () => { | |
| 43 | + | const outer = new AbortController(); | |
| 44 | + | let seen: AbortSignal | undefined; | |
| 45 | + | const call = withDeadline( | |
| 46 | + | "repos get_repo", | |
| 47 | + | 1000, | |
| 48 | + | (signal) => | |
| 49 | + | new Promise<never>((_, reject) => { | |
| 50 | + | seen = signal; | |
| 51 | + | signal.addEventListener("abort", () => reject(signal.reason)); | |
| 52 | + | }), | |
| 53 | + | outer.signal, | |
| 54 | + | ); | |
| 55 | + | outer.abort(new Error("visitor left")); | |
| 56 | + | await assert.rejects(call, /visitor left/); | |
| 57 | + | assert.equal(seen?.aborted, true); | |
| 58 | + | }); | |
| 59 | + | ||
| 60 | + | test("each call gets the time its work needs", () => { | |
| 61 | + | assert.equal(deadlineFor("repos", "get_repo", false), READ_DEADLINE_MS); | |
| 62 | + | assert.equal(deadlineFor("work", "merge_pull", true), WRITE_DEADLINE_MS); | |
| 63 | + | assert.equal(deadlineFor("security", "overview", false), SLOW_READ_DEADLINE_MS); | |
| 64 | + | assert.equal(deadlineFor("repos", "blame", false), SLOW_READ_DEADLINE_MS); | |
| 65 | + | assert.equal(deadlineFor("integrations", "github_import", true), LONG_DEADLINE_MS); | |
| 66 | + | assert.equal(deadlineFor("agents", "computer_sleep", true), LONG_DEADLINE_MS); | |
| 67 | + | }); | |
| 68 | + | ||
| 69 | + | test("a long method of one service is not long in another", () => { | |
| 70 | + | assert.equal(deadlineFor("agents", "draft", true), LONG_DEADLINE_MS); | |
| 71 | + | assert.equal(deadlineFor("work", "draft", true), WRITE_DEADLINE_MS); | |
| 72 | + | assert.equal(deadlineFor("identity", "overview", false), READ_DEADLINE_MS); | |
| 73 | + | }); | |
| 74 | + | ||
| 75 | + | test("no read outlasts the browser's patience with a navigation", () => { | |
| 76 | + | assert.ok(SLOW_READ_DEADLINE_MS < STUCK_NAVIGATION_MS && READ_DEADLINE_MS < STUCK_NAVIGATION_MS); | |
| 77 | + | }); |
| 1 | + | /** | |
| 2 | + | * How long the site waits on a service before it gives up. Nothing on | |
| 3 | + | * Workers ends a subrequest that stops answering: the page waits as long | |
| 4 | + | * as the visitor does, so a service stuck on one call holds the page on | |
| 5 | + | * its loading bar for good. With a deadline it fails instead, as an error | |
| 6 | + | * the page can draw and the next click can get past. | |
| 7 | + | */ | |
| 8 | + | ||
| 9 | + | /** A read: no page should wait longer than this on one service call. */ | |
| 10 | + | export const READ_DEADLINE_MS = 15_000; | |
| 11 | + | ||
| 12 | + | /** A write: some do real work (a merge, a commit), so they get longer. */ | |
| 13 | + | export const WRITE_DEADLINE_MS = 60_000; | |
| 14 | + | ||
| 15 | + | /** Reads that do real work inline the first time, or walk history. */ | |
| 16 | + | export const SLOW_READ_DEADLINE_MS = 60_000; | |
| 17 | + | ||
| 18 | + | /** Jobs a person starts and waits on: an import, a model's draft, a computer waking. */ | |
| 19 | + | export const LONG_DEADLINE_MS = 600_000; | |
| 20 | + | ||
| 21 | + | /** | |
| 22 | + | * Reads that can take longer than `READ_DEADLINE_MS` and still be right, | |
| 23 | + | * by service binding and method. Cut off, their work is lost and starts | |
| 24 | + | * again on the next visit, so they get the time to finish. | |
| 25 | + | */ | |
| 26 | + | const SLOW_READS = new Set([ | |
| 27 | + | // A repository's first look runs its dependency scan inline (services/security/src/deps.rs). | |
| 28 | + | "security overview", | |
| 29 | + | "security dependency_graph", | |
| 30 | + | "security sbom", | |
| 31 | + | // Walks up to 400 commits, reading trees and blobs one after another (services/repos/src/blame.rs). | |
| 32 | + | "repos blame", | |
| 33 | + | // Up to 2,500 store reads per call (services/repos/src/last_commits.rs). | |
| 34 | + | "repos last_commits", | |
| 35 | + | ]); | |
| 36 | + | ||
| 37 | + | /** | |
| 38 | + | * Writes that run for minutes and still finish: copying a repository in, | |
| 39 | + | * syncing a mirror, a model writing a draft, a computer's home moved in or | |
| 40 | + | * out of storage, a scan over many repositories. | |
| 41 | + | */ | |
| 42 | + | const LONG_CALLS = new Set([ | |
| 43 | + | "repos create", | |
| 44 | + | "integrations github_import", | |
| 45 | + | "integrations mirror_add", | |
| 46 | + | "integrations mirror_sync", | |
| 47 | + | "integrations mirror_move_in", | |
| 48 | + | "integrations mirror_hand_back", | |
| 49 | + | "agents draft", | |
| 50 | + | "agents try_draft", | |
| 51 | + | "agents redraft", | |
| 52 | + | "agents draft_skill", | |
| 53 | + | "agents sync_skill_mirror", | |
| 54 | + | "agents computer_wake", | |
| 55 | + | "agents computer_sleep", | |
| 56 | + | "security dry_run_pattern", | |
| 57 | + | "security rescan", | |
| 58 | + | "security check_updates", | |
| 59 | + | "artifacts add_repo_space", | |
| 60 | + | ]); | |
| 61 | + | ||
| 62 | + | /** How long a call to `service`'s `method` may take; `writes` as lib/perf.ts `mayWrite` says. */ | |
| 63 | + | export function deadlineFor(service: string, method: string, writes: boolean): number { | |
| 64 | + | const call = `${service} ${method}`; | |
| 65 | + | if (LONG_CALLS.has(call)) return LONG_DEADLINE_MS; | |
| 66 | + | if (SLOW_READS.has(call)) return SLOW_READ_DEADLINE_MS; | |
| 67 | + | return writes ? WRITE_DEADLINE_MS : READ_DEADLINE_MS; | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /** What a call past its deadline throws. */ | |
| 71 | + | export class DeadlineExceeded extends Error { | |
| 72 | + | constructor(what: string, ms: number) { | |
| 73 | + | super(`${what} did not answer within ${ms}ms`); | |
| 74 | + | this.name = "DeadlineExceeded"; | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /** | |
| 79 | + | * `run`'s answer, or a `DeadlineExceeded` after `ms`. `run` is handed a | |
| 80 | + | * signal that aborts at the deadline, so the call it makes is cancelled | |
| 81 | + | * too; `outer`, when given, aborts it sooner. Whatever `run` reads must be | |
| 82 | + | * read inside it: the deadline ends when it returns. | |
| 83 | + | */ | |
| 84 | + | export async function withDeadline<T>( | |
| 85 | + | what: string, | |
| 86 | + | ms: number, | |
| 87 | + | run: (signal: AbortSignal) => Promise<T>, | |
| 88 | + | outer?: AbortSignal | null, | |
| 89 | + | ): Promise<T> { | |
| 90 | + | const controller = new AbortController(); | |
| 91 | + | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 92 | + | const expired = new Promise<never>((_, reject) => { | |
| 93 | + | timer = setTimeout(() => { | |
| 94 | + | const error = new DeadlineExceeded(what, ms); | |
| 95 | + | controller.abort(error); | |
| 96 | + | reject(error); | |
| 97 | + | }, ms); | |
| 98 | + | }); | |
| 99 | + | const stop = () => controller.abort(outer?.reason); | |
| 100 | + | if (outer?.aborted) stop(); | |
| 101 | + | else outer?.addEventListener("abort", stop, { once: true }); | |
| 102 | + | try { | |
| 103 | + | // Raced as well as aborted: a binding that ignores the signal still lets go. | |
| 104 | + | return await Promise.race([run(controller.signal), expired]); | |
| 105 | + | } finally { | |
| 106 | + | clearTimeout(timer); | |
| 107 | + | outer?.removeEventListener("abort", stop); | |
| 108 | + | } | |
| 109 | + | } |
| 18 | 18 | sessionFor, | |
| 19 | 19 | SESSION_SERVICES, | |
| 20 | 20 | } from "./perf"; | |
| 21 | + | import { deadlineFor, withDeadline } from "./deadline"; | |
| 21 | 22 | ||
| 22 | 23 | /** | |
| 23 | 24 | * What one request to the site did: when it started, each service call | |
| ⋯ | |||
| 71 | 72 | return { | |
| 72 | 73 | async fetch(input: string, init?: RequestInit) { | |
| 73 | 74 | const perf = scope.getStore(); | |
| 74 | − | if (!perf) return binding.fetch(input, init); | |
| 75 | + | const method = rpcMethodOf(input); | |
| 76 | + | if (!perf) return answerInTime(binding, name, method, input, init); | |
| 75 | 77 | const session = sessionFor(name, perf.bookmarks, perf.writing, Math.floor(Date.now() / 1000)); | |
| 76 | 78 | let sent = init; | |
| 77 | 79 | if (session) { | |
| ⋯ | |||
| 80 | 82 | sent = { ...init, headers }; | |
| 81 | 83 | perf.sessions.set(name, session.startsWith("first-") ? session.slice(6) : "bookmark"); | |
| 82 | 84 | } | |
| 83 | − | if (SESSION_SERVICES.has(name) && mayWrite(rpcMethodOf(input))) perf.wrote = true; | |
| 85 | + | if (SESSION_SERVICES.has(name) && mayWrite(method)) perf.wrote = true; | |
| 84 | 86 | const from = Date.now(); | |
| 85 | − | const response = await binding.fetch(input, sent); | |
| 87 | + | let response: Response; | |
| 88 | + | try { | |
| 89 | + | response = await answerInTime(binding, name, method, input, sent); | |
| 90 | + | } finally { | |
| 91 | + | // A call that failed or ran out of time still took the page's time. | |
| 92 | + | perf.intervals.push([from, Date.now()]); | |
| 93 | + | } | |
| 86 | 94 | const to = Date.now(); | |
| 87 | − | perf.intervals.push([from, to]); | |
| 88 | 95 | const timing = (perf.services[name] ??= { calls: 0, wallMs: 0, serviceMs: 0 }); | |
| 89 | 96 | timing.calls += 1; | |
| 90 | 97 | timing.wallMs += to - from; | |
| ⋯ | |||
| 103 | 110 | } | |
| 104 | 111 | ||
| 105 | 112 | /** | |
| 113 | + | * One call to `binding`, answered in full (body read) within its deadline | |
| 114 | + | * (lib/deadline.ts): a service that stops answering fails the call rather | |
| 115 | + | * than holding the page open. RPC answers are small JSON, so reading the | |
| 116 | + | * body here costs nothing and puts it under the deadline too. | |
| 117 | + | */ | |
| 118 | + | async function answerInTime(binding: ServiceBinding, name: string, method: string, input: string, init?: RequestInit): Promise<Response> { | |
| 119 | + | const ms = deadlineFor(name, method, mayWrite(method)); | |
| 120 | + | return withDeadline( | |
| 121 | + | `${name} ${method || input}`, | |
| 122 | + | ms, | |
| 123 | + | async (signal) => { | |
| 124 | + | const response = await binding.fetch(input, { ...init, signal }); | |
| 125 | + | const body = await response.arrayBuffer(); | |
| 126 | + | // A 204 or 304 may not be rebuilt with a body, even an empty one. | |
| 127 | + | return new Response(body.byteLength > 0 ? body : null, response); | |
| 128 | + | }, | |
| 129 | + | init?.signal, | |
| 130 | + | ); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /** | |
| 106 | 134 | * Whether this request must read current data: it writes, or the person | |
| 107 | 135 | * wrote moments ago (lib/perf.ts `PRIMARY_WINDOW_SECONDS`). Caches step | |
| 108 | 136 | * aside then (lib/cache.server.ts). | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { stuckTarget } from "./stuck.ts"; | |
| 5 | + | ||
| 6 | + | const location = { pathname: "/flagon-io/g1t/pulls", search: "?state=closed", hash: "#top" }; | |
| 7 | + | ||
| 8 | + | test("an idle page has nothing to retry", () => { | |
| 9 | + | assert.equal(stuckTarget({ state: "idle" }), null); | |
| 10 | + | }); | |
| 11 | + | ||
| 12 | + | test("a navigation still loading is retried at its full address", () => { | |
| 13 | + | assert.equal(stuckTarget({ state: "loading", location }), "/flagon-io/g1t/pulls?state=closed#top"); | |
| 14 | + | }); | |
| 15 | + | ||
| 16 | + | test("a form still being sent is never sent again", () => { | |
| 17 | + | assert.equal(stuckTarget({ state: "submitting", location }), null); | |
| 18 | + | }); |
| 1 | + | import { useEffect } from "react"; | |
| 2 | + | import { type Location, createPath, useNavigation } from "react-router"; | |
| 3 | + | ||
| 4 | + | /** | |
| 5 | + | * How long a client navigation may load before the page gives up on it and | |
| 6 | + | * loads the address as a whole page. Longer than any read may take on the | |
| 7 | + | * server (lib/deadline.ts `SLOW_READ_DEADLINE_MS`): a slow page that would | |
| 8 | + | * arrive is never cut off and started again, and a stuck service shows its | |
| 9 | + | * error page first. This catches what the server cannot: a request lost on | |
| 10 | + | * the way, a connection that went quiet, a route's code that never arrived. | |
| 11 | + | */ | |
| 12 | + | export const STUCK_NAVIGATION_MS = 75_000; | |
| 13 | + | ||
| 14 | + | /** | |
| 15 | + | * Where to send a navigation that has been loading too long, or null when | |
| 16 | + | * there is nothing safe to retry. Only "loading" is retried: it reads, even | |
| 17 | + | * after a form was posted (the action already answered and the page is | |
| 18 | + | * reading what it changed). "submitting" is a write still on its way, and | |
| 19 | + | * sending it again could do it twice. | |
| 20 | + | */ | |
| 21 | + | export function stuckTarget(navigation: { state: string; location?: Pick<Location, "pathname" | "search" | "hash"> | null }): string | null { | |
| 22 | + | if (navigation.state !== "loading" || !navigation.location) return null; | |
| 23 | + | return createPath(navigation.location); | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /** | |
| 27 | + | * Never leaves the page on its loading bar: a navigation still loading | |
| 28 | + | * after `STUCK_NAVIGATION_MS` is finished as a document load of the same | |
| 29 | + | * address, which either arrives or shows the server's error page. | |
| 30 | + | */ | |
| 31 | + | export function useStuckNavigationRescue(ms = STUCK_NAVIGATION_MS) { | |
| 32 | + | const target = stuckTarget(useNavigation()); | |
| 33 | + | useEffect(() => { | |
| 34 | + | if (!target) return; | |
| 35 | + | const timer = window.setTimeout(() => { | |
| 36 | + | console.warn(`g1t: navigation to ${target} still loading after ${ms}ms; loading it as a whole page`); | |
| 37 | + | window.location.assign(target); | |
| 38 | + | }, ms); | |
| 39 | + | return () => window.clearTimeout(timer); | |
| 40 | + | }, [target, ms]); | |
| 41 | + | } |
| 49 | 49 | import { addresses } from "./lib/addresses.server"; | |
| 50 | 50 | import { RELOADED_KEY, RELOAD_GIVE_UP_MS, clientNavigated, reloadFixes, reloadedBefore } from "./lib/stale-build"; | |
| 51 | 51 | import { useNonce } from "./lib/nonce"; | |
| 52 | + | import { useStuckNavigationRescue } from "./lib/stuck"; | |
| 52 | 53 | import { isNeedsSignIn } from "./lib/website-token"; | |
| 53 | 54 | import { offerTicket, setLiveViaToken } from "./lib/live-socket"; | |
| 54 | 55 | import { socketTicketFor } from "./lib/socket-ticket.server"; | |
| ⋯ | |||
| 476 | 477 | identify(userId, lastUserId.current); | |
| 477 | 478 | lastUserId.current = userId; | |
| 478 | 479 | }, [userId]); | |
| 480 | + | // A navigation that never arrives is loaded as a whole page (lib/stuck.ts). | |
| 481 | + | useStuckNavigationRescue(); | |
| 479 | 482 | return <Outlet />; | |
| 480 | 483 | } | |
| 481 | 484 | ||
| 7 | 7 | import { hardenRegistryHeaders } from "../app/lib/content-safety"; | |
| 8 | 8 | import { withSiteHeaders } from "../app/lib/page-headers"; | |
| 9 | 9 | import { finishResponse, withRequestPerf } from "../app/lib/perf.server"; | |
| 10 | + | import { READ_DEADLINE_MS, withDeadline } from "../app/lib/deadline"; | |
| 10 | 11 | import { gitLimited, pageLimited, usercontentLimited } from "../app/lib/front-door-limits"; | |
| 11 | 12 | import { goImport } from "../app/lib/go-get"; | |
| 12 | 13 | import { repositoryOfPage, stillPublic } from "../app/lib/public-cache"; | |
| ⋯ | |||
| 125 | 126 | /** Fresh for this long; then served once more while a new copy is made. */ | |
| 126 | 127 | const PUBLIC_FRESH_SECONDS = 30; | |
| 127 | 128 | const PUBLIC_STALE_SECONDS = 300; | |
| 129 | + | /** The longest a cache lookup holds up a public page. */ | |
| 130 | + | const CACHE_DEADLINE_MS = 2_000; | |
| 128 | 131 | ||
| 129 | 132 | function anonymousPage(request: Request, pathname: string): boolean { | |
| 130 | 133 | if (request.method !== "GET") return false; | |
| ⋯ | |||
| 142 | 145 | const key = new Request(keyUrl, { method: "GET" }); | |
| 143 | 146 | const repository = PUBLIC_PROJECT.test(new URL(request.url).pathname) ? repositoryOfPage(new URL(request.url).pathname) : null; | |
| 144 | 147 | // Asked alongside the cache, so a hit waits for one indexed read at most. | |
| 145 | − | const [cached, visible] = await Promise.all([cache.match(key), repository ? isStillPublic(env, repository) : Promise.resolve(true)]); | |
| 148 | + | // A cache that does not answer is a miss: the page is drawn afresh. | |
| 149 | + | const [cached, visible] = await Promise.all([ | |
| 150 | + | withDeadline("cache", CACHE_DEADLINE_MS, () => cache.match(key)).catch(() => undefined), | |
| 151 | + | repository ? isStillPublic(env, repository) : Promise.resolve(true), | |
| 152 | + | ]); | |
| 146 | 153 | if (cached && !visible) { | |
| 147 | 154 | ctx.waitUntil(cache.delete(key).then(() => undefined, () => undefined)); | |
| 148 | 155 | return render(); | |
| ⋯ | |||
| 179 | 186 | /** Whether the repository is there and public; anything else, including no answer, is no. */ | |
| 180 | 187 | async function isStillPublic(env: Env, repository: string): Promise<boolean> { | |
| 181 | 188 | try { | |
| 182 | − | const answer = await env.REPOS.fetch("https://service/rpc/visibility", { | |
| 183 | − | method: "POST", | |
| 184 | − | headers: { "content-type": "application/json" }, | |
| 185 | − | body: JSON.stringify({ paths: [repository] }), | |
| 189 | + | return await withDeadline("repos visibility", READ_DEADLINE_MS, async (signal) => { | |
| 190 | + | const answer = await env.REPOS.fetch("https://service/rpc/visibility", { | |
| 191 | + | method: "POST", | |
| 192 | + | headers: { "content-type": "application/json" }, | |
| 193 | + | body: JSON.stringify({ paths: [repository] }), | |
| 194 | + | signal, | |
| 195 | + | }); | |
| 196 | + | return answer.ok && stillPublic(await answer.json(), repository); | |
| 186 | 197 | }); | |
| 187 | − | return answer.ok && stillPublic(await answer.json(), repository); | |
| 188 | 198 | } catch { | |
| 189 | 199 | return false; | |
| 190 | 200 | } | |