Improved details for build and pull (#40596)

* Improved details for build and pull

Craig Loewen committed May 28, 2026 at 12:25 UTC 6b39b6dad37d0eadf4fbf5f614a5cb98d3696b82
6 files changed +189 -71
src/shared/inc/stringshared.h
+24
@@ -825,6 +825,30 @@ struct CaseInsensitiveCompare
825 }
826 };
827
828 +inline std::wstring FormatBytes(uint64_t bytes)
829 +{
830 + constexpr double c_kB = 1000.0;
831 + constexpr double c_MB = 1000.0 * 1000.0;
832 + constexpr double c_GB = 1000.0 * 1000.0 * 1000.0;
833 +
834 + if (bytes >= static_cast<uint64_t>(c_GB))
835 + {
836 + return std::format(L"{:.2f} GB", bytes / c_GB);
837 + }
838 + else if (bytes >= static_cast<uint64_t>(c_MB))
839 + {
840 + return std::format(L"{:.2f} MB", bytes / c_MB);
841 + }
842 + else if (bytes >= static_cast<uint64_t>(c_kB))
843 + {
844 + return std::format(L"{:.2f} KB", bytes / c_kB);
845 + }
846 + else
847 + {
848 + return std::format(L"{} B", bytes);
849 + }
850 +}
851 +
852 } // namespace wsl::shared::string
853
854 template <>
src/windows/inc/docker_schema.h
+3 -1
@@ -584,8 +584,10 @@ struct BuildKitStatus
584 {
585 std::string id;
586 std::string vertex;
587 + int64_t current{};
588 + int64_t total{};
589
588 - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(BuildKitStatus, id, vertex);
590 + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(BuildKitStatus, id, vertex, current, total);
591 };
592
593 struct BuildKitLog
src/windows/wslc/services/BuildImageCallback.cpp
+100 -60
@@ -19,6 +19,15 @@ namespace wsl::windows::wslc::services {
19
20 using wsl::windows::common::string::MultiByteToWide;
21
22 +namespace {
23 + constexpr std::wstring_view c_escapeMoveCursorUpAndClear = L"\033[{}A\033[J";
24 + constexpr std::wstring_view c_escapeBrightGreen = L"\033[92m";
25 + constexpr std::wstring_view c_escapeResetAttributes = L"\033[0m";
26 + constexpr std::wstring_view c_escapeHideCursorDim = L"\033[?25l\033[2m";
27 + constexpr std::wstring_view c_escapeClearLineAndNewline = L"\033[K\n";
28 + constexpr std::wstring_view c_escapeUndimShowCursor = L"\033[22m\033[?25h";
29 +} // namespace
30 +
31 BuildImageCallback::~BuildImageCallback()
32 try
33 {
@@ -58,15 +67,26 @@ void BuildImageCallback::CollapseWindow()
67 {
68 if (m_displayedLines > 0)
69 {
61 - WriteTerminal(std::format(L"\033[{}A\033[J", m_displayedLines));
70 + WriteTerminal(std::format(c_escapeMoveCursorUpAndClear, m_displayedLines));
71 m_displayedLines = 0;
72 }
73
74 m_lines.clear();
75 m_pendingLine.clear();
76 + m_pullLines.clear();
77 }
78
69 -HRESULT BuildImageCallback::OnProgress(LPCSTR status, LPCSTR id, ULONGLONG /*current*/, ULONGLONG /*total*/)
79 +void BuildImageCallback::RedrawIfNeeded()
80 +{
81 + auto now = std::chrono::steady_clock::now();
82 + if (now - m_lastRedraw >= c_redrawInterval)
83 + {
84 + Redraw();
85 + m_lastRedraw = now;
86 + }
87 +}
88 +
89 +HRESULT BuildImageCallback::OnProgress(LPCSTR status, LPCSTR id, ULONGLONG current, ULONGLONG total)
90 try
91 {
92 if (status == nullptr || *status == '\0')
@@ -81,78 +101,90 @@ try
101 return S_OK;
102 }
103
104 + const std::string_view idView = (id != nullptr) ? id : std::string_view{};
105 + const bool isLog = (idView == "log");
106 + const bool isPullProgress = (!idView.empty() && total > 0 && !isLog);
107 +
108 if (m_verbose || !m_isConsole)
109 {
86 - wprintf(L"%hs", status);
110 + // Skip pull progress updates when output is redirected, show only major steps
111 + if (!isPullProgress)
112 + {
113 + wprintf(L"%hs", status);
114 + }
115 return S_OK;
116 }
117
90 - // Match the specific "log" sentinel sent by WSLCSession::BuildImage rather than
91 - // accepting any non-empty id, so future or unrelated id usage defaults to permanent.
92 - const bool isLog = (id != nullptr && std::string_view{id} == "log");
93 -
94 - if (!isLog)
118 + // Pull/download progress: update the per-entry map so Redraw can show each entry
119 + // on a single line that updates in place.
120 + if (isPullProgress)
121 {
96 - // Permanent line: collapse the scrolling window then print directly.
97 - CollapseWindow();
98 - WriteTerminal(MultiByteToWide(status));
122 + m_pullLines[id] = status;
123 + RedrawIfNeeded();
124 +
125 return S_OK;
126 }
127
102 - // Log line: add to the scrolling window.
103 - for (const char* p = status; *p != '\0'; ++p)
128 + if (isLog)
129 {
105 - if (*p == '\n')
130 + // Log line: add to the scrolling window.
131 + for (const char* p = status; *p != '\0'; ++p)
132 {
107 - // Store with the trailing newline so the byte count matches what is replayed.
108 - // Cap retained log output to avoid unbounded growth on very long builds.
109 - m_allLines.push_back(m_pendingLine + '\n');
110 - m_allLinesBytes += m_allLines.back().size();
111 - while (m_allLinesBytes > c_maxAllLinesBytes && !m_allLines.empty())
133 + if (*p == '\n')
134 {
113 - m_allLinesBytes -= m_allLines.front().size();
114 - m_allLines.pop_front();
115 - }
135 + // Store with the trailing newline so the byte count matches what is replayed.
136 + // Cap retained log output to avoid unbounded growth on very long builds.
137 + m_allLines.push_back(m_pendingLine + '\n');
138 + m_allLinesBytes += m_allLines.back().size();
139 + while (m_allLinesBytes > c_maxAllLinesBytes && !m_allLines.empty())
140 + {
141 + m_allLinesBytes -= m_allLines.front().size();
142 + m_allLines.pop_front();
143 + }
144
117 - m_lines.push_back(std::move(m_pendingLine));
118 - m_pendingLine.clear();
119 - if (m_lines.size() > c_maxDisplayLines)
120 - {
121 - m_lines.pop_front();
145 + m_lines.push_back(std::move(m_pendingLine));
146 + m_pendingLine.clear();
147 + if (m_lines.size() > c_maxDisplayLines)
148 + {
149 + m_lines.pop_front();
150 + }
151 }
123 - }
124 - else if (*p == '\r')
125 - {
126 - // \r\n is a line ending; standalone \r overwrites the current line.
127 - if (*(p + 1) != '\n')
152 + else if (*p == '\r')
153 {
129 - // Flush a throttled redraw before clearing so \r-based progress
130 - // updates are visible even when batched in a single OnProgress call.
131 - auto now = std::chrono::steady_clock::now();
132 - if (!m_pendingLine.empty() && now - m_lastRedraw >= c_redrawInterval)
154 + // \r\n is a line ending; standalone \r overwrites the current line.
155 + if (*(p + 1) != '\n')
156 {
134 - Redraw();
135 - m_lastRedraw = now;
157 + // Flush a throttled redraw before clearing so \r-based progress
158 + // updates are visible even when batched in a single OnProgress call.
159 + if (!m_pendingLine.empty())
160 + {
161 + RedrawIfNeeded();
162 + }
163 + m_pendingLine.clear();
164 }
137 - m_pendingLine.clear();
165 + }
166 + else
167 + {
168 + m_pendingLine += *p;
169 }
170 }
140 - else
141 - {
142 - m_pendingLine += *p;
143 - }
144 - }
171
146 - // Throttle redraws to avoid blocking the server's IO loop with console writes
147 - // during rapid output. Lines accumulate in the deque immediately; the display
148 - // catches up at ~20fps.
149 - auto now = std::chrono::steady_clock::now();
150 - if (now - m_lastRedraw >= c_redrawInterval)
151 - {
152 - Redraw();
153 - m_lastRedraw = now;
172 + // Throttle redraws to avoid blocking the server's IO loop with console writes
173 + // during rapid output. Lines accumulate in the deque immediately; the display
174 + // catches up at ~20fps.
175 + RedrawIfNeeded();
176 +
177 + return S_OK;
178 }
179
180 + // Else is a build step
181 + CollapseWindow();
182 + auto wide = MultiByteToWide(status);
183 + const auto bodyLength = wide.find_last_not_of(L"\r\n") + 1;
184 + const auto newlines = wide.substr(bodyLength);
185 + wide.resize(bodyLength);
186 +
187 + WriteTerminal(std::format(L"{}{}{}{}", c_escapeBrightGreen, wide, c_escapeResetAttributes, newlines));
188 return S_OK;
189 }
190 CATCH_RETURN();
@@ -167,27 +199,29 @@ void BuildImageCallback::Redraw()
199 // to std::wstring::resize).
200 const SHORT consoleWidth = std::max<SHORT>(0, info.srWindow.Right - info.srWindow.Left);
201
170 - // Determine how many completed lines to show, leaving room for the pending line.
202 + // Determine how many completed lines to show, leaving room for the pending line and pull progress.
203 const bool showPending = !m_pendingLine.empty();
204 + const SHORT pullCount = static_cast<SHORT>(m_pullLines.size());
205 SHORT completedCount = static_cast<SHORT>(m_lines.size());
173 - if (showPending && completedCount >= c_maxDisplayLines)
206 + const SHORT reservedLines = (showPending ? 1 : 0) + pullCount;
207 + if (completedCount + reservedLines > c_maxDisplayLines)
208 {
175 - completedCount = c_maxDisplayLines - 1;
209 + completedCount = std::max<SHORT>(0, c_maxDisplayLines - reservedLines);
210 }
177 - const SHORT displayCount = completedCount + (showPending ? 1 : 0);
211 + const SHORT displayCount = completedCount + reservedLines;
212
213 // Build the entire frame in one buffer to minimize console writes. Hide the cursor
214 // during the redraw so the user doesn't see it bouncing through the cursor movement,
215 // then show it again at the final position. The dim attribute (\033[2m) renders the
216 // scrolling lines de-emphasized regardless of the user's theme.
183 - std::wstring buffer = L"\033[?25l\033[2m";
217 + std::wstring buffer{c_escapeHideCursorDim};
218
219 // Move cursor to the start of the display area and erase from there to the end of
220 // the screen. \033[J handles the case where the new display is shorter than the
221 // previous one (e.g. when \r clears the pending line without a replacement).
222 if (m_displayedLines > 0)
223 {
190 - buffer += std::format(L"\033[{}A\033[J", m_displayedLines);
224 + buffer += std::format(c_escapeMoveCursorUpAndClear, m_displayedLines);
225 }
226
227 auto appendLine = [&](const std::string& line) {
@@ -197,7 +231,7 @@ void BuildImageCallback::Redraw()
231 wline.resize(consoleWidth);
232 }
233 buffer += wline;
200 - buffer += L"\033[K\n";
234 + buffer += c_escapeClearLineAndNewline;
235 };
236
237 // Print completed lines (skip older ones if we need room for the pending line).
@@ -217,7 +251,13 @@ void BuildImageCallback::Redraw()
251 appendLine(m_pendingLine);
252 }
253
220 - buffer += L"\033[22m\033[?25h";
254 + // Render per-entry pull progress (each entry updates in place via the map).
255 + for (const auto& [key, line] : m_pullLines)
256 + {
257 + appendLine(line);
258 + }
259 +
260 + buffer += c_escapeUndimShowCursor;
261
262 WriteTerminal(buffer);
263 m_displayedLines = displayCount;
src/windows/wslc/services/BuildImageCallback.h
+4
@@ -15,6 +15,7 @@ Abstract:
15 #include "ChangeTerminalMode.h"
16 #include "SessionService.h"
17 #include <deque>
18 +#include <map>
19
20 namespace wsl::windows::wslc::services {
21 class DECLSPEC_UUID("3EDD5DBF-CA6C-4CF7-923A-AD94B6A732E5") BuildImageCallback
@@ -35,6 +36,7 @@ private:
36
37 void CollapseWindow();
38 void Redraw();
39 + void RedrawIfNeeded();
40 // Use WriteConsoleW directly rather than wprintf: wprintf is noticeably slower for
41 // the per-redraw scrolling display and produces visible flicker.
42 void WriteTerminal(std::wstring_view content) const;
@@ -54,6 +56,8 @@ private:
56 std::string m_pendingLine;
57 SHORT m_displayedLines = 0;
58 std::chrono::steady_clock::time_point m_lastRedraw{};
59 + // Per-entry pull progress lines, keyed by entry id. Updated in place by Redraw. std::map so order is consistent.
60 + std::map<std::string, std::string> m_pullLines;
61 // Captured at construction so the destructor can detect destruction during exception unwinding.
62 int m_uncaughtExceptions = std::uncaught_exceptions();
63 };
src/windows/wslc/services/ImageProgressCallback.cpp
+41 -6
@@ -84,23 +84,58 @@ std::wstring ImageProgressCallback::GenerateStatusLine(LPCSTR status, LPCSTR id,
84 std::wstring line;
85 if (total != 0)
86 {
87 - line = std::format(L"{} '{}': {}%", status, id, current * 100 / total);
87 + constexpr int c_progressBarWidth = 30;
88 +
89 + int filled = 0;
90 + if (current >= total)
91 + {
92 + filled = c_progressBarWidth;
93 + }
94 + else
95 + {
96 + auto ratio = static_cast<long double>(current) / static_cast<long double>(total);
97 + filled = static_cast<int>(ratio * c_progressBarWidth);
98 + }
99 +
100 + filled = std::clamp(filled, 0, c_progressBarWidth);
101 +
102 + std::wstring bar;
103 + bar.reserve(c_progressBarWidth);
104 + bar.append(filled, L'=');
105 + bar.append(L">");
106 + bar.resize(c_progressBarWidth, L' ');
107 +
108 + line = std::format(
109 + L"{}: {} [{}] {}/{}", id, status, bar, wsl::shared::string::FormatBytes(current), wsl::shared::string::FormatBytes(total));
110 }
111 else if (current != 0)
112 {
91 - line = std::format(L"{} '{}': {}s", status, id, current);
113 + line = std::format(L"{}: {} {}s", id, status, current);
114 }
115 else
116 {
95 - line = std::format(L"{} '{}'", status, id);
117 + line = std::format(L"{}: {}", id, status);
118 }
119
98 - // Erase any previously written char on that line.
99 - while (line.size() < info.dwSize.X)
120 + // Use the visible window width (not the buffer width) to prevent wrapping.
121 + const auto visibleWidth = std::max<SHORT>(0, info.srWindow.Right - info.srWindow.Left + 1);
122 +
123 + // Truncate to console width to prevent wrapping that would break cursor repositioning.
124 + if (line.size() > static_cast<size_t>(visibleWidth))
125 {
101 - line += L' ';
126 + line.resize(visibleWidth);
127 +
128 + // Avoid splitting a surrogate pair — if the last code unit is a high surrogate,
129 + // drop it so we don't emit an invalid UTF-16 sequence.
130 + if (!line.empty() && IS_HIGH_SURROGATE(line.back()))
131 + {
132 + line.pop_back();
133 + }
134 }
135
136 + // Erase any previously written char on that line.
137 + line.resize(visibleWidth, L' ');
138 +
139 return line;
140 }
141 } // namespace wsl::windows::wslc::services
src/windows/wslcsession/WSLCSession.cpp
+17 -4
@@ -827,10 +827,10 @@ try
827 return " [" + name + "] ";
828 };
829
830 - auto reportProgress = [&](const std::string& message, const char* id = "") {
830 + auto reportProgress = [&](const std::string& message, const char* id = "", ULONGLONG current = 0, ULONGLONG total = 0) {
831 if (ProgressCallback != nullptr)
832 {
833 - THROW_IF_FAILED(ProgressCallback->OnProgress(message.c_str(), id, 0, 0));
833 + THROW_IF_FAILED(ProgressCallback->OnProgress(message.c_str(), id, current, total));
834 }
835 };
836
@@ -924,8 +924,21 @@ try
924
925 for (const auto& entry : status.statuses)
926 {
927 - if (auto it = digestToStageName.find(entry.vertex);
928 - it != digestToStageName.end() && !entry.id.empty() && reportedSteps.insert(entry.id).second)
927 + auto it = digestToStageName.find(entry.vertex);
928 + if (it == digestToStageName.end() || entry.id.empty())
929 + {
930 + continue;
931 + }
932 +
933 + if (entry.total > 0)
934 + {
935 + auto currentBytes = static_cast<ULONGLONG>(std::max<int64_t>(entry.current, 0));
936 + auto totalBytes = static_cast<ULONGLONG>(std::max<int64_t>(entry.total, 0));
937 + auto current = wsl::shared::string::FormatBytes(currentBytes);
938 + auto total = wsl::shared::string::FormatBytes(totalBytes);
939 + reportProgress(std::format("{}{} {} / {}", logPrefix(it->second), entry.id, current, total), entry.id.c_str(), currentBytes, totalBytes);
940 + }
941 + else if (reportedSteps.insert(entry.id).second)
942 {
943 flushLine();
944 reportProgress(logPrefix(it->second) + entry.id + "\n");