Repository navigation
Add socket buffer pooling to reduce memory usage - #2343
Conversation
Revives #1549 on top of current main. Client sockets opt in to a shared IO::BufferPool (per buffer size): write buffers are returned to the pool after each flush, and read buffers when all buffered data is consumed. Sockets read with a non-blocking recv into a pooled buffer, and only on EAGAIN return the buffer and wait for readability. The original PR waited for readability before reading, which with the edge-triggered event loop could hang when unread data remained in the socket after a fill. SIGUSR1 debug output now includes fiber stack usage and buffer pool stats. The BoolChannel part of #1549 already landed in #1603. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
IO::Buffered#write allocates through the private out_buffer method, which wasn't overridden, so every write buffer was a fresh allocation that flush then pushed into the pool. The pool grew towards its cap instead of being reused: with 1000 idle connections it held 4001 buffers (64 MiB) after 2 allocations from it. With 10000 idle connections, one channel each, server RSS is now 387 MiB vs 562 MiB on main, and the GC heap 185 MiB vs 570 MiB, served by 3 pooled buffers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
âđïļ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with ð while any review is running, comments if it has suggestions, and reacts with ð once all reviews finish with no findings. |
|
No issues found. |
There was a problem hiding this comment.
ðĄ Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bd38f47cfd
âđïļ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with ð.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Client#close_socket closes the socket without the write lock, and IO::Buffered#close flushes. If a delivery fiber was blocked in flush, the first flush to finish returned the buffer to the pool while the other was still writing from it, so another connection could get the buffer and its data could be sent on this socket. close now detaches the IO from the pool first, leaving its buffers to the GC like before pooling. Also correct the CHANGELOG: TLS connections still hold a read buffer while waiting for data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
|
Re the review above, both points are fixed in 9604be2 (pushing next):
Generated by Claude Code |
The non-blocking recv in fill_socket_buffer bypassed Socket#unbuffered_read and its @fd_lock.read, so with multiple threads a concurrent close could close the fd, and the OS reuse it for a new connection, before recv ran. Take the same read lock around the recv and the readability wait. Also fix the burst spec deadlocking on macOS, where the UNIX socket send buffer is smaller than the 10 kB written before the reader was spawned, and add a spec that close wakes up a fiber waiting for data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
|
CI on bd38f47 had two Spec failures:
Generated by Claude Code |
âĶ-bcyckr # Conflicts: # CHANGELOG.md
fill_socket_buffer bypasses Socket#unbuffered_read and relies on stdlib internals (Socket's @fd_lock and the event loop's wait_readable). Emit a compile-time warning on Crystal versions other than 1.21.x so they're re-verified on upgrade; CI builds with --error-on-warnings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
fill_socket_buffer used LibC.recv and Socket's @fd_lock directly. Move that into a Socket#read_nonblock monkey patch (nil when no data is available) plus Socket#wait_readable, the shape a stdlib API would likely take, so fill_socket_buffer only uses those two methods and the stdlib internals and the Crystal version warning live in one place. wait_readable holds an fd reference instead of the read lock while waiting, which still keeps a concurrent close from releasing the fd. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
âĶ-bcyckr # Conflicts: # CHANGELOG.md
The buffer pool was a Deque behind one process-wide mutex, taken twice per flush on every connection, and kept up to 10,000 buffers forever. - Each thread now has its own cache per pool: a LIFO stack of at most 64 buffers, so acquire/release take no locks and reuse the most recently used buffer. Releases beyond that are dropped to the GC and counted as `dropped` (previously counted as `released`). Pools for other buffer sizes are retired by IO::BufferPool.for, so a socket_buffer_size reload doesn't keep the old size's buffers. - IO::Buffered overrides fall back to the stdlib (previous_def) for IOs without a pool, and carry the same Crystal version warning as Socket#read_nonblock. - Read buffers are released at end of stream, and an emptied buffer (by read_byte, peek or skip) is released before a read that may wait, including reads directly into large slices. - Socket#wait_readable uses the socket's event_loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
Fiber#stack_used read the saved stack pointer, which is stale for the current fiber and fibers running on other threads. Use the actual stack pointer for the current fiber and return nil for other running fibers, which SIGUSR1 prints as "running". Also print the pool's thread count and dropped buffers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
Client#close_socket closed the socket without the write lock, so close's flush of buffered data could run concurrently with a writer's flush. With pooled buffers one of them could return the buffer to the pool while the other still wrote from it, so another connection's data could be sent on this socket. AMQP and MQTT clients now close under their write lock. A writer blocked on a client that stopped reading would then also block closing the connection, so accepted sockets (TCP, TLS, UNIX and WebSocket) get a write timeout, configurable with tcp_send_timeout in [main] (default 15 s, like RabbitMQ's send_timeout). Such a client is now disconnected instead of blocking its writers forever. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
The TCPSocket under an OpenSSL::SSL::Socket kept the stdlib's default 32 KiB read buffer for the life of the connection. It now uses the buffer pool (with writes left unbuffered, OpenSSL writes whole records), so idle TLS connections don't hold it. With 10k idle AMQPS connections the server used 805 MiB instead of 890 MiB (976 MiB on main). Turning off the TLS socket's own read buffer as well saved another 87 MiB but cost about 14% TLS throughput, so it's kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
Reverts 0c2e4c0, which is now its own PR (#2363). Until that's merged, close can still flush concurrently with a writer here. With pooled buffers that can hand a buffer in use back to the pool, so #2363 should be merged first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
|
amqp-quickcheck property tests was cancelled on 3277587 after GitHub's 6-hour job limit. Every test passed within a minute except Generated by Claude Code |
### Why `Client#close_socket` (AMQP and MQTT) closed the socket without taking the client's write lock. `IO::Buffered#close` flushes buffered data, so a close could run concurrently with a writer's `flush` of the same buffer. That can send the buffered bytes twice, or in the middle of a frame being written. This matters more with pooled socket buffers (#2343), where one of the two flushes could return the buffer to the pool while the other still writes from it. Closing under the write lock alone would let a writer blocked on a client that stopped reading also block closing that connection, forever. ### What - `AMQP::Client#close_socket` and `MQTT::Client#close_socket` first shut the connection down (`shutdown(2)`, both ways) without taking the lock. That makes an ongoing write fail right away. They then close under the write lock (`@write_lock` / `@lock`), so closing never runs concurrently with a write. - The new `Socket#shutdown_read_write` doesn't close the fd. - TLS sockets delegate it to their underlying socket. So does `WebSocketIO`, which now keeps the connection the WebSocket runs over. - Shutting down first also lets a TLS close_notify fail immediately instead of waiting for another write timeout. - Buffered data is dropped instead of flushed, through a new `IO::Buffered#discard_write_buffer`. The connection is being abandoned at that point, and graceful closes have already flushed their Close/CloseOk. After a failed write the buffer may already have been partly sent, so flushing it again would resend bytes. - Accepted sockets (TCP, TLS, UNIX and WebSocket, including `wss`) get a write timeout. It's set at accept time, because the event loop reads `@write_timeout` when a write starts. The value comes from a new option, `tcp_send_timeout` in `[main]`: default 15 seconds, like RabbitMQ's `send_timeout`, and it must be positive. - **Behaviour change:** a client that doesn't read what the server sends for `tcp_send_timeout` is disconnected. Previously the write blocked forever. The timeout is per write with no progress, so slow consumers that keep reading aren't affected. Closing the connection of a slowly reading client (`force_close`, vhost deletion, shutdown) no longer waits for the message being delivered. - Specs: - a consumer that stops reading is disconnected after the timeout (AMQP); - it gets no resent data when it reads again (AMQP and MQTT); - `force_close` completes while a delivery to a consumer that doesn't read is blocked; - `force_close` completes during a 32 MiB delivery to a consumer that reads slowly; - `wss` connections get the timeout, and shutting one down reaches the TCP socket under TLS; - the config option is parsed and validated. The connection and websocket specs fail without their fixes. ðĪ Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF --------- Co-authored-by: Claude <noreply@anthropic.com>
- flush detaches the write buffer before writing and releases it in an ensure, so a concurrent flush or close can't write or release a buffer that may already be back in the pool - buffers are released when a read or write fails, and when the write buffer is discarded, instead of being left to the GC - buffer_size= refuses a size that differs from the pool's, as pooled buffers are usually released and the stdlib's check doesn't see them - a thread's cache is installed before it's registered for stats, so a fiber that moves between threads can't register an unused one - wait_readable times out with "Read timed out", like a read - wrapping subtraction in Fiber#stack_used, for the SIGUSR1 handler - explicit requires in protocol_server.cr and launcher.cr - the Prometheus pool metrics take the stats field from the table Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
Registering a new cache takes a lock, which may suspend the fiber and resume it on another thread. new_thread_cache then returned the first thread's cache, and acquire/release used its array from the other thread, racing with that thread's own fibers. thread_cache now loops until it finds the current thread's cache without yielding. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvgmf6u8joxBgMdRsUGKrF
The proxy spawned its copying fibers in the accept loop, so they shared the loop's client and server variables instead of capturing their own. The next accept? overwrote them, and once the proxy closed it set client to nil: a copy that finished after that closed a nil socket, and Spec macOS died with an invalid memory access. Each connection is now forwarded by a method of its own. Ported from 50bd4c7 (#2352).
|
Two updates, pushing next as f6b9603:
After merging main, Generated by Claude Code |
|
CI on f6b9603 is green, Spec macOS included, except amqp-quickcheck property tests. That job has been stuck in "Run property tests" since 06:40. I don't think this PR causes it:
The property tests have no per-test timeout, so a hang lasts until GitHub's 6-hour job limit. No fix exists yet; it would belong in lavinmq-quickcheck. I'll re-run the job once after it's cancelled. Generated by Claude Code |
- close flushes through the pool before detaching from it, so pending data is written from a detached buffer that's returned to the pool, instead of through the stdlib flush, which neither detaches nor returns it - a failed read returns the buffer to the pool it was acquired from, also when a concurrent close has detached the IO from the pool - the non-socket read path (TLS) returns the buffer at end of stream, like the socket path - document that fill_socket_buffer assumes one reader per socket, as wait_readable doesn't serialize readers like a blocking read - ProtocolServer caches the pool for the configured buffer size instead of taking IO::BufferPool.for's lock on every accept - drop IO::BufferPool.each, launcher uses IO::BufferPool.all - the close-during-a-blocked-flush spec fills the socket's send buffer first, so the flush blocks for certain
|
Spec arm64 failed on 55ad42e in This failure isn't caused by this PR. It depends on spec order: it happens whenever "keeps the link of an exchange redeclared under the same name" ( That earlier spec closes its link while the link is still connecting, so No fix exists yet. Here's a proposed patch for that spec. With it, the pair passed 30 out of 30 on main, and the whole file passed in 5 random orders: upstream.links.should eq [link]
+ # Closing the link closes its connection only once it has one. A link
+ # still connecting would connect to a server already shutting down,
+ # and its client would log the forced close during a later spec.
+ wait_for { link.state.running? }
ensure
upstream.try &.closeEvery other check passed on this head. I'm re-running the failed job once. Generated by Claude Code |
### Why `LavinMQ::Federation::Upstream shutdown closes federation links without logging unexpected-close errors` fails whenever the random order runs "keeps the link of an exchange redeclared under the same name" right before it. It captures `connection closed unexpectedly: End of file reached` from `amqp.client.connection`. It failed Spec arm64 on #2343 (seed 58088). Running only those two examples in that order fails 30 out of 30 times on main. The earlier spec closes its federation link while the link is still connecting, so `Link#stop` has no connection to close yet. The connection then completes against the server as that server shuts down, and the server force-closes it. The amqp-client logs the EOF a moment later, while the next spec's `Log.capture` is running. ### What The earlier spec waits for its link to be running before closing it. Closing the link then also closes its connection, against a live server. Only the spec changes. With the fix, the two examples pass 30 out of 30 times in that order, and `spec/upstream_spec.cr` passes in 5 random orders. No other example in `upstream_spec.cr` makes the shutdown spec fail when run before it.
Conflicts: - protocol_server: parse a PROXY header before TLS after setting up the pooled TCP read buffer (#2343) - control socket: keep ControlSocket, which only deletes a socket it bound (#2423), with main's ControlSocketError hierarchy; port its specs - prometheus: keep the runner metrics hook along with the buffer pool metrics - CHANGELOG, docs/monitoring.md: keep both sides Also: - require proxy_protocol in protocol_server, no longer pulled in through the clustering client - replace not_nil! in the raft specs, Lint/NotNil is enabled for all files - poll the cluster members on the Nodes page with the shared Poller, and use the renamed DataSource autoReload option
The socket buffer pool (#2343) overrides `IO::Buffered#read` and calls `previous_def`, which compiles to a separate function that isn't inlined. Decoding an AMQP frame reads a few bytes at a time, so every field of every frame paid for that call plus the pool checks. A read that is smaller than what's left in the read buffer now copies from the buffer directly, the same as the stdlib's read does. Such a read can't empty the buffer, so there's nothing to release to the pool.
Why
Client connections are mostly idle, yet each one holds its own read and write buffer (
socket_buffer_size, 16 KiB each by default). Sharing buffers across connections cuts memory use with many connections. Revives #1549 on top of current main (its BoolChannel part already landed in #1603).Builds on #2363 (merged), which closes client sockets under the write lock. Without it,
closecould flush concurrently with a writer, and with pooled buffers one of them could hand the buffer back to the pool while the other still writes from it.What
Buffer pool (
src/stdlib/io_buffered.cr)IO::BufferPool, one per buffer size (IO::BufferPool.for(size)). Each thread has its own cache per pool: a LIFO stack, so acquire and release take no locks and reuse the most recently used buffer. Each cache holds up to 4 MiB of idle buffers (256 at the default 16 KiB). Releases beyond that go to the GC and are counted asdropped. Whensocket_buffer_sizechanges on a config reload, new connections use a pool for the new size, and the old pool keeps serving the connections opened before the reload.IO::Buffered#buffer_pool=is opt-in, and set only for client sockets. IOs without a pool use the stdlib implementation unchanged (previous_def). With a pool attached,buffer_size=refuses any size other than the pool's.read_byte/peek/skip, and direct reads into large slices).Socket#read_nonblockinto a pooled buffer. When no data is available, the buffer goes back to the pool and the socket waits withSocket#wait_readable, so idle sockets hold no read buffer. Unlike a blocking read, the wait doesn't serialize readers, so this assumes one reading fiber per socket, as in LavinMQ's client read loops.closeflushes pending data through the pool, so the write buffer is returned, and then detaches the IO from the pool.Socket#read_nonblock(slice) : Int32?andSocket#wait_readable(src/stdlib/socket_read_nonblock.cr) add the non-blocking read the stdlib lacks, shaped like a stdlib API.read_nonblockdoesrecv(MSG_DONTWAIT)under the socket's fd read lock.wait_readableholds an fd reference, uses the socket's event loop, and times out with "Read timed out", like a read.--error-on-warnings, so a new Crystal minor release fails CI until they're re-verified and the version is bumped.TLS: the TCP socket under each TLS socket now uses the pool for its read buffer (it used to hold the stdlib's default 32 KiB for the whole connection), so idle TLS connections don't hold it. The TLS socket's own read buffer is still held while idle: turning it off saved another 87 MiB at 10k idle AMQPS connections, but cost about 14% TLS throughput, because each small read becomes an
SSL_read. A follow-up could avoid holding it by waiting for readability beforeSSL_read.Metrics:
/metricsexposessocket_buffer_pool_available,_allocated_total,_reused_total,_released_totaland_dropped_total, labeled bybuffer_size(documented indocs/monitoring.md). A steadily increasingdropped_totalmeans more connections are busy at once than the cache holds.Debug output:
SIGUSR1prints stack usage per fiber, or "running" for fibers whose saved stack pointer is stale, plus the pool stats.Differences from #1549:
Measurements
Release builds, 4-core container, server and
lavinmqperfon the same host. LavinMQ's default execution context runs client fibers on one thread, so the per-thread cache acts as a global one.Idle connections (
lavinmqperf connection-count, 10,000 connections with one channel each):2 pooled buffers served all 10k plain connections.
Throughput (
lavinmqperf throughput, 15 s runs, average consume rate in messages per second):Run-to-run noise was Âą5%. The previous shared-mutex pool was about 3% below main on 50 / 50 at 16 B.
Cache size (15 s runs, 2 per cap, buffers dropped from the cache per run):
At most about 350 buffers were in use at once. Throughput didn't differ measurably between caps.
Testing
spec/stdlib/io_buffered_spec.crcovers:dropped, releases on another thread, pools for another size still reusing buffers);read_byte;buffer_size=refused while a pool is attached;Socket#read_nonblockand#wait_readable, including its timeout message;spec/server_spec.cr: an idle TLS connection doesn't hold its TCP read buffer.spec/api/prometheus_spec.cr: the pool metrics are exposed for the configured buffer size.make test: 2584 examples, 0 failures.make lintis clean, and the release build passes with--error-on-warnings.