Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4e2a937
fix: buffer ciphertext in the write BIO instead of blocking under ssl…
krynju Sep 23, 2026
3e437f3
test: pick the ReadPEMCert certificate by content, not by position
krynju Sep 23, 2026
76fdde6
test: detect the parked writer instead of assuming 8 MB is enough
krynju Sep 23, 2026
8104a5c
fix: close stream on drain failure, chunk SSL_write_ex submissions
krynju Sep 23, 2026
e8916bc
chore: bump version to 1.6.2
krynju Sep 23, 2026
eb7e3ac
fix: each SSL call owns and orders its own ciphertext
krynju Sep 28, 2026
367e3fd
fix: consume the ticket of a writer cancelled while waiting its turn
krynju Sep 28, 2026
7fc3e89
fix: eof spun forever on a record truncated by the peer closing
krynju Sep 28, 2026
ce8741a
test: report where the CancelledWriter server is stuck when it does n…
krynju Sep 28, 2026
3c8fccc
fix: give each SSL call its own byte count
krynju Sep 28, 2026
7f45600
test: have the server close first in CancelledWriter
krynju Sep 28, 2026
e1efe0d
fix: send peek output outside eoflock, refuse to write to a closed st…
krynju Sep 28, 2026
7a73396
fix: no close_notify from the finalizer; count queued writers for the…
krynju Sep 28, 2026
a7a8429
Address review: close, abort and cancellation semantics, handshake ti…
krynju Sep 30, 2026
3b126e1
test: CloseNotifyEOF and the fallback case work on Windows and macOS
krynju Sep 30, 2026
8545e4e
test: ThrowingLogger's fallback case ends its raw peer reader from th…
krynju Sep 30, 2026
a2c6a1e
chore: bump version to 1.7.0
krynju Sep 30, 2026
4fb5008
perf: write one record per SSL_write_ex, and reuse the sent chunk as …
krynju Sep 30, 2026
543711c
test: park_writer writes more than the kernel buffers grow to
krynju Sep 30, 2026
04bffd4
test: CloseWithQueuedWriter reads on the client, so its close does no…
krynju Sep 30, 2026
fb331be
Keep the 1.x contracts: one-round accept, raw SSL calls; clean up whe…
krynju Oct 2, 2026
f82a407
test: RawSSLCalls asserts the raw write waits only while the parked r…
krynju Oct 2, 2026
bfff910
test: skip the in-flight writer cancellations on Windows
krynju Oct 2, 2026
3bb045b
Bound the raw write's wait, cut only a write libuv may hold, fail wit…
krynju Oct 3, 2026
abe9d7a
test: a task cancelled inside a raw write's socket write
krynju Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name = "OpenSSL"
uuid = "4d8831e6-92b7-49fb-bdf8-b643e874388c"
version = "1.6.1"
version = "1.7.0"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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) without timeout is one non-blocking round again and throws OpenSSLError when it needs more bytes, so a retry loop with its own deadline behaves as on main (LegacyAccept tests a 0.5 s deadline against a silent client). The whole handshake is accept(ssl; timeout=Inf) or with a deadline.
  • Calls made on ssl.ssl from 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 raw SSL_accept loop 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 IOError and closes the stream rather than throwing OpenSSLError and leaving it open, and a raw write that fails cuts the socket (libuv may still hold a request pointing into OpenSSL's buffer, which SSL_free would otherwise free under it). A raw write parked on a peer that does not read still blocks under ssl.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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 for CLOSE_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 main too, 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.
  • CancelledInFlightWriter and ThrowingLogger run on Linux only: they cancel a writer inside the socket write, and Base's uv_writecb_task schedules 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.

authors = ["Greg Lapinski <grzegorz.lapinski@live.com>", "Jacob Quinn <quinn.jacobd@gmail.com>"]

[deps]
Expand Down
83 changes: 51 additions & 32 deletions src/OpenSSL.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1560,8 +1560,8 @@ mutable struct BIO

# note that `data` must be held as a reference somewhere else
# since it is not referenced by the BIO directly
# e.g. in SSLStream, we keep the `io` reference that is passed to
# the read/write BIOs
# e.g. SSLStream gives both its BIOs the same `BIOStreamData`, and keeps it
# in its `data` field
ccall(
(:BIO_set_data, libcrypto),
Cvoid,
Expand Down Expand Up @@ -3053,46 +3053,65 @@ Base.show(io::IO, evp_pkey::EvpPKey) = write(io, String(evp_pkey))
Error handling.
"""
function get_error()::String
# Create memory BIO
bio = BIO(BIOMethodMemory())

local error_msg::String

# Check existing error messages stored in task TLS.
# what the thread's queue holds, and a message a call left in the task's local
# storage (see `update_tls_error_state`) ahead of it, taken off as it is read
queue = errorqueue()
if haskey(task_local_storage(), :openssl_err)
# Copy existing error from task TLS.
tls_msg = task_local_storage(:openssl_err)
delete!(task_local_storage(), :openssl_err)
return "$(tls_msg) : $(queue)"
end
return queue
end

# Clear the error queue, print the error messages to the memory BIO.
ccall(
(:ERR_print_errors, libcrypto),
Cvoid,
(BIO,),
bio)

bio_msg = String(bio_get_mem_data(bio))
error_msg = "$(tls_msg) : $(bio_msg)"
else
# Clear the error queue, print the error messages to the memory BIO.
# the thread's OpenSSL error queue, printed into a memory BIO and so taken off the
# queue; cleared all the same should the print fail. The BIO made here by hand: the
# constructor reports a failure through `get_error`, which reads the queue here, and
# would go round, taking a task-local message on the way
function errorqueue()::String
ptr = ccall(
(:BIO_new, libcrypto),
Ptr{Cvoid},
(BIOMethod,),
BIOMethodMemory())
# nothing to print into (OpenSSL out of memory, most likely, which is then the very
# reason to report): the queue read entry by entry into a buffer of Julia's instead
ptr == C_NULL && return errorqueue_lines()
# the BIO freed through its pointer, whatever the wrapper made of it
try
bio = BIO(ptr)
ccall(
(:ERR_print_errors, libcrypto),
Cvoid,
(BIO,),
bio)

error_msg = String(bio_get_mem_data(bio))
return String(bio_get_mem_data(bio))
finally
clear_errors!()
ccall((:BIO_free, libcrypto), Cint, (Ptr{Cvoid},), ptr)
end
end

# the thread's error queue as `ERR_error_string_n` gives each entry, one a line, taken
# off the queue; for when no BIO can be made to print it
function errorqueue_lines()::String
out = IOBuffer()
line = Vector{UInt8}(undef, 256)
try
while (code = ccall((:ERR_get_error, libcrypto), Culong, ())) != 0
GC.@preserve line begin
ccall(
(:ERR_error_string_n, libcrypto),
Cvoid,
(Culong, Ptr{UInt8}, Csize_t),
code, line, length(line))
println(out, unsafe_string(pointer(line)))
end
end
finally
clear_errors!()
end

# Read the formatted error messages from the memory BIO.

# Ensure the queue is clear (if ERR_print_errors fails).
clear_errors!()

# Free bio.
free(bio)

return error_msg
return String(take!(out))
end

function clear_errors!()
Expand Down
Loading
Loading