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
10 changes: 9 additions & 1 deletion src/hooks/useHostPingPolling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { allConnections } from "@/stores/connectionStore";
import { useSessionStore } from "@/stores/sessionStore";
import { buildPingTargets } from "@/services/ping/pingTargets";
import { probeTarget } from "@/services/ping/probe";
import { TICK_MS, selectDue } from "@/services/ping/schedule";
import { DOWN_RETRY_MS, TICK_MS, confirmDown, selectDue } from "@/services/ping/schedule";

export function useHostPingPolling() {
const [enabled] = useToggle("reachability");
Expand All @@ -20,6 +20,7 @@ export function useHostPingPolling() {
let cancelled = false;
let dueAt: Record<string, number> = {};
const inFlight = new Set<string>();
const failingSince = new Map<string, number>();

// Stores are read through getState() rather than subscribed to: a write to
// the connection store must not restart the schedule or trigger a probe.
Expand All @@ -34,9 +35,16 @@ export function useHostPingPolling() {
for (const target of selected.due) {
if (inFlight.has(target.key)) continue;
inFlight.add(target.key);
const startedAt = Date.now();
void probeTarget(target).then(({ status, latencyMs }) => {
inFlight.delete(target.key);
if (cancelled) return;
if (status !== "down") {
failingSince.delete(target.key);
} else if (!confirmDown(failingSince, target.key, startedAt)) {
dueAt[target.key] = Date.now() + DOWN_RETRY_MS;
return;
}
for (const id of target.connectionIds) setStatus(id, status, latencyMs);
});
}
Expand Down
31 changes: 30 additions & 1 deletion src/services/ping/schedule.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { STARTUP_SPREAD_MS, intervalFor, jitterFor, selectDue } from "./schedule";
import { DOWN_CONFIRM_MS, DOWN_RETRY_MS, STARTUP_SPREAD_MS, confirmDown, intervalFor, jitterFor, selectDue } from "./schedule";
import type { PingTarget } from "./pingTargets";

const SESSION_MS = 5_000;
Expand Down Expand Up @@ -86,3 +86,32 @@ describe("selectDue", () => {
expect(third.due).toHaveLength(0);
});
});

describe("confirmDown", () => {
test("a single failed probe is not reported as down", () => {
expect(confirmDown(new Map(), "h1:22", 1_000)).toBe(false);
});

test("failures spanning the confirm window are reported as down", () => {
const failingSince = new Map<string, number>();
expect(confirmDown(failingSince, "h1:22", 0)).toBe(false);
expect(confirmDown(failingSince, "h1:22", DOWN_RETRY_MS)).toBe(false);
expect(confirmDown(failingSince, "h1:22", DOWN_CONFIRM_MS)).toBe(true);
});

test("once confirmed, later failures stay down", () => {
const failingSince = new Map([["h1:22", 0]]);
expect(confirmDown(failingSince, "h1:22", DOWN_CONFIRM_MS + IDLE_MS)).toBe(true);
});

test("a success in between restarts the window", () => {
const failingSince = new Map<string, number>();
confirmDown(failingSince, "h1:22", 0);
failingSince.delete("h1:22");
expect(confirmDown(failingSince, "h1:22", DOWN_CONFIRM_MS)).toBe(false);
});

test("retries well inside the confirm window", () => {
expect(DOWN_RETRY_MS * 2).toBeLessThanOrEqual(DOWN_CONFIRM_MS);
});
});
10 changes: 10 additions & 0 deletions src/services/ping/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { PingTarget } from "./pingTargets";

export const TICK_MS = 1_000;
export const STARTUP_SPREAD_MS = 5_000;
/// One failed dial (lost SYN, network still waking from sleep) is not proof a
/// host is down; retries stay under `ufw limit`'s 6 connections per 30s.
export const DOWN_CONFIRM_MS = 15_000;
export const DOWN_RETRY_MS = 7_500;

/// FNV-1a over the target key: stable across ticks and app restarts, so a
/// large host list does not fire on a single tick.
Expand Down Expand Up @@ -54,3 +58,9 @@ export function selectDue(

return { due, dueAt: next };
}

export function confirmDown(failingSince: Map<string, number>, key: string, startedAt: number): boolean {
const since = failingSince.get(key) ?? startedAt;
failingSince.set(key, since);
return startedAt - since >= DOWN_CONFIRM_MS;
}
Loading