| author | |
| committer | |
| log | 2288d416aa3bcb5f2eeb4004228872593296971e |
| tree | 6895c05065c9deb643b8c8494fa76bd22fa781fa |
| parent | ddfe45c66eccd93c0adf61db92a16cbbbcde23e1 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>4 files changed, 62 insertions(+), 9 deletions(-)
apps/web/src/components/SlowRpcRequestToastCoordinator.tsx+4-1| ... | ... | @@ -5,7 +5,10 @@ import { toastManager } from "./ui/toast"; |
| 5 | 5 | |
| 6 | 6 | function describeSlowRequests(requests: ReadonlyArray<SlowRpcAckRequest>): string { |
| 7 | 7 | const count = requests.length; |
| 8 | const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); | |
| 8 | // Thresholds vary per method, so report the smallest one the batch has passed. | |
| 9 | const thresholdSeconds = Math.round( | |
| 10 | Math.min(...requests.map((request) => request.thresholdMs)) / 1000, | |
| 11 | ); | |
| 9 | 12 | |
| 10 | 13 | return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; |
| 11 | 14 | } |
apps/web/src/connection/platform.ts+1-1| ... | ... | @@ -590,7 +590,7 @@ const rpcRequestObserverLayer = Layer.succeed( |
| 590 | 590 | Effect.sync(() => { |
| 591 | 591 | nextObservedRpcRequestId += 1; |
| 592 | 592 | const requestId = `${environmentId}:${nextObservedRpcRequestId}`; |
| 593 | trackRpcRequestSent(requestId, `${method} · ${environmentId}`); | |
| 593 | trackRpcRequestSent(requestId, method, `${method} · ${environmentId}`); | |
| 594 | 594 | return Effect.sync(() => { |
| 595 | 595 | acknowledgeRpcRequest(requestId); |
| 596 | 596 | }); |
apps/web/src/rpc/requestLatencyState.test.ts+27| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | getSlowRpcAckRequests, |
| 7 | 7 | resetRequestLatencyStateForTests, |
| 8 | 8 | trackRpcRequestSent, |
| 9 | LONG_RUNNING_RPC_ACK_THRESHOLD_MS, | |
| 9 | 10 | SLOW_RPC_ACK_THRESHOLD_MS, |
| 10 | 11 | MAX_TRACKED_RPC_ACK_REQUESTS, |
| 11 | 12 | } from "./requestLatencyState"; |
| ... | ... | @@ -58,6 +59,32 @@ describe("requestLatencyState", () => { |
| 58 | 59 | expect(getSlowRpcAckRequests()).toEqual([]); |
| 59 | 60 | }); |
| 60 | 61 | |
| 62 | it("keeps ignoring untracked methods when a display tag is supplied", () => { | |
| 63 | trackRpcRequestSent( | |
| 64 | "1", | |
| 65 | WS_METHODS.previewAutomationConnect, | |
| 66 | `${WS_METHODS.previewAutomationConnect} · env-1`, | |
| 67 | ); | |
| 68 | vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); | |
| 69 | ||
| 70 | expect(getSlowRpcAckRequests()).toEqual([]); | |
| 71 | }); | |
| 72 | ||
| 73 | it("gives provider updates a longer threshold before warning", () => { | |
| 74 | trackRpcRequestSent("1", WS_METHODS.serverUpdateProvider, "server.updateProvider · env-1"); | |
| 75 | vi.advanceTimersByTime(LONG_RUNNING_RPC_ACK_THRESHOLD_MS - 1); | |
| 76 | expect(getSlowRpcAckRequests()).toEqual([]); | |
| 77 | ||
| 78 | vi.advanceTimersByTime(1); | |
| 79 | expect(getSlowRpcAckRequests()).toMatchObject([ | |
| 80 | { | |
| 81 | requestId: "1", | |
| 82 | tag: "server.updateProvider · env-1", | |
| 83 | thresholdMs: LONG_RUNNING_RPC_ACK_THRESHOLD_MS, | |
| 84 | }, | |
| 85 | ]); | |
| 86 | }); | |
| 87 | ||
| 61 | 88 | it("evicts the oldest pending requests once the tracker reaches capacity", () => { |
| 62 | 89 | for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) { |
| 63 | 90 | trackRpcRequestSent(String(index), "server.getConfig"); |
apps/web/src/rpc/requestLatencyState.ts+30-7| ... | ... | @@ -5,6 +5,12 @@ import { Atom } from "effect/unstable/reactivity"; |
| 5 | 5 | import { appAtomRegistry } from "./atomRegistry"; |
| 6 | 6 | |
| 7 | 7 | export const SLOW_RPC_ACK_THRESHOLD_MS = 15_000; |
| 8 | /** | |
| 9 | * Some requests are slow by design — they shell out to a package manager on the | |
| 10 | * server and only respond once the install finishes. Warning about those after | |
| 11 | * 15s is noise, so they get a much longer leash. | |
| 12 | */ | |
| 13 | export const LONG_RUNNING_RPC_ACK_THRESHOLD_MS = 120_000; | |
| 8 | 14 | export const MAX_TRACKED_RPC_ACK_REQUESTS = 256; |
| 9 | 15 | let slowRpcAckThresholdMs = SLOW_RPC_ACK_THRESHOLD_MS; |
| 10 | 16 | |
| ... | ... | @@ -22,7 +28,12 @@ interface PendingRpcAckRequest { |
| 22 | 28 | } |
| 23 | 29 | |
| 24 | 30 | const pendingRpcAckRequests = new Map<string, PendingRpcAckRequest>(); |
| 25 | const untrackedRpcAckTags = new Set<string>([WS_METHODS.previewAutomationConnect]); | |
| 31 | const untrackedRpcAckMethods = new Set<string>([WS_METHODS.previewAutomationConnect]); | |
| 32 | const longRunningRpcAckMethods = new Set<string>([ | |
| 33 | WS_METHODS.serverUpdateProvider, | |
| 34 | WS_METHODS.serverRefreshProviders, | |
| 35 | WS_METHODS.serverUpdateServer, | |
| 36 | ]); | |
| 26 | 37 | |
| 27 | 38 | const slowRpcAckRequestsAtom = Atom.make<ReadonlyArray<SlowRpcAckRequest>>([]).pipe( |
| 28 | 39 | Atom.keepAlive, |
| ... | ... | @@ -37,16 +48,27 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray<SlowRpcAckRequest> { |
| 37 | 48 | return appAtomRegistry.get(slowRpcAckRequestsAtom); |
| 38 | 49 | } |
| 39 | 50 | |
| 40 | function shouldTrackRpcAck(tag: string): boolean { | |
| 41 | return !tag.includes("subscribe") && !untrackedRpcAckTags.has(tag); | |
| 51 | function shouldTrackRpcAck(method: string): boolean { | |
| 52 | return !method.includes("subscribe") && !untrackedRpcAckMethods.has(method); | |
| 53 | } | |
| 54 | ||
| 55 | function rpcAckThresholdMs(method: string): number { | |
| 56 | return longRunningRpcAckMethods.has(method) | |
| 57 | ? Math.max(slowRpcAckThresholdMs, LONG_RUNNING_RPC_ACK_THRESHOLD_MS) | |
| 58 | : slowRpcAckThresholdMs; | |
| 42 | 59 | } |
| 43 | 60 | |
| 44 | 61 | export function getSlowRpcAckRequests(): ReadonlyArray<SlowRpcAckRequest> { |
| 45 | 62 | return getSlowRpcAckRequestsValue(); |
| 46 | 63 | } |
| 47 | 64 | |
| 48 | export function trackRpcRequestSent(requestId: string, tag: string): void { | |
| 49 | if (!shouldTrackRpcAck(tag)) { | |
| 65 | /** | |
| 66 | * Starts the slow-request timer for one in-flight unary RPC. `method` is the | |
| 67 | * bare WS method (used to decide whether and how long to wait); `tag` is the | |
| 68 | * human-readable label shown in the toast, which defaults to the method. | |
| 69 | */ | |
| 70 | export function trackRpcRequestSent(requestId: string, method: string, tag = method): void { | |
| 71 | if (!shouldTrackRpcAck(method)) { | |
| 50 | 72 | return; |
| 51 | 73 | } |
| 52 | 74 | |
| ... | ... | @@ -54,17 +76,18 @@ export function trackRpcRequestSent(requestId: string, tag: string): void { |
| 54 | 76 | evictOldestPendingRpcRequestIfNeeded(); |
| 55 | 77 | |
| 56 | 78 | const startedAtMs = Date.now(); |
| 79 | const thresholdMs = rpcAckThresholdMs(method); | |
| 57 | 80 | const request: SlowRpcAckRequest = { |
| 58 | 81 | requestId, |
| 59 | 82 | startedAt: new Date(startedAtMs).toISOString(), |
| 60 | 83 | startedAtMs, |
| 61 | 84 | tag, |
| 62 | thresholdMs: slowRpcAckThresholdMs, | |
| 85 | thresholdMs, | |
| 63 | 86 | }; |
| 64 | 87 | const timeoutId = setTimeout(() => { |
| 65 | 88 | pendingRpcAckRequests.delete(requestId); |
| 66 | 89 | appendSlowRpcAckRequest(request); |
| 67 | }, slowRpcAckThresholdMs); | |
| 90 | }, thresholdMs); | |
| 68 | 91 | |
| 69 | 92 | pendingRpcAckRequests.set(requestId, { |
| 70 | 93 | request, |