Skip to content
Merged
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
5 changes: 4 additions & 1 deletion apps/web/src/components/SlowRpcRequestToastCoordinator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { toastManager } from "./ui/toast";

function describeSlowRequests(requests: ReadonlyArray<SlowRpcAckRequest>): string {
const count = requests.length;
const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000);
// Thresholds vary per method, so report the smallest one the batch has passed.
const thresholdSeconds = Math.round(
Math.min(...requests.map((request) => request.thresholdMs)) / 1000,
);

return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ const rpcRequestObserverLayer = Layer.succeed(
Effect.sync(() => {
nextObservedRpcRequestId += 1;
const requestId = `${environmentId}:${nextObservedRpcRequestId}`;
trackRpcRequestSent(requestId, `${method} · ${environmentId}`);
trackRpcRequestSent(requestId, method, `${method} · ${environmentId}`);
return Effect.sync(() => {
acknowledgeRpcRequest(requestId);
});
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/rpc/requestLatencyState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getSlowRpcAckRequests,
resetRequestLatencyStateForTests,
trackRpcRequestSent,
LONG_RUNNING_RPC_ACK_THRESHOLD_MS,
SLOW_RPC_ACK_THRESHOLD_MS,
MAX_TRACKED_RPC_ACK_REQUESTS,
} from "./requestLatencyState";
Expand Down Expand Up @@ -58,6 +59,32 @@ describe("requestLatencyState", () => {
expect(getSlowRpcAckRequests()).toEqual([]);
});

it("keeps ignoring untracked methods when a display tag is supplied", () => {
trackRpcRequestSent(
"1",
WS_METHODS.previewAutomationConnect,
`${WS_METHODS.previewAutomationConnect} · env-1`,
);
vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2);

expect(getSlowRpcAckRequests()).toEqual([]);
});

it("gives provider updates a longer threshold before warning", () => {
trackRpcRequestSent("1", WS_METHODS.serverUpdateProvider, "server.updateProvider · env-1");
vi.advanceTimersByTime(LONG_RUNNING_RPC_ACK_THRESHOLD_MS - 1);
expect(getSlowRpcAckRequests()).toEqual([]);

vi.advanceTimersByTime(1);
expect(getSlowRpcAckRequests()).toMatchObject([
{
requestId: "1",
tag: "server.updateProvider · env-1",
thresholdMs: LONG_RUNNING_RPC_ACK_THRESHOLD_MS,
},
]);
});

it("evicts the oldest pending requests once the tracker reaches capacity", () => {
for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) {
trackRpcRequestSent(String(index), "server.getConfig");
Expand Down
37 changes: 30 additions & 7 deletions apps/web/src/rpc/requestLatencyState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { Atom } from "effect/unstable/reactivity";
import { appAtomRegistry } from "./atomRegistry";

export const SLOW_RPC_ACK_THRESHOLD_MS = 15_000;
/**
* Some requests are slow by design — they shell out to a package manager on the
* server and only respond once the install finishes. Warning about those after
* 15s is noise, so they get a much longer leash.
*/
export const LONG_RUNNING_RPC_ACK_THRESHOLD_MS = 120_000;
export const MAX_TRACKED_RPC_ACK_REQUESTS = 256;
let slowRpcAckThresholdMs = SLOW_RPC_ACK_THRESHOLD_MS;

Expand All @@ -22,7 +28,12 @@ interface PendingRpcAckRequest {
}

const pendingRpcAckRequests = new Map<string, PendingRpcAckRequest>();
const untrackedRpcAckTags = new Set<string>([WS_METHODS.previewAutomationConnect]);
const untrackedRpcAckMethods = new Set<string>([WS_METHODS.previewAutomationConnect]);
const longRunningRpcAckMethods = new Set<string>([
WS_METHODS.serverUpdateProvider,
WS_METHODS.serverRefreshProviders,
WS_METHODS.serverUpdateServer,
]);

const slowRpcAckRequestsAtom = Atom.make<ReadonlyArray<SlowRpcAckRequest>>([]).pipe(
Atom.keepAlive,
Expand All @@ -37,34 +48,46 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray<SlowRpcAckRequest> {
return appAtomRegistry.get(slowRpcAckRequestsAtom);
}

function shouldTrackRpcAck(tag: string): boolean {
return !tag.includes("subscribe") && !untrackedRpcAckTags.has(tag);
function shouldTrackRpcAck(method: string): boolean {
return !method.includes("subscribe") && !untrackedRpcAckMethods.has(method);
}

function rpcAckThresholdMs(method: string): number {
return longRunningRpcAckMethods.has(method)
? Math.max(slowRpcAckThresholdMs, LONG_RUNNING_RPC_ACK_THRESHOLD_MS)
: slowRpcAckThresholdMs;
}

export function getSlowRpcAckRequests(): ReadonlyArray<SlowRpcAckRequest> {
return getSlowRpcAckRequestsValue();
}

export function trackRpcRequestSent(requestId: string, tag: string): void {
if (!shouldTrackRpcAck(tag)) {
/**
* Starts the slow-request timer for one in-flight unary RPC. `method` is the
* bare WS method (used to decide whether and how long to wait); `tag` is the
* human-readable label shown in the toast, which defaults to the method.
*/
export function trackRpcRequestSent(requestId: string, method: string, tag = method): void {
if (!shouldTrackRpcAck(method)) {
return;
}

clearTrackedRpcRequest(requestId);
evictOldestPendingRpcRequestIfNeeded();

const startedAtMs = Date.now();
const thresholdMs = rpcAckThresholdMs(method);
const request: SlowRpcAckRequest = {
requestId,
startedAt: new Date(startedAtMs).toISOString(),
startedAtMs,
tag,
thresholdMs: slowRpcAckThresholdMs,
thresholdMs,
};
const timeoutId = setTimeout(() => {
pendingRpcAckRequests.delete(requestId);
appendSlowRpcAckRequest(request);
}, slowRpcAckThresholdMs);
}, thresholdMs);

pendingRpcAckRequests.set(requestId, {
request,
Expand Down
Loading