fix(host-health): retain trial ownership until requests settle (#1547)

This commit is contained in:
4gray authored and GitHub committed 2026-09-06 00:43:54 +02:00
1 parent 0140146716
commit e40f31db97
14 files changed
+606 -54

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
type: fix
area: host-health
issues: [1439]
---
Portal recovery now waits for an active probe to finish before sending another request, even when redirects or a slow response take longer than 45 seconds. Completed and cancelled requests release the probe slot reliably.
+6
View File
@@ -176,6 +176,12 @@ categories by provider ID and type. See `docs/architecture/category-management.m
## Portal Connectivity Preference
- Half-open trial slots follow the complete request lifetime with no elapsed-time
expiry. All four Electron/web-backend portal handlers release in `finally`,
independently of outcome reporting; cleanup preserves trial/epoch ownership
and works while the environment override is disabled. Contract:
`docs/architecture/host-connectivity-guard.md` ("Trial ownership follows the
request lifetime").
- Desktop Settings > General > Portal connections exposes default-on
`Settings.portalConnectivityGuard`. Only explicit false opts out. Save mirrors
the value to Electron `PORTAL_CONNECTIVITY_GUARD` and applies it without restart;
+6
View File
@@ -1661,6 +1661,12 @@ No formal migration system yet. Schema changes are applied via raw SQL in the `c
## Portal Connectivity Preference
- Half-open trial slots follow the complete request lifetime with no elapsed-time
expiry. All four Electron/web-backend portal handlers release in `finally`,
independently of outcome reporting; cleanup preserves trial/epoch ownership
and works while the environment override is disabled. Contract:
`docs/architecture/host-connectivity-guard.md` ("Trial ownership follows the
request lifetime").
- Desktop Settings > General > Portal connections exposes default-on
`Settings.portalConnectivityGuard`. Only explicit false opts out. Save mirrors
the value to Electron `PORTAL_CONNECTIVITY_GUARD` and applies it without restart;
@@ -83,6 +83,75 @@ describe('StalkerEvents host connectivity guard', () => {
consoleWarnSpy.mockRestore();
});
it('holds a live trial beyond 45 seconds and releases a cancelled request', async () => {
let now = 1_000;
const clock = jest.spyOn(Date, 'now').mockImplementation(() => now);
let cancel!: (error: Error) => void;
let arrived!: () => void;
const pending = new Promise<never>((_, reject) => {
cancel = reject;
});
const started = new Promise<void>((resolve) => {
arrived = resolve;
});
let trial: Promise<unknown> | undefined;
try {
axiosMock.mockRejectedValue(connectionRefused());
await expect(request()).rejects.toBeDefined();
await expect(request()).rejects.toBeDefined();
now += 30_001;
axiosMock.mockImplementationOnce(() => {
arrived();
return pending;
});
trial = request().catch((error) => error);
await started;
now += 45_001;
await expect(request()).rejects.toThrow(
buildHostConnectivityFastFailMessage(PORTAL_ENDPOINT)
);
expect(axiosMock).toHaveBeenCalledTimes(3);
cancel(
Object.assign(new Error('cancelled'), { code: 'ERR_CANCELED' })
);
await trial;
axiosMock.mockResolvedValue({ status: 200, data: [], headers: {} });
await expect(request()).resolves.toBeDefined();
expect(axiosMock).toHaveBeenCalledTimes(4);
} finally {
cancel(new Error('test cleanup'));
await trial;
clock.mockRestore();
}
});
it('releases the trial in finally when debug reporting throws before the outcome report', async () => {
let now = 1_000;
const clock = jest.spyOn(Date, 'now').mockImplementation(() => now);
const debug = await import('./portal-debug.events');
const reportingError = new Error('debug reporting failed');
try {
axiosMock.mockRejectedValue(connectionRefused());
await expect(request()).rejects.toBeDefined();
await expect(request()).rejects.toBeDefined();
now += 30_001;
jest.mocked(debug.emitPortalDebugEvent).mockImplementationOnce(
() => {
throw reportingError;
}
);
await expect(request({ requestId: 'debug-trial' })).rejects.toBe(
reportingError
);
axiosMock.mockResolvedValue({ status: 200, data: [], headers: {} });
await expect(request()).resolves.toBeDefined();
expect(axiosMock).toHaveBeenCalledTimes(4);
} finally {
jest.mocked(debug.emitPortalDebugEvent).mockReset();
clock.mockRestore();
}
});
it('stops contacting a portal host that refused twice in a row', async () => {
axiosMock.mockRejectedValue(connectionRefused());
@@ -26,6 +26,7 @@ import {
observeGuardedHostRequest,
reportGuardedHostFailure,
reportGuardedHostSuccess,
releaseGuardedHostRequest,
} from '../util/host-connectivity-guard';
export default class StalkerEvents {
@@ -264,6 +265,8 @@ ipcMain.handle(
status: 500,
};
}
} finally {
releaseGuardedHostRequest(guardToken);
}
}
);
@@ -341,6 +341,174 @@ describe('XtreamEvents host connectivity guard', () => {
delete process.env[GUARD_DISABLED_ENV];
});
it.each(['success', 'failure', 'cancel', 'redirect-failure'])(
'holds a long request or redirect-chain trial until %s settles',
async (outcome) => {
let now = 1_000;
const clock = jest.spyOn(Date, 'now').mockImplementation(() => now);
const finalHop = createDeferred<unknown>();
const arrived = createDeferred<void>();
let trial: Promise<unknown> | undefined;
const expectedCalls = outcome === 'failure' ? 3 : 5;
try {
axiosMock.mockRejectedValue(timedOut());
await expect(request()).rejects.toBeDefined();
await expect(request()).rejects.toBeDefined();
now += 30_001;
// Direct failure uses a slow pending transfer. Other outcomes
// traverse the real validated-axios loop with two 25 s hops.
if (outcome !== 'failure') {
axiosMock
.mockImplementationOnce(async () => {
now += 25_000;
return {
status: 302,
headers: { location: '/second' },
};
})
.mockImplementationOnce(async () => {
now += 25_000;
return {
status: 302,
headers: { location: '/third' },
};
});
}
axiosMock.mockImplementationOnce(() => {
if (outcome === 'failure') now += 50_000;
arrived.resolve();
return finalHop.promise;
});
trial = request().then(
(value) => ({ value }),
(error) => ({ error })
);
await arrived.promise;
await expect(request()).rejects.toThrow(
buildHostConnectivityFastFailMessage(SERVER_ENDPOINT)
);
expect(axiosMock).toHaveBeenCalledTimes(expectedCalls);
if (outcome === 'success') {
finalHop.resolve({ status: 200, data: [], headers: {} });
} else {
finalHop.reject(
Object.assign(timedOut(), {
code:
outcome === 'cancel'
? 'ERR_CANCELED'
: 'ETIMEDOUT',
config: {
url:
outcome === 'redirect-failure'
? `${SERVER_URL}/third`
: `${SERVER_URL}/player_api.php`,
},
})
);
}
await trial;
if (outcome === 'failure') {
await expect(request()).rejects.toThrow(
buildHostConnectivityFastFailMessage(SERVER_ENDPOINT)
);
now += 30_001;
}
axiosMock.mockResolvedValue({
status: 200,
data: [],
headers: {},
});
await expect(request()).resolves.toMatchObject({ payload: [] });
expect(axiosMock).toHaveBeenCalledTimes(expectedCalls + 1);
} finally {
finalHop.resolve({ status: 200, data: [], headers: {} });
await trial;
clock.mockRestore();
}
}
);
it('ignores an old pending trial failure after reset while a replacement is active', async () => {
let now = 1_000;
const clock = jest.spyOn(Date, 'now').mockImplementation(() => now);
const old = createDeferred<unknown>();
const replacement = createDeferred<unknown>();
const oldStarted = createDeferred<void>();
const replacementStarted = createDeferred<void>();
let oldRequest: Promise<unknown> | undefined;
let newRequest: Promise<unknown> | undefined;
try {
axiosMock.mockRejectedValue(timedOut());
await expect(request()).rejects.toBeDefined();
await expect(request()).rejects.toBeDefined();
now += 30_001;
axiosMock.mockImplementationOnce(() => {
oldStarted.resolve();
return old.promise;
});
oldRequest = request().catch((error) => error);
await oldStarted.promise;
await resetHandler({}, { url: SERVER_URL });
await expect(request()).rejects.toBeDefined();
await expect(request()).rejects.toBeDefined();
now += 30_001;
axiosMock.mockImplementationOnce(() => {
replacementStarted.resolve();
return replacement.promise;
});
newRequest = request();
await replacementStarted.promise;
old.reject(timedOut());
await oldRequest;
now += 45_001;
await expect(request()).rejects.toThrow(
buildHostConnectivityFastFailMessage(SERVER_ENDPOINT)
);
expect(axiosMock).toHaveBeenCalledTimes(6);
replacement.resolve({ status: 200, data: [], headers: {} });
await expect(newRequest).resolves.toMatchObject({ payload: [] });
} finally {
old.resolve({ status: 200, data: [], headers: {} });
replacement.resolve({ status: 200, data: [], headers: {} });
await Promise.all([oldRequest, newRequest]);
clock.mockRestore();
}
});
it('releases the trial in finally when debug reporting throws before the outcome report', async () => {
let now = 1_000;
const clock = jest.spyOn(Date, 'now').mockImplementation(() => now);
const debug = await import('./portal-debug.events');
const reportingError = new Error('debug reporting failed');
try {
axiosMock.mockRejectedValue(timedOut());
await expect(request()).rejects.toBeDefined();
await expect(request()).rejects.toBeDefined();
now += 30_001;
jest.mocked(debug.emitPortalDebugEvent).mockImplementationOnce(
() => {
throw reportingError;
}
);
await expect(
requestHandler(
{},
{
url: SERVER_URL,
params: { action: 'get_live_categories' },
requestId: 'debug-trial',
}
)
).rejects.toBe(reportingError);
axiosMock.mockResolvedValue({ status: 200, data: [], headers: {} });
await expect(request()).resolves.toBeDefined();
expect(axiosMock).toHaveBeenCalledTimes(4);
} finally {
jest.mocked(debug.emitPortalDebugEvent).mockReset();
clock.mockRestore();
}
});
it('stops contacting a panel that timed out twice in a row', async () => {
axiosMock.mockRejectedValue(timedOut());
@@ -22,6 +22,7 @@ import {
beginGuardedHostRequest,
reportGuardedHostFailure,
reportGuardedHostSuccess,
releaseGuardedHostRequest,
} from '../util/host-connectivity-guard';
import {
createXtreamMainPerformanceCaptureForRequest,
@@ -265,6 +266,7 @@ ipcMain.handle(
};
}
} finally {
releaseGuardedHostRequest(guardToken);
if (activeRequestKey) {
activeXtreamRequests.delete(activeRequestKey);
}
@@ -140,6 +140,15 @@ export function reportGuardedHostFailure(
}
}
/** Finally cleanup, even if reporting or debug/error formatting threw. */
export function releaseGuardedHostRequest(
token: HostRequestToken | null
): void {
if (token && currentTokens.delete(token)) {
getHostConnectivityGuard().reportInconclusive(token);
}
}
/** Forgets the recorded failures for the endpoint `url` points at. */
export function resetGuardedHost(url: string): boolean {
const endpoint = portalEndpointKeyOf(url);
+2 -2
View File
@@ -101,8 +101,8 @@ export function admitProviderRequest(
* For a request that was admitted and then abandoned before it went out — a URL
* the SSRF policy refused, for instance. That says nothing about reachability,
* so it must not count as a failure; but the slot a half-open trial reserved
* has to be released, or the breaker waits out the full trial timeout for a
* request that never happened.
* has to be released. Every admitted route also calls this in finally, even
* if outcome reporting throws; cleanup is idempotent and records no failure.
*/
export function releaseProviderRequest(
guard: HostConnectivityGuard,
@@ -13,6 +13,7 @@ import {
} from '@iptvnator/shared/host-health';
import { isHostConnectivityFastFailMessage } from '@iptvnator/shared/interfaces';
import { createWebBackendApp } from './web-backend-app';
import express, { Response } from 'express';
import {
registerProviderTarget,
resolvePublicHost,
@@ -538,6 +539,226 @@ describe('web backend host connectivity guard', () => {
);
});
it('keeps ownership through a real axios redirect and unfinished streaming body', async () => {
const provider = express();
let stream!: Response;
let arrived!: () => void;
const started = new Promise<void>((resolve) => {
arrived = resolve;
});
provider.get('/player_api.php', (_req, res) => res.redirect('/body'));
provider.get('/body', (_req, res) => {
if (stream) {
res.json({ duplicate: true });
return;
}
stream = res;
res.type('json').write('{"items":[');
arrived();
});
const { guard, advance } = createTestGuard();
await withServer(provider, async (providerUrl) => {
for (let attempt = 0; attempt < 2; attempt++) {
const admission = guard.check(providerUrl);
if (!admission.allowed)
throw new Error('Expected initial admission');
guard.reportFailure(admission.token);
}
advance(OPEN_DURATION_MS + 1);
await withServer(
createWebBackendApp({
hostGuard: guard,
allowPrivateNetworkTargets: true,
}),
async (baseUrl) => {
const targetId = await registerProviderTarget(
baseUrl,
providerUrl
);
const call = () =>
fetch(`${baseUrl}/xtream?targetId=${targetId}`);
const trial = call();
try {
await started;
advance(25_000);
stream.write('1,');
advance(25_000);
const response = await call();
const body = (await response.json()) as {
message: string;
};
expect(
isHostConnectivityFastFailMessage(body.message)
).toBe(true);
} finally {
stream?.end('2]}');
await trial;
}
await expect((await trial).json()).resolves.toMatchObject({
payload: { items: [1, 2] },
});
expect(guard.check(providerUrl)).toMatchObject({
allowed: true,
token: { trial: false },
});
}
);
});
});
it.each(['xtream', 'stalker'])(
'%s releases its trial if outcome reporting throws',
async (route) => {
const httpClient = new StubHttpClient();
const { advance, guard } = createTestGuard();
httpClient.queueNetworkError(hostLevelFailure());
httpClient.queueNetworkError(hostLevelFailure());
httpClient.queueNetworkError(hostLevelFailure());
httpClient.queueResponse([]);
await withServer(
createWebBackendApp({
hostGuard: guard,
httpClient,
resolveHostname: resolvePublicHost,
}),
async (baseUrl) => {
const targetId = await registerProviderTarget(
baseUrl,
'http://portal.example'
);
const call = () =>
fetch(`${baseUrl}/${route}?targetId=${targetId}`);
await call();
await call();
advance(OPEN_DURATION_MS + 1);
const report = jest
.spyOn(guard, 'reportFailure')
.mockImplementationOnce(() => {
throw new Error('outcome reporting failed');
});
try {
expect((await call()).status).toBe(500);
} finally {
report.mockRestore();
}
await expect((await call()).json()).resolves.toMatchObject({
payload: [],
});
expect(httpClient.requests).toHaveLength(4);
}
);
}
);
it.each(['xtream', 'stalker'])(
'%s holds a live trial beyond 45 seconds until the transport settles',
async (route) => {
for (const outcome of [
'success',
'failure',
'cancel',
'redirect-failure',
]) {
const httpClient = new StubHttpClient();
const { advance, guard } = createTestGuard();
httpClient.queueNetworkError(hostLevelFailure());
httpClient.queueNetworkError(hostLevelFailure());
await withServer(
createWebBackendApp({
hostGuard: guard,
httpClient,
resolveHostname: resolvePublicHost,
}),
async (baseUrl) => {
const targetId = await registerProviderTarget(
baseUrl,
'http://portal.example'
);
const call = () =>
fetch(
`${baseUrl}/${route}?targetId=${targetId}&action=get_genres`
);
await call();
await call();
advance(OPEN_DURATION_MS + 1);
let settle!: () => void;
let arrived!: () => void;
const pending = new Promise<void>((resolve) => {
settle = resolve;
});
const started = new Promise<void>((resolve) => {
arrived = resolve;
});
const transport = jest
.spyOn(httpClient, 'get')
.mockImplementationOnce(async () => {
arrived();
await pending;
if (outcome !== 'success') {
throw Object.assign(
hostLevelFailure(
outcome === 'cancel'
? 'ERR_CANCELED'
: 'ETIMEDOUT'
),
{
request: {
_currentUrl:
outcome ===
'redirect-failure'
? 'http://cdn.example/slow'
: `http://portal.example/${route === 'xtream' ? 'player_api.php' : ''}`,
},
}
);
}
return { data: [] as never };
});
const trial = call();
try {
await started;
// Advance only the guard clock; the HTTP route remains genuinely pending.
advance(45_001);
const blocked = await call();
expect(
isHostConnectivityFastFailMessage(
(
(await blocked.json()) as {
message: string;
}
).message
)
).toBe(true);
expect(transport).toHaveBeenCalledTimes(1);
} finally {
settle();
await trial;
transport.mockRestore();
}
if (outcome === 'failure') {
const blocked = await call();
expect(
isHostConnectivityFastFailMessage(
(
(await blocked.json()) as {
message: string;
}
).message
)
).toBe(true);
advance(OPEN_DURATION_MS + 1);
}
httpClient.queueResponse([]);
await expect(
(await call()).json()
).resolves.toMatchObject({ payload: [] });
expect(httpClient.requests).toHaveLength(3);
}
);
}
}
);
it('lets exactly one request through once the open window elapses', async () => {
const httpClient = new StubHttpClient();
httpClient.queueNetworkError(hostLevelFailure());
@@ -352,6 +352,8 @@ export function createWebBackendApp(
});
logProviderRequestFailure({ error, route: '/xtream', url });
res.json(normalizeProviderError(error));
} finally {
releaseProviderRequest(hostGuard, guardToken);
}
});
@@ -459,6 +461,8 @@ export function createWebBackendApp(
});
logProviderRequestFailure({ error, route: '/stalker', url });
res.json(normalizeProviderError(error));
} finally {
releaseProviderRequest(hostGuard, guardToken);
}
});
+39 -16
View File
@@ -65,9 +65,6 @@ timeouts but not the breaker, deliberately:
add-playlist dialog sends `PLAYLIST_PARSE_BY_URL`). Refusing an immediate
retry is a regression, not a protection, and there is no natural reset site on
that path the way portal Retry has one.
- A large XMLTV transfer can legitimately run for minutes — the timeout is
idle-based, see above — which outlives the 45 s half-open trial expiry and
would let a second trial in behind the first.
## Desktop preference and account feedback
@@ -95,8 +92,8 @@ show a localized **Requests temporarily paused** explanation with **Retry now**.
Stalker keeps cached account data visible and offers the same retry beside the
paused refresh notice. Retry resets before requesting again; actual network
failures retain the generic unavailable state. These notices describe the last
request outcome, not a live countdown. The preference does not fix the separate
same-millisecond sibling-counting issue #1438.
request outcome, not a live countdown. Same-millisecond sibling counting is
handled by monotonic admission ids (#1438).
## Rules
@@ -180,7 +177,7 @@ Two more rules exist because of specific failure modes:
are never reused when an endpoint's state is evicted. A recreated record has
no failure streak, so the first old in-flight failure can still count once;
its remaining siblings cannot add further links to that streak. Timestamps
still determine streak expiry, cooldown and trial timeout. Only a failure that
still determine streak expiry and cooldown. Only a failure that
was actually counted may trip the threshold: a sibling settling after the open
window elapsed would otherwise start a fresh one off the existing count and
push the half-open trial past the intended cooldown.
@@ -190,14 +187,38 @@ Two more rules exist because of specific failure modes:
behind settle right after they press Retry and re-open the breaker underneath
the very retry that cleared it.
A half-open trial that never reports back expires after 45 s, so a leaked token
cannot leave the breaker open forever. That expiry is why the slot has an
identity: a trial can genuinely outlive its window — `requestWithValidatedRedirects`
gives each of up to five redirect hops its own 30 s budget — and once a
replacement has been admitted, the abandoned request's late report must not free
the replacement's slot and let a third request through. `trial: true` alone
cannot tell the two apart, so the token carries the slot id it owns; an
abandoned trial's failure is still counted as ordinary evidence.
### Trial ownership follows the request lifetime
A half-open slot has no wall-clock expiry (#1439). A redirect chain gives each
hop a separate timeout, and a body that keeps delivering bytes can exceed any
fixed deadline without reaching its inactivity timeout. Neither may admit a
second trial while the first request remains pending.
The four request owners (Electron Xtream/Stalker IPC and web-backend
`/xtream`/`/stalker`) acquire inside `try`, await the whole transport operation,
report its outcome, and unconditionally release any remaining token in `finally`.
`reportInconclusive` is the idempotent cleanup operation: it releases only the
matching `trialId` and epoch and records no reachability evidence. This is the
leak backstop even when debug logging, classification, or response formatting
throws before an outcome report. Cleanup also runs while the environment kill
switch is on, so switching it back off cannot revive a completed trial's slot.
Desktop preference transitions invalidate the old guard and tokens as before.
Cancellation releases after the awaited transport rejects, when it actually
settles, rather than merely when an abort is requested. Xtream's AbortSignal
continues through all Electron redirect hops. Stalker has no IPC cancellation
signal, and closing a PWA client connection does not cancel the backend's outbound
request; those slots remain owned until that transport settles. The transport's
existing timeout handles inactivity. No heartbeat, polling timer, or larger
trial deadline is needed. A future caller must preserve the `try`/`finally`
contract; a custom transport that never settles needs cancellation at its own
layer and must not be silently overlapped by a second trial.
The slot still has an identity: delayed cleanup from a released owner must not
free its replacement. A late failure still follows the ordinary streak and epoch
rules; a real HTTP response still proves reachability and clears the record.
Explicit reset, discovery success, preference transitions and bounded state
eviction retain their existing rules for forgetting evidence.
## The fast-fail error is a renderer contract
@@ -347,7 +368,8 @@ module's contract that unreachable ≠ contacted-and-refused. The
## Tests
- `libs/shared/host-health/src/lib/host-connectivity-guard.spec.ts` — the state
machine, with an injected clock.
machine, with an injected clock, long-lived trial ownership, late cleanup,
and settlement while the environment kill switch is on.
- `apps/electron-backend/src/app/util/host-connectivity-guard.spec.ts` — the
main-process singleton and redirect attribution through it.
- `apps/web-backend/src/app/web-backend-app.host-guard.spec.ts` — the proxy
@@ -362,6 +384,7 @@ module's contract that unreachable ≠ contacted-and-refused. The
that the discovery bypass is forwarded, and that a reset calls the backend.
- `apps/electron-backend/src/app/events/stalker.events.spec.ts` and
`xtream.events.spec.ts` — trip, fast-fail without contacting axios, reset,
exemption, and the absence of per-request log spam.
exemption, long pending requests and redirect chains, cancellation, cleanup
when debug reporting throws, and the absence of per-request log spam.
- `libs/portal/stalker/data-access/src/lib/stalker-portal-discovery.utils.spec.ts`
and `stalker-portal-repair.service.spec.ts` — the message contract.
@@ -15,7 +15,6 @@ const HOST = 'http://portal.example.com:8080';
const GUARD_DISABLED_ENV = 'IPTVNATOR_DISABLE_CONNECTIVITY_GUARD';
const OPEN_DURATION_MS = 30_000;
const FAILURE_WINDOW_MS = 120_000;
const TRIAL_TIMEOUT_MS = 45_000;
function timeoutError(code = 'ETIMEDOUT') {
return Object.assign(new Error('timeout of 30000ms exceeded'), { code });
@@ -431,6 +430,32 @@ describe('HostConnectivityGuard', () => {
expect(guard.check(HOST).allowed).toBe(false);
});
it('keeps a live trial exclusive beyond 45 seconds and the idle TTL', () => {
const trial = expectAllowed();
advance(45_001);
expectBlocked();
advance(600_001);
guard.check('http://another.example');
expectBlocked();
guard.reportSuccess(trial);
expect(expectAllowed().trial).toBe(false);
});
it.each([
'reportSuccess',
'reportFailure',
'reportInconclusive',
] as const)(
'releases a completed trial via %s while the environment override is disabled',
(report) => {
const trial = expectAllowed();
process.env[GUARD_DISABLED_ENV] = '1';
guard[report](trial);
delete process.env[GUARD_DISABLED_ENV];
expect(expectAllowed().trial).toBe(true);
}
);
it('closes the breaker when the trial succeeds', () => {
const trial = expectAllowed();
guard.reportSuccess(trial);
@@ -461,12 +486,9 @@ describe('HostConnectivityGuard', () => {
});
it('lets only the request holding the slot release it', () => {
// A trial can outlive its own 45 s window: the validated-redirect
// transport gives each of up to five hops its own 30 s budget. Once
// a replacement has been admitted, the abandoned request's late
// report must not hand the slot to a third request.
// Cleanup may run again after a replacement has acquired the slot.
const abandoned = expectAllowed();
advance(TRIAL_TIMEOUT_MS);
guard.reportInconclusive(abandoned);
const replacement = expectAllowed();
expect(replacement.trial).toBe(true);
@@ -475,11 +497,24 @@ describe('HostConnectivityGuard', () => {
expectBlocked();
});
it('recovers when a trial never reports back', () => {
expectAllowed();
it('keeps the replacement slot when the old owner reports a late failure', () => {
const old = expectAllowed();
guard.reportInconclusive(old);
const replacement = expectAllowed();
guard.reportFailure(old);
advance(OPEN_DURATION_MS + 1);
expectBlocked();
guard.reportInconclusive(old);
expectBlocked();
guard.reportSuccess(replacement);
expect(expectAllowed().trial).toBe(false);
});
advance(TRIAL_TIMEOUT_MS);
it('recovers when the owner releases a trial without an outcome report', () => {
const trial = expectAllowed();
expectBlocked();
// The request owner's finally is independent of outcome reporting.
guard.reportInconclusive(trial);
const replacement = expectAllowed();
expect(replacement.trial).toBe(true);
@@ -46,12 +46,6 @@ export const OPEN_DURATION_MS = 30_000;
* 30 s timeouts must fit inside it, hence comfortably above 60 s.
*/
const FAILURE_WINDOW_MS = 120_000;
/**
* Safety net for a half-open trial that never reports back. Above the longest
* request timeout (30 s) plus margin, so it only fires if a caller leaked the
* token — without it a lost report would keep the breaker open forever.
*/
const TRIAL_TIMEOUT_MS = 45_000;
/** Hosts tracked at once; portals per user are few, this is just a bound. */
const MAX_TRACKED_HOSTS = 256;
/** Idle records are forgotten; they hold nothing worth remembering. */
@@ -111,11 +105,8 @@ export interface HostRequestToken {
/**
* Which half-open slot this request holds, when it holds one.
*
* A trial can outlive its own window — `requestWithValidatedRedirects`
* gives each of up to five redirect hops its own 30 s budget — and once a
* replacement has been admitted, the abandoned request must not be able to
* free the replacement's slot. `trial: true` alone cannot tell the two
* apart, so the owner is identified explicitly.
* Cleanup from a released owner can arrive after another trial has been
* admitted. Only this identity (plus epoch) may release the current slot.
*/
readonly trialId: number;
}
@@ -133,7 +124,7 @@ interface HostState {
/** Latest guard-wide admission id when this host's last failure counted. */
lastFailureAdmissionId: number;
openUntil: number;
trialStartedAt: number | null;
trialInFlight: boolean;
/** Monotonic id of the half-open slot; see `HostRequestToken.trialId`. */
trialId: number;
epoch: number;
@@ -247,13 +238,10 @@ export class HostConnectivityGuard {
// let one request through and hold the rest back until it settles.
let trial = false;
if (state.openUntil > 0) {
const trialStale =
state.trialStartedAt !== null &&
now - state.trialStartedAt >= TRIAL_TIMEOUT_MS;
if (state.trialStartedAt !== null && !trialStale) {
if (state.trialInFlight) {
return { allowed: false, retryAfterMs: 0 };
}
state.trialStartedAt = now;
state.trialInFlight = true;
state.trialId += 1;
trial = true;
}
@@ -276,21 +264,29 @@ export class HostConnectivityGuard {
*/
reportSuccess(token: HostRequestToken): void {
const state = this.states.get(token.endpoint);
if (!state || isHostConnectivityGuardDisabled()) {
if (!state) {
return;
}
if (isHostConnectivityGuardDisabled()) {
this.releaseTrial(state, token);
return;
}
state.consecutiveFailures = 0;
state.lastFailureAt = 0;
state.openUntil = 0;
state.trialStartedAt = null;
state.trialInFlight = false;
state.lastTouchedAt = this.now();
}
/** The host did not answer at all. */
reportFailure(token: HostRequestToken): void {
const state = this.states.get(token.endpoint);
if (!state || isHostConnectivityGuardDisabled()) {
if (!state) {
return;
}
if (isHostConnectivityGuardDisabled()) {
this.releaseTrial(state, token);
return;
}
@@ -307,7 +303,7 @@ export class HostConnectivityGuard {
// through the streak rules below and leaves the replacement alone.
const wasTrial = this.ownsTrial(state, token);
if (wasTrial) {
state.trialStartedAt = null;
state.trialInFlight = false;
}
state.lastTouchedAt = now;
@@ -354,10 +350,13 @@ export class HostConnectivityGuard {
/**
* The request failed for a reason that says nothing about the host. Only
* releases the half-open slot, so the next request can be the trial.
* Owners MUST also call this in finally, independently of outcome reporting.
* It is idempotent and still releases while the guard is disabled by the environment;
* no elapsed-time expiry can distinguish a leak from an active transfer.
*/
reportInconclusive(token: HostRequestToken): void {
const state = this.states.get(token.endpoint);
if (!state || isHostConnectivityGuardDisabled()) {
if (!state) {
return;
}
@@ -376,7 +375,7 @@ export class HostConnectivityGuard {
state.consecutiveFailures = 0;
state.lastFailureAt = 0;
state.openUntil = 0;
state.trialStartedAt = null;
state.trialInFlight = false;
state.epoch += 1;
state.lastTouchedAt = now;
}
@@ -409,7 +408,7 @@ export class HostConnectivityGuard {
private ownsTrial(state: HostState, token: HostRequestToken): boolean {
return (
token.trial &&
state.trialStartedAt !== null &&
state.trialInFlight &&
state.epoch === token.epoch &&
state.trialId === token.trialId
);
@@ -417,7 +416,7 @@ export class HostConnectivityGuard {
private releaseTrial(state: HostState, token: HostRequestToken): void {
if (this.ownsTrial(state, token)) {
state.trialStartedAt = null;
state.trialInFlight = false;
}
}
@@ -433,7 +432,7 @@ export class HostConnectivityGuard {
lastFailureAt: 0,
lastFailureAdmissionId: 0,
openUntil: 0,
trialStartedAt: null,
trialInFlight: false,
trialId: 0,
epoch: 0,
lastTouchedAt: now,
@@ -447,7 +446,7 @@ export class HostConnectivityGuard {
if (
now - state.lastTouchedAt > IDLE_TTL_MS &&
state.openUntil <= now &&
state.trialStartedAt === null
!state.trialInFlight
) {
this.states.delete(endpoint);
}