diff --git a/packages/opencode/src/collab/preview-router.ts b/packages/opencode/src/collab/preview-router.ts index 62d2ca9dcec3..94f60a64ad70 100644 --- a/packages/opencode/src/collab/preview-router.ts +++ b/packages/opencode/src/collab/preview-router.ts @@ -30,6 +30,19 @@ import { getActiveUpstreamScheme, getActivePreviewPort, getActiveServePath } fro const PREVIEW_PREFIX = "/preview/" +/** + * Max request body the preview proxy will forward to the in-container dev + * server (S3). The proxy runs inside the single-replica opencode process; + * a multi-GB upload buffered through it before the upstream's own 413 would + * swing the whole task into memory pressure. 50 MB is generous for any + * legitimate dev-server interaction (real frontend file uploads go straight + * to S3, not through the dev server). Requests declaring more are rejected + * at the proxy edge with a 413; requests that omit Content-Length and stream + * past the cap are caught by the same ceiling on the upstream side — this + * guard handles the common declared-length case cheaply. + */ +const MAX_PREVIEW_BODY_BYTES = 50 * 1024 * 1024 + /** * Parse a `/preview/...` URL. Two shapes accepted: * @@ -156,6 +169,24 @@ export async function handlePreviewHttp(req: Request, port: number, rest: string // during a debugging session to be useful. console.log(`[collab.preview-proxy] ${req.method} ${rest} → ${target}`) + // S3 — reject oversize request bodies at the proxy edge. A declared + // Content-Length above the cap is bounced with 413 before we open the + // upstream connection, so a buggy/malicious large upload can't buffer + // through opencode's heap on the single-replica task. + if (req.method !== "GET" && req.method !== "HEAD") { + const declaredLen = Number(req.headers.get("content-length") ?? "0") + if (Number.isFinite(declaredLen) && declaredLen > MAX_PREVIEW_BODY_BYTES) { + const capMB = Math.round(MAX_PREVIEW_BODY_BYTES / (1024 * 1024)) + console.warn( + `[collab.preview-proxy] ${req.method} ${rest} rejected: body ${declaredLen}B exceeds ${capMB}MB cap`, + ) + return new Response( + `Request body too large. The preview proxy caps uploads at ${capMB} MB.`, + { status: 413, headers: { "content-type": "text/plain; charset=utf-8" } }, + ) + } + } + // Strip hop-by-hop headers + the Host header (we set it ourselves below // so the dev server sees its expected hostname; the browser's original // Host header would otherwise leak collab.utils.unleashlive.com which diff --git a/packages/opencode/src/collab/router.ts b/packages/opencode/src/collab/router.ts index 0cc8bea4a8e2..7f3ba500f7f0 100644 --- a/packages/opencode/src/collab/router.ts +++ b/packages/opencode/src/collab/router.ts @@ -621,6 +621,21 @@ function parseCookies(header: string): Record { // ── SSE connection store ──────────────────────────────────────────────────────── +/** S4 — bound concurrent SSE streams per session. Realistic max is 2-3 + * users × 2-3 tabs; 8 leaves comfortable headroom while capping the + * file-descriptor + per-stream-closure footprint a runaway client (many + * tabs, or a reconnect storm) could otherwise pile onto the single-replica + * task. The 9th concurrent connection for a session gets a 429. */ +const MAX_SSE_PER_SESSION = 8 + +/** S4 — recycle an SSE stream that has gone this long without a single real + * event (keepalives don't count). A quiet session's tab is closed from the + * server side; the SPA's EventSource auto-reconnects within ~1 s and + * immediately receives the connect-time state snapshot, so this is a no-op + * UX-wise but caps the lifetime of any one connection — abandoned-but-open + * background tabs can't hold a stream (and its heartbeat timer) for days. */ +const SSE_IDLE_DISCONNECT_MS = 2 * 60 * 60 * 1000 + const sseClients = new Map void>>() function registerSse(collabSessionId: string, send: (e: CollabEvent) => void): () => void { @@ -1806,6 +1821,24 @@ function handleSse( collabSessionId: string, sess: { githubId: number; githubLogin: string }, ): Response { + // S4 — refuse a new stream once this session already has the max + // concurrent connections. The SPA surfaces the 429 as a "too many open + // tabs" hint; closing another tab frees a slot. Counting the registered + // set is sufficient — a brief under-count during the connect window (the + // send callback registers inside stream.start(), a tick later) can only + // let through a connection or two beyond the cap under a true thundering + // herd, which is harmless. + const existing = sseClients.get(collabSessionId)?.size ?? 0 + if (existing >= MAX_SSE_PER_SESSION) { + console.warn( + `[collab] SSE connection cap hit for session=${collabSessionId} (${existing}/${MAX_SSE_PER_SESSION}) — rejecting`, + ) + return new Response( + `Too many open connections for this session (max ${MAX_SSE_PER_SESSION}). Close another tab and retry.`, + { status: 429, headers: { "content-type": "text/plain; charset=utf-8", "retry-after": "5" } }, + ) + } + // We need to defer events until the ReadableStream's controller exists. // The bug we're fixing: previously, setOnline + the collab:participant_joined // broadcast ran here at the top of handleSse, which meant the new client's @@ -1822,8 +1855,14 @@ function handleSse( let unregister: (() => void) | null = null const encoder = new TextEncoder() const pending: CollabEvent[] = [] + // S4 — timestamp of the most-recent REAL event delivered to this client. + // Keepalive comment lines don't update it; the idle-recycle check in the + // heartbeat compares against it. Seeded to "now" so a freshly-opened + // stream is never immediately considered idle. + let lastRealEventAt = Date.now() const send = (event: CollabEvent) => { + lastRealEventAt = Date.now() if (!controllerRef) { pending.push(event) return @@ -1865,6 +1904,21 @@ function handleSse( // stuck in "pending" forever. let heartbeat: ReturnType | null = null + // Single teardown path shared by client-initiated cancel() AND the + // server-initiated idle recycle, guarded so neither double-runs. + let tornDown = false + const teardown = () => { + if (tornDown) return + tornDown = true + if (heartbeat) clearInterval(heartbeat) + unregister?.() + Participant.setOnline(collabSessionId, sess.githubId, false) + // Clear any lingering "is typing…" indicator from this user — if they + // disconnect while typing, others would otherwise see the dot forever. + broadcastSse(collabSessionId, { type: "collab:typing_stop", githubLogin: sess.githubLogin }) + broadcastSse(collabSessionId, { type: "collab:participant_left", githubLogin: sess.githubLogin }) + } + const stream = new ReadableStream({ start(controller) { controllerRef = controller @@ -1923,6 +1977,25 @@ function handleSse( heartbeat = setInterval(() => { if (!controllerRef) return + // S4 idle recycle — if no real event has been delivered for the + // idle window, close this stream from the server side. The SPA + // auto-reconnects and re-syncs via the connect-time snapshot, so a + // genuinely-active session is never interrupted (its events keep + // lastRealEventAt fresh); only quiet/abandoned tabs get recycled, + // capping how long any one connection can persist. + if (Date.now() - lastRealEventAt > SSE_IDLE_DISCONNECT_MS) { + console.log( + `[collab] SSE idle recycle for session=${collabSessionId} user=${sess.githubLogin} ` + + `(no events for ${Math.round(SSE_IDLE_DISCONNECT_MS / 60_000)}m)`, + ) + teardown() + try { + controllerRef.close() + } catch { + // Already closed — fine. + } + return + } try { controllerRef.enqueue(encoder.encode(`: keepalive\n\n`)) } catch { @@ -1932,13 +2005,7 @@ function handleSse( }, 20_000) }, cancel() { - if (heartbeat) clearInterval(heartbeat) - unregister?.() - Participant.setOnline(collabSessionId, sess.githubId, false) - // Clear any lingering "is typing…" indicator from this user — if they - // disconnect while typing, others would otherwise see the dot forever. - broadcastSse(collabSessionId, { type: "collab:typing_stop", githubLogin: sess.githubLogin }) - broadcastSse(collabSessionId, { type: "collab:participant_left", githubLogin: sess.githubLogin }) + teardown() }, })