Repository navigation
Conversation
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
force-pushed
the
kr/write-outside-lock
branch
from
October 16, 2026 10:02
138854b to
17af84a
Compare
krynju
marked this pull request as draft
October 16, 2026 10:16
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
krynju
force-pushed
the
kr/write-outside-lock
branch
from
October 16, 2026 10:50
17af84a to
4857a74
Compare
`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
force-pushed
the
kr/write-outside-lock
branch
from
October 16, 2026 13:22
4857a74 to
2346769
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_exandSSL_read_exsharessl.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).incall, set underssl.lockby@geterror), the write BIO appends what the call produces to a per-stream buffer (BIOStreamData). A rawSSL_*call made onssl.sslfrom outside the package hasincallfalse and writes straight to the socket, as before.ssl.lock, a writer (unsafe_write, the handshake) callssendout!: it takessendlock, swaps the buffer out underssl.lockand 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 (afailedflag covers bytes another task sent or dropped on its behalf).unsafe_read,eof) callssendreply!: a reply OpenSSL made goes out from a task that only takes a freesendlock. Whoever holds the lock re-checks the buffer underssl.lockbefore releasing it, and the append happens underssl.locktoo, so a reply is never left behind and a read never waits behind a parked writer.closebuffers the close_notify and sends it from a task, which closes the socket in afinally; should the close_notify's socket write take longer thanCLOSE_NOTIFY_GRACE(5 s, a peer that stopped reading) the task closes the socket outright instead, so neither the socket norsendlockis held forever. With a socket write in flight at the time of theclose, 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 anduv_is_closing, notio.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 emptyVector{UInt8}after a send, not a spare buffer. Thereadbytes/writebytesfields 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 ascloseruns 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 beforeclosearrive, then the close_notify),WriteSizes(sizes around a record and a 1 MiB write),ReplyBehindSender(the hand-off whensendlockis held),LegacyAccept(one round, unchanged),RawSSLCalls(raw accept loop underssl.lock, a raw write landing whole among a parked write's records),PeerResetMidRecord. Confirmed onmain's source:FullDuplexfails its 120 s timeout andConcurrentReadWritehangs inclosewaiting forssl.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; andwrite(ssl, …)must not be called withssl.lockheld, sincesendout!would then wait on asendlockholder that needsssl.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 whenclosecut a write that had in fact just completed.Base internals used:
jl_forceclose_uvwithiolock_begin/iolock_endand the socket'sstatus, the same as Base's ownclose; the tests also readuv_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 (
sendlockis taken beforessl.lock, never the other way round blocking; the reply task usestrylock), and theinflightbookkeeping thatclosereads to decide between sending the close_notify and cutting the socket.🤖 Generated with Claude Code