Conversation
print_progress() computed (float)count / max with max taken straight from libcurl's dltotal. A response using chunked transfer encoding reports dltotal == 0, and the guard in progress_callback only short-circuits while dltotal == dlnow, so as soon as any bytes arrive the callback falls through to print_progress(dlnow, 0, ...). The ratio is then infinite. Converting that to int is undefined; on x86-64 it yields INT_MIN, so the bar-fill loop does not run and the padding loop below it runs from INT_MIN to bar_width - roughly 2.1 billion putchar calls on every progress tick, once a second. The pull looks frozen and floods the terminal. Handle an unknown total explicitly by reporting the running byte count instead of a percentage, and move the bar arithmetic into computeProgressBarCells(), which returns 0 for an unknown total and clamps the result to [0, barWidth]. The clamp also covers a server reporting more bytes than it announced, which previously overran the bar. computeProgressBarCells() is declared in curl_downloader.hpp only so the arithmetic can be unit tested - print_progress() itself is a file-local static that writes to stdout. Happy to inline it and drop the tests if the smaller header surface is preferred. Tests: adds CurlDownloaderProgressTest covering the unknown-total case that caused the hang, normal ratio tracking, and clamping at both ends.
There was a problem hiding this comment.
🟡 Changes recommended
The cURL cleanup conflict and test reliability issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Fixes download progress handling for unknown or inaccurate content lengths, adds regression coverage, and updates LoRA test fixtures.
Changes:
- Bounds progress-bar calculations and handles zero totals.
- Adds chunked-transfer and progress tests.
- Updates LoRA repositories and cURL initialization logic.
File summaries
| File | Summary and review findings |
|---|---|
third_party/libgit2/lfs.patch |
Clamps the embedded LFS progress bar length. |
src/test/pull_hf_model_test.cpp |
Adds regression tests and updates fixtures. Moderate (3 votes): tests may not force a progress tick, and the timeout assertion can block while awaiting the async task. |
src/pull_module/curl_downloader.hpp |
Declares the progress-bar helper and testable arithmetic. |
src/pull_module/curl_downloader.cpp |
Handles unknown totals and safe bar sizing. Critical (2 votes): introduces an uncoordinated cURL lifetime owner. Nit (1 vote): unknown-size transfers do not emit a terminating newline. |
Review details
Suppressed comments (1)
src/pull_module/curl_downloader.cpp:81
- For an unknown-size transfer this branch returns without ever writing a terminating newline. Since the callback keeps
dltotal == 0whiledlnow > 0, the final progress text is left on the same line and the subsequent model-complete message or shell prompt is concatenated with it. Track completion at the call site and emit a newline only once the transfer finishes.
printf("\rProgress: %.2f %s downloaded, total size unknown", received, sizeUnits[receivedUnitId]);
print_download_speed_info(count, elapsed_time);
fflush(stdout);
return;
- Files reviewed: 4/4 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| return 0; | ||
| } | ||
| const double ratio = static_cast<double>(count) / static_cast<double>(max); | ||
| // Written as a positive test so a NaN ratio also lands here rather than falling through. |
There was a problem hiding this comment.
"Written as positive test"?
| } | ||
| const double ratio = static_cast<double>(count) / static_cast<double>(max); | ||
| // Written as a positive test so a NaN ratio also lands here rather than falling through. | ||
| if (!(ratio > 0.0)) { |
| if (!(ratio > 0.0)) { | ||
| return 0; | ||
| } | ||
| if (ratio >= 1.0) { |
There was a problem hiding this comment.
Could be else if with above condition?
| // A response with no Content-Length reports dltotal == 0, so there is no ratio to show; | ||
| // report the running byte count instead. Dividing by max here yielded an infinite ratio | ||
| // whose conversion to int is undefined - in practice INT_MIN, which drove the padding | ||
| // loop below through roughly 2.1 billion putchar calls on every progress tick. |
There was a problem hiding this comment.
Looks like leftover comment from development process. It's hard for me to read as this comment seems to assume I have knowledge about some external context "...Content-Length reports dltotal == 0...".
|
|
||
| // Number of filled cells in a barWidth-wide progress bar for count out of max bytes, | ||
| // clamped to [0, barWidth]. max == 0 means the server sent no Content-Length, so there is | ||
| // no ratio to render and the result is 0. Declared here so the arithmetic can be unit tested. |
There was a problem hiding this comment.
| // no ratio to render and the result is 0. Declared here so the arithmetic can be unit tested. | |
| // no ratio to render and the result is 0. |
Unnecessary overhead. If it's not natural, we should change it. If it is, no need for a comment.
| // A response without Content-Length makes libcurl report dltotal == 0. The progress bar must | ||
| // not divide by it: the ratio becomes infinite and converting that to int is undefined, which | ||
| // in practice produced INT_MIN and a ~2.1 billion iteration padding loop. |
There was a problem hiding this comment.
I don't think we need it here.
| // Regression test for the call-site, not just the extracted helper: a real chunked-transfer | ||
| // response (no Content-Length) drives libcurl's progress callback with dltotal == 0 on every | ||
| // tick. Before the fix this hung the download in a ~2.1 billion iteration padding loop; here | ||
| // we bound the wait so a reintroduced regression fails instead of hanging the test suite. |
There was a problem hiding this comment.
Repetition of the comment above function signature.
| #include <filesystem> | ||
| #include <iostream> | ||
| #include <memory> | ||
| #include <mutex> |
There was a problem hiding this comment.
Why do we need to include mutex in this PR?
There was a problem hiding this comment.
std::once_flag and std::call_once
| // Keep one balanced libcurl global initialization for this downloader's process lifetime. | ||
| // The previous per-call guard held a null unique_ptr, so its deleter never ran and every | ||
| // download added another unmatched curl_global_init() call. | ||
| static Status ensureCurlGlobalInit() { | ||
| static std::once_flag initFlag; | ||
| static CURLcode initResult = CURLE_OK; | ||
| std::call_once(initFlag, []() { | ||
| initResult = curl_global_init(CURL_GLOBAL_DEFAULT); | ||
| if (initResult == CURLE_OK) { | ||
| std::atexit([]() { curl_global_cleanup(); }); | ||
| } | ||
| }); | ||
| if (initResult != CURLE_OK) { | ||
| SPDLOG_ERROR("curl error: {}. Error code: {}", curl_easy_strerror(initResult), (int)initResult); | ||
| return StatusCode::INTERNAL_ERROR; | ||
| } | ||
| return StatusCode::OK; | ||
| } |
There was a problem hiding this comment.
This means that every unit test that did start OVMS before did curl init and curl cleanup. So ovms behaves now more differently in ovms gtest scenarios than in prod. Why could we not just move this initialization to pull module?
Why we can't follow pattern used in httpservermodule.cpp?
Init curl in module init, cleanup in shutdown?
Then we could dispose whole part of curl_global_init from functions used here/.
There was a problem hiding this comment.
First question - yes.
We could follow the HttpServerModule init/shutdown pattern for the cases where HfPullModelModule::start()/shutdown() actually runs — but curl_downloader's functions aren't only invoked through that module's lifecycle. The curl_downloader.cpp-local std::call_once + atexit guard exists precisely to make the downloader self-sufficient regardless of how it's invoked. That said, it is true this changes gtest behavior (every test that exercises this file now does one process-wide curl_global_init/atexit-cleanup pair instead of zero) — but that's a correctness fix, not a new inconsistency: previously the guard was broken.
There was a problem hiding this comment.
It is used only in pull_module. The other place we use it is:
https://github.com/openvinotoolkit/model_server/blob/main/src/llm/io_processing/image_utils.cpp#L68
but its entirely different curl call with their own guards.
Why did previous solution not work?
If there was issue with guards it could be reused solution from image_utils if you want to keep it self-contained. If we want to keep it called only once (which is not required by curl) then we could have it pull module.
🛠 Summary
JIRA 194742
ISSUE 4550
Contribution: #4551
🧪 Checklist
``