Skip to content

Commit 84dd1b5

Browse files
authored
feat(ui): live progress + speed for library/component downloads (#128)
Library / component dependency downloads now show the same live progress bar, byte counts and transfer speed that toolchain downloads already show. Three paths fed download events; only toolchain and builtin-index installs rendered progress. Project / custom-index dependency installs went through the silenced direct xlings CLI and ran dark — "Downloading <pkg>" then a long, feedback-free hang. - Route project/custom-index installs through the xlings NDJSON `interface install_packages` capability (the capability and the `install` CLI share `cmd_install`; install destination is chosen by package scope, so packages still land in the project-local data root). This restores the live bar on that path. - Add an indeterminate ("connecting…") render for the pre-sizing window where the downloader reports totalBytes==0 (DNS/TLS/redirect, or a body streamed with no Content-Length), so the line never freezes. - Centralize the download-progress state machine + render policy in `mcpp.ui` (DownloadProgress); toolchain, builtin-index and custom-index installs now share one UI. - Pin bundled xlings to 0.4.51. Bumps version to 0.0.53. Updates e2e 52/58/60 to expect the interface transport while keeping their project-local-install and hook-ordering invariants. Design: .agents/docs/2026-06-09-library-download-progress-design.md
1 parent 9215dee commit 84dd1b5

11 files changed

Lines changed: 506 additions & 182 deletions
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# Library / Component Download Progress — Design
2+
3+
> Status: approved, in implementation
4+
> Target release: mcpp 0.0.53
5+
> Scope: make library / component downloads show the same live progress + speed
6+
> that toolchain downloads already show, across Linux / macOS / Windows.
7+
8+
## 1. Problem
9+
10+
`mcpp toolchain install` shows a live progress bar with percent, bytes and
11+
transfer speed. Installing **library / component dependencies** (during
12+
`mcpp build`, dependency resolution, or `mcpp new --template`) prints a single
13+
`Downloading <pkg> v<ver>` line and then **appears to hang** for a long time with
14+
no percent, no speed and no sign of life — especially for large packages on slow
15+
mirrors.
16+
17+
## 2. Root cause (evidence-based)
18+
19+
There are **three** download code paths. They were not equally instrumented.
20+
21+
| Path | Trigger | Transport | Symptom |
22+
|------|---------|-----------|---------|
23+
| ① Toolchain | `mcpp toolchain install` | NDJSON `interface install_packages` + `CliInstallProgress` | progress OK |
24+
| ② Builtin-index deps | dep resolves via builtin index (`useProjectEnv = false`) | same as ① | progress OK *once bytes flow* |
25+
| ③ Custom/project-index deps | dep resolves via a project-added index (`useProjectEnv = true`) | **`install_direct(projEnv, target, quiet=true)`** | **fully silent** |
26+
27+
Two independent defects fall out of this:
28+
29+
### Defect A — path ③ is silenced
30+
31+
`src/cli.cppm` routes project/custom-index dependency installs through
32+
`mcpp::xlings::install_direct(projEnv, target, /*quiet=*/true)`. `quiet=true`
33+
redirects xlings' stdout/stderr to the platform null device, so xlings' own
34+
progress output is discarded and **no NDJSON events are parsed** (the direct CLI
35+
does not speak NDJSON). The `Downloading <pkg>` line is printed by mcpp *before*
36+
this call, then the whole download runs dark.
37+
38+
This path was switched to the direct CLI in a prior change to guarantee that
39+
project-index packages land in the **project-local** xlings data root (so a
40+
package's install hook can find sibling packages from the same index). That
41+
change traded away progress as a side effect.
42+
43+
**Why the NDJSON interface is in fact safe here (verified against xlings
44+
source):** in the pinned xlings, the `install_packages` *capability* and the
45+
`install` *CLI* both call the same `xim::cmd_install(...)`. The install
46+
destination is chosen by **package scope** — `storeRoot = (scope == Project ?
47+
project_data_dir() : global_data_dir()) / "xpkgs"` — which is derived from
48+
*which index the package came from*, not from interface-vs-CLI. `download_progress`
49+
events are emitted unconditionally (no scope gate). So the NDJSON interface
50+
installs project-scoped packages into the project-local data root **and** streams
51+
progress. The earlier switch was working around index *exposure*, which is now
52+
handled separately (the project index is symlinked/exposed into the project data
53+
dir and targets are passed as `indexName:fqname@version`).
54+
55+
### Defect B — the "connecting" phase freezes the line (all NDJSON paths)
56+
57+
Captured NDJSON for a real package download shows the downloader emits
58+
`download_progress` events with `totalBytes = 0` (and often `downloadedBytes = 0`)
59+
during the initial connect / TLS / redirect / pre-sizing window, before the
60+
transfer's total size is known:
61+
62+
```
63+
elapsed 0.2s–1.4s : downloadedBytes=0 totalBytes=0 sizesReady=false (connecting)
64+
elapsed 1.6s : downloadedBytes=57344 totalBytes=1754511 (bytes flow)
65+
elapsed 2.0s : downloadedBytes=1754511 totalBytes=1754511 finished
66+
```
67+
68+
Both `CliInstallProgress::on_data` and `make_bootstrap_progress_callback` only
69+
call `ProgressBar::update_bytes` when `total > 0`, so during the connecting
70+
window the line shows nothing. For a large file on a slow mirror this window can
71+
last many seconds and reads as a hang. Toolchain downloads hide this because the
72+
connect window is tiny relative to a multi-minute transfer; library downloads do
73+
not.
74+
75+
## 3. Design
76+
77+
Three changes, all behind existing abstractions, all cross-platform.
78+
79+
### 3.1 Fix 1 — path ③ uses the NDJSON interface (mcpp-style bar)
80+
81+
In `src/cli.cppm`, the `useProjectEnv` branch of the dependency install lambda
82+
calls the NDJSON interface with the project env and an `CliInstallProgress`
83+
handler, instead of the silenced direct CLI:
84+
85+
```cpp
86+
if (useProjectEnv) {
87+
auto projEnv = mcpp::config::make_project_xlings_env(**cfg, *root);
88+
auto argsJson = std::format(R"({{"targets":["{}"],"yes":true}})", target);
89+
CliInstallProgress progress;
90+
auto r = mcpp::xlings::call(projEnv, "install_packages", argsJson, &progress);
91+
if (!r) return std::unexpected(mcpp::pm::CallError{r.error()});
92+
return *r;
93+
}
94+
```
95+
96+
This yields the same cyan `Downloading … [bar] X/Y Z/s` UI on **all** three
97+
paths. Package scope (and therefore install location) is unchanged — packages
98+
from a project index still install into the project-local data root.
99+
100+
### 3.2 Fix 2 — indeterminate rendering while size is unknown
101+
102+
Add `ProgressBar::update_indeterminate(current_bytes, elapsed_sec)`: render a
103+
swept/indeterminate bar plus an info suffix that ticks — `connecting… Ns`, or a
104+
byte counter `X.Y MB Ns` once bytes arrive without a known total. This also
105+
covers servers that stream without `Content-Length`.
106+
107+
Wire both progress consumers (`CliInstallProgress::on_data` and
108+
`make_bootstrap_progress_callback`) to call `update_indeterminate` when the
109+
active file has `total == 0` (and `update_bytes` once `total > 0`).
110+
111+
Refactor `render_line` so the bar string is produced by a small callable; the
112+
percent path and the indeterminate path share the terminal-width budgeting.
113+
114+
### 3.3 Fix 3 — pin xlings to the latest release
115+
116+
Bump `pinned::kXlingsVersion` in `src/xlings.cppm` to the latest xlings release
117+
(the version that ships the unified `cmd_install` + scope-routed install +
118+
unconditional `download_progress`). This removes any dependence on older xlings
119+
behavior for the path ③ install location.
120+
121+
## 4. Cross-platform
122+
123+
- Progress rendering already runs on Linux/macOS/Windows (toolchain path uses it
124+
today); no new platform branches are introduced.
125+
- `mcpp::xlings::call` / `build_interface_command` already handle the Windows vs
126+
POSIX command shape and env propagation; path ③ inherits that.
127+
- `update_indeterminate` uses the same `render_line` budgeting (TIOCGWINSZ /
128+
`$COLUMNS` / 80-col fallback) as the existing bar; non-TTY / `--quiet` paths
129+
remain silent via the existing `g_quiet` guard.
130+
131+
## 5. Testing
132+
133+
- **Unit-ish / build:** project builds clean with the existing warning gate.
134+
- **E2E regression:** `tests/e2e/52_local_path_namespaced_index.sh` and
135+
`tests/e2e/58_preinstall_mcpp_deps_for_hooks.sh` currently assert path ③ uses
136+
the *direct* CLI. They are updated to assert the **interface** transport while
137+
keeping their existing invariants (project-local install location, and
138+
`mcpp.deps` installed before a dependent package's install hook runs).
139+
- **Real integration check:** with a real project-local custom index and a real
140+
installable package, confirm (a) the package installs into the project-local
141+
data root, (b) the install hook still sees its `mcpp.deps`, and (c) the
142+
`Downloading … [bar] X/Y Z/s` UI renders. This is the empirical gate before
143+
merge.
144+
145+
## 6. Rollout
146+
147+
1. Implement Fix 1–3; update e2e tests.
148+
2. Local build + e2e + real integration check.
149+
3. Bump `MCPP_VERSION` (`src/toolchain/fingerprint.cppm`) and `mcpp.toml` to
150+
`0.0.53`; CHANGELOG entry.
151+
4. PR → CI green → squash merge → trigger release `0.0.53`.
152+
5. Ecosystem: add mcpp `0.0.53` to the package index (mcpp xpkg descriptor:
153+
version + per-platform sha256), publish release artifacts to the
154+
release-asset mirrors, PR + merge, verify an end-to-end install of the new
155+
version.
156+
157+
## 7. Out of scope / follow-ups
158+
159+
- No change to xlings' own native CLI progress UI.
160+
- No change to the dependency-resolution / preinstall ordering logic (it already
161+
installs deps before dependents; only the per-install transport on path ③
162+
changes).

.xlings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"workspace": {
3-
"mcpp": "0.0.52"
3+
"mcpp": "0.0.53"
44
}
55
}

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,26 @@
33
> 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。
44
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
55
6+
## [0.0.53] — 2026-06-09
7+
8+
### 新增
9+
10+
- 库 / 组件下载现在与工具链下载一样显示实时进度条、字节进度与速度。自定义 /
11+
项目索引依赖改经 xlings NDJSON `interface install_packages` 安装(仍落在项目
12+
本地数据根,不改变安装位置与 install hook 顺序),不再静默卡住。
13+
14+
### 修复
15+
16+
- 下载连接 / 预取大小阶段(`totalBytes` 尚未知)进度行不再"冻结"无反馈:
17+
新增不确定态渲染,显示 `connecting…` + 已用时,流式无 `Content-Length`
18+
时显示已下载字节,直到拿到总大小再切换为百分比进度条。
19+
20+
### 其他
21+
22+
- 内置 xlings 版本上调至 `0.4.51`
23+
- 下载进度的状态机与渲染集中到 `mcpp.ui`(`DownloadProgress`),工具链 /
24+
内置索引 / 自定义索引三条路径共用同一套 UI。
25+
626
## [0.0.46] — 2026-06-03
727

828
### 新增

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcpp"
3-
version = "0.0.52"
3+
version = "0.0.53"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

src/cli.cppm

Lines changed: 54 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -448,119 +448,52 @@ mcpp::ui::PathContext make_path_ctx(const mcpp::config::GlobalConfig* cfg,
448448
return ctx;
449449
}
450450

451-
// Stateless adapter from `mcpp::config::BootstrapProgress` (xlings
452-
// download_progress event) to a sticky ProgressBar. Used by
453-
// load_or_init() during the one-time sandbox bootstrap (xim:patchelf,
454-
// xim:ninja, plus their transitive deps).
455-
//
456-
// Two xlings quirks the callback has to absorb:
457-
// 1. Each file's `finished=true` event arrives twice in a row.
458-
// 2. During multi-package installs the `files[]` array reshuffles
459-
// between events (the active download isn't always at slot 0).
460-
// The fix mirrors CliInstallProgress: dedupe via a `finished_` set and
461-
// always pick "active first if still in event, else first
462-
// started+unfinished" rather than reading slot 0 blindly.
463-
mcpp::config::BootstrapProgressCallback make_bootstrap_progress_callback() {
464-
auto bar = std::make_shared<std::optional<mcpp::ui::ProgressBar>>();
465-
auto active = std::make_shared<std::string>();
466-
auto finished = std::make_shared<std::unordered_set<std::string>>();
467-
return [bar, active, finished](const mcpp::config::BootstrapProgress& ev) {
468-
// Process newly-finished entries.
469-
for (auto& f : ev.files) {
470-
if (finished->contains(f.name)) continue;
471-
if (!f.finished) continue;
472-
if (*active == f.name) {
473-
if (*bar) (*bar)->finish();
474-
bar->reset();
475-
active->clear();
476-
}
477-
finished->insert(f.name);
478-
}
479-
480-
// Pick what to display: prefer continuing with `*active` if it's
481-
// still in the array and not finished, otherwise the first
482-
// started+unfinished entry.
483-
const mcpp::config::BootstrapFile* current = nullptr;
484-
for (auto& f : ev.files) {
485-
if (f.name == *active && !f.finished
486-
&& !finished->contains(f.name)) { current = &f; break; }
487-
}
488-
if (!current) {
489-
for (auto& f : ev.files) {
490-
if (finished->contains(f.name)) continue;
491-
if (f.started && !f.finished) { current = &f; break; }
492-
}
451+
// Map a decoded NDJSON `download_progress` files[] snapshot onto the neutral
452+
// `mcpp::ui::DownloadFile` the centralized renderer consumes.
453+
template <class File>
454+
std::vector<mcpp::ui::DownloadFile> to_ui_download_files(const std::vector<File>& files) {
455+
std::vector<mcpp::ui::DownloadFile> out;
456+
out.reserve(files.size());
457+
for (auto& f : files) {
458+
if constexpr (requires { f.downloadedBytes; }) {
459+
out.push_back({ f.name,
460+
static_cast<std::size_t>(f.downloadedBytes),
461+
static_cast<std::size_t>(f.totalBytes),
462+
f.started, f.finished });
463+
} else {
464+
out.push_back({ f.name,
465+
static_cast<std::size_t>(f.downloaded),
466+
static_cast<std::size_t>(f.total),
467+
f.started, f.finished });
493468
}
494-
if (!current) return;
469+
}
470+
return out;
471+
}
495472

496-
if (current->name != *active) {
497-
if (*bar) (*bar)->finish();
498-
*active = current->name;
499-
bar->emplace("Downloading", current->name);
500-
}
501-
if (current->totalBytes > 0) {
502-
(*bar)->update_bytes(static_cast<std::size_t>(current->downloadedBytes),
503-
static_cast<std::size_t>(current->totalBytes),
504-
ev.elapsedSec);
505-
}
473+
// Adapter from `mcpp::config::BootstrapProgress` (xlings download_progress
474+
// event) to the centralized download renderer. Used by load_or_init() during
475+
// the one-time sandbox bootstrap (xim:patchelf, xim:ninja + transitive deps).
476+
mcpp::config::BootstrapProgressCallback make_bootstrap_progress_callback() {
477+
auto progress = std::make_shared<mcpp::ui::DownloadProgress>();
478+
return [progress](const mcpp::config::BootstrapProgress& ev) {
479+
auto files = to_ui_download_files(ev.files);
480+
progress->update(files, ev.elapsedSec);
506481
};
507482
}
508483

484+
// EventHandler that forwards xlings `download_progress` events to the same
485+
// centralized renderer. Used for toolchain, builtin-index and custom-index
486+
// installs alike, so all three show identical UI.
509487
struct CliInstallProgress : mcpp::fetcher::EventHandler {
510-
std::optional<mcpp::ui::ProgressBar> bar_;
511-
std::string active_;
512-
std::unordered_set<std::string> finished_;
488+
mcpp::ui::DownloadProgress progress_;
513489

514490
void on_data(const mcpp::fetcher::DataEvent& d) override {
515491
if (d.dataKind != "download_progress") return;
516492
auto files = parse_all_install_files(d.payloadJson);
517493
if (files.empty()) return;
518-
519-
// 1. Process any newly-finished entries. Each file is reported
520-
// twice with finished=true (xlings quirk); the `finished_`
521-
// set dedupes both that AND the rotation case where the
522-
// same file shows up at a different array slot in a later
523-
// event.
524-
for (auto& f : files) {
525-
if (finished_.contains(f.name)) continue;
526-
if (!f.finished) continue;
527-
if (active_ == f.name) {
528-
if (bar_) bar_->finish();
529-
bar_.reset();
530-
active_.clear();
531-
}
532-
finished_.insert(f.name);
533-
}
534-
535-
// 2. Pick what to display. Prefer continuing with the current
536-
// `active_` if it's still in the array and not finished —
537-
// otherwise the first started+unfinished entry. This stops
538-
// the bar from flickering between names when xlings reshuffles
539-
// files[] across events during a multi-package install.
540-
const InstallProgressFile* current = nullptr;
541-
for (auto& f : files) {
542-
if (f.name == active_ && !f.finished
543-
&& !finished_.contains(f.name)) { current = &f; break; }
544-
}
545-
if (!current) {
546-
for (auto& f : files) {
547-
if (finished_.contains(f.name)) continue;
548-
if (f.started && !f.finished) { current = &f; break; }
549-
}
550-
}
551-
if (!current) return;
552-
553-
if (current->name != active_) {
554-
if (bar_) bar_->finish();
555-
active_ = current->name;
556-
bar_.emplace("Downloading", current->name);
557-
}
558-
if (current->total > 0) {
559-
double elapsed = extract_payload_number(d.payloadJson, "elapsedSec");
560-
bar_->update_bytes(static_cast<std::size_t>(current->downloaded),
561-
static_cast<std::size_t>(current->total),
562-
elapsed);
563-
}
494+
double elapsed = extract_payload_number(d.payloadJson, "elapsedSec");
495+
auto ui_files = to_ui_download_files(files);
496+
progress_.update(ui_files, elapsed);
564497
}
565498

566499
void on_log(const mcpp::fetcher::LogEvent& e) override {
@@ -579,7 +512,8 @@ struct CliInstallProgress : mcpp::fetcher::EventHandler {
579512
mcpp::log::info("xlings", std::format("hint: {}", e.hint));
580513
}
581514

582-
~CliInstallProgress() override { if (bar_) bar_->finish(); }
515+
// progress_'s own destructor finishes the active bar.
516+
~CliInstallProgress() override = default;
583517
};
584518

585519
// Compose a stable canonical compile-flags string for fingerprinting.
@@ -2312,11 +2246,24 @@ prepare_build(bool print_fingerprint,
23122246

23132247
auto install_one = [&](std::string target) -> std::expected<mcpp::xlings::CallResult, mcpp::pm::CallError> {
23142248
if (useProjectEnv) {
2249+
// Project/custom-index deps install into the project-local
2250+
// xlings data root (so a package's install hook can find
2251+
// sibling packages from the same index). The NDJSON
2252+
// interface honors this: in the pinned xlings the
2253+
// `install_packages` capability and the `install` CLI share
2254+
// `xim::cmd_install`, and the install destination is chosen
2255+
// by package *scope* (project vs global), not by transport.
2256+
// Using the interface (rather than the silenced direct CLI)
2257+
// restores the live `Downloading … [bar] X/Y Z/s` UI here,
2258+
// matching the toolchain and builtin-index paths.
23152259
auto projEnv = mcpp::config::make_project_xlings_env(**cfg, *root);
2316-
int directRc = mcpp::xlings::install_direct(projEnv, target, /*quiet=*/true);
2317-
mcpp::xlings::CallResult result;
2318-
result.exitCode = directRc;
2319-
return result;
2260+
auto argsJson = std::format(
2261+
R"({{"targets":["{}"],"yes":true}})", target);
2262+
CliInstallProgress progress;
2263+
auto r = mcpp::xlings::call(
2264+
projEnv, "install_packages", argsJson, &progress);
2265+
if (!r) return std::unexpected(mcpp::pm::CallError{r.error()});
2266+
return *r;
23202267
}
23212268
std::vector<std::string> targets{ std::move(target) };
23222269
CliInstallProgress progress;

0 commit comments

Comments
 (0)