Skip to content

Write the ciphertext to the socket outside ssl.lock (#72), minimal - #91

Draft
krynju wants to merge 5 commits into
JuliaWeb:mainfrom
krynju:kr/write-outside-lock
Draft

krynju wants to merge 5 commits into
JuliaWeb:mainfrom
krynju:kr/write-outside-lock

Conversation

@krynju

@krynju krynju commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

The minimal #72 fix, re-derived from the design described in the #73 review. #73 stays open as the reference for the larger version; this one is meant to replace it. Stacked on #86, #87, #88 and #89 (the first four commits); the last commit is this PR.

Bug (#72). SSL_write_ex and SSL_read_ex share ssl.lock, and the write BIO wrote to the socket from inside the call. A write waiting for a peer that had stopped reading held the lock, so every read on the stream waited behind it: a full-duplex exchange, or a read beside a large write, deadlocked.

Fix (~190 lines in src/ssl.jl).

  • During one of the package's own SSL calls (incall, set under ssl.lock by @geterror), the write BIO appends what the call produces to a per-stream buffer (BIOStreamData). A raw SSL_* call made on ssl.ssl from outside the package has incall false and writes straight to the socket, as before.
  • After the call, outside ssl.lock, a writer (unsafe_write, the handshake) calls sendout!: it takes sendlock, swaps the buffer out under ssl.lock and writes it to the socket. Records leave in the order they were produced, and the writer returns only once its bytes are on the socket, or with the error that kept them from getting there (a failed flag covers bytes another task sent or dropped on its behalf).
  • A reader (unsafe_read, eof) calls sendreply!: a reply OpenSSL made goes out from a task that only takes a free sendlock. Whoever holds the lock re-checks the buffer under ssl.lock before releasing it, and the append happens under ssl.lock too, so a reply is never left behind and a read never waits behind a parked writer.
  • Writes go to OpenSSL in 64 KiB chunks under a writer lock, so concurrent writes arrive whole.
  • close buffers the close_notify and sends it from a task, which closes the socket in a finally; should the close_notify's socket write take longer than CLOSE_NOTIFY_GRACE (5 s, a peer that stopped reading) the task closes the socket outright instead, so neither the socket nor sendlock is held forever. With a socket write in flight at the time of the close, no close_notify is made and the socket handle is closed outright at once (jl_forceclose_uv, as Base does for an unconnected socket), which fails the parked write. forceclose! goes by the handle and uv_is_closing, not io.status, since Base marks a socket closing itself on the peer's FIN on Julia 1.6/1.7.

Compared with #73 this gives up the handshake timeout, the progress-bounded graceful close behind a parked writer, and the protection for tasks cancelled inside a socket write. Per stream it keeps one empty Vector{UInt8} after a send, not a spare buffer. The readbytes/writebytes fields stay (from #88).

Tests (all pass on Linux, Julia 1.12 and 1.6.7 with OpenSSL 3.5 and Julia 1.10 with OpenSSL 1.1, 1 and 4 threads, repeated runs): ConcurrentReadWrite (the #72 scenario: a read beside a parked 32 MiB write), FullDuplex (8 MiB each way, both ends reading and writing), ConcurrentWriters (now also whole writes), CloseWithParkedWriter (close returns at once, the writer fails), CloseRacingWriter (a write completing as close runs returns success and arrives; the client reads once first so the server's session tickets are not left unread, since on Windows the reset that a close with unread data sends discards what the peer has not read yet), CloseBehindRawWrite (the bounded close_notify drain), CloseNotifyEOF (writes before close arrive, then the close_notify), WriteSizes (sizes around a record and a 1 MiB write), ReplyBehindSender (the hand-off when sendlock is held), LegacyAccept (one round, unchanged), RawSSLCalls (raw accept loop under ssl.lock, a raw write landing whole among a parked write's records), PeerResetMidRecord. Confirmed on main's source: FullDuplex fails its 120 s timeout and ConcurrentReadWrite hangs in close waiting for ssl.lock.

Documented limits (not handled, as before this PR for the first): a raw SSL_* call must not overlap the package's own calls on the same stream, or its record overtakes buffered ciphertext on the wire; and write(ssl, …) must not be called with ssl.lock held, since sendout! would then wait on a sendlock holder that needs ssl.lock.

A second review pass (Opus) found and I fixed: forceclose! skipping a socket Base had already marked closing (Julia 1.6/1.7 on FIN), the close task leaking the socket when the close_notify write failed, the unbounded close_notify drain, and a writer failing although its bytes were delivered when close cut a write that had in fact just completed.

Base internals used: jl_forceclose_uv with iolock_begin/iolock_end and the socket's status, the same as Base's own close; the tests also read uv_stream_get_write_queue_size.

Two points I'd like a second look at, since this is re-derived rather than your prototype: the lock order (sendlock is taken before ssl.lock, never the other way round blocking; the reply task uses trylock), and the inflight bookkeeping that close reads to decide between sending the close_notify and cutting the socket.

🤖 Generated with Claude Code

The finalizer called `close(ssl)`, which sends the close_notify, a
socket write. A write that has to wait for the socket switches tasks,
which a finalizer may not do: on nightly, where the socket write path
waits more readily, `NoCloseStream` printed "task switch not allowed
from inside gc finalizer". Older versions only wait when the socket
buffer is full, so the hazard was latent. The finalizer now calls
`close(ssl, false)`: the SSL object is freed and the socket closed,
nothing is written.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@krynju
krynju force-pushed the kr/write-outside-lock branch from 138854b to 17af84a Compare October 16, 2026 10:02
@krynju
krynju marked this pull request as draft October 16, 2026 10:16
@codecov

codecov Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.17857% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.18%. Comparing base (10f7b7b) to head (2346769).
⚠️ Report is 41 commits behind head on main.

Files with missing lines Patch % Lines
src/ssl.jl 90.17% 11 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #91      +/-   ##
==========================================
+ Coverage   77.56%   80.18%   +2.62%     
==========================================
  Files           2        2              
  Lines        1083     1272     +189     
==========================================
+ Hits          840     1020     +180     
- Misses        243      252       +9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@krynju
krynju force-pushed the kr/write-outside-lock branch from 17af84a to 4857a74 Compare October 16, 2026 10:50
krynju and others added 4 commits October 16, 2026 15:17
`on_bio_stream_write` returned 0 when `unsafe_write` on the socket threw.
With no retry flag set on the BIO, OpenSSL (3.5 included) takes a 0 from
a write callback as "nothing written yet": it keeps the record pending
and `SSL_write_ex` reports success. A write whose socket had already
closed was therefore returned to the caller as written. Return -1, which
fails the SSL call; `@geterror` then closes the stream and throws
`IOError`.

Adds `WriteCallbackFailure`: closes the socket under a connected stream
and checks that the next write throws and closes the stream.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
When the peer closes mid-record, `SSL_peek_ex` keeps returning
WANT_READ, `haspending` stays true for the partial record, and
`eof(ssl.io)` returns true at once, so `eof` looped without ever
yielding, starving every other task on the thread. Return true there:
the bytes that would complete the record are never coming.

One more round is allowed first: a concurrent read waiting on the socket
ahead of this task may have taken the rest of the record while this
task waited, in which case `bytesavailable` shows the decrypted bytes
and `eof` is false.

Adds `TruncatedRecordEOF`, which sends a partial record header and
closes the socket cleanly. Without the fix it spins instead of failing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`readbytes`, `writebytes`, `peekbuf` and `peekbytes` are `Ref`s on the
stream, shared by every task using it, and read back after `@geterror`
released `ssl.lock`. Another task's SSL call can overwrite the count in
that gap: a writer reading back a smaller count than its own thinks its
chunk was not written and submits most of it again, duplicating
plaintext on the wire. Reads and peeks have the same hole.

The counts are per call now. The fields stay on the struct, unused by
this package, because code outside it reads `transport.readbytes[]`.

Adds `ConcurrentWriters`: four writers with different chunk sizes, the
server checks every writer's bytes arrived exactly once.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`SSL_write_ex` and `SSL_read_ex` share `ssl.lock`, and the write BIO
wrote to the socket from inside the call. A write waiting for a peer
that had stopped reading therefore held the lock, and every read on the
stream waited behind it: a full-duplex exchange, or a read beside a
large write, deadlocked.

The write BIO now appends what one of the package's own SSL calls
produces to a per-stream buffer (`BIOStreamData`, an `incall` flag set
under `ssl.lock` by `@geterror`). The call's task sends the buffer once
the lock is released:
- a writer (`unsafe_write`, the handshake) with `sendout!`: it takes
  `sendlock`, swaps the buffer out under `ssl.lock` and writes it, so
  records leave in the order they were produced, and it returns only
  once its bytes are on the socket, or with the error that kept them
  from getting there;
- a reader (`unsafe_read`, `eof`) with `sendreply!`: a reply OpenSSL
  made goes out from a task that only takes a free `sendlock`. Whoever
  holds the lock re-checks the buffer under `ssl.lock` before releasing
  it, so the reply is never left behind, and a read never waits behind
  a parked writer.
Writes go to OpenSSL in 64 KiB chunks under a writer lock, so concurrent
writes arrive whole. A raw `SSL_*` call made on `ssl.ssl` from outside
the package still writes straight to the socket.

`close` buffers the close_notify and sends it from a task, closing the
socket outright should that take longer than `CLOSE_NOTIFY_GRACE`. With
a socket write in flight it makes no close_notify and closes the socket
handle outright at once (`jl_forceclose_uv`, as Base does for an
unconnected socket), which fails the parked write.

Tests: ConcurrentReadWrite (the JuliaWeb#72 scenario), FullDuplex,
ConcurrentWriters (whole writes), CloseWithParkedWriter,
CloseRacingWriter, CloseBehindRawWrite, CloseNotifyEOF, WriteSizes,
ReplyBehindSender, LegacyAccept, RawSSLCalls, PeerResetMidRecord.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@krynju
krynju force-pushed the kr/write-outside-lock branch from 4857a74 to 2346769 Compare October 16, 2026 13:22
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.

1 participant