|
343 | 343 | # watchdog only wins when libcurl has wedged (see the watchdog comment in gRPCRequest). |
344 | 344 | const GRPC_DEADLINE_GRACE = 0.25 |
345 | 345 |
|
| 346 | +# How often (seconds) an in-flight request-streaming call re-issues |
| 347 | +# `curl_easy_pause(CURLPAUSE_CONT)` while libcurl has our request buffer drained. See |
| 348 | +# the `unpause` field in gRPCRequest for why this is needed. |
| 349 | +const GRPC_UNPAUSE_INTERVAL = 0.05 |
| 350 | + |
346 | 351 | mutable struct gRPCRequest |
347 | 352 | # CURL multi lock for exclusive access to the easy handle after its added to the multi |
348 | 353 | lock::ReentrantLock |
@@ -407,6 +412,30 @@ mutable struct gRPCRequest |
407 | 412 | # Client-side deadline watchdog, see the comment in the constructor |
408 | 413 | timer::Union{Nothing, Timer} |
409 | 414 |
|
| 415 | + # Repeating un-pause for a request-streaming call, `nothing` for every other kind. |
| 416 | + # |
| 417 | + # read_callback pauses libcurl's send direction whenever the request buffer runs |
| 418 | + # dry, which for a streaming request is every time the caller has nothing queued, |
| 419 | + # and `curl_easy_pause(CURLPAUSE_CONT)` from the request pump is what resumes it. |
| 420 | + # Every un-pause the pump issues is one-shot, and libcurl can drop the transfer on |
| 421 | + # the floor around one: it stops servicing the transfer's socket entirely, so the |
| 422 | + # peer's response bytes pile up unread in the receive queue while |
| 423 | + # curl_multi_socket_action keeps reporting CURLM_OK without consuming any of them. |
| 424 | + # Nothing but another un-pause shakes it loose - driving the multi handle, by socket |
| 425 | + # event or by timeout, does not - so a bidirectional stream whose caller is waiting |
| 426 | + # on responses before it sends anything more deadlocks until its deadline expires. |
| 427 | + # This is what made streaming unusable on Julia 1.10, whose LibCURL_jll is pinned to |
| 428 | + # the 8.4 series: https://github.com/JuliaIO/gRPCClient.jl/issues/68 |
| 429 | + # |
| 430 | + # So rather than rely on a single un-pause landing, keep re-issuing it for as long as |
| 431 | + # such a call is in flight and libcurl holds none of our request data. That covers |
| 432 | + # the end of the stream too, where the un-pause is what draws the read_callback that |
| 433 | + # returns 0 and closes the request. curl_easy_pause returns immediately without |
| 434 | + # doing anything when the transfer is not paused, and an un-pause with an empty |
| 435 | + # request buffer just draws one read_callback that pauses straight back, so a healthy |
| 436 | + # call pays only for the call itself while a stalled one recovers within an interval. |
| 437 | + unpause::Union{Nothing, Timer} |
| 438 | + |
410 | 439 | # Absolute `time()` at which this request's deadline expires, Inf when there is no |
411 | 440 | # deadline. grpc_async_await uses it to attribute a transport error that landed after |
412 | 441 | # the deadline to DEADLINE_EXCEEDED rather than INTERNAL: when libcurl tears a |
@@ -637,10 +666,37 @@ mutable struct gRPCRequest |
637 | 666 | grpc, |
638 | 667 | false, |
639 | 668 | watchdog, |
| 669 | + nothing, |
640 | 670 | expiry, |
641 | 671 | ) |
642 | 672 | preserve_handle(req) |
643 | 673 |
|
| 674 | + # Arm the repeating un-pause once `req` exists, see the `unpause` field above. |
| 675 | + # Only a request-streaming call ever pauses, so nothing else needs one. |
| 676 | + if isstreaming_request(req) |
| 677 | + req.unpause = Timer( |
| 678 | + GRPC_UNPAUSE_INTERVAL; |
| 679 | + interval = GRPC_UNPAUSE_INTERVAL, |
| 680 | + ) do _ |
| 681 | + try |
| 682 | + lock(grpc.lock) do |
| 683 | + # cleanup_request runs under this lock and frees the easy |
| 684 | + # handle, so re-check both before touching it |
| 685 | + (req.completed || !grpc.running) && return |
| 686 | + # Only once libcurl has taken every byte of the request buffer, |
| 687 | + # which is exactly when there is nothing in flight for the |
| 688 | + # un-pause to disturb. While the pump has a batch staged the |
| 689 | + # buffer is non-empty, so this stays out of the handoff the pump |
| 690 | + # is in the middle of driving itself. |
| 691 | + req.request_ptr == req.request.size && |
| 692 | + curl_easy_pause(req.easy, CURLPAUSE_CONT) |
| 693 | + end |
| 694 | + catch err |
| 695 | + @error("streaming un-pause: unexpected error", err, maxlog = 1_000) |
| 696 | + end |
| 697 | + end |
| 698 | + end |
| 699 | + |
644 | 700 | req_p = pointer_from_objref(req) |
645 | 701 | curl_easy_setopt(easy_handle, CURLOPT_PRIVATE, req_p) |
646 | 702 |
|
@@ -671,6 +727,7 @@ mutable struct gRPCRequest |
671 | 727 | # shut-down handle is a submission-time FAILED_PRECONDITION per the |
672 | 728 | # contract at the top of this constructor. |
673 | 729 | isnothing(watchdog) || close(watchdog) |
| 730 | + isnothing(req.unpause) || close(req.unpause) |
674 | 731 | curl_easy_cleanup(easy_handle) |
675 | 732 | curl_slist_free_all(headers) |
676 | 733 | unpreserve_handle(req) |
@@ -747,6 +804,7 @@ mutable struct gRPCRequest |
747 | 804 | grpc, |
748 | 805 | true, |
749 | 806 | nothing, |
| 807 | + nothing, |
750 | 808 | expiry, |
751 | 809 | ) |
752 | 810 |
|
@@ -1307,6 +1365,9 @@ function cleanup_request(grpc::gRPCCURL, req::gRPCRequest) |
1307 | 1365 | req.completed = true |
1308 | 1366 | # Stop the deadline watchdog |
1309 | 1367 | isnothing(req.timer) || close(req.timer) |
| 1368 | + # Stop the repeating streaming un-pause. It is a *repeating* Timer, so leaving it |
| 1369 | + # open would keep the libuv event loop alive for the rest of the process. |
| 1370 | + isnothing(req.unpause) || close(req.unpause) |
1310 | 1371 | # First remove from the multi |
1311 | 1372 | curl_multi_remove_handle(grpc.multi, req.easy) |
1312 | 1373 | # Cleanup the easy handle |
|
0 commit comments