Skip to content

uws: cursor-based BackPressure buffer so erase() is a pointer bump - #34824

Merged
Jarred-Sumner merged 7 commits into
mainfrom
farm/b8860f3a/uws-backpressure-cursor-buffer
Jul 21, 2026
Merged

Jarred-Sumner merged 7 commits into
mainfrom
farm/b8860f3a/uws-backpressure-cursor-buffer

Conversation

@robobun

@robobun robobun commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Replaces the std::string-backed uWS::BackPressure with a single malloc'd slab tracked by head/tail cursors, so draining a backpressured socket is a pointer bump instead of a front-erase memmove followed by a shrink_to_fit realloc.

Why?

The previous shape:

void erase(unsigned int n) {
    pendingRemoval += n;
    if (pendingRemoval > (buffer.length() >> 5)) {
        buffer.erase(0, pendingRemoval);   // memmove(remaining)
        buffer.shrink_to_fit();            // alloc(remaining) + memcpy(remaining) + free(old)
        pendingRemoval = 0;
    }
}

A full drain of N buffered bytes crosses that 1/32 threshold ~32 times, and each crossing moves roughly the entire remaining buffer twice (once for the front-erase memmove, once for the shrink realloc). append() separately went through std::string::append, which reallocates and copies the whole buffer (including the dead pendingRemoval prefix) whenever capacity is exceeded.

Fix

BackPressure is now char *buf; size_t head, tail, cap; with the data contiguous in [head, tail):

  • erase(n) bumps head; on full drain it resets both cursors to 0 and frees.
  • append() / resize() write at tail. When the tail would overrun cap, first try compacting into the drained head gap (one memmove); only grow when the live bytes plus the new bytes genuinely do not fit. Growth uses realloc() when head == 0 so mimalloc / glibc can extend in place, and drops dead head bytes otherwise.
  • getBufferedAmount() now reports length() (unsent bytes); memoryCost() reports totalLength() (allocation footprint) so GC extra-memory reporting keeps reflecting the real heap allocation.
  • clear() still releases the allocation, matching the previous behaviour on full drain.

The API (data(), length(), size(), resize(), reserve(), append(), erase(), clear(), totalLength()) is unchanged and data() still spans length() contiguous bytes, so no call site other than getBufferedAmount / memoryCost had to change.

Relationship to #34023

That PR raises the 1/32 compaction threshold to 1/2 and drops the shrink_to_fit, which removes the worst of the repeated realloc. This PR goes further by making erase() free of any data movement and letting append() reuse the drained head space without growing. If #34023 lands first the conflict is trivial (both rewrite the same small struct).

Measurements

Release build, ws.sendBinary through Bun.serve with a raw-socket drain (linux x64, average of 3):

pattern main this PR
stream 256MB through a 8MB backpressure window 223ms 195ms
single 256MB send then drain, peak RSS over baseline +311MB +311MB

The steady-state streaming case is faster because each drain no longer memmoves and reallocates the live window; the single-send peak is unchanged because both implementations hold one ~256MB buffer and the brief shrink_to_fit 2x spike on main is shorter than ru_maxrss can observe under mimalloc's mmap-backed large allocations. The new buffer retains its high-water capacity until the next full drain instead of shrinking per 1/32 step, which in the 8MB-window case shows as ~6MB higher steady RSS (56MB vs 50MB peak).

How did you verify your code works?

New integrity tests in test/js/bun/websocket/websocket-server-backpressure-buffer.test.ts push 32MB (direct append + erase) and 4096 x 4KB frames (cork overflow into resize() while repeatedly compacting) through a backpressured ServerWebSocket to a raw-socket client and sha1-compare every payload byte.

bun bd test test/js/bun/websocket/websocket-server-backpressure-buffer.test.ts
(pass) BackPressure buffer > delivers a large direct send byte-for-byte while draining [2337.16ms]
(pass) BackPressure buffer > delivers many corked frames while appending into a partly-drained buffer [2162.85ms]

Also ran node-http-backpressure.test.ts, serve-response-gc-backpressure-abort.test.ts, serve.test.ts, node-http.test.ts, websocket-server.test.ts; no new failures relative to main.


no test proof · iteration 3 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/websocket/websocket-server-backpressure-buffer.test.ts

…d compacts instead of reallocating)

The std::string-backed BackPressure front-erased and shrink_to_fit() every
time pendingRemoval crossed 1/32 of the buffer, so draining N bytes paid
~32 passes of memmove(remaining) + realloc(remaining) + memcpy(remaining),
and each realloc briefly held old + new allocations.

Replace it with a single malloc'd slab and head/tail cursors:
- erase(n) bumps head; on full drain resets to 0,0 and frees.
- append()/resize() reuse the drained head gap via one memmove before
  growing, and use realloc() when head==0 so the allocator can extend
  in place.
- getBufferedAmount() reports unsent bytes (length()); memoryCost()
  reports allocation footprint (totalLength()).

Adds integrity tests that push large direct and corked-frame sends
through a backpressured ServerWebSocket and sha1-compare the drained
bytes.
@coderabbitai

coderabbitai Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 872bd049-3d63-41d7-bcc7-12dee5296ee9

📥 Commits

Reviewing files that changed from the base of the PR and between 4b0e432 and 8db78aa.

📒 Files selected for processing (2)
  • packages/bun-uws/src/AsyncSocketData.h
  • test/js/bun/websocket/websocket-server-backpressure-buffer.test.ts

Walkthrough

Changes

The WebSocket backpressure buffer now uses manually managed contiguous storage with explicit compaction, growth, and release. Buffered amount reports live unsent bytes, memory reporting uses allocated capacity, and paused-client tests validate large and repeated-frame draining.

WebSocket backpressure

Layer / File(s) Summary
BackPressure storage and lifecycle
packages/bun-uws/src/AsyncSocketData.h
Replaces string-based buffering with indexed heap storage, explicit capacity management, move-only semantics, and deterministic cleanup.
Socket buffering and memory metrics
packages/bun-uws/src/AsyncSocket.h, packages/bun-uws/src/WebSocket.h
Updates erase conversions, reports live buffered bytes, and bases memory cost on allocated buffer capacity.
BackPressure drain validation
test/js/bun/websocket/websocket-server-backpressure-buffer.test.ts
Adds paused-client tests for large sends and repeated frames, including drain monotonicity, threshold, byte-count, and hash checks.

Possibly related PRs

  • oven-sh/bun#34511: Updates related backpressure accounting for buffered amounts in the node:http pipeline.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main BackPressure change and its erase behavior.
Description check ✅ Passed The description includes the required purpose and verification sections, plus implementation and test details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:59 PM PT - Jul 20th, 2026

✅ @robobun, your commit 8db78aa00b3d46f2a0c4408d10ae139ec89458fa passed in Build #76387! 🎉


🧪   To try this PR locally:

bunx bun-pr 34824

That installs a local version of the PR into your bun-34824 executable, so you can run:

bun-34824 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. uws: fix O(n^2) BackPressure buffer compaction and reallocation on large writes #34023 - Modifies the same BackPressure struct in AsyncSocketData.h with the same erase()/compaction optimizations; uws: cursor-based BackPressure buffer so erase() is a pointer bump #34824 supersedes it with a cursor-based slab rewrite

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline nits, I also checked: the getSendBuffer() fallback's resize() + write-at-data() + existingBackpressure pattern still lands at the correct offset after compaction/grow (head resets to 0, live bytes at [0, live)); and every getBufferedAmount() caller in HttpContext.h / WebSocketContext.h / HttpResponse.h is an == 0, > 0, or before/after progress comparison, so the switch from totalLength() to length() doesn't break any of them.

This rewrites a hot-path buffer with hand-rolled malloc/head/tail cursors, changes user-visible getBufferedAmount() semantics, and trades incremental shrink for high-water retention until full drain — worth a maintainer sign-off.

Extended reasoning...

This is a full rewrite of uWS::BackPressure from std::string to a hand-managed malloc'd slab with head/tail cursors, sitting under every buffered HTTP/WebSocket write in Bun.serve. It also changes getBufferedAmount() from totalLength() (which previously included the already-written pendingRemoval prefix) to length() (unsent bytes only) — user-visible via ws.getBufferedAmount() and every maxBackpressure check — and changes memory-retention behavior (capacity held at high-water until full drain rather than shrinking every 1/32). It also overlaps with open PR #34023. That combination is well outside "simple/mechanical", so deferring to a human even though the only findings are nits.

Comment thread test/js/bun/websocket/websocket-server-backpressure-buffer.test.ts Outdated
Comment thread test/js/bun/websocket/websocket-server-backpressure-buffer.test.ts
Comment thread packages/bun-uws/src/AsyncSocketData.h

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/bun-uws/src/AsyncSocketData.h`:
- Around line 28-32: Condense the BackPressure comment in
packages/bun-uws/src/AsyncSocketData.h#L28-L32 to no more than three lines while
preserving its key rationale. Also condense the comment above the “many corked
frames” test in
test/js/bun/websocket/websocket-server-backpressure-buffer.test.ts#L143-L146 to
no more than three lines, without changing test behavior.
- Around line 96-125: Guard the size arithmetic in ensureTailRoom before any
capacity checks or growth calculations: validate tail + n, live + n, and cap * 2
against SIZE_MAX and abort or otherwise reject the request on overflow. Ensure
newCap is computed only from checked values, preserving the existing compaction
and allocation behavior for valid sizes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 51e9ec5f-c3e0-4e62-b889-cd545bd74eb8

📥 Commits

Reviewing files that changed from the base of the PR and between 1ff1454 and 4b0e432.

📒 Files selected for processing (4)
  • packages/bun-uws/src/AsyncSocket.h
  • packages/bun-uws/src/AsyncSocketData.h
  • packages/bun-uws/src/WebSocket.h
  • test/js/bun/websocket/websocket-server-backpressure-buffer.test.ts

Comment thread packages/bun-uws/src/AsyncSocketData.h Outdated
Comment thread packages/bun-uws/src/AsyncSocketData.h
Comment thread test/js/bun/websocket/websocket-server-backpressure-buffer.test.ts Outdated
robobun added 2 commits July 20, 2026 13:05
Winsock's loopback send() accepts the full payload (100MB+ with the client
paused) into kernel buffers so the WebSocket fast path never falls back to the
uws BackPressure buffer on Windows. The cork-overflow test still exercises the
buffer on that platform. Also defer the send until after the client has paused
so the assertion that something was buffered is deterministic on POSIX.
Comment thread test/js/bun/websocket/websocket-server-backpressure-buffer.test.ts Outdated
Comment thread test/js/bun/websocket/websocket-server-backpressure-buffer.test.ts
Comment thread packages/bun-uws/src/AsyncSocketData.h
These resolve to mimalloc in non-ASAN builds and to libc under ASAN, matching
every other allocation in bun-usockets/bun-uws.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found in this pass — all earlier feedback (OOM handling, overflow guard, test timeouts/failure wiring, comment length, mimalloc routing) is addressed and the Windows test-2 concern was empirically refuted. Deferring only because this is a hand-rolled buffer replacing std::string on the send path for every Bun.serve HTTP/WS socket, plus a semantic shift in getBufferedAmount() (now unsent-only) — worth a human sign-off.

What was reviewed

  • ensureTailRoom: overflow guard, compact-vs-grow, us_realloc(nullptr, …) first-alloc, null-checked → abort().
  • erase/clear/dtor free via us_free (mi_* under non-ASAN); us_free(nullptr) is safe.
  • All getBufferedAmount() call sites in HttpContext/WebSocketContext — the == 0 / progress checks are correct (arguably more so) under length() semantics.
  • data() only reached when length() > 0 or after resize(>0), so buf is never null there.
Extended reasoning...

Overview

Rewrites uWS::BackPressure from a std::string + pendingRemoval counter to a raw char* buf slab with head/tail/cap cursors, so erase() is a pointer bump and append()/resize() compact into the drained head gap before growing. getBufferedAmount() now returns length() (unsent bytes only) instead of totalLength(); memoryCost() reports cap for GC extra-memory accounting. Allocation is routed through us_malloc/us_realloc/us_free (mimalloc in non-ASAN builds) per Jarred's request. Two new integrity tests push 8MB (direct write2 path) and 2048×4KB (cork-overflow → resize() + compaction) through a paused raw-socket client and sha1-compare payloads.

Security risks

None identified. Inputs to the buffer are outbound data the server itself is sending; sizes come from application code, not the peer. The overflow guard n > SIZE_MAX - tail → abort() covers wrap on tail + n and live + n; cap * 2 wrapping is benign since live + n then wins the max(). OOM aborts, matching the old std::string behavior under -fno-exceptions.

Level of scrutiny

High. This is manual C++ memory management (raw owning pointer, move ctor, explicit free) sitting under every outbound write from Bun.serve — HTTP responses, WebSocket sends, cork-buffer overflow. A double-free, leak, or off-by-one here would affect essentially all server workloads. The getBufferedAmount() semantic change is user-visible (JS ws.getBufferedAmount() now excludes already-written bytes still sitting behind the head cursor) and feeds maxBackpressure gating and drain-progress checks in WebSocketContext.h / HttpContext.h; I audited those call sites and they remain correct (the == 0 and before > after checks behave as intended, arguably more correctly than before), but that's exactly the kind of cross-cutting behavior change a maintainer should confirm.

Other factors

Every prior review thread on this PR is resolved: dead serverWs, handshake failure wiring, unchecked malloc/realloc, size-arithmetic overflow, per-test timeouts, comment-length cap, mimalloc routing. My earlier concern about test 2 failing on Windows was refuted with empirical evidence (5/5 local runs + all three Windows CI lanes on build 76270). The bug-hunting system found nothing new this round. Jarred has already engaged with the PR, so it's on a maintainer's radar; given the blast radius I'd rather they click merge than me.

@Jarred-Sumner
Jarred-Sumner merged commit 0550d4e into main Jul 21, 2026
78 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/b8860f3a/uws-backpressure-cursor-buffer branch July 21, 2026 04:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants