diff --git a/.changeset/tidy-eels-invite.md b/.changeset/tidy-eels-invite.md new file mode 100644 index 000000000..38d6c2dfd --- /dev/null +++ b/.changeset/tidy-eels-invite.md @@ -0,0 +1,27 @@ +--- +'@object-ui/app-shell': minor +--- + +fix(app-shell): a transient 404 no longer retires the shared inbox feed for the page's lifetime + +`sharedUserFeeds`' `markUnavailable()` was a one-way door: `refresh`, `schedule` +and `onVisibilityChange` all returned early on `unavailable`, so a feed that took +one missing-resource answer stayed retired until the user reloaded the page or +switched identity. Since #4225 pointed both the header bell and Home's action +centre at one inbox feed, that took both panels together — reproducing the +#4110 / #4230 dead-bell signature from a lost race rather than from a real +absence. + +The split is reachable because `isMissingResource` is status-shaped +(`httpStatus === 404` alone qualifies) and the ObjectStack client stamps +`httpStatus` from the response status before it reads the body — so a 404 from +anywhere in the transport arrives indistinguishable from the registry's +considered `OBJECT_NOT_FOUND`. + +A retired feed now re-probes at most `UNAVAILABLE_PROBE_LIMIT` (3) times, no +more often than `UNAVAILABLE_PROBE_MS` (60s), on the poll timer and on +`visibilitychange` alike; a probe that answers with rows revives the feed and +restores its normal cadence. The retired state itself is unchanged — status +stays `ready`, the value stays empty, and no error is ever rendered — so a +deployment that genuinely has no messaging pipeline still reads as an answer and +now costs three extra reads across the whole page rather than one. diff --git a/packages/app-shell/src/hooks/__tests__/sharedInboxFeed.transient404.test.tsx b/packages/app-shell/src/hooks/__tests__/sharedInboxFeed.transient404.test.tsx new file mode 100644 index 000000000..1094a439a --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/sharedInboxFeed.transient404.test.tsx @@ -0,0 +1,332 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * #4289 — one missing-resource answer used to retire the inbox feed for the + * whole page, and BOTH panels with it. + * + * ## What the card recorded, and what had moved under it + * + * #4289 was filed against `AppHeader`'s own poll: a `notificationsUnavailableRef` + * that latched on one 404 and was cleared by nothing — while `useHomeInbox`, + * reading the same object with the same filter, simply retried. One transient + * missing-resource answer therefore reproduced the #4110 / #4230 three-way + * signature (bell empty under both filters, Home's card correct, no requests on + * open) from a cause nobody was looking for. + * + * That poll no longer exists — #4225 / PR #4327 deleted it and pointed both + * surfaces at `sharedUserFeeds`' inbox feed. The asymmetry the card described is + * gone, but it closed in the WRONG DIRECTION: the surviving reader is the + * latching one. `markUnavailable()` set `unavailable = true`, and `refresh()`, + * `schedule()` and `onVisibilityChange()` all returned early on it, so nothing + * short of a key change (a different user or adapter) or a page reload could + * revive the feed. A remount did not; returning to the tab did not; a later + * successful read never happened because no later read was issued. The blast + * radius grew rather than shrank: the bell and Home's action centre now die + * together, and the "Home still works" contrast that made the old signature + * diagnosable is no longer available to whoever triages the next report. + * + * ## Why a transient 404 is reachable — the predicate is STATUS-shaped + * + * `isMissingResource` is `httpStatus === 404 || status === 404 || + * errorCodeIs(err, 'OBJECT_NOT_FOUND')`, and its two status arms are already + * pinned true WITHOUT any error code + * (`sharedUserFeeds.isMissingResource.test.ts`, "is true for a plain `status` + * 404 as well"). The ObjectStack client stamps `error.httpStatus = res.status` + * on every non-ok response before it inspects the body at all + * (`packages/client/src/index.ts`), so a 404 produced ANYWHERE in the transport + * — an edge router or reverse proxy with no healthy upstream mid-deploy, a host + * answering its catch-all for `/api/v1/data/...` before the API is mounted — + * arrives here indistinguishable from the registry's considered + * `404 OBJECT_NOT_FOUND`. Only the second population is the answer #4315 ruled + * on; the first is a lost race that used to cost the session its inbox. + * + * ## What these pin + * + * A bounded recovery, and the bound. Retirement keeps its meaning — the poll + * stops, the status stays `ready`, the affirmative empty copy stays earned — + * but a retired feed re-probes at most `UNAVAILABLE_PROBE_LIMIT` times, no more + * often than `UNAVAILABLE_PROBE_MS`, and any of those probes that answers with + * rows brings the feed (and both panels) back. A deployment that genuinely has + * no messaging pipeline pays those few reads once and is then silent for the + * rest of the page, which is the noise argument the retirement was built on. + * + * Reverse verification (direction predicted BEFORE running, measured in this PR): + * - the recovery cases are RED on `origin/main` — the feed never re-reads, so + * the rows waiting behind the transient 404 are never seen; + * - the "identical UX" control is GREEN on `origin/main` and stays green: a + * permanently-missing object still answers `ready` with an empty value and + * never renders an error, before and after; + * - the "spends its budget and then stops" case is RED on `origin/main` in the + * OPPOSITE direction from the recovery cases — main issues FEWER reads (1) + * than the bound, not more. It is the noise ceiling, so it has to be able to + * fail upward; that it also fails downward today is what makes it evidence + * that the probes exist at all rather than a tautology. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; + +/** `null` is the pre-auth session; every case here is one signed-in user. */ +let userFixture: { id: string } | null = { id: 'u1' }; +vi.mock('@object-ui/auth', () => ({ useAuth: () => ({ user: userFixture }) })); + +/** Rows the inbox read answers with once it stops rejecting. */ +const ROWS = [ + { + id: 'ibx_1', + user_id: 'u1', + notification_id: 'ntf_1', + topic: 'approval.reminder', + title: 'Approval request 1 needs your decision', + action_url: '/apps/crm/sys_approval_request/record/a_1', + created_at: '2026-08-11T09:00:00Z', + }, +]; + +/** + * A missing-resource rejection. `code` is optional on purpose: the transport + * 404 that the card is about carries a status and no semantic code at all, and + * the predicate accepts it either way — which is precisely why the two + * populations cannot be told apart at this seam. + */ +function objectMissing(withCode: boolean): Promise { + const err = new Error('Object not found: sys_inbox_message') as Error & { + httpStatus?: number; + code?: string; + }; + err.httpStatus = 404; + if (withCode) err.code = 'OBJECT_NOT_FOUND'; + return Promise.reject(err); +} + +/** What `find('sys_inbox_message')` does for the current case. */ +let inboxBehaviour: () => Promise = async () => ({ data: [] }); +const findCalls: Array<{ object: string }> = []; +const fakeAdapter = { + find: (object: string) => { + findCalls.push({ object }); + if (object === 'sys_inbox_message') return inboxBehaviour(); + // Receipts and activity answer emptily — neither is this suite's subject. + return Promise.resolve({ data: [] }); + }, + getClient: () => undefined, +}; +vi.mock('../../providers/AdapterProvider', () => ({ useAdapter: () => fakeAdapter })); + +import { + UNAVAILABLE_PROBE_LIMIT, + UNAVAILABLE_PROBE_MS, + useSharedInboxFeed, + __resetSharedUserFeeds, +} from '../sharedUserFeeds'; +import { useHomeInbox } from '../useHomeInbox'; + +const inboxReads = () => findCalls.filter((c) => c.object === 'sys_inbox_message').length; + +/** Let the attach-time read settle without moving the clock past a probe. */ +const settle = () => act(async () => { await vi.advanceTimersByTimeAsync(0); }); +/** Move the clock, flushing the promises each timer wakes. */ +const advance = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); }); + +beforeEach(() => { + vi.useFakeTimers(); + userFixture = { id: 'u1' }; + findCalls.length = 0; + inboxBehaviour = async () => ({ data: [] }); + // Module-scoped stores outlive any one render tree, so cases would otherwise + // inherit each other's rows, status and retirement. + __resetSharedUserFeeds(); + // Approvals degrade to 0 (404) so nothing here depends on the REST feed. + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('{}', { status: 404 })))); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('#4289 — a transient missing-resource answer does not cost the session its inbox', () => { + it('re-probes after retiring, and comes back when the read answers', async () => { + // The card's scenario on the machinery that replaced it: ONE 404 during + // bootstrap, rows available from the very next read. On `origin/main` the + // next read never happens. + let reads = 0; + inboxBehaviour = () => { + reads += 1; + return reads === 1 ? objectMissing(true) : Promise.resolve({ data: ROWS }); + }; + + const { result } = renderHook(() => useSharedInboxFeed()); + await settle(); + + // Retired: an answer, not an error — that part is unchanged and stays so. + expect(result.current.status).toBe('ready'); + expect(result.current.value).toEqual([]); + expect(inboxReads()).toBe(1); + + await advance(UNAVAILABLE_PROBE_MS); + + expect(inboxReads()).toBe(2); + expect(result.current.status).toBe('ready'); + expect(result.current.value).toHaveLength(1); + expect(result.current.value[0].title).toBe('Approval request 1 needs your decision'); + }); + + it('recovers from a code-less transport 404 too, not just OBJECT_NOT_FOUND', async () => { + // The population the reachability argument rests on. A proxy 404 has a + // status and nothing else; the predicate takes it, so the recovery has to. + let reads = 0; + inboxBehaviour = () => { + reads += 1; + return reads === 1 ? objectMissing(false) : Promise.resolve({ data: ROWS }); + }; + + const { result } = renderHook(() => useSharedInboxFeed()); + await settle(); + expect(result.current.value).toEqual([]); + + await advance(UNAVAILABLE_PROBE_MS); + + expect(result.current.value).toHaveLength(1); + }); + + it('brings BOTH surfaces back — the bell and Home read one feed (#4225)', async () => { + // #4289's asymmetry closed in the wrong direction when the read converged: + // the surviving reader was the latching one, so a single lost race took the + // bell AND the action centre, removing the "Home still works" contrast that + // made the #4110 / #4230 signature diagnosable in the first place. + let reads = 0; + inboxBehaviour = () => { + reads += 1; + return reads === 1 ? objectMissing(true) : Promise.resolve({ data: ROWS }); + }; + + const { result } = renderHook(() => ({ + bell: useSharedInboxFeed(), + home: useHomeInbox(), + })); + await settle(); + + expect(result.current.bell.value).toEqual([]); + expect(result.current.home.notifications).toEqual([]); + expect(result.current.home.unreadTopicCount).toBe(0); + + await advance(UNAVAILABLE_PROBE_MS); + + // One read served both, exactly as #4225 requires — and it revived both. + expect(inboxReads()).toBe(2); + expect(result.current.bell.value).toHaveLength(1); + expect(result.current.home.notifications).toHaveLength(1); + expect(result.current.home.unreadTopicCount).toBe(1); + }); + + it('resumes its normal cadence once revived, rather than staying on probes', async () => { + // Recovery is not a one-shot read: a feed that answers is a live feed + // again, polling at the bell's 10s (ADR-0030 L5) like one that never + // failed. Pinned because "it came back once" and "it is working" are + // different claims, and only the second one is a bell. + let reads = 0; + inboxBehaviour = () => { + reads += 1; + return reads === 1 ? objectMissing(true) : Promise.resolve({ data: ROWS }); + }; + + renderHook(() => useSharedInboxFeed()); + await settle(); + await advance(UNAVAILABLE_PROBE_MS); + const atRevival = inboxReads(); + + // Half the probe cadence at the foreground poll rate is many ticks; the + // exact number is the cadence's business, that there ARE more is this + // case's. + await advance(UNAVAILABLE_PROBE_MS / 2); + expect(inboxReads()).toBeGreaterThan(atRevival + 1); + }); +}); + +describe('#4289 — the recovery is BOUNDED, and a genuinely absent object still reads as an answer', () => { + /** The community build with no service-messaging: every read, forever. */ + const alwaysMissing = () => objectMissing(true); + + it('CONTROL: a permanently-missing object answers `ready`-and-empty, never an error', async () => { + // #4315's ruling, untouched. This is the case the retirement exists for and + // the one clause of it that may not move: a deployment without the + // messaging pipeline has nothing waiting, so Home's affirmative empty copy + // stays earned and no error line is ever rendered. Green before this change + // and green after — the probes are invisible at this surface by design. + inboxBehaviour = alwaysMissing; + + const { result } = renderHook(() => useSharedInboxFeed()); + await settle(); + + for (let i = 0; i < 8; i += 1) { + await advance(UNAVAILABLE_PROBE_MS); + expect(result.current.status).toBe('ready'); + expect(result.current.value).toEqual([]); + } + // …and the whole page's worth of reads stays inside the ceiling. This half + // is the noise bound the retirement was built to hold. + expect(inboxReads()).toBeLessThanOrEqual(1 + UNAVAILABLE_PROBE_LIMIT); + }); + + it('spends its probe budget and then stops for good', async () => { + // The ceiling, asserted exactly. An hour on a community build costs the + // first read plus the budget and not one request more — against the ~360 + // an un-retired 10s poll would have issued. + inboxBehaviour = alwaysMissing; + + renderHook(() => useSharedInboxFeed()); + await settle(); + + await advance(UNAVAILABLE_PROBE_MS * (UNAVAILABLE_PROBE_LIMIT + 4)); + expect(inboxReads()).toBe(1 + UNAVAILABLE_PROBE_LIMIT); + + await advance(60 * 60_000); + expect(inboxReads()).toBe(1 + UNAVAILABLE_PROBE_LIMIT); + }); + + it('does not let tab-flipping burn the budget faster than the cadence', async () => { + // `visibilitychange` is a refetch trigger the store already honours, and it + // is user-driven — so it may re-probe, but it may not turn a user switching + // tabs into a request storm against an object that is not there. The + // cadence gates both paths, not just the timer. + inboxBehaviour = alwaysMissing; + + renderHook(() => useSharedInboxFeed()); + await settle(); + expect(inboxReads()).toBe(1); + + for (let i = 0; i < 20; i += 1) { + document.dispatchEvent(new Event('visibilitychange')); + await advance(50); + } + + expect(inboxReads()).toBe(1); + }); + + it('re-probes when the user returns to the tab after the cadence has elapsed', async () => { + // The other half of the same rule: coming back to a tab that has been away + // long enough is exactly when a bell should be current, and it is the hook + // the store already uses for its `markFailed` branch (#4225). + let reads = 0; + inboxBehaviour = () => { + reads += 1; + return reads === 1 ? objectMissing(true) : Promise.resolve({ data: ROWS }); + }; + + const { result } = renderHook(() => useSharedInboxFeed()); + await settle(); + expect(result.current.value).toEqual([]); + + // Away long enough that a probe is due, with the tab hidden so no timer + // beat us to it, then back. + vi.spyOn(document, 'hidden', 'get').mockReturnValue(true); + await advance(UNAVAILABLE_PROBE_MS + 1_000); + vi.spyOn(document, 'hidden', 'get').mockReturnValue(false); + document.dispatchEvent(new Event('visibilitychange')); + await settle(); + + expect(result.current.value).toHaveLength(1); + }); +}); diff --git a/packages/app-shell/src/hooks/sharedUserFeeds.ts b/packages/app-shell/src/hooks/sharedUserFeeds.ts index 608e68a6c..31c8e37b8 100644 --- a/packages/app-shell/src/hooks/sharedUserFeeds.ts +++ b/packages/app-shell/src/hooks/sharedUserFeeds.ts @@ -80,6 +80,36 @@ const MAX_BACKOFF_MS = 120_000; * a beat before the page body does. */ const FRESH_MS = 30_000; +/** + * How many times a RETIRED feed re-probes before it is retired for good, and + * how long it waits between those probes (#4289). + * + * `markUnavailable()` used to be a one-way door: `unavailable` was set, and + * `refresh`, `schedule` and `onVisibilityChange` all returned early on it, so + * only a key change (a different user or adapter) or a page reload could revive + * the feed. That is sound for the case it was written for — a deployment + * without the approvals plugin / `sys_activity` / the messaging pipeline will + * answer 404 forever, and re-asking is pure noise — but `isMissingResource` is + * STATUS-shaped, not code-shaped: `httpStatus === 404` alone qualifies, and the + * ObjectStack client stamps `httpStatus` from `res.status` on every non-ok + * response before it looks at the body. So a 404 from anywhere in the transport + * (an edge router mid-deploy, a host's catch-all before the API is mounted) + * enters this door wearing the same clothes as the registry's considered + * `OBJECT_NOT_FOUND` — and used to cost the page its inbox until reload, on + * BOTH surfaces at once now that the bell and Home read one feed (#4225). + * + * A bounded re-probe separates them without giving either the wrong answer: the + * lost race is recovered within a probe cadence, and the genuinely-absent + * object costs `UNAVAILABLE_PROBE_LIMIT` extra reads over the page's whole + * lifetime and is then silent — against the ~360/hour an un-retired 10s poll + * would have issued. Nothing about the retired STATE changes: the status stays + * `ready`, the value stays empty, and the affirmative empty copy stays earned + * (#4315). Exported because the pins assert the ceiling, and a ceiling whose + * number lives only in the test is not the same ceiling as the code's. + */ +export const UNAVAILABLE_PROBE_LIMIT = 3; +/** @see UNAVAILABLE_PROBE_LIMIT */ +export const UNAVAILABLE_PROBE_MS = 60_000; /** * Stable empty value — `useSyncExternalStore` re-renders in a loop if @@ -156,6 +186,10 @@ class SharedFeed { private consumers = 0; private inFlight = false; private unavailable = false; + /** When the feed last retired or spent a probe — gates the probe cadence. */ + private retiredAt = 0; + /** Re-probes left before the retirement becomes permanent (#4289). */ + private probesLeft = UNAVAILABLE_PROBE_LIMIT; private fetchedAt = 0; private timer: ReturnType | null = null; /** Current inter-poll delay: `pollMs`, doubled per failure, capped. */ @@ -201,6 +235,8 @@ class SharedFeed { if (key !== this.key) { this.key = key; this.unavailable = false; + this.retiredAt = 0; + this.probesLeft = UNAVAILABLE_PROBE_LIMIT; this.fetchedAt = 0; this.backoffMs = this.pollMs; // A different key means the cached value belongs to someone else, and @@ -232,8 +268,12 @@ class SharedFeed { */ private async refresh(force = false): Promise { const runner = this.runner; - if (!runner || this.unavailable || this.inFlight) return; - if (!force && this.fetchedAt && Date.now() - this.fetchedAt < FRESH_MS) return; + if (!runner || this.inFlight) return; + // A retired feed is not dead, it is quiet: it still re-probes, a bounded + // number of times and no faster than the probe cadence (#4289). + const probing = this.unavailable; + if (probing && !this.mayProbe()) return; + if (!force && !probing && this.fetchedAt && Date.now() - this.fetchedAt < FRESH_MS) return; this.inFlight = true; // Only announce "loading" when there is no prior answer to show. A poll // tick over a `ready` feed must not flash its consumers back through the @@ -242,35 +282,74 @@ class SharedFeed { let failed = false; try { const next = await runner({ - markUnavailable: () => { - this.unavailable = true; - this.stopPolling(); - // A missing resource IS an answer: this deployment has none, so - // nothing is waiting. Degrading to empty and calling it `ready` is - // the split `useHomeInbox` already applied to its own read (#4235). - this.fetchedAt = Date.now(); - this.publish(this.snapshot.value, 'ready'); - }, + // Already retired ⇒ this WAS a probe, and it came back missing again. + // Spend it and stay exactly as we were; the status must not move. + markUnavailable: () => (probing ? this.spendProbe() : this.retire()), markFailed: () => { failed = true; }, }); if (next !== undefined) { + // An answer with a value: the feed is alive, whatever it was before. + // This is the ONLY way out of retirement, and it restores the full + // budget — a feed that has answered once has earned the next lost race. + this.unavailable = false; + this.probesLeft = UNAVAILABLE_PROBE_LIMIT; this.fetchedAt = Date.now(); this.backoffMs = this.pollMs; this.publish(next, 'ready'); } else if (failed) { - this.fail(); + // A probe that fails for some OTHER reason is still not an answer, and + // it does not get to drag a retired feed into `error`: the surface has + // been showing the earned empty state and a 404-then-500 sequence is no + // reason to start telling a community build its inbox is broken. Spend + // the probe, keep the state, let the next one decide (#4315). + if (probing) this.spendProbe(); + else this.fail(); } } catch { // Not swallowed into "keep the last value and say nothing": the value // stays, but consumers are told it is no longer an answer (#4225). - this.fail(); + if (probing) this.spendProbe(); + else this.fail(); } finally { this.inFlight = false; + // Re-arm from one place. `schedule` is a no-op while a timer is pending, + // so the poll tick's own re-arm below is unaffected; what this adds is + // the probe timer for a feed that has just retired (and for a + // non-polled feed, whose only other scheduling point is attach). + if (this.consumers > 0) this.schedule(); } } + /** Whether a retired feed is due another probe. */ + private mayProbe(): boolean { + return this.probesLeft > 0 && Date.now() - this.retiredAt >= UNAVAILABLE_PROBE_MS; + } + + /** + * The resource is missing — an answer, so `ready` and the poll stops. What + * used to be a one-way door now leaves the probe budget standing. + */ + private retire(): void { + this.unavailable = true; + this.retiredAt = Date.now(); + // A missing resource IS an answer: this deployment has none, so nothing is + // waiting. Degrading to empty and calling it `ready` is the split + // `useHomeInbox` already applied to its own read (#4235). + this.fetchedAt = Date.now(); + this.stopPolling(); + this.publish(this.snapshot.value, 'ready'); + } + + /** A probe that did not answer: spend one, publish nothing, stay retired. */ + private spendProbe(): void { + this.probesLeft = Math.max(0, this.probesLeft - 1); + this.retiredAt = Date.now(); + this.fetchedAt = Date.now(); + this.stopPolling(); + } + /** A read that should have worked did not — report it and back the poll off. */ private fail(): void { this.backoffMs = Math.min(Math.max(this.backoffMs, this.pollMs) * 2, MAX_BACKOFF_MS); @@ -278,16 +357,31 @@ class SharedFeed { } private schedule(): void { - if (this.pollMs <= 0 || this.unavailable || this.timer) return; + if (this.timer) return; + // A retired feed schedules PROBES, not polls — including a feed whose + // `pollMs` is 0 (`sys_activity` fetches once), whose only other re-read + // point is a consumer attaching and which the retirement therefore used to + // silence just as permanently. + if (this.unavailable) { + if (this.probesLeft <= 0) return; + this.timer = setTimeout(() => { + this.timer = null; + void this.refresh(true); + }, UNAVAILABLE_PROBE_MS); + return; + } + if (this.pollMs <= 0) return; // Backgrounded tabs poll at the slower cadence — but never FASTER than the // current backoff, so a failing feed stays backed off either way. const hidden = typeof document !== 'undefined' && document.hidden; const delay = hidden ? Math.max(HIDDEN_POLL_MS, this.backoffMs) : this.backoffMs; this.timer = setTimeout(() => { this.timer = null; - void this.refresh(true).finally(() => { - if (this.consumers > 0) this.schedule(); - }); + // `refresh` re-arms in its own `finally`, for the poll tick and the probe + // tick alike — one re-arm point, so a feed that changes state mid-read + // (a poll that retires, a probe that revives) cannot end up scheduling + // the cadence it just left. + void this.refresh(true); }, delay); } @@ -298,12 +392,15 @@ class SharedFeed { */ private readonly onVisibilityChange = (): void => { if (typeof document === 'undefined' || document.hidden) return; - if (this.consumers === 0 || this.unavailable) return; + if (this.consumers === 0) return; + // Returning to a tab is exactly when a bell should be current, so a retired + // feed re-probes here too — but under the same cadence gate as the timer, + // or a user switching tabs would spend the whole budget in three seconds + // and turn a bounded recovery into a request storm (#4289). + if (this.unavailable && !this.mayProbe()) return; this.stopPolling(); - this.backoffMs = this.pollMs; - void this.refresh(true).finally(() => { - if (this.consumers > 0) this.schedule(); - }); + if (!this.unavailable) this.backoffMs = this.pollMs; + void this.refresh(true); }; private bindVisibility(): void { @@ -342,6 +439,8 @@ class SharedFeed { this.consumers = 0; this.inFlight = false; this.unavailable = false; + this.retiredAt = 0; + this.probesLeft = UNAVAILABLE_PROBE_LIMIT; this.fetchedAt = 0; this.backoffMs = this.pollMs; }