History

Refresh connection health after OAuth reconnect (#1545)

* e2e: repro stale health verdict after OAuth reconnect * Refresh connection health after OAuth reconnect * Add changeset * Prevent server crash from unobserved MCP fetch rejection; merge health verdicts by freshness * Lint suppression for the adapter catch * Retrigger CI

Rhys Sullivan committed 3 months ago

Commit df62bb31 parent 4fae8515 files · +307 −39Browse files

Unified · Split

5 files
  1. .changeset/reconnect-health-refresh.md +7 −0
  2. e2e/selfhost/mcp-oauth-reconnect-health.test.ts +219 −17
  3. packages/core/sdk/src/executor.ts +5 −0
  4. packages/plugins/mcp/src/sdk/connection.ts +9 −0
  5. packages/react/src/lib/use-connection-health.ts +67 −22
.changeset/reconnect-health-refresh.md+7−0
@@ -0,0 +1,7 @@
1---
2"executor": patch
3---
4
5**Fix: reconnecting an OAuth connection now refreshes its health status in place — no page reload needed**
6
7Completing a reconnect previously left the stale "Expired" verdict on the connection row (and the integrations-list summary) until a hard refresh. Re-minting now clears the persisted verdict, and the UI re-probes as soon as the refreshed connection arrives.
e2e/selfhost/mcp-oauth-reconnect-health.test.ts+219−17
@@ -1,6 +1,9 @@
1// Selfhost repros for two MCP OAuth bugs seen with a DCR connection whose1// Selfhost repros for MCP OAuth bugs seen with a DCR connection whose
2// refresh token is rejected by the provider as `invalid_grant`.2// refresh token is rejected by the provider as `invalid_grant`, including the
3// reconnect journey: completing Reconnect must refresh the health verdict on
4// the page without a hard reload.
3import { randomBytes } from "node:crypto";5import { randomBytes } from "node:crypto";
6import { createServer } from "node:http";
4 7
5import { Effect } from "effect";8import { Effect } from "effect";
6import { expect } from "@effect/vitest";9import { expect } from "@effect/vitest";
@@ -48,31 +51,136 @@ const requiredRedirect = (response: Response, from: string): string => { · 41 unmodified lines
48 return new URL(location, from).toString();51 return new URL(location, from).toString();
49};52};
50 53
54/** The test server's login page is plain text with Basic-auth POST — nothing a
55 * browser can click. Complete it out of band and hand back the callback URL. */
56const submitProviderLogin = async (loginUrl: string): Promise<string> => {
57 const credentials = Buffer.from("alice:password").toString("base64");
58 const response = await fetch(loginUrl, {
59 method: "POST",
60 redirect: "manual",
61 headers: { authorization: `Basic ${credentials}` },
62 });
63 const location = response.headers.get("location");
64 if (response.status !== 302 || !location) {
65 throw new Error(`provider login did not redirect (${response.status})`);
66 }
67 return new URL(location, loginUrl).toString();
68};
69
51const completeAuthorization = (authorizationUrl: string) =>70const completeAuthorization = (authorizationUrl: string) =>
52 Effect.promise(async () => {71 Effect.promise(async () => {
53 const login = await fetch(authorizationUrl, { redirect: "manual" });72 const login = await fetch(authorizationUrl, { redirect: "manual" });
54 const loginUrl = requiredRedirect(login, authorizationUrl);73 const loginUrl = requiredRedirect(login, authorizationUrl);
55 const credentials = Buffer.from("alice:password").toString("base64");74 const callbackUrl = await submitProviderLogin(loginUrl);
56 const callback = await fetch(loginUrl, {
57 method: "POST",
58 headers: { authorization: `Basic ${credentials}` },
59 redirect: "manual",
60 });
61 const callbackUrl = requiredRedirect(callback, loginUrl);
62 const parsed = new URL(callbackUrl);75 const parsed = new URL(callbackUrl);
63 const code = parsed.searchParams.get("code");76 const code = parsed.searchParams.get("code");
64 if (!code) throw new Error(`OAuth callback did not include a code: ${callbackUrl}`);77 if (!code) throw new Error(`OAuth callback did not include a code: ${callbackUrl}`);
65 return { code };78 return { code };
66 });79 });
67 80
68const seedExpiredDcrMcpOAuthConnection = (client: Client, prefix: string) =>81/** AS whose refresh grants are dead forever — every token it mints is already
82 * expired and refresh is rejected as `invalid_grant`. */
83const serveDeadGrantOAuthServer = () =>
84 serveOAuthTestServer({
85 scopes: ["channels:history", "users:read"],
86 supportRefresh: false,
87 tokenExpiresInSeconds: 0,
88 invalidRefreshTokenDescription: "Grant not found",
89 });
90
91interface GrantRevocationGate {
92 /** Token endpoint to register with executor instead of the real one. */
93 readonly tokenUrl: string;
94 /** The provider comes back: minted tokens get their real lifetime and
95 * refresh grants are honored again. */
96 readonly restore: () => void;
97 readonly refreshRejections: () => number;
98 readonly close: () => void;
99}
100
101/** Token-endpoint proxy in front of the test AS. While "revoked" it behaves
102 * like a provider whose grants are dead — authorization_code exchanges still
103 * succeed but mint already-expired tokens, and refresh grants are rejected
104 * with `invalid_grant` — and after `restore()` it is a plain passthrough. The
105 * test server itself cannot flip behavior after construction, and this repro
106 * needs "expired now, healthy after a fresh reconnect". */
107const serveGrantRevocationGate = (upstreamTokenUrl: string) =>
108 Effect.acquireRelease(
109 Effect.callback<GrantRevocationGate>((resume) => {
110 let revoked = true;
111 let refreshRejections = 0;
112 const server = createServer((request, response) => {
113 const chunks: Buffer[] = [];
114 request.on("data", (chunk: Buffer) => chunks.push(chunk));
115 request.on("end", () => {
116 const body = Buffer.concat(chunks).toString("utf8");
117 if (revoked && new URLSearchParams(body).get("grant_type") === "refresh_token") {
118 refreshRejections += 1;
119 response.writeHead(400, { "content-type": "application/json" });
120 response.end(
121 JSON.stringify({ error: "invalid_grant", error_description: "Grant not found" }),
122 );
123 return;
124 }
125 fetch(upstreamTokenUrl, {
126 method: "POST",
127 headers: {
128 "content-type":
129 request.headers["content-type"] ?? "application/x-www-form-urlencoded",
130 ...(request.headers.authorization
131 ? { authorization: request.headers.authorization }
132 : {}),
133 },
134 body,
135 })
136 .then(async (upstream) => {
137 const text = await upstream.text();
138 if (revoked && upstream.ok) {
139 const parsed = JSON.parse(text) as Record<string, unknown>;
140 parsed["expires_in"] = 0;
141 response.writeHead(upstream.status, { "content-type": "application/json" });
142 response.end(JSON.stringify(parsed));
143 return;
144 }
145 response.writeHead(upstream.status, {
146 "content-type": upstream.headers.get("content-type") ?? "application/json",
147 });
148 response.end(text);
149 })
150 .catch(() => {
151 response.writeHead(502, { "content-type": "application/json" });
152 response.end(JSON.stringify({ error: "bad_gateway" }));
153 });
154 });
155 });
156 server.listen(0, "127.0.0.1", () => {
157 const address = server.address();
158 const port = typeof address === "object" && address !== null ? address.port : 0;
159 resume(
160 Effect.succeed({
161 tokenUrl: `http://127.0.0.1:${port}/token`,
162 restore: () => {
163 revoked = false;
164 },
165 refreshRejections: () => refreshRejections,
166 close: () => {
167 server.close();
168 server.closeAllConnections();
169 },
170 }),
171 );
172 });
173 }),
174 (gate) => Effect.sync(gate.close),
175 );
176
177const seedDcrMcpOAuthConnection = (
178 client: Client,
179 prefix: string,
180 oauth: OAuthTestServerShape,
181 options?: { readonly tokenUrl?: string },
182) =>
69 Effect.gen(function* () {183 Effect.gen(function* () {
70 const oauth = yield* serveOAuthTestServer({
71 scopes: ["channels:history", "users:read"],
72 supportRefresh: false,
73 tokenExpiresInSeconds: 0,
74 invalidRefreshTokenDescription: "Grant not found",
75 });
76 const slug = IntegrationSlug.make(freshSlug(prefix));184 const slug = IntegrationSlug.make(freshSlug(prefix));
77 const clientSlug = OAuthClientSlug.make(freshSlug(`${prefix}-client`));185 const clientSlug = OAuthClientSlug.make(freshSlug(`${prefix}-client`));
78 186
@@ -101,7 +209,7 @@ const seedExpiredDcrMcpOAuthConnection = (client: Client, prefix: string) => · 22 unmodified lines
101 issuer: probe.issuer ?? null,209 issuer: probe.issuer ?? null,
102 registrationEndpoint: probe.registrationEndpoint,210 registrationEndpoint: probe.registrationEndpoint,
103 authorizationUrl: probe.authorizationUrl,211 authorizationUrl: probe.authorizationUrl,
104 tokenUrl: probe.tokenUrl,212 tokenUrl: options?.tokenUrl ?? probe.tokenUrl,
105 resource: probe.resource ?? oauth.mcpResourceUrl,213 resource: probe.resource ?? oauth.mcpResourceUrl,
106 scopes: probe.scopesSupported ?? [],214 scopes: probe.scopesSupported ?? [],
107 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,215 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
@@ -140,6 +248,12 @@ const seedExpiredDcrMcpOAuthConnection = (client: Client, prefix: string) => · 32 unmodified lines
140 return { oauth, slug };248 return { oauth, slug };
141 });249 });
142 250
251const seedExpiredDcrMcpOAuthConnection = (client: Client, prefix: string) =>
252 Effect.gen(function* () {
253 const oauth = yield* serveDeadGrantOAuthServer();
254 return yield* seedDcrMcpOAuthConnection(client, prefix, oauth);
255 });
256
143const logTokenRequests = (label: string, oauth: OAuthTestServerShape) =>257const logTokenRequests = (label: string, oauth: OAuthTestServerShape) =>
144 Effect.gen(function* () {258 Effect.gen(function* () {
145 const requests = yield* oauth.requests;259 const requests = yield* oauth.requests;
@@ -224,6 +338,94 @@ scenario( · 78 unmodified lines
224 ),338 ),
225);339);
226 340
341// The reconnect journey from the bug report: a connection reads Expired, the
342// user completes Reconnect through the OAuth popup, and the page must show the
343// recovered health WITHOUT a hard refresh. The gate makes the provider's
344// grants dead during seeding (already-expired tokens, refresh rejected) and
345// healthy again before the reconnect, so the only thing standing between the
346// user and a green dot is the UI updating itself.
347scenario(
348 "MCP OAuth · completed reconnect refreshes the health verdict without a page reload",
349 {
350 timeout: 240_000,
351 },
352 Effect.scoped(
353 Effect.gen(function* () {
354 const target = yield* Target;
355 const browser = yield* Browser;
356 const { client: makeApiClient } = yield* Api;
357 const identity = yield* target.newIdentity();
358 const client = yield* makeApiClient(api, identity);
359
360 const oauth = yield* serveOAuthTestServer({
361 scopes: ["channels:history", "users:read"],
362 });
363 const gate = yield* serveGrantRevocationGate(`${oauth.issuerUrl}/token`);
364 const { slug } = yield* seedDcrMcpOAuthConnection(client, "mcp-reconnect-live", oauth, {
365 tokenUrl: gate.tokenUrl,
366 });
367
368 // Persist the expired verdict exactly as the user's "Check now" would.
369 const seededHealth = yield* client.connections.checkHealth({
370 params: { owner: "org", integration: slug, name },
371 query: {},
372 });
373 expect(seededHealth.status, "the dead grant seeds an expired verdict").toBe("expired");
374 expect(gate.refreshRejections(), "the expiry came from a rejected refresh").toBeGreaterThan(
375 0,
376 );
377
378 yield* browser.session(identity, async ({ page, step }) => {
379 const connections = connectionsSection(page);
380 const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first();
381
382 await step("Open the integration: the connection reads Expired", async () => {
383 await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
384 await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 });
385 await connections.getByLabel("Status: Expired").waitFor({ timeout: 30_000 });
386 });
387
388 await step("Reconnect and complete the OAuth flow in the popup", async () => {
389 // The provider comes back before the user reconnects — fresh grants
390 // are fully healthy from here on.
391 gate.restore();
392
393 const popupPromise = page.waitForEvent("popup", { timeout: 30_000 });
394 await menuTrigger.click();
395 await page.getByRole("menuitem", { name: "Reconnect" }).click();
396 const popup = await popupPromise;
397
398 // The test AS login page is plain text driven by Basic-auth POST, so
399 // complete it out of band and drive the popup to the callback — the
400 // same journey a user's click-through consent takes.
401 await popup.waitForURL(/\/login\?/, { timeout: 30_000 });
402 const callbackUrl = await submitProviderLogin(popup.url());
403 await popup.goto(callbackUrl);
404 await page.getByText("Reconnected", { exact: true }).waitFor({ timeout: 30_000 });
405 });
406
407 await step("The backend already sees the connection as healthy", async () => {
408 // Evidence that only the UI is stale: the same health endpoint the
409 // page would call classifies the re-minted grant as healthy.
410 const response = await page.request.post(healthPath(slug));
411 const body = (await response.json()) as { readonly status?: string };
412 console.info(`[BUG repro] post-reconnect health: ${response.status()} ${body.status}`);
413 expect(response.status(), "post-reconnect health check succeeds").toBe(200);
414 expect(body.status, "the re-minted grant is healthy").toBe("healthy");
415 });
416
417 await step("BUG: the row must flip to Healthy without a hard page refresh", async () => {
418 await connections.getByLabel("Status: Healthy").waitFor({ timeout: 30_000 });
419 await connections.getByText("Expired", { exact: true }).waitFor({
420 state: "hidden",
421 timeout: 5_000,
422 });
423 });
424 });
425 }),
426 ),
427);
428
227scenario(429scenario(
228 "MCP OAuth · DCR reconnect keeps the dialog open and reaches OAuth start",430 "MCP OAuth · DCR reconnect keeps the dialog open and reaches OAuth start",
229 {431 {
packages/core/sdk/src/executor.ts+5−0
@@ -2962,6 +2962,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2961 unmodified lines
2962 input.missingOAuthScopes && input.missingOAuthScopes.length > 02962 input.missingOAuthScopes && input.missingOAuthScopes.length > 0
2963 ? { missingOAuthScopes: input.missingOAuthScopes }2963 ? { missingOAuthScopes: input.missingOAuthScopes }
2964 : null,2964 : null,
2965 // A re-mint replaces the grant, so any persisted verdict describes
2966 // a credential that no longer exists. Clear it rather than let a
2967 // pre-reconnect "expired" outlive the reconnect; the next health
2968 // check writes the verdict for the new grant.
2969 last_health: null,
2965 updated_at: now,2970 updated_at: now,
2966 };2971 };
2967 if (existing) {2972 if (existing) {
packages/plugins/mcp/src/sdk/connection.ts+9−0
@@ -172,6 +172,15 @@ const fetchFromHttpClientLayer = ( · 171 unmodified lines
172 }172 }
173 return response;173 return response;
174 });174 });
175 // Mark the request promise observed (a no-op handler on the ORIGINAL
176 // promise; callers still see the rejection). The MCP SDK fires some
177 // requests without a rejection handler — a cancellation notification
178 // after a request timeout, an SSE dial raced against an abort — and when
179 // the upstream is already gone that rejection is unhandled, which kills
180 // the whole Bun server process, not just this call. Browsers never crash
181 // on an unobserved fetch rejection; this adapter must match.
182 // oxlint-disable-next-line executor/no-promise-catch -- boundary: Fetch-compatible adapter must observe rejections the SDK abandons
183 promise.catch(() => undefined);
175 if (!init?.signal) return promise;184 if (!init?.signal) return promise;
176 // oxlint-disable-next-line executor/no-promise-reject -- boundary: Fetch-compatible adapter mirrors abort rejection semantics185 // oxlint-disable-next-line executor/no-promise-reject -- boundary: Fetch-compatible adapter mirrors abort rejection semantics
177 if (init.signal.aborted) return Promise.reject(abortError(init.signal));186 if (init.signal.aborted) return Promise.reject(abortError(init.signal));
packages/react/src/lib/use-connection-health.ts+67−22
@@ -42,6 +42,33 @@ const revalidateQuery = ( · 41 unmodified lines
42): { readonly ifStaleMs?: number } =>42): { readonly ifStaleMs?: number } =>
43 last?.status === "healthy" ? { ifStaleMs: HEALTH_REVALIDATE_MS } : {};43 last?.status === "healthy" ? { ifStaleMs: HEALTH_REVALIDATE_MS } : {};
44 44
45/** Identity of a persisted verdict, for detecting the reconnect transition.
46 * An OAuth re-mint clears `last_health`, so a verdict giving way to `null`
47 * means the grant was replaced and the row must re-probe even though it never
48 * remounts (its React key is owner:integration:name, unchanged by a
49 * reconnect). This CLEARING transition is the only re-trigger: reacting to
50 * every epoch change instead would race probes against cache refetches
51 * (a refetch can deliver a snapshot older than a just-adopted verdict) and
52 * storm upstreams with re-probes. `null` is a real epoch — never-checked or
53 * just-re-minted — distinct from the "never seen" sentinel `undefined`. */
54const verdictEpoch = (last: HealthCheckResult | null | undefined): number | null =>
55 last?.checkedAt ?? null;
56
57/** The verdict to display: whichever of the live probe and the persisted
58 * verdict is FRESHEST. A plain live-over-persisted preference would let a
59 * pre-reconnect probe shadow the verdict a completed reconnect persisted
60 * (any surface may write a newer verdict server-side; this hook only learns
61 * of it through the refetched row). Ties keep the live result: identical
62 * timestamps mean it IS the persisted verdict, echoed back. */
63const freshestVerdict = (
64 live: HealthCheckResult | null,
65 persisted: HealthCheckResult | null | undefined,
66): HealthCheckResult | null => {
67 if (live === null) return persisted ?? null;
68 if (persisted == null) return live;
69 return persisted.checkedAt > live.checkedAt ? persisted : live;
70};
71
45/**72/**
46 * Imperative invalidation of the connections cache for one owner. The server73 * Imperative invalidation of the connections cache for one owner. The server
47 * persists every verdict on `last_health`, so after a check we must re-read the74 * persists every verdict on `last_health`, so after a check we must re-read the
@@ -70,35 +97,46 @@ export function useConnectionHealth(connection: Connection): { · 22 unmodified lines
70 readonly status: HealthStatus;97 readonly status: HealthStatus;
71 readonly runCheck: () => Promise<Exit.Exit<HealthCheckResult, unknown>>;98 readonly runCheck: () => Promise<Exit.Exit<HealthCheckResult, unknown>>;
72} {99} {
73 // A live probe result, once a check has run, overrides the persisted one.100 // A live probe result, once a check has run; merged with the persisted
101 // verdict by freshness (see freshestVerdict for why not live-always-wins).
74 const [liveProbe, setLiveProbe] = useState<HealthCheckResult | null>(null);102 const [liveProbe, setLiveProbe] = useState<HealthCheckResult | null>(null);
75 const doCheck = useAtomSet(checkConnectionHealth, { mode: "promiseExit" });103 const doCheck = useAtomSet(checkConnectionHealth, { mode: "promiseExit" });
76 const invalidateConnections = useInvalidateConnections();104 const invalidateConnections = useInvalidateConnections();
77 105
78 const probe = liveProbe ?? connection.lastHealth ?? null;106 const probe = freshestVerdict(liveProbe, connection.lastHealth);
79 const status: HealthStatus = probe?.status ?? "unknown";107 const status: HealthStatus = probe?.status ?? "unknown";
80 108
81 // Health checks are AUTOMATIC: loading the list revalidates any verdict109 // Health checks are AUTOMATIC: loading the list revalidates any verdict
82 // older than the freshness window (or never checked), stale-while-revalidate110 // older than the freshness window (or never checked), stale-while-revalidate
83 // style: the persisted verdict renders instantly, the probe corrects it in111 // style: the persisted verdict renders instantly, the probe corrects it in
84 // place.112 // place. The guard is once per mount PLUS once per clearing: the ref holds
85 const revalidated = useRef(false);113 // the last epoch seen, and a verdict giving way to `null` (an OAuth re-mint
114 // cleared it) re-arms the probe — that is how a completed reconnect gets its
115 // recovery probe without a page reload. Only the clearing transition
116 // re-arms; every other epoch change (a probe's own verdict echoed back by
117 // the refetch, a concurrent surface's fresher verdict) stays quiet, keeping
118 // the no-probe-storm invariant of the original once-per-mount guard.
119 const seenEpoch = useRef<number | null | undefined>(undefined);
86 useEffect(() => {120 useEffect(() => {
87 if (revalidated.current) return;
88 const last = connection.lastHealth;121 const last = connection.lastHealth;
122 const epoch = verdictEpoch(last);
123 const firstSight = seenEpoch.current === undefined;
124 const cleared = epoch === null && seenEpoch.current !== null && !firstSight;
125 seenEpoch.current = epoch;
126 if (!firstSight && !cleared) return;
89 if (healthyAndFresh(last)) return;127 if (healthyAndFresh(last)) return;
90 revalidated.current = true;
91 void doCheck({128 void doCheck({
92 params: connectionParams(connection),129 params: connectionParams(connection),
93 query: revalidateQuery(last),130 query: revalidateQuery(last),
94 }).then((exit) => {131 }).then((exit) => {
95 // Background refresh: update the dot on success, stay quiet on failure132 // Background refresh: update the dot on success, stay quiet on failure
96 // (the persisted verdict is still the best known state). Invalidate the133 // (the persisted verdict is still the best known state). Invalidate the
97 // connections cache ONLY when the verdict actually changed: on the common134 // connections cache ONLY when the verdict actually changed: on the
98 // no-change reconfirm we skip it, so an automatic probe never churns the135 // common no-change reconfirm we skip it, so an automatic probe never
99 // cache (which would refetch connections, re-run this effect, and, but136 // churns the cache (which would refetch connections, re-run this
100 // for the once-per-mount ref guard, risk a probe loop).137 // effect, and, but for the epoch guard, risk a probe loop).
101 if (!Exit.isSuccess(exit)) return;138 if (!Exit.isSuccess(exit)) return;
139 seenEpoch.current = exit.value.checkedAt;
102 setLiveProbe(exit.value);140 setLiveProbe(exit.value);
103 if (exit.value.status !== (last?.status ?? "unknown")) {141 if (exit.value.status !== (last?.status ?? "unknown")) {
104 invalidateConnections(connection.owner);142 invalidateConnections(connection.owner);
@@ -108,14 +146,17 @@ export function useConnectionHealth(connection: Connection): { · 3 unmodified lines
108 146
109 const runCheck = useCallback(async () => {147 const runCheck = useCallback(async () => {
110 // Manual "Check now": invalidate the connections cache unconditionally so148 // Manual "Check now": invalidate the connections cache unconditionally so
111 // every surface picks up the freshly persisted verdict. Re-running this149 // every surface picks up the freshly persisted verdict. Adopting the
112 // effect after the refetch is harmless: the ref guard blocks a re-probe.150 // result's epoch keeps the resulting refetch from re-probing.
113 const exit = await doCheck({151 const exit = await doCheck({
114 params: connectionParams(connection),152 params: connectionParams(connection),
115 query: {},153 query: {},
116 reactivityKeys: connectionCheckKeys,154 reactivityKeys: connectionCheckKeys,
117 });155 });
118 if (Exit.isSuccess(exit)) setLiveProbe(exit.value);156 if (Exit.isSuccess(exit)) {
157 seenEpoch.current = exit.value.checkedAt;
158 setLiveProbe(exit.value);
159 }
119 return exit;160 return exit;
120 }, [connection, doCheck]);161 }, [connection, doCheck]);
121 162
@@ -140,25 +181,29 @@ export function useConnectionsHealth( · 18 unmodified lines
140 const doCheck = useAtomSet(checkConnectionHealth, { mode: "promiseExit" });181 const doCheck = useAtomSet(checkConnectionHealth, { mode: "promiseExit" });
141 const invalidateConnections = useInvalidateConnections();182 const invalidateConnections = useInvalidateConnections();
142 183
143 // Once per mount PER CONNECTION: the list streams in asynchronously, so the184 // Once per VERDICT per connection (same epoch guard as the single-connection
144 // effect re-runs as rows arrive; the key set keeps each row to one probe.185 // hook): the list streams in asynchronously, so the effect re-runs as rows
145 const revalidated = useRef(new Set<string>());186 // arrive; each row probes once per persisted-verdict epoch, and a re-minted
187 // connection (epoch cleared to null) probes again without a remount.
188 const revalidated = useRef(new Map<string, number | null>());
146 useEffect(() => {189 useEffect(() => {
147 for (const connection of connections) {190 for (const connection of connections) {
148 const key = probeKey(connection);191 const key = probeKey(connection);
149 if (revalidated.current.has(key)) continue;
150 const last = connection.lastHealth;192 const last = connection.lastHealth;
193 const epoch = verdictEpoch(last);
194 if (revalidated.current.has(key) && revalidated.current.get(key) === epoch) continue;
195 revalidated.current.set(key, epoch);
151 if (healthyAndFresh(last)) continue;196 if (healthyAndFresh(last)) continue;
152 revalidated.current.add(key);
153 void doCheck({197 void doCheck({
154 params: connectionParams(connection),198 params: connectionParams(connection),
155 query: revalidateQuery(last),199 query: revalidateQuery(last),
156 }).then((exit) => {200 }).then((exit) => {
157 // Same automatic-path rule as the single-connection hook: reflect the201 // Same automatic-path rule as the single-connection hook: reflect the
158 // verdict, and invalidate the connections cache only when it changed so202 // verdict, adopt its epoch so the refetch doesn't re-probe, and
159 // an unchanged reconfirm never churns the cache (the per-key ref guard203 // invalidate the connections cache only when the verdict changed so an
160 // already prevents a re-probe on the resulting re-render).204 // unchanged reconfirm never churns the cache.
161 if (!Exit.isSuccess(exit)) return;205 if (!Exit.isSuccess(exit)) return;
206 revalidated.current.set(key, exit.value.checkedAt);
162 setLiveProbes((current) => new Map(current).set(key, exit.value));207 setLiveProbes((current) => new Map(current).set(key, exit.value));
163 if (exit.value.status !== (last?.status ?? "unknown")) {208 if (exit.value.status !== (last?.status ?? "unknown")) {
164 invalidateConnections(connection.owner);209 invalidateConnections(connection.owner);
@@ -169,7 +214,7 @@ export function useConnectionsHealth( · 4 unmodified lines
169 214
170 return useCallback(215 return useCallback(
171 (connection: Connection) =>216 (connection: Connection) =>
172 liveProbes.get(probeKey(connection)) ?? connection.lastHealth ?? null,217 freshestVerdict(liveProbes.get(probeKey(connection)) ?? null, connection.lastHealth),
173 [liveProbes],218 [liveProbes],
174 );219 );
175}220}