Initial Commit
This commit is contained in:
80
packages/api-stack/api/api-ws-bun/src/heartbeat/heartbeat.md
Normal file
80
packages/api-stack/api/api-ws-bun/src/heartbeat/heartbeat.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# `heartbeat`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the per-connection ping/pong state machine described in design §11.4. The engine instantiates one `HeartbeatController` per accepted connection, drives `tick()` from a timer, and routes pong frames into `recordPong()`. The controller decides whether to send another ping, do nothing, or abort the connection — that decision is delegated to `evaluateHeartbeat` in `@suaveplan/api-ws` so policy stays uniform across engines.
|
||||
|
||||
The controller is clock-injected so tests can use a `FakeClock` to advance time deterministically without relying on real timers.
|
||||
|
||||
## Features
|
||||
|
||||
- Tracks both wall-clock time and consecutive missed-pong counts.
|
||||
- Emits `onPing` once per heartbeat window; double-`tick` inside the same window is a no-op so the engine can poll cheaply.
|
||||
- Throws `WS_BUN.HEARTBEAT_FAILED` after `missedPongLimit` misses to keep the abort path uniform with the other WS_BUN failure modes.
|
||||
- `aborted` and `missedPongs` accessors surface state to OTel instruments.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { HeartbeatController } from "@suaveplan/api-ws-bun";
|
||||
|
||||
const clock = { now: () => Date.now() };
|
||||
const ctl = new HeartbeatController({
|
||||
connectionId: "c1",
|
||||
heartbeatMs: 30_000,
|
||||
missedPongLimit: 2,
|
||||
clock,
|
||||
onPing: () => ws.ping(),
|
||||
onAbort: () => ws.close(1011, "heartbeat"),
|
||||
});
|
||||
setInterval(() => ctl.tick(), 5_000);
|
||||
ws.on("pong", () => ctl.recordPong());
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { FakeClock } from "@suaveplan/testing";
|
||||
import { HeartbeatController, WS_BUN_CODES } from "@suaveplan/api-ws-bun";
|
||||
|
||||
const clock = new FakeClock();
|
||||
let aborted = false;
|
||||
const ctl = new HeartbeatController({
|
||||
connectionId: "c1",
|
||||
heartbeatMs: 1000,
|
||||
missedPongLimit: 2,
|
||||
clock: { now: () => clock.now() },
|
||||
onPing: () => undefined,
|
||||
onAbort: () => {
|
||||
aborted = true;
|
||||
},
|
||||
});
|
||||
clock.advance(1000);
|
||||
ctl.tick(); // ping 1
|
||||
clock.advance(1000);
|
||||
try {
|
||||
ctl.tick(); // 2 missed → abort
|
||||
} catch (err) {
|
||||
// err.code === WS_BUN_CODES.HEARTBEAT_FAILED
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `HeartbeatClock` — `{ now(): number }`.
|
||||
- `HeartbeatControllerOptions` — `{ connectionId, heartbeatMs, missedPongLimit, clock, onPing, onAbort }`.
|
||||
- `new HeartbeatController(options)` — constructor.
|
||||
- `controller.tick(): void` — advance the state machine.
|
||||
- `controller.recordPong(): void` — reset the missed-counter.
|
||||
- `controller.aborted: boolean` — true once the abort transition has fired.
|
||||
- `controller.missedPongs: number` — current missed-pong count.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`tick()` checks `now - lastPingSent` to avoid spamming pings on every poll — the controller pings only once per window. This makes the engine free to call `tick()` at any cadence shorter than `heartbeatMs` without affecting wire behaviour.
|
||||
|
||||
After an abort fires, `recordPong()` and subsequent `tick()` calls become no-ops. The engine still receives the `onAbort` callback exactly once because the throw immediately follows it, so any double-bookkeeping at the engine layer is guaranteed safe.
|
||||
@@ -0,0 +1,140 @@
|
||||
import { FakeClock } from "@suaveplan/testing";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { WS_BUN_CODES, WsBunError } from "../error-codes/error-codes.js";
|
||||
import { HeartbeatController } from "./heartbeat.js";
|
||||
|
||||
describe("HeartbeatController", () => {
|
||||
it("ticks within window are no-op", { timeout: 5000 }, () => {
|
||||
const clock = new FakeClock({ startTime: 1_000 });
|
||||
let pings = 0;
|
||||
const ctl = new HeartbeatController({
|
||||
connectionId: "c1",
|
||||
heartbeatMs: 1000,
|
||||
missedPongLimit: 2,
|
||||
clock: { now: () => clock.now() },
|
||||
onPing: () => {
|
||||
pings += 1;
|
||||
},
|
||||
onAbort: () => undefined,
|
||||
});
|
||||
clock.advance(500);
|
||||
ctl.tick();
|
||||
expect(pings).toBe(0);
|
||||
});
|
||||
|
||||
it("sends a ping after one window", { timeout: 5000 }, () => {
|
||||
const clock = new FakeClock({ startTime: 1_000 });
|
||||
let pings = 0;
|
||||
const ctl = new HeartbeatController({
|
||||
connectionId: "c1",
|
||||
heartbeatMs: 1000,
|
||||
missedPongLimit: 2,
|
||||
clock: { now: () => clock.now() },
|
||||
onPing: () => {
|
||||
pings += 1;
|
||||
},
|
||||
onAbort: () => undefined,
|
||||
});
|
||||
clock.advance(1000);
|
||||
ctl.tick();
|
||||
expect(pings).toBe(1);
|
||||
expect(ctl.missedPongs).toBe(1);
|
||||
});
|
||||
|
||||
it("aborts after missedPongLimit consecutive misses", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clock = new FakeClock({ startTime: 1_000 });
|
||||
let aborted = false;
|
||||
const ctl = new HeartbeatController({
|
||||
connectionId: "c1",
|
||||
heartbeatMs: 1000,
|
||||
missedPongLimit: 2,
|
||||
clock: { now: () => clock.now() },
|
||||
onPing: () => undefined,
|
||||
onAbort: () => {
|
||||
aborted = true;
|
||||
},
|
||||
});
|
||||
clock.advance(1000);
|
||||
ctl.tick(); // miss 1
|
||||
clock.advance(1000);
|
||||
ctl.tick(); // miss 2
|
||||
clock.advance(1000);
|
||||
try {
|
||||
ctl.tick(); // misses (2) >= limit (2) -> abort
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err instanceof WsBunError).toBe(true);
|
||||
expect((err as WsBunError).wsBunCode).toBe(
|
||||
WS_BUN_CODES.HEARTBEAT_FAILED
|
||||
);
|
||||
}
|
||||
expect(aborted).toBe(true);
|
||||
expect(ctl.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("recordPong resets miss counter", { timeout: 5000 }, () => {
|
||||
const clock = new FakeClock({ startTime: 1_000 });
|
||||
const ctl = new HeartbeatController({
|
||||
connectionId: "c1",
|
||||
heartbeatMs: 1000,
|
||||
missedPongLimit: 2,
|
||||
clock: { now: () => clock.now() },
|
||||
onPing: () => undefined,
|
||||
onAbort: () => undefined,
|
||||
});
|
||||
clock.advance(1000);
|
||||
ctl.tick();
|
||||
expect(ctl.missedPongs).toBe(1);
|
||||
ctl.recordPong();
|
||||
expect(ctl.missedPongs).toBe(0);
|
||||
});
|
||||
|
||||
it("ticks after abort are no-op", { timeout: 5000 }, () => {
|
||||
const clock = new FakeClock({ startTime: 1_000 });
|
||||
const ctl = new HeartbeatController({
|
||||
connectionId: "c1",
|
||||
heartbeatMs: 1000,
|
||||
missedPongLimit: 1,
|
||||
clock: { now: () => clock.now() },
|
||||
onPing: () => undefined,
|
||||
onAbort: () => undefined,
|
||||
});
|
||||
clock.advance(1000);
|
||||
ctl.tick(); // misses -> 1
|
||||
clock.advance(1000);
|
||||
try {
|
||||
ctl.tick(); // misses (1) >= limit (1) -> abort
|
||||
} catch {
|
||||
/* abort */
|
||||
}
|
||||
expect(ctl.aborted).toBe(true);
|
||||
// After abort, recordPong + tick must be no-ops.
|
||||
ctl.recordPong();
|
||||
ctl.tick();
|
||||
expect(ctl.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("repeat tick inside the same window does not re-ping", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const clock = new FakeClock({ startTime: 1_000 });
|
||||
let pings = 0;
|
||||
const ctl = new HeartbeatController({
|
||||
connectionId: "c1",
|
||||
heartbeatMs: 1000,
|
||||
missedPongLimit: 5,
|
||||
clock: { now: () => clock.now() },
|
||||
onPing: () => {
|
||||
pings += 1;
|
||||
},
|
||||
onAbort: () => undefined,
|
||||
});
|
||||
clock.advance(1000);
|
||||
ctl.tick();
|
||||
ctl.tick();
|
||||
ctl.tick();
|
||||
expect(pings).toBe(1);
|
||||
});
|
||||
});
|
||||
84
packages/api-stack/api/api-ws-bun/src/heartbeat/heartbeat.ts
Normal file
84
packages/api-stack/api/api-ws-bun/src/heartbeat/heartbeat.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { evaluateHeartbeat } from "@suaveplan/api-ws";
|
||||
import { raiseHeartbeatFailed } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Clock abstraction used by the heartbeat controller. The engine wires
|
||||
* `Date.now` in production; tests pass a `FakeClock`.
|
||||
*/
|
||||
export interface HeartbeatClock {
|
||||
now(): number;
|
||||
}
|
||||
|
||||
/** Constructor options for {@link HeartbeatController}. */
|
||||
export interface HeartbeatControllerOptions {
|
||||
readonly connectionId: string;
|
||||
readonly heartbeatMs: number;
|
||||
readonly missedPongLimit: number;
|
||||
readonly clock: HeartbeatClock;
|
||||
readonly onPing: () => void;
|
||||
readonly onAbort: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-connection heartbeat state machine. The engine drives `tick()` on a
|
||||
* timer (or directly from a test-controlled `FakeClock`); the controller
|
||||
* compares the current view of "ms since last pong" + "consecutive missed
|
||||
* pongs" against the contract's `evaluateHeartbeat` evaluator.
|
||||
*/
|
||||
export class HeartbeatController {
|
||||
private lastPong: number;
|
||||
private misses = 0;
|
||||
private lastPingSent: number;
|
||||
private done = false;
|
||||
|
||||
public constructor(private readonly options: HeartbeatControllerOptions) {
|
||||
this.lastPong = options.clock.now();
|
||||
this.lastPingSent = options.clock.now();
|
||||
}
|
||||
|
||||
/** Called when a pong frame arrives. Resets the missed-counter. */
|
||||
public recordPong(): void {
|
||||
if (this.done) return;
|
||||
this.lastPong = this.options.clock.now();
|
||||
this.misses = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the state machine. Emits `onPing` when the heartbeat
|
||||
* interval has elapsed, or `onAbort` (plus throws
|
||||
* `WS_BUN.HEARTBEAT_FAILED`) after `missedPongLimit` consecutive
|
||||
* misses.
|
||||
*/
|
||||
public tick(): void {
|
||||
if (this.done) return;
|
||||
const now = this.options.clock.now();
|
||||
const elapsed = now - this.lastPong;
|
||||
const action = evaluateHeartbeat(elapsed, this.misses, {
|
||||
heartbeatMs: this.options.heartbeatMs,
|
||||
maxMissedPongs: this.options.missedPongLimit,
|
||||
});
|
||||
if (action === "abort") {
|
||||
this.done = true;
|
||||
this.options.onAbort();
|
||||
raiseHeartbeatFailed(this.options.connectionId);
|
||||
}
|
||||
if (action === "ping") {
|
||||
// Only send a ping if we have not pinged this window already.
|
||||
if (now - this.lastPingSent >= this.options.heartbeatMs) {
|
||||
this.lastPingSent = now;
|
||||
this.misses += 1;
|
||||
this.options.onPing();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the controller has fired its abort transition. */
|
||||
public get aborted(): boolean {
|
||||
return this.done;
|
||||
}
|
||||
|
||||
/** Current missed-pong count — surfaced for instruments. */
|
||||
public get missedPongs(): number {
|
||||
return this.misses;
|
||||
}
|
||||
}
|
||||
5
packages/api-stack/api/api-ws-bun/src/heartbeat/index.ts
Normal file
5
packages/api-stack/api/api-ws-bun/src/heartbeat/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type HeartbeatClock,
|
||||
HeartbeatController,
|
||||
type HeartbeatControllerOptions,
|
||||
} from "./heartbeat.js";
|
||||
Reference in New Issue
Block a user