diff --git a/src/shared/inc/stringshared.h b/src/shared/inc/stringshared.h index 8187101e7..978bd9cbd 100644 --- a/src/shared/inc/stringshared.h +++ b/src/shared/inc/stringshared.h @@ -825,6 +825,30 @@ struct CaseInsensitiveCompare } }; +inline std::wstring FormatBytes(uint64_t bytes) +{ + constexpr double c_kB = 1000.0; + constexpr double c_MB = 1000.0 * 1000.0; + constexpr double c_GB = 1000.0 * 1000.0 * 1000.0; + + if (bytes >= static_cast(c_GB)) + { + return std::format(L"{:.2f} GB", bytes / c_GB); + } + else if (bytes >= static_cast(c_MB)) + { + return std::format(L"{:.2f} MB", bytes / c_MB); + } + else if (bytes >= static_cast(c_kB)) + { + return std::format(L"{:.2f} KB", bytes / c_kB); + } + else + { + return std::format(L"{} B", bytes); + } +} + } // namespace wsl::shared::string template <> diff --git a/src/windows/inc/docker_schema.h b/src/windows/inc/docker_schema.h index 2cadfd8d5..e6fb2a3d1 100644 --- a/src/windows/inc/docker_schema.h +++ b/src/windows/inc/docker_schema.h @@ -584,8 +584,10 @@ struct BuildKitStatus { std::string id; std::string vertex; + int64_t current{}; + int64_t total{}; - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(BuildKitStatus, id, vertex); + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(BuildKitStatus, id, vertex, current, total); }; struct BuildKitLog diff --git a/src/windows/wslc/services/BuildImageCallback.cpp b/src/windows/wslc/services/BuildImageCallback.cpp index 847a3a90f..3e4590ce7 100644 --- a/src/windows/wslc/services/BuildImageCallback.cpp +++ b/src/windows/wslc/services/BuildImageCallback.cpp @@ -64,9 +64,10 @@ void BuildImageCallback::CollapseWindow() m_lines.clear(); m_pendingLine.clear(); + m_pullLines.clear(); } -HRESULT BuildImageCallback::OnProgress(LPCSTR status, LPCSTR id, ULONGLONG /*current*/, ULONGLONG /*total*/) +HRESULT BuildImageCallback::OnProgress(LPCSTR status, LPCSTR id, ULONGLONG current, ULONGLONG total) try { if (status == nullptr || *status == '\0') @@ -81,78 +82,103 @@ try return S_OK; } + const std::string_view idView = (id != nullptr) ? id : std::string_view{}; + const bool isLog = (idView == "log"); + const bool isPullProgress = (!idView.empty() && total > 0 && !isLog); + if (m_verbose || !m_isConsole) { - wprintf(L"%hs", status); + // Skip pull progress updates when output is redirected, show only major steps + if (!isPullProgress) + { + wprintf(L"%hs", status); + } return S_OK; } - // Match the specific "log" sentinel sent by WSLCSession::BuildImage rather than - // accepting any non-empty id, so future or unrelated id usage defaults to permanent. - const bool isLog = (id != nullptr && std::string_view{id} == "log"); - - if (!isLog) + // Pull/download progress: update the per-entry map so Redraw can show each entry + // on a single line that updates in place. + if (isPullProgress) { - // Permanent line: collapse the scrolling window then print directly. - CollapseWindow(); - WriteTerminal(MultiByteToWide(status)); + m_pullLines[id] = status; + + auto now = std::chrono::steady_clock::now(); + if (now - m_lastRedraw >= c_redrawInterval) + { + Redraw(); + m_lastRedraw = now; + } + return S_OK; } - // Log line: add to the scrolling window. - for (const char* p = status; *p != '\0'; ++p) + if (isLog) { - if (*p == '\n') + // Log line: add to the scrolling window. + for (const char* p = status; *p != '\0'; ++p) { - // Store with the trailing newline so the byte count matches what is replayed. - // Cap retained log output to avoid unbounded growth on very long builds. - m_allLines.push_back(m_pendingLine + '\n'); - m_allLinesBytes += m_allLines.back().size(); - while (m_allLinesBytes > c_maxAllLinesBytes && !m_allLines.empty()) + if (*p == '\n') { - m_allLinesBytes -= m_allLines.front().size(); - m_allLines.pop_front(); - } + // Store with the trailing newline so the byte count matches what is replayed. + // Cap retained log output to avoid unbounded growth on very long builds. + m_allLines.push_back(m_pendingLine + '\n'); + m_allLinesBytes += m_allLines.back().size(); + while (m_allLinesBytes > c_maxAllLinesBytes && !m_allLines.empty()) + { + m_allLinesBytes -= m_allLines.front().size(); + m_allLines.pop_front(); + } - m_lines.push_back(std::move(m_pendingLine)); - m_pendingLine.clear(); - if (m_lines.size() > c_maxDisplayLines) - { - m_lines.pop_front(); + m_lines.push_back(std::move(m_pendingLine)); + m_pendingLine.clear(); + if (m_lines.size() > c_maxDisplayLines) + { + m_lines.pop_front(); + } } - } - else if (*p == '\r') - { - // \r\n is a line ending; standalone \r overwrites the current line. - if (*(p + 1) != '\n') + else if (*p == '\r') { - // Flush a throttled redraw before clearing so \r-based progress - // updates are visible even when batched in a single OnProgress call. - auto now = std::chrono::steady_clock::now(); - if (!m_pendingLine.empty() && now - m_lastRedraw >= c_redrawInterval) + // \r\n is a line ending; standalone \r overwrites the current line. + if (*(p + 1) != '\n') { - Redraw(); - m_lastRedraw = now; + // Flush a throttled redraw before clearing so \r-based progress + // updates are visible even when batched in a single OnProgress call. + auto now = std::chrono::steady_clock::now(); + if (!m_pendingLine.empty() && now - m_lastRedraw >= c_redrawInterval) + { + Redraw(); + m_lastRedraw = now; + } + m_pendingLine.clear(); } - m_pendingLine.clear(); + } + else + { + m_pendingLine += *p; } } - else + + // Throttle redraws to avoid blocking the server's IO loop with console writes + // during rapid output. Lines accumulate in the deque immediately; the display + // catches up at ~20fps. + auto now = std::chrono::steady_clock::now(); + if (now - m_lastRedraw >= c_redrawInterval) { - m_pendingLine += *p; + Redraw(); + m_lastRedraw = now; } - } - // Throttle redraws to avoid blocking the server's IO loop with console writes - // during rapid output. Lines accumulate in the deque immediately; the display - // catches up at ~20fps. - auto now = std::chrono::steady_clock::now(); - if (now - m_lastRedraw >= c_redrawInterval) - { - Redraw(); - m_lastRedraw = now; + return S_OK; } + // Else is a build step + CollapseWindow(); + auto wide = MultiByteToWide(status); + const auto bodyLength = wide.find_last_not_of(L"\r\n") + 1; + const auto newlines = wide.substr(bodyLength); + wide.resize(bodyLength); + + WriteTerminal(std::format(L"\033[92m{}\033[0m{}", wide, newlines)); return S_OK; } CATCH_RETURN(); @@ -167,14 +193,16 @@ void BuildImageCallback::Redraw() // to std::wstring::resize). const SHORT consoleWidth = std::max(0, info.srWindow.Right - info.srWindow.Left); - // Determine how many completed lines to show, leaving room for the pending line. + // Determine how many completed lines to show, leaving room for the pending line and pull progress. const bool showPending = !m_pendingLine.empty(); + const SHORT pullCount = static_cast(m_pullLines.size()); SHORT completedCount = static_cast(m_lines.size()); - if (showPending && completedCount >= c_maxDisplayLines) + const SHORT reservedLines = (showPending ? 1 : 0) + pullCount; + if (completedCount + reservedLines > c_maxDisplayLines) { - completedCount = c_maxDisplayLines - 1; + completedCount = std::max(0, c_maxDisplayLines - reservedLines); } - const SHORT displayCount = completedCount + (showPending ? 1 : 0); + const SHORT displayCount = completedCount + reservedLines; // Build the entire frame in one buffer to minimize console writes. Hide the cursor // during the redraw so the user doesn't see it bouncing through the cursor movement, @@ -217,6 +245,12 @@ void BuildImageCallback::Redraw() appendLine(m_pendingLine); } + // Render per-entry pull progress (each entry updates in place via the map). + for (const auto& [key, line] : m_pullLines) + { + appendLine(line); + } + buffer += L"\033[22m\033[?25h"; WriteTerminal(buffer); diff --git a/src/windows/wslc/services/BuildImageCallback.h b/src/windows/wslc/services/BuildImageCallback.h index df45addbe..c696ff502 100644 --- a/src/windows/wslc/services/BuildImageCallback.h +++ b/src/windows/wslc/services/BuildImageCallback.h @@ -15,6 +15,7 @@ Module Name: #include "ChangeTerminalMode.h" #include "SessionService.h" #include +#include namespace wsl::windows::wslc::services { class DECLSPEC_UUID("3EDD5DBF-CA6C-4CF7-923A-AD94B6A732E5") BuildImageCallback @@ -54,6 +55,8 @@ class DECLSPEC_UUID("3EDD5DBF-CA6C-4CF7-923A-AD94B6A732E5") BuildImageCallback std::string m_pendingLine; SHORT m_displayedLines = 0; std::chrono::steady_clock::time_point m_lastRedraw{}; + // Per-entry pull progress lines, keyed by entry id. Updated in place by Redraw. std::map so order is consistent. + std::map m_pullLines; // Captured at construction so the destructor can detect destruction during exception unwinding. int m_uncaughtExceptions = std::uncaught_exceptions(); }; diff --git a/src/windows/wslc/services/ImageProgressCallback.cpp b/src/windows/wslc/services/ImageProgressCallback.cpp index 7cee4674f..a8bea2154 100644 --- a/src/windows/wslc/services/ImageProgressCallback.cpp +++ b/src/windows/wslc/services/ImageProgressCallback.cpp @@ -84,23 +84,58 @@ std::wstring ImageProgressCallback::GenerateStatusLine(LPCSTR status, LPCSTR id, std::wstring line; if (total != 0) { - line = std::format(L"{} '{}': {}%", status, id, current * 100 / total); + constexpr int c_progressBarWidth = 30; + + int filled = 0; + if (current >= total) + { + filled = c_progressBarWidth; + } + else + { + auto ratio = static_cast(current) / static_cast(total); + filled = static_cast(ratio * c_progressBarWidth); + } + + filled = std::clamp(filled, 0, c_progressBarWidth); + + std::wstring bar; + bar.reserve(c_progressBarWidth); + bar.append(filled, L'='); + bar.append(L">"); + bar.resize(c_progressBarWidth, L' '); + + line = std::format( + L"{}: {} [{}] {}/{}", id, status, bar, wsl::shared::string::FormatBytes(current), wsl::shared::string::FormatBytes(total)); } else if (current != 0) { - line = std::format(L"{} '{}': {}s", status, id, current); + line = std::format(L"{}: {} {}s", id, status, current); } else { - line = std::format(L"{} '{}'", status, id); + line = std::format(L"{}: {}", id, status); } - // Erase any previously written char on that line. - while (line.size() < info.dwSize.X) + // Use the visible window width (not the buffer width) to prevent wrapping. + const auto visibleWidth = std::max(0, info.srWindow.Right - info.srWindow.Left + 1); + + // Truncate to console width to prevent wrapping that would break cursor repositioning. + if (line.size() > static_cast(visibleWidth)) { - line += L' '; + line.resize(visibleWidth); + + // Avoid splitting a surrogate pair — if the last code unit is a high surrogate, + // drop it so we don't emit an invalid UTF-16 sequence. + if (!line.empty() && IS_HIGH_SURROGATE(line.back())) + { + line.pop_back(); + } } + // Erase any previously written char on that line. + line.resize(visibleWdith, L' '); + return line; } } // namespace wsl::windows::wslc::services diff --git a/src/windows/wslcsession/WSLCSession.cpp b/src/windows/wslcsession/WSLCSession.cpp index f9ca9ffd6..3ff85f02e 100644 --- a/src/windows/wslcsession/WSLCSession.cpp +++ b/src/windows/wslcsession/WSLCSession.cpp @@ -827,10 +827,10 @@ try return " [" + name + "] "; }; - auto reportProgress = [&](const std::string& message, const char* id = "") { + auto reportProgress = [&](const std::string& message, const char* id = "", ULONGLONG current = 0, ULONGLONG total = 0) { if (ProgressCallback != nullptr) { - THROW_IF_FAILED(ProgressCallback->OnProgress(message.c_str(), id, 0, 0)); + THROW_IF_FAILED(ProgressCallback->OnProgress(message.c_str(), id, current, total)); } }; @@ -924,8 +924,21 @@ try for (const auto& entry : status.statuses) { - if (auto it = digestToStageName.find(entry.vertex); - it != digestToStageName.end() && !entry.id.empty() && reportedSteps.insert(entry.id).second) + auto it = digestToStageName.find(entry.vertex); + if (it == digestToStageName.end() || entry.id.empty()) + { + continue; + } + + if (entry.total > 0) + { + auto currentBytes = static_cast(std::max(entry.current, 0)); + auto totalBytes = static_cast(std::max(entry.total, 0)); + auto current = wsl::shared::string::FormatBytes(currentBytes); + auto total = wsl::shared::string::FormatBytes(totalBytes); + reportProgress(std::format("{}{} {} / {}", logPrefix(it->second), entry.id, current, total), entry.id.c_str(), currentBytes, totalBytes); + } + else if (reportedSteps.insert(entry.id).second) { flushLine(); reportProgress(logPrefix(it->second) + entry.id + "\n");