Skip to content

Fix heap allocation leak in Socket.getValue - #1

Closed
ianegordon wants to merge 31 commits into
mainfrom
ian/tvt-1062-socketgetvalue-leaks-a-heap-allocation-on-every-call
Closed

Fix heap allocation leak in Socket.getValue#1
ianegordon wants to merge 31 commits into
mainfrom
ian/tvt-1062-socketgetvalue-leaks-a-heap-allocation-on-every-call

Conversation

@ianegordon

Copy link
Copy Markdown
Owner

Summary

  • Socket.getValue allocated an UnsafeMutablePointer<O.SocketValue> that was never deallocated on either the success or throw path, leaking one heap block on every socket-option read (called by socketType, buffer-size accessors, etc.).
  • Fix: defer { valuePtr.deallocate() } immediately after allocation. This satisfies the documented deallocate() precondition — the pointer is the start of the allocated block and the memory is never initialized through Swift's typed model.

Resolves TVT-1062.

Testing

  • Full suite passes: 404 tests in 49 suites.
  • Correct-value regression coverage already exists (SocketTests for socketType, receiveBufferSize, sendBufferSize); the leak itself is not unit-assertable, so no new tests were added.

🤖 Generated with Claude Code

phuccvx12 and others added 30 commits April 24, 2026 16:50
Per RFC 6455 §5.2, extended payload lengths must be interpreted as
unsigned integers in network byte order (Big-Endian). Previously,
the 64-bit decoding path (used when length0 is 127) was incorrectly
implemented as Little-Endian, causing decoding failures for frames
larger than 65,535 bytes.

This change:
- Corrects the 8-byte decoding path to use Big-Endian shifts.
- Normalizes WSFrameEncoderTests to use Big-Endian expectations.
- Adds edge case coverage for 0, 126, and 65,536 byte boundaries.
- Adds tests for truncated data in both 16-bit and 64-bit paths.
- Ensures mask bit presence is correctly handled during decoding.
HTTPRequest.shouldKeepAlive now treats HTTP/1.1 as persistent unless
`Connection: close` is sent, and HTTP/1.0 as closed unless `keep-alive`
is sent. Splits the Connection header on commas so multi-token values
like `Keep-Alive, Upgrade` are recognized (RFC 9110 §7.6.1).

HTTPServer.handleRequest no longer overwrites a Connection header that
the handler explicitly set (e.g. WebSocket's `Upgrade`); it only echoes
the request's Connection token when the response has none.

Adds nine HTTPRequest.shouldKeepAlive cases covering HTTP/1.1 default,
multi-token, and HTTP/1.0 semantics. Updates the keep-alive iteration
test to terminate via `Connection: close` instead of relying on the
old (buggy) behavior.

Closes TVT-288

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
HTTPDecoder.readBody now honors Transfer-Encoding: chunked (RFC 9112
§7.1) by routing the body through a new HTTPChunkedTransferDecoder, the
read-side mirror of HTTPChunkedTransferEncoder. Trailer fields are
consumed and discarded.

readBody also throws HTTPDecoder.Error on framing violations:
- both Content-Length and Transfer-Encoding present (§6.1)
- non-numeric or negative Content-Length (§6.3 swhitty#5)
- Transfer-Encoding whose final coding is not `chunked` (§6.1)

Throwing surfaces as a connection-close (RFC-permitted for unrecoverable
framing errors); HTTPServer.handleConnection has a TODO to upgrade this
to an explicit 400 Bad Request response in a future change.

readBody's signature changes from (from:length:) to
(from:contentLength:transferEncoding:). Both decodeRequest and
decodeResponse pass headers[.contentLength] and headers[.transferEncoding].

Closes TVT-287

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Six additional HTTPDecoderTests targeting the uncovered branches in
HTTPChunkedTransferDecoder and HTTPDecoder.readBody:
- chunkExt_IsIgnored (RFC 9112 §7.1: chunk-ext after `;` is parsed but ignored)
- invalidChunkSize_ThrowsError (non-hex chunk-size)
- missingCRLFAfterChunkData_ThrowsError (chunk-data not followed by CRLF)
- truncatedChunkSize_ThrowsError / truncatedChunkData_ThrowsError
  (stream ends mid-line / mid-chunk -> SocketError.disconnected)
- unsupportedTransferEncoding_ThrowsError (e.g. `gzip`)

Lifts HTTPChunkedDecodedSequence.swift to 86.79%/88.73% region/line
coverage and HTTPDecoder.swift's TVT-287 additions to 100%.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The `MessageFrameWSHandler.start` implementation previously lacked explicit
termination for the `messagesIn` stream, which could lead to message handlers
hanging indefinitely if they relied on the stream ending naturally. Additionally,
concurrent tasks could prematurely finish the `framesOut` stream with a
`CancellationError` when one task completed before the other.

This change:
- Uses `defer` blocks to guarantee that both `messagesIn` and `framesOut`
  continuations are finished when the handler exits.
- Filters out `CancellationError` during task cleanup to prevent reporting
  spurious errors to the client during a clean shutdown.
- Centralizes stream termination logic, removing redundant `.finish()` calls
  from individual tasks.
- Adds an exhaustive catch block to ensure the task group closures remain
  non-throwing as required by the Swift concurrency model.
…p-alive-default

Honor HTTP/1.1 persistent-connection default per RFC 9112 §9.3
…quest-bodies

Decode chunked request bodies; reject invalid framing per RFC 9112
Existing chunked-body coverage runs through HTTPDecoder. These tests
exercise the decoder directly to pin behavior that integration tests
don't cover: that consumption stops at the trailer terminator (so
keep-alive pipelining is safe), that nextBuffer(suggested:) honors
the suggested cap, that uppercase HEXDIG is accepted per RFC 5234
§2.3, and that chunk-sizes exceeding Int.max are rejected as a
framing error.
nginx (ngx_http_set_etag) and Apache HTTPD's default FileETag MTime Size
both derive a static-file ETag from the same metadata. Doing the same
here lets FileHTTPHandler and DirectoryHTTPHandler skip the per-request
Data(contentsOf:) + SHA-256 load.

Closes TVT-290

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add direct unit tests for HTTPChunkedTransferDecoder
…ndler-loads-whole-file-into-memory-to-compute-etag

Compute ETag from (mtime, size) to skip whole-file load per request
Matches FileHTTPHandler so a multi-gigabyte asset under a directory
handler no longer loads fully into memory per request. Existence-check
moves into the HTTPBodySequence(file:) throw, mirroring FileHTTPHandler's
do/catch → 404 pattern.

Closes TVT-286

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Control frames (ping/pong/close) MUST have payload length ≤ 125 bytes.
Without this guard, a peer could send a 1 MB ping and have it echoed
back verbatim as a pong by WSHandler.makeResponseFrames.

Closes TVT-306

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds tests for Cache-Control/Date/Last-Modified/ETag header
emission on 200 responses, plus 304 round-trips and 200 fallthrough
for both If-Modified-Since and If-None-Match. Exercises lines in
DirectoryHTTPHandler.handleRequest that previously had no coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ttphandler-loads-entire-file-into-memory-no
…idator-reject-control-frames-with-payload-gt125
HTTPDecoder pulls one byte per syscall while parsing the status line and
headers: `bytes.lines.takeNext()` and `readHeaders(from:)` both end up in
CollectUntil.next() calling iterator.next(), which on AsyncSocketReadSequence
does an unbuffered `socket.read()` per byte. For a typical request with
200–500 bytes of status line + headers that's 200–500 single-byte read(2)
syscalls and a corresponding suspendSocket cycle whenever a TCP segment
boundary lands mid-header.

Adding an internal buffer to AsyncSocketReadSequence.next() would lose bytes
between iterators, because HTTPDecoder constructs a fresh iterator for the
body reader and HTTPRequestSequence creates a fresh iterator per request on
a keepalive connection. Any bytes buffered-but-unconsumed when one iterator
is dropped would be unreachable to the next.

Add AsyncBufferingSequence<Base>: a reference-typed wrapper backed by an
actor that owns one iterator into Base and a shared in-memory buffer.
Iterators created from the same wrapper consume from the shared backing
buffer, so bytes pulled from Base are never lost between successive
iterators. Uses the same Transferring idiom that AsyncSharedReplaySequence
already uses to call mutating async functions on a value-type iterator
across actor isolation.

Wrap socket.bytes once per HTTPConnection and thread the wrapper through
both HTTPRequestSequence and the WebSocket upgrade path, so any bytes
pulled past the upgrade request remain available to the framer.

Measurements: release build of an MRP REST daemon under identical workload,
16 s perf captures: total cycles 45.8e9 -> 42.2e9 (-7.9%); average CPU rate
3056 Mc/s -> 2649 Mc/s (-13%). HTTPDecoder.decodeRequest self time drops
from indistinguishable in the noise to 0.0-0.02%; the parser essentially
disappears from the profile.

All 426 existing tests pass.
Buffer HTTP request bytes once per connection
read()/recvfrom()/recvmsg() returning 0 is an orderly EOF, not an error,
and does not set errno. The error classification checked errno ==
EWOULDBLOCK before count == 0, so an EOF read whose errno was left as
EWOULDBLOCK by an earlier would-block read on the same thread was
misclassified as .blocked instead of .disconnected.

The reader then re-suspended waiting for more data instead of closing.
Under concurrency (errno is per-thread) this both leaked the connection
(CLOSE-WAIT, never closed) and, because epoll re-reports the readable
EOF socket, spun the pool re-arming and re-reading it, pegging CPU.

Check count == 0 first; errno is only meaningful after a -1 return.
epoll_wait returns -1/EINTR when a signal interrupts it, which is not a
fatal condition. getNotifications() treated any non-positive return as a
failure and threw SocketError.makeFailed("epoll wait"), tearing down the
server ("epoll wait(4): Interrupted system call"). Return no events on
EINTR so the caller polls again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Kkgk9vUQAZUTMUnbus1RR
…assification

Socket: treat read()/recv()==0 as EOF before consulting errno
make HTTPClient public ~Copyable
UnsafeMutablePointer.allocate was never deallocated on either the
success or throw path, leaking one heap block per socket-option read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ianegordon
ianegordon force-pushed the ian/tvt-1062-socketgetvalue-leaks-a-heap-allocation-on-every-call branch from 0f0e8d8 to 48e68fa Compare July 21, 2026 01:14
@ianegordon

Copy link
Copy Markdown
Owner Author

Superseded by upstream PR swhitty#226 — the fix now targets the parent repository directly.

@ianegordon ianegordon closed this Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants