From 01f721d8d1a718c7b1f192b3af3d110f7d84d340 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 6 Sep 2026 00:42:19 -0700 Subject: [PATCH] Cut what a signed-in tab costs by two thirds Two changes on the push path, both measured against a real Stalwart 0.16.20 with the container capped at 256 MiB and tabs added in steps of 200 until the kernel killed it: tabs held per tab of which native before 1,665 133 KiB 81 KiB pin upstream calls to STALWART_URL 3,400 58 KiB 8 KiB + raw push relay 4,979 37 KiB 10 KiB Stalwart advertises absolute https URLs in every session, and the proxy followed them -- so even with STALWART_URL naming a private plain-HTTP hop on the same Docker network, every held push stream went out through TLS. That leg is about 80 KiB of OpenSSL state per tab: native memory Node cannot see, which is why neither the heap ceiling nor the stream buffer size ever moved the number. absoluteUpstream() now keeps the path and query from the advertised URL and the scheme, host and port from the configured one. A setup that must reach Stalwart at an origin other than the one it was given sets STALWART_FOLLOW_ADVERTISED_URLS=1. With the transport out of the way, the fetch()-based relay was the next cost: an undici Response, a web ReadableStream, a reader and Hono's stream bridge held alive per tab, about 44 KiB of heap for a session that otherwise costs 4 KiB. relayPushRaw() pipes the upstream socket into the Node response and tells the adapter the response is already sent. RAW_PUSH_RELAY=0 restores the fetch path for comparison. JMAP throughput is unchanged (2,383/s against 2,484/s at 50 users, inside run-to-run noise); the relay does not touch that path. Verified that a push stream through the raw relay delivers a StateChange while mail is written. The install page's advice to set --max-old-space-size was measured in the same runs and made no difference at all -- 3,400 tabs with it and without -- and is withdrawn in the docs alongside this change. --- server/src/app.ts | 59 +++++++++++++++++++++++++++++++++++++ server/src/compress.test.ts | 8 +++++ server/src/config.ts | 4 +++ server/src/upstream.ts | 27 ++++++++++++++++- 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/server/src/app.ts b/server/src/app.ts index f543a20..327f247 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -2,6 +2,9 @@ import { Hono } from "hono"; import type { Context, MiddlewareHandler } from "hono"; import { getCookie, setCookie, deleteCookie } from "hono/cookie"; import { compress } from "hono/compress"; +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response"; import { getConnInfo } from "@hono/node-server/conninfo"; import { config } from "./config.js"; import { SessionStore, type SessionBackend, type LiveSession } from "./sessions.js"; @@ -700,6 +703,7 @@ export function createApp(basePath = config.basePath): Hono { try { const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username)); const url = absoluteUpstream(expandTemplate(upstream.eventSourceUrl, { types, closeafter, ping }), upstream.baseUrl); + if (config.rawPushRelay) return relayPushRaw(c, url, session.authorization); const controller = new AbortController(); c.req.raw.signal.addEventListener("abort", () => controller.abort()); const res = await fetch(url, { @@ -790,6 +794,61 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, */ const PASSTHROUGH_HEADERS = new Set(["content-type", "content-disposition", "content-language", "etag", "last-modified", "retry-after"]); +/** + * Hold a push stream open with the least machinery that will do it. + * + * The fetch() version above builds an undici Response, a web ReadableStream, + * a reader, and Hono's stream-to-Node bridge for every tab, and keeps all of + * it alive for as long as the tab is open. Measured against a real Stalwart + * that is about 44 KiB of JavaScript heap per tab -- twelve times what the + * session itself costs -- and a signed-in tab is otherwise nothing but this + * one held connection. Here the upstream socket is piped straight into the + * Node response, so what stays resident per tab is two sockets and their + * small IncomingMessage/ServerResponse pair. + * + * Returns a Response Hono treats as already sent: the raw bindings are + * written to directly, and the returned value is never serialised. + */ +function relayPushRaw(c: Context, url: string, authorization: string): Response { + const out = (c.env as { outgoing: import("node:http").ServerResponse }).outgoing; + const target = new URL(url); + const req = (target.protocol === "https:" ? httpsRequest : httpRequest)(target, { + method: "GET", + headers: { authorization, accept: "text/event-stream" }, + }); + const abort = () => req.destroy(); + c.req.raw.signal.addEventListener("abort", abort); + out.on("close", abort); + req.on("response", (res) => { + if (res.statusCode !== 200) { + res.resume(); + out.writeHead(502, { "content-type": "application/json", "cache-control": "no-store" }); + out.end(JSON.stringify({ error: "upstream_error" })); + return; + } + out.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + "x-accel-buffering": "no", + }); + out.flushHeaders(); + res.pipe(out); + }); + req.on("error", () => { + if (!out.headersSent) { + out.writeHead(502, { "content-type": "application/json", "cache-control": "no-store" }); + out.end(JSON.stringify({ error: "upstream_error" })); + } else { + out.end(); + } + }); + req.end(); + // Tells @hono/node-server the raw ServerResponse has been written to and + // must be left alone. + return RESPONSE_ALREADY_SENT; +} + function passthrough(res: Response): Response { const headers = new Headers(); res.headers.forEach((v, k) => { diff --git a/server/src/compress.test.ts b/server/src/compress.test.ts index 49da5e6..6e83167 100644 --- a/server/src/compress.test.ts +++ b/server/src/compress.test.ts @@ -74,3 +74,11 @@ test("the liveness probe is not compressed, since gzip would make it bigger", as assert.equal(res.status, 200); assert.equal(res.headers.get("content-encoding"), null); }); + +test("advertised upstream URLs are pinned to the configured origin", async () => { + const { absoluteUpstream } = await import("./upstream.js"); + const pinned = absoluteUpstream("https://mail.public.example/jmap/eventsource/?types=*", "http://stalwart:8080"); + assert.equal(pinned, "http://stalwart:8080/jmap/eventsource/?types=*"); + // A relative URL still resolves against the base, as before. + assert.equal(absoluteUpstream("/jmap/", "http://stalwart:8080/"), "http://stalwart:8080/jmap/"); +}); diff --git a/server/src/config.ts b/server/src/config.ts index b9d34bb..3119f0e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -298,6 +298,10 @@ export const config = { cookieName: env("COOKIE_NAME", "ihm_session"), staticDir: process.env.STATIC_DIR ?? fileURLToPath(new URL("../../web/dist", import.meta.url)), loginRateLimit: int("LOGIN_RATE_LIMIT", 10), + /* See relayPushRaw(): pipe the push stream socket-to-socket instead of through fetch(). */ + rawPushRelay: process.env.RAW_PUSH_RELAY !== "0", + /* See absoluteUpstream(): follow Stalwart's advertised origin instead of pinning to ours. */ + followAdvertisedUrls: process.env.STALWART_FOLLOW_ADVERTISED_URLS === "1", }; export type Config = typeof config; diff --git a/server/src/upstream.ts b/server/src/upstream.ts index 06c9361..40eebf8 100644 --- a/server/src/upstream.ts +++ b/server/src/upstream.ts @@ -288,9 +288,34 @@ export function localizeSession(s: UpstreamSession, extras: Record