web+backend: a warming MCP health card says only "Starting instance... (attempt n of 12)" and re-probes for ~60s before it warns (D-220)

The card composed its line from the backend's explanation ("did not answer within
3s - it scales to zero when idle ... ~20-30s"): probe mechanics nobody waiting on a
starting instance needs. It now writes its own one-liner and ignores check.message,
so the server's "warming" and a client-side probe timeout read identically and the
wording does not depend on a backend deploy. The backend message is shortened but
still carries the budget for direct API callers.

The re-poll budget of 8 x 5s (~40s) expired on a real cold start - Container Apps
activation has no upper bound we control - so it is now 12 x 5s (~60s), with a test
pinning that the stated seconds come from cadence x attempts.
This commit is contained in:
Oliver Kieselbach
2026-09-07 21:27:07 +02:00
parent 066b6e6f04
commit 31535b07e0
5 changed files with 34 additions and 13 deletions
@@ -327,7 +327,9 @@ public class HealthCheckService
// fault — and with minReplicas=0 it is the expected path for an idle server,
// which is why it logs at Information and must not colour any aggregate.
check.Status = "warming";
check.Message = $"MCP server did not answer within {timeoutSeconds}s — it scales to zero when idle and is likely still starting (a cold start takes ~20-30s).";
// Short on purpose: the portal card renders its own one-line warming text and
// ignores this message, so it only has to be honest for a direct API caller.
check.Message = $"MCP server is starting (no answer within {timeoutSeconds}s; it scales to zero when idle)";
_logger.LogInformation(ex, "MCP server health probe found a cold server (no answer within {TimeoutSeconds}s); activation continues in the background", timeoutSeconds);
}
catch (HttpRequestException ex)
@@ -156,7 +156,9 @@ export default function HealthCheckPage() {
description: 'AI query interface availability',
status: clientTimedOut ? 'warming' : 'warning',
message: clientTimedOut
? 'MCP status check timed out — the server is likely still starting.'
// Never rendered — resolveMcpCardState writes the warming line itself — but it
// keeps this object honest for anything that reads the raw check.
? 'MCP server is starting (probe timed out)'
: error instanceof Error ? error.message : 'MCP status check could not complete',
});
} finally {
@@ -6,6 +6,7 @@ import {
visibleHealthDetails,
resolveMcpCardState,
MCP_WARMING_MAX_ATTEMPTS,
MCP_WARMING_POLL_MS,
} from "../healthCheckView";
const checks: HealthCheck[] = [
@@ -98,7 +99,7 @@ describe("visibleHealthDetails", () => {
});
describe("resolveMcpCardState", () => {
const warming = (message = "MCP server did not answer within 3s — it scales to zero when idle."): HealthCheck => ({
const warming = (message = "MCP server is starting (no answer within 3s; it scales to zero when idle)"): HealthCheck => ({
name: "MCP Server",
description: "AI query interface availability",
status: "warming",
@@ -124,13 +125,18 @@ describe("resolveMcpCardState", () => {
expect(s.display.status).toBe("warming");
expect(s.ratedStatus).toBeNull();
expect(s.shouldPoll).toBe(true);
expect(s.display.message).toContain("attempt 1 of 8");
expect(s.display.message).toBe(`Starting instance… (attempt 1 of ${MCP_WARMING_MAX_ATTEMPTS})`);
});
it("does not carry the server's explanation into the card", () => {
const s = resolveMcpCardState({ check: warming(), loading: false, attempts: 0 });
expect(s.display.message).not.toContain("scales to zero");
});
it("keeps showing warming while the next probe runs, so the card cannot flicker", () => {
const s = resolveMcpCardState({ check: warming(), loading: true, attempts: 3 });
expect(s.display.status).toBe("warming");
expect(s.display.message).toContain("attempt 4 of 8");
expect(s.display.message).toContain(`attempt 4 of ${MCP_WARMING_MAX_ATTEMPTS}`);
expect(s.shouldPoll).toBe(true);
});
@@ -139,6 +145,10 @@ describe("resolveMcpCardState", () => {
expect(s.display.status).toBe("warning");
expect(s.ratedStatus).toBe("warning");
expect(s.shouldPoll).toBe(false);
// The stated budget must be the one actually spent, not a number that drifted.
expect(s.display.message).toContain(
`~${Math.round((MCP_WARMING_MAX_ATTEMPTS * MCP_WARMING_POLL_MS) / 1000)}s`,
);
});
it("rates a healthy server and stops polling", () => {
@@ -36,13 +36,15 @@ export function visibleHealthChecks(checks: readonly HealthCheck[], operatorView
* (see HealthCheckService.CheckMcpServerAsync) rather than holding the request. The card
* then re-polls on its own until the replica is up.
*
* 5s x 8 attempts ≈ 46s of coverage against a measured 13-25s activation (worst observed
* first byte 31.5s). Shorter intervals buy nothing — every attempt costs the backend a 3s
* held request — and more attempts would turn a genuinely broken container into a silent
* forever-spinner instead of an honest warning.
* 5s x 12 attempts ≈ 60s of coverage. The measured activation is 13-25s (worst observed
* first byte 31.5s), but a 40s budget was seen to expire on a real cold start — Container
* Apps activation has no upper bound we control — so the budget is 60s. Shorter intervals
* buy nothing (every attempt costs the backend a 3s held request), and stretching it much
* further would turn a genuinely broken container into a silent forever-spinner instead of
* an honest warning.
*/
export const MCP_WARMING_POLL_MS = 5_000;
export const MCP_WARMING_MAX_ATTEMPTS = 8;
export const MCP_WARMING_MAX_ATTEMPTS = 12;
/**
* Client-side budget for one probe. Comfortably above the backend's ~3s answer, and
@@ -88,7 +90,7 @@ export function resolveMcpCardState(args: {
display: {
...MCP_CARD_BASE,
status: "warning",
message: `MCP server did not come up within ~${Math.round((MCP_WARMING_MAX_ATTEMPTS * MCP_WARMING_POLL_MS) / 1000)}s. Use Re-check, or the container may be failing to start.`,
message: `Instance did not start within ~${Math.round((MCP_WARMING_MAX_ATTEMPTS * MCP_WARMING_POLL_MS) / 1000)}s — use Re-check.`,
},
ratedStatus: "warning",
shouldPoll: false,
@@ -96,11 +98,16 @@ export function resolveMcpCardState(args: {
}
// Deliberately keeps showing "warming" while the next probe is in flight: switching to
// "checking" every 5s would make the card flicker for the whole cold start.
//
// The card owns this wording and ignores check.message on purpose: a scale-from-zero is
// routine, nobody watching the page needs the probe budget or the mechanics of it (they
// stay in the backend log and in the API message), and the two warming sources — the
// server's own "warming" and a client-side probe timeout — must read identically.
return {
display: {
...MCP_CARD_BASE,
status: "warming",
message: `${check.message} Re-checking automatically (attempt ${attempts + 1} of ${MCP_WARMING_MAX_ATTEMPTS})…`,
message: `Starting instance… (attempt ${attempts + 1} of ${MCP_WARMING_MAX_ATTEMPTS})`,
details: check.details,
},
ratedStatus: null,