Repository navigation
Conversation
….lock `SSL_write_ex`, `SSL_connect`, `SSL_accept` and `SSL_shutdown` all run with `ssl.lock` held, and `SSL_read_ex` needs the same lock. The write BIO callback wrote to the socket from inside those calls, so a write waiting for the peer's receive window blocked every read on the same connection, and traffic that saturates both directions at once deadlocked until one side closed. The read callback already avoids this: it returns a retry when the socket has nothing buffered and the waiting happens after the lock is released. The write callback now appends to a buffer owned by `BIOStreamData` and returns, and `drain!` hands that ciphertext to the socket after each SSL call, outside `ssl.lock`. Back pressure is unchanged: the task that called `write` is the one that waits in `drain!`. Ordering is kept by a second lock that only the socket writes take, and `drain!` returns immediately when there is nothing buffered, so a reader never queues behind a blocked writer. The BIO callbacks still accept a plain `IO`, which is what the certificate and key serialization paths pass. Adds a test that starts a server which stops reading, parks an 8 MB write against it, and reads bytes the peer had already sent. Without the fix the read never completes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #73 +/- ##
==========================================
+ Coverage 77.56% 80.71% +3.14%
==========================================
Files 2 2
Lines 1083 1742 +659
==========================================
+ Hits 840 1406 +566
- Misses 243 336 +93 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
`ReadPEMCert` took the second entry of `MozillaCACerts_jll.cacert` and asserted it carries an `OU`. That is a property of Mozilla's root list, not of the parser: on the bundle shipped with Julia 1.13 and nightly the second entry is COMODO ECC Certification Authority, which has no `OU`, so the testset failed on every platform for those versions and, being a top level testset, took the rest of the file with it. The lts jobs, resolving an older bundle, passed. Search for a certificate that has the fields under test instead. The roots in the bundle are self signed, so the issuer checks still hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The new test asserted that a single 8 MB write is still in flight after a second, which holds where the socket buffers are smaller than that but not on Windows, where all four jobs failed on that assertion alone. Write in a loop instead and wait until the byte count stops moving, which is what "the peer's receive window is full" means on any platform. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With the buffering write BIO a failed socket write no longer surfaces as an SSL error, so `@geterror` never closed the stream and a dead connection kept reporting `isopen(ssl) == true`. `drain!(::SSLStream)` now closes the stream (without shutdown) and rethrows, matching the previous behaviour. `close` drains through the `BIOStreamData` directly to avoid recursing into that path. `unsafe_write` passed the whole input to a single `SSL_write_ex`, which made the write BIO buffer a complete ciphertext copy of the payload before anything reached the socket. Submit at most 1 MiB per call and drain between chunks so the buffer stays bounded. The length argument is now `Csize_t` rather than `Cint`, which also removes truncation of writes above 2 GiB. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The write BIO buffer was shared: whoever reached `drain!` first wrote whatever was in it. Between a writer releasing `ssl.lock` and taking `drainlock`, a concurrent reader's `drain!` could pick up the writer's records and block on the peer inside `unsafe_read` or `eof`, the same stall as JuliaWeb#72 through a narrower window. A second writer could also see its records swallowed by the first writer's drain loop and return before they reached the socket, missing the failure if they never did. `@geterror` now takes the ciphertext the SSL call produced while it still holds `ssl.lock`, so `buf` only ever holds the records of the call in progress, and hands it a ticket. `drain!` writes chunks in ticket order under a `Threads.Condition`, so records leave in the order OpenSSL made them and every task waits for its own bytes. `buflock` and `drainlock` are gone: the callback and `take!` both run under `ssl.lock`. Fatal alerts reach the peer again. The error paths in `@geterror` close the stream under the lock, take the alert OpenSSL queued, and send it best effort after releasing the lock before closing the socket. `close` is split into `closelocked!` and `closesocket!` for that. `Sockets.accept` goes through `@geterror` too, so it checks `closed`, holds `ssl.lock` around `SSL_accept`, and waits for the peer on `WANT_READ` like `connect` does instead of throwing `OpenSSLError` for the caller to retry. Adds `ConcurrentWriters`: four tasks writing to one stream at once, the server reads everything back intact. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The wait for the turn in `drain!` sat outside the `try/finally` that passes the turn on. A task cancelled while parked there (`schedule(t, ex; error=true)`, an interrupt, a timeout wrapper) never consumed its ticket, so `turn` stopped one short of it forever and every writer queued behind it waited on the condition for good. The wait now records a cancelled ticket as abandoned, or passes the turn straight on when the notification and the cancellation raced and the turn was already ours, and `passturn!` skips abandoned tickets. The cancelled writer's record never reaches the peer, which leaves the TLS stream unusable, so `drain!(::SSLStream)` still closes it; the writers behind it now fail on the closed socket instead of hanging, and `close` returns. Adds `CancelledWriter`: a parked writer, one waiting behind it that gets cancelled, and a third behind that which has to finish. Without the fix the third one never does. 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. `CancelledWriter` hit this on macOS, where a client that closes with unread session tickets still gets a FIN through rather than a RST, so the server saw the truncated tail of the parked write followed by EOF. On Linux the RST turned it into an error instead. Adds `TruncatedRecordEOF`, which sends a partial record header and closes the socket cleanly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ot finish macOS CI fails the server_task timeout in this testset and nothing else, and it does not reproduce on Linux or Windows. Record the server's stage and byte count and print them, with the client socket state and the writer results, when the wait times out. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`readbytes`, `writebytes` and `peekbytes` were `Ref`s on the stream, shared by every task using it, and read back after `@geterror` released `ssl.lock`. Since the socket write now happens in that gap, another task's `SSL_write_ex` could overwrite the count first: a writer parked on the socket next to a task writing one byte would read back 1, think its chunk was not written, and submit almost all of it a second time, duplicating plaintext on the wire. Reads and peeks had the same hole. The counts are per call now. `ConcurrentWriters` gives every writer a different chunk size, which turns the stale count into a byte count mismatch on the server; with the shared `Ref`s it fails. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The server read the client's parked data until EOF, which depends on how the platform tears down a socket closed with unread session tickets on one side and an unsent tail on the other. On the macOS runners the server never saw a FIN or a RST and waited past the timeout, on Linux and Windows it did not. What the test is about, the writer queued behind the cancelled one failing instead of hanging and `close` returning, does not need the data read: the server now goes away first, as in `ConcurrentReadWrite`, which fails the parked write on every platform. Drops the stage report added to find this. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ream `eof` ran `SSL_peek_ex` through `@geterror`, which drained what the peek produced (a KeyUpdate reply, or the alert for a record that failed) while `eoflock` was still held. With a writer parked on a peer that is not reading, that reader queued behind it under `eoflock`, and every other reader queued behind that: the JuliaWeb#72 shape through a rare path. `@geterror` is now `@sslcall`, the part under `ssl.lock` that evaluates to `(ret, pending, err)`, plus `finish_sslcall!`, which sends or throws. `eof` releases `eoflock` before calling the second half and goes round the loop again; the WANT_READ wait stays under `eoflock`, since the race that lock guards against is about that wait. `drain!` takes the owning stream and, once it has the turn, refuses to write when the stream was closed meanwhile. Records before this one were lost, so the write only ever returned success for data the peer would reject; now it is an `IOError` at once. `closesocket!` rethrows anything that is not an `IOError` or `EOFError` from sending the alert, so an interrupt or a cancellation is not swallowed at `@debug`, and closes the socket in `finally` either way. `CancelledWriter` waits until both queued writers are in the condition's wait queue before cancelling one; delivering an exception to a task that is still running on another thread is not allowed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… test `CancelledWriter` looked for the queued writers in the condition's wait queue, which nightly no longer stores as tasks. `BIOStreamData` counts the tasks parked in `drain!` instead, under the condition's lock, and the test polls that. The finalizer called `close(ssl)`, which sends the close_notify, a socket write. Nightly's socket write path waits there, and a finalizer may not switch tasks: `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>
|
@quinnj Can you please take a look at this? This was supposed to only fix a deadlock scenario, but has a few more fixes as well. Every CI run and review I ran uncovered more random issues, but it should be good now |
…meouts Follow-up to the non-blocking write BIO, from review of the earlier commits and of quinnj's comments. Squashed from local iterations; the behaviour, by area: Closing - close(ssl) is graceful by default: writes issued on other tasks before it finish first, later ones are refused (iswritable says so), then the close_notify goes out behind them and the socket is closed with what is queued on it flushed. - close(ssl, false) aborts: the stream is marked closed at once, what was produced before still goes out in order, then the socket is closed. - Either kind is bounded by progress: an AbortWatch closes the socket outright once nothing has moved for CLOSE_GRACE / ABORT_GRACE seconds, failing a write parked on a peer that is not reading. A graceful close on a stream that lost a record ends as an abort, with no close_notify. - A failed SSL call closes and aborts the stream in one go under ssl.lock, and its alert still goes out; the error now carries OpenSSL's reason (e.g. "tlsv1 alert unknown ca") and leaves the thread's error queue empty. Write ordering and cancellation - take! hands out tickets without taking any lock but ssl.lock; drain! waits for its turn. A writer cancelled while waiting consumes its ticket; one cancelled inside the socket write has its socket closed outright (cut!) and its chunk kept alive until libuv lets go of it (no use-after-free). - Cleanup paths take their locks through `surely`: an exception thrown into a task while it waits leaves the rest of the cleanup to a library task instead of skipping it. Interrupts are held across cleanup sections; library tasks report an interrupt they could not pass on. Handshake - Sockets.connect and Sockets.accept take `timeout` (a deadline for the whole handshake, verification included); the stream is closed on any failure. - accept runs the whole handshake, as connect does; ssl_accept is gone (it could not send its output once the BIO only buffers). Robustness - Log calls on cleanup paths are @guarded, so a logger that throws cannot stop one. - A timer helper (`ticker`) replaces Timer callbacks, with interrupts held around each callback. - The finalizer aborts a dropped stream, or closes it gracefully if its socket outlived it. Tests - New testsets for concurrent readers and writers, cancelled and parked writers, graceful and abort closes against a stalled peer, handshake deadlines, alert delivery, interrupted cleanup, throwing loggers and lost records. Stall-prone reads run under deadlines (awaitpeer, awaitread, boundedread, boundedfetch) so a regression fails a testset instead of hanging the suite. Checked on Julia 1.6, 1.7, 1.8, 1.10, 1.12 and nightly, 1 and 4 threads; throughput of small writes and allocations per write unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- CloseNotifyEOF: the client reads before closing, taking the server's session tickets off its socket; closed with them unread, Windows resets the connection and the server saw ECONNRESET instead of the close_notify. - CancelledInFlightWriter's fallback case ends its raw peer reader by closing the server's socket, once the chunk being let go shows the client's close got through: the macOS runners deliver neither a FIN nor a reset to that reader. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e server side - As in CancelledInFlightWriter: once the client's socket is closed, the server closes its own to end the raw reader, the macOS runners delivering neither a FIN nor a reset to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The write BIO only buffers now, so callers driving SSL_* on ssl.ssl directly (e.g. their own SSL_accept loop) no longer get their output sent; a minor bump keeps "1.6" compat from picking this up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the next buffer The write BIO grew a fresh buffer for the whole output of each SSL_write_ex, up to 1 MiB of plaintext per call, by repeated resize!: about 4 bytes allocated per byte written, and 1 MiB writes at under half of main's throughput. Cap each call at one record (16 KiB), and keep a chunk that reached the socket as the next buffer, so a long write reuses one buffer. Loopback, one thread, main -> before -> after: 1 MiB writes 800-850 -> 344-386 -> 735-758 MB/s alloc/1 MiB write (reader included) 1032 -> 4093 -> 1042 kB 64 B alloc/write 27 -> 219 -> 75 B Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With one record per SSL_write_ex, a parked write of 1.5 chunks (24 KiB) has only one record queued in libuv. Windows and macOS keep growing the socket buffers for a peer that does not read, so that write completed of itself and CancelledCloser and AbortDuringGracefulClose saw it finish. Write 32 MiB per call instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t reset The client never read the server's session tickets, so closing its socket with them unread made Linux reset the connection, and the server lost the tail of the writes it had not read yet along with the close_notify. With 32 MiB parked writes the server lags far enough behind for that to happen (seen on LTS with OpenSSL 1.1). The client reads, as in CloseNotifyEOF. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@quinnj I addressed these review comments you left and ran a couple of reviews overnight. It was bringing up correctness issues until review number ~60 |
quinnj
left a comment
There was a problem hiding this comment.
[reviewed by AI] Thanks for working through the earlier comments. I re-reviewed 04bffd45cb365276576e09e29e41ec2446ed9146. The five earlier findings are addressed: the EOF scope fix, cancellation during lock acquisition, aborting an in-progress graceful close, queued-writer ordering, and the accept timeout/documentation changes.
I still see two issues to address before merging: the minor version bump doesn't isolate the breaking API changes, and failure to submit a detached drain task can leave later writers stuck. Details inline.
Pkg.test() passes locally on macOS with Julia 1.12.7 and OpenSSL_jll 3.5.6, with both 1 and 4 threads. The platform-specific FailedWriteKeepsNothing test wasn't exercised locally. All 14 CI checks are green, and the normal HTTP.jl integration job ran its tests. The OpenSSL 1.1 integration job skipped its tests after a dependency resolution failure (OpenSSL_jll@1.1 conflicts with the project's 3.5.6 - 3 compatibility); its green check doesn't establish 1.1 compatibility.
The record-chunk reuse checks passed byte-for-byte, and I didn't reproduce a bulk throughput regression in a loopback benchmark with matched read-ahead and TCP_NODELAY. Small-write timings varied too much to confirm the reported 8–12% cost.
| name = "OpenSSL" | ||
| uuid = "4d8831e6-92b7-49fb-bdf8-b643e874388c" | ||
| version = "1.6.1" | ||
| version = "1.7.0" |
There was a problem hiding this comment.
[reviewed by AI] [P1] A minor bump won't protect existing callers from these API changes
Julia's default caret compatibility treats OpenSSL = "1.6" as >=1.6.0, <2.0.0; 1.7.0 is accepted. I verified this with Pkg.Types.semver_spec("1.6"). The PR's claim that the minor bump keeps those environments on 1.6 is incorrect.
The timeout keyword and doc changes address my earlier comment, but existing callers still need code changes. With a silent TCP client, an unchanged retry loop with a 0.5 s deadline returns :deadline_expired on the base; here it is still blocked in its first accept(ssl) call after 1.5 s. A raw SSL_accept loop also completes on the base but stalls here with 2,316 bytes of unsent handshake ciphertext.
Can we preserve the existing contracts in 1.x, or make this a 2.0 change with migration guidance? Documentation and the new keyword won't stop a compatible dependency update from hanging existing callers.
There was a problem hiding this comment.
[addressed by Claude] Fixed in fb331be. You're right that "1.6" admits 1.7.0; that claim was wrong. I went with keeping the 1.x contracts rather than a 2.0:
Sockets.accept(ssl)withouttimeoutis one non-blocking round again and throwsOpenSSLErrorwhen it needs more bytes, so a retry loop with its own deadline behaves as onmain(LegacyAccepttests a 0.5 s deadline against a silent client). The whole handshake isaccept(ssl; timeout=Inf)or with a deadline.- Calls made on
ssl.sslfrom outside the package work again. The write BIO callback checks whether one of the package's own calls is in progress (incall); outside one it writes to the socket itself, as before, after waiting for any ticketed records so order is kept. TLSStreams 0.2.0's rawSSL_acceptloop completes the handshake on this branch unchanged (RawSSLCalls, plus the TLSStreams suite). ssl_accept(::SSL)is restored as it was.- Two deliberate differences remain in those paths: a failed round throws
IOErrorand closes the stream rather than throwingOpenSSLErrorand leaving it open, and a raw write that fails cuts the socket (libuv may still hold a request pointing into OpenSSL's buffer, whichSSL_freewould otherwise free under it). A raw write parked on a peer that does not read still blocks underssl.lock, as every write did before 1.6.2; only the caller can bound that, by closing the socket.
Two undocumented things still differ: close(ssl) returns nothing instead of the @async task, and the readbytes/writebytes fields are gone. Happy to restore either if you think they matter. The version stays 1.7.0 for the new timeout keyword.
There was a problem hiding this comment.
[addressed by Claude] Follow-up in 3bb045b, after another review pass over the raw-call path:
- The wait a raw write does for the ticketed records before it is now bounded: it holds
ssl.lock, which every close, abort and watch needs first, so a ticket no task would ever drain could have held the stream for good. It gives up once nothing has moved forCLOSE_GRACE, and the call fails (RawWriteGivesUp). The socket write itself still blocks on a peer that does not read, as before 1.6.2. - A failed raw write cuts the socket only when libuv may still hold the request; otherwise it just marks the stream unsendable, so records before it still go out with the close.
- The write callback returns -1 on failure, not 0. OpenSSL up to 3.5.6 takes a zero with no retry flag as "nothing written yet", reports the call a success and keeps the record pending (verified against the 3.5.6 source; the 3.5 branch has since made it fatal). The callback's catch-all returned 0 on
maintoo, so on 3.x a socket error inside it was reported as a successful write. finish_sslcall!gives the reply's ticket up only when the task was not scheduled, so a drain and the catch cannot both pass one turn.CancelledInFlightWriterandThrowingLoggerrun on Linux only: they cancel a writer inside the socket write, and Base'suv_writecb_taskschedules the waiting task unconditionally while the request names it, so a write completing at that moment throws "schedule: Task not runnable" out of the libuv callback. Windows and macOS grow the socket buffers for a non-reading peer, which completes the parked record and hits that window (it hung two Windows jobs). Not something this package can fix.
| # failed: the stream was closed and aborted in `@sslcall` already | ||
| err === nothing || throw(err) | ||
| if detach && pending !== nothing | ||
| background() do |
There was a problem hiding this comment.
[reviewed by AI] [P2] Handle failure to create or schedule the detached drain task
pending already owns a ciphertext ticket here. The try/catch inside the task can't run if background(...) itself throws, so the record is neither drained nor abandoned and the stream stays open.
I checked this with a valid 27-byte KeyUpdate record and targeted fault injection: make background throw OutOfMemoryError before it submits the task, then restore it. That leaves turn == 2, nextticket == 3, and an empty abandoned set. A later write stayed parked on ticket 3 after 0.5 s. Injecting InterruptException at the same boundary gave the same result.
Please cover task creation/submission with cleanup that accounts for the ticket and aborts the stream before rethrowing. Simply advancing the turn would send later ciphertext across a missing TLS record. A regression test should verify that subsequent writers terminate with an error rather than waiting forever.
There was a problem hiding this comment.
[addressed by Claude] Fixed in fb331be. finish_sslcall! now wraps the task's creation and scheduling; if that throws, the ticket is given up with abandon!, which refuses every later ticket instead of parking them, and the stream is aborted, both with interrupts held back, then the exception is rethrown. If the task was scheduled after all (an interrupt landing after the fact), its drain finds the ticket given up and does nothing harmful.
DetachedDrainNotScheduled covers it. It drives finish_sslcall! directly with a taken ticket, through an ONBACKGROUND test hook that makes background throw OutOfMemoryError for that task only: on OpenSSL 3.5.6 the client's reply to a KeyUpdate goes out with its next write rather than during the read, so real traffic does not reach this path here. The test checks the ticket is consumed, lostfrom is set, and a later write fails with IOError at once rather than waiting.
…n a reply task cannot be made Review round two. A minor bump protects nobody, "1.6" compat admits 1.7.0, so the contracts stay as they were instead: - Sockets.accept(ssl) without timeout is one non-blocking round again, throwing OpenSSLError when it needs more bytes, so the retry loops written to it, with their own deadline between calls, work as on main. The whole handshake is accept(ssl; timeout=Inf) or with a deadline. - A call made on ssl.ssl from outside the package (a caller's own SSL_accept loop, ssl_accept) has its output written to the socket by the write BIO callback itself, as before: the callback tells the package's own calls by BIOStreamData.incall, set around each under ssl.lock, and outside one it waits for the ticketed records to be through, so the records keep their order, then writes. ssl_accept(::SSL) is restored unchanged. - finish_sslcall!: when the task that sends a read's reply cannot be made or scheduled, the reply's ticket is given up and the stream aborted, so the writers after it fail instead of waiting for its turn for good. Tests: LegacyAccept, RawSSLCalls (TLSStreams 0.2.0's raw loop also completes the handshake against this), DetachedDrainNotScheduled, via an ONBACKGROUND test hook rather than a method overwrite. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ecord is pending Windows grows the socket buffers and may let the parked record through within the half second, after which the raw write is free to go; the ticket turn tells whether it is still pending. Read after istaskdone, so a done write with the turn still on the record is the only failure. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CancelledInFlightWriter and ThrowingLogger cancel a writer inside the socket write. Base's uv_writecb_task schedules the waiting task unconditionally while the request still names it, so a write completing just as its task is cancelled throws "schedule: Task not runnable" out of the libuv callback into the task running the event loop, and can wedge the loop. Windows grows the socket buffers for a peer that does not read, so a parked write completes on its own there and hits that window often (seen hanging two CI jobs). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…h -1 Third review round, on the raw-call path and the unscheduled reply task: - awaitdrained gives up once the turn has not moved for CLOSE_GRACE. It waits under ssl.lock, which every close, abort and watch needs first, so a ticket no task will ever drain would otherwise hold the stream for good with nothing able to reach it. - rawwrite cuts the socket only for a write that was started and that libuv did not finish, the one case where a request may still point into OpenSSL's buffer. Any other failure just records the loss, so the records ticketed before it still go out with the close. An exception thrown into the raw caller's task is logged rather than dropped silently. - A failing write callback returns -1, not 0: OpenSSL up to 3.5.6 takes a zero with no retry flag as "nothing written yet", reports the call a success and keeps the record pending; -1 fails it in every version. The callback's catch-all returned 0 on main too. - background reports whether it scheduled the task, and finish_sslcall! gives the ticket up only when it did not: the drain and the catch passing one turn between them would carry it past the tickets. - CancelledInFlightWriter and ThrowingLogger run on Linux only; macOS grows the socket buffers as Windows does. The park_writer comment says which part of a parked write can still complete on its own. Test: RawWriteGivesUp. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Linux only, as CancelledInFlightWriter. The cancellation is logged and the SSL call fails; the socket is cut, which cancels the request libuv may still hold into OpenSSL's buffer, and the loss is recorded. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fixes #72.
The bug
SSLStreamguards itsSSLobject with one lock, as OpenSSL requires. But the write BIO callback wrote to the socket from inside that lock:SSL_write_ex,SSL_connect,SSL_acceptandSSL_shutdownholdssl.lockwhile OpenSSL runs that callback, andSSL_read_exneeds the same lock. A write waiting for the peer's receive window therefore blocked every read on the connection. Any traffic that fills both directions at once deadlocks.The fix
take!while it still holdsssl.lock. Then it releases the lock and writes the chunk to the socket withdrain!.take!, keeps chunks in the order OpenSSL produced them, which the record MAC requires.writereturns once its ciphertext has reached the socket, and gets theIOErroritself if it did not.Most of the diff is about keeping this correct when tasks are cancelled or the stream is closed. The resulting behaviour:
Closing
close(ssl)is graceful.iswritablereturns false from the start of the close.close(ssl, false)aborts. The stream is marked closed at once. Output produced before the abort still goes out in order, then the socket is closed.CLOSE_GRACE(10 s, graceful close) orABORT_GRACE(1 s, abort). That fails the parked write.ssl.lock.IOErrornow carries OpenSSL's reason, for exampletlsv1 alert unknown caorcertificate verify failed.Cancellation
schedule(task, ex; error=true), an interrupt and a timeout wrapper. The cancelled writer's record is lost, which makes the stream unusable, so the stream is aborted.Handshake
Sockets.connect(ssl; timeout=…)andSockets.accept(ssl; timeout=…)take one deadline for the whole handshake, including the certificate check. On any failure, including the timeout, the stream is closed.timeout=Infruns the whole handshake with no deadline.Sockets.accept(ssl)withouttimeoutkeeps its old contract: one round,OpenSSLErrorwhen it needs more bytes from the peer, the caller's loop waiting and checking its own deadline between calls. A round that fails outright now throwsIOErrorand closes the stream, as every SSL call does.Compatibility
The 1.x contracts are kept, so this can go out as 1.7.0: a minor bump for the new
timeoutkeyword, nothing a"1.6"compat entry picks up changes behaviour.Sockets.accept(ssl)withouttimeoutis still one non-blocking round that throwsOpenSSLErrorwhen it needs more bytes. Retry loops with their own deadline work as before (tested:LegacyAccept). The whole handshake isaccept(ssl; timeout=Inf)or a deadline. One deliberate difference: a round that fails outright (a rejected certificate, say) throwsIOErrorand closes the stream, as every SSL call on this branch does;mainthrewOpenSSLErrorthere too and left the broken stream open.ssl.sslfrom outside the package keep working. The write BIO callback knows whether one of the package's own calls is in progress (incall); outside one, it writes to the socket itself, as every write did before, after waiting for any records the package's calls have ticketed, so order is kept. Such callers holdssl.lock, as they always had to. The wait for the ticketed records is bounded: if nothing moves forCLOSE_GRACE, the raw call fails rather than holdingssl.lock, where no close or watch could reach it, for good. The socket write itself, once started, blocks on a peer that does not read as every write did before 1.6.2, until the caller closes the socket. A raw write that fails marks the stream unsendable; one that libuv may still hold also cuts the socket, so the request is cancelled beforeSSL_freefrees its buffer.mainas well, so on 3.x a socket error inside it was reported to the caller as a successful write. TLSStreams 0.2.0's ownSSL_acceptloop completes the handshake on this branch unchanged (tested:RawSSLCalls, and the TLSStreams suite).ssl_accept(::SSL)is back, unchanged frommain.close(ssl)returnsnothingrather than the@asynctask that closed the socket, and thereadbytes/writebytesfields ofSSLStreamare gone (the counts are per call now). Say if either should be kept too.get_errorgives the same result as before. It now reads the error queue through a helper,errorqueue, which is also whatIOErrormessages use.Verification
CI covers Julia LTS, 1 and nightly on Linux, macOS and Windows. The LTS job's "Test with OpenSSL v1.1" step runs the suite against OpenSSL_jll 1.1 as well (it caught a test race on this branch).
master(2.x) no longer depends on OpenSSL.jl, which is also why the OpenSSL 1.1 integration job skips. HTTP.jl 1.x over this branch was checked locally instead: 480 keep-alive GET/POST rounds on 16 threads against a local server, thenConnection: closerequests.codecov/patchis red for a measurement reason: GitHub's API omits the diff ofsrc/ssl.jlas too large, so codecov has no diff lines for it and scores the patch oversrc/OpenSSL.jlalone (35 lines, 23 hit). The misses areerrorqueue's allocation-failure fallbacks. Project coverage is up from 77.6% to 80.7%.33 new testsets cover:
acceptcontract, rawSSL_*calls (including one that gives up behind a ticket nobody drains), and a reply task that cannot be scheduledReads that could stall run under deadlines, so a regression fails its testset instead of hanging the suite.
ConcurrentReadWriteis the SSL_write holds ssl.lock across the socket write, so a blocked write deadlocks reads on the same SSLStream #72 scenario: one write parked against a peer that stopped reading, and one read of bytes the peer already sent. Onmainthe read times out; on this branch it completes.Performance against
main, over loopback on one thread:mainEach
SSL_write_excall covers one record (16 KiB), and a chunk that reached the socket becomes the next buffer. Before that change, 1 MiB writes ran at under half ofmain's speed.A self-signed server rejected by a verifying client now fails with
tlsv1 alert unknown ca, not a bare EOF.Two platform differences show up in the tests. The tests handle both, so neither is a library bug:
uv_writecb_taskschedules the task waiting inuv_writeunconditionally while the request still names it, so a task cancelled (schedule(task, ex; error=true)) just as its write completes gets "schedule: Task not runnable" thrown out of the libuv callback into whatever task runs the event loop, and the loop can wedge. The two testsets that cancel a writer inside the socket write,CancelledInFlightWriterandThrowingLogger, are skipped on Windows for that reason. The package's own handling of such a cancellation (the kept chunk, the cut) is unaffected; it is only the moment of cancellation Base cannot make safe.Also in here: an unrelated CI failure
ReadPEMCerttook the second entry ofMozillaCACerts_jll.cacertand asserted that it has anOU. In the bundle shipped with Julia 1.13 and nightly, that entry has none. The testset failed, and because it sits at top level it stopped the rest of the file. The test now picks a certificate that has the fields it checks. Happy to split this out.Notes for review
🤖 Generated with Claude Code