Made wslc build output more detailed#40530
Conversation
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (8)
src/windows/wslc/services/BuildView.cpp:231
[internal]BuildKit vertexes (load build context, parse Dockerfile, etc.) are now always rendered as their own targets/steps. The previous implementation skipped these unless--verbosewas set. With the rewrite, every build will show "[internal]" stages in the live view, which clutters the output and is inconsistent with the verbose flag's intent. Consider filtering targets whosenamebegins with[internal]unless verbose is enabled.
for (const auto& vertex : msg.vertexes)
{
size_t targetIdx, stepIdx;
auto it = m_digestIndex.find(vertex.digest);
if (it != m_digestIndex.end())
{
targetIdx = it->second.first;
stepIdx = it->second.second;
}
else
{
auto parsed = ParseTarget(vertex.name);
auto targetIt = m_targetNameToIdx.find(parsed.targetName);
if (targetIt != m_targetNameToIdx.end())
{
targetIdx = targetIt->second;
}
else
{
targetIdx = m_targets.size();
m_targets.push_back(ViewTarget{parsed.targetName, {}});
m_targetNameToIdx[parsed.targetName] = targetIdx;
}
ViewStep newStep{};
newStep.digest = vertex.digest;
newStep.stepLabel = parsed.stepLabel;
newStep.stepNum = parsed.stepNum;
newStep.stepTotal = parsed.stepTotal;
newStep.inputs = vertex.inputs;
stepIdx = m_targets[targetIdx].steps.size();
m_targets[targetIdx].steps.push_back(std::move(newStep));
m_digestIndex[vertex.digest] = {targetIdx, stepIdx};
if (std::find(targetsToSort.begin(), targetsToSort.end(), targetIdx) == targetsToSort.end())
{
targetsToSort.push_back(targetIdx);
}
}
src/windows/wslc/services/BuildImageCallback.cpp:219
- Malformed BuildKit JSON is silently swallowed here, which makes it very hard to diagnose issues if BuildKit ever changes its wire format or sends an unexpected line: the user simply sees a blank/stale live view with no indication that messages are being dropped. At minimum log the parse failure via
LOG_CAUGHT_EXCEPTION()so it shows up in telemetry/traces, rather than discarding the exception with no record.
catch (...)
{
// Ignore malformed JSON
}
src/windows/wslc/services/BuildImageCallback.cpp:203
- The protocol between server (WSLCSession) and client (BuildImageCallback) is now an implicit contract: the server sends raw BuildKit JSON with id "buildkit", and any other id (or status without the "buildkit" id) is silently dropped on the client. This is brittle — there is no central definition of the "buildkit" sentinel shared by both sides. Consider exposing a single shared constant (in a header included by both
WSLCSession.cppandBuildImageCallback.cpp) and comparing against it on both ends to avoid future drift.
// BuildKit JSON messages are identified by the "buildkit" id
if (idStr == "buildkit")
src/windows/wslc/services/BuildImageCallback.cpp:475
m_viewportHeightis sampled on the first render and never updated. If the user resizes the terminal after the first frame (especially shrinking it), the renderer will keep using the original height, which can produce corrupted output: in "fixed" mode it will write more lines than fit in the current viewport (each terminated with\033[K\n) and the screen-clear logic in the destructor (\033[H\033[Jvs. cursor-up N) will be based on the wrong height. The author notes terminal resize is not supported, but the live build can run for many minutes so resize is realistic; at minimum re-snapshotsrWindow.Bottom - srWindow.Top + 1each frame instead of only on the first one.
// Snapshot viewport height on first render.
if (m_viewportHeight == 0)
{
m_viewportHeight = info.srWindow.Bottom - info.srWindow.Top + 1;
}
src/windows/wslc/services/BuildView.cpp:374
- Logs are trimmed of leading whitespace (line 352-353) before being split into lines. For build steps such as
npmorpipinstall output, leading indentation is often meaningful (tree-style output, nested package listings) and stripping it makes the live log peek much harder to read. Consider trimming only trailing whitespace from the whole blob and per-line trailing whitespace, but preserving leading indentation on each line.
// Trim trailing whitespace.
auto endPos = text.find_last_not_of(" \n\r\t");
text = (endPos != std::string::npos) ? text.substr(0, endPos + 1) : "";
// Trim leading whitespace.
auto startPos = text.find_first_not_of(" \n\r\t");
text = (startPos != std::string::npos) ? text.substr(startPos) : "";
if (text.empty())
{
continue;
}
// Split on newlines
std::istringstream stream(text);
std::string line;
while (std::getline(stream, line))
{
// Trim each sub-line
auto lineEnd = line.find_last_not_of(" \r\t");
line = (lineEnd != std::string::npos) ? line.substr(0, lineEnd + 1) : "";
auto lineStart = line.find_first_not_of(" \r\t");
line = (lineStart != std::string::npos) ? line.substr(lineStart) : "";
if (!line.empty())
{
step.logOutput.push_back(std::move(line));
}
}
src/windows/wslc/services/BuildView.cpp:374
step.logOutputis appended to without bound for the entire duration of a build step. For long-running steps that print verbose output (apt-get, pip install, compilation), this can grow to many megabytes per step and balloons memory usage; the previous implementation hadc_maxAllLinesBytes = 10 MiBcap for exactly this reason. Consider keeping only the last N lines (e.g. the most recent few hundred) per step, since only the tail is ever displayed (c_maxLogPeekLines) and the final replay also doesn't need unbounded history.
if (!line.empty())
{
step.logOutput.push_back(std::move(line));
}
}
src/windows/wslc/services/BuildImageCallback.cpp:84
- The destructor calls
THROW_IF_WIN32_BOOL_FALSE(GetConsoleScreenBufferInfo(...))and then performs significant work (BuildFrameLines, formatting, terminal writes). If the destructor is invoked during stack unwinding from a build error,std::uncaught_exceptions() > m_uncaughtExceptionsis true and the error-replay path executes. A throw fromGetConsoleScreenBufferInfowould be caught by the function-tryCATCH_LOG(), but it would also short-circuit the entire error-replay block, so the user never sees the final build state or error log. Consider usingLOG_IF_WIN32_BOOL_FALSEand falling back to a default width instead of throwing, so error replay still runs even if the console handle has been redirected/closed.
CONSOLE_SCREEN_BUFFER_INFO info{};
THROW_IF_WIN32_BOOL_FALSE(GetConsoleScreenBufferInfo(m_console, &info));
const SHORT consoleWidth = std::max<SHORT>(40, info.srWindow.Right - info.srWindow.Left);
src/windows/wslc/services/BuildImageCallback.cpp:519
- In fixed mode,
tailStart = allLines.size() - tailCountis computed without verifying thattailCount <= allLines.size(). The enclosing branch guardstotalLines >= m_viewportHeightandtailCount = m_viewportHeight - 1, which keeps the subtraction safe today. However,m_viewportHeightcould in principle be 0 (it's a snapshot ofBottom - Top + 1, which is normally >= 1 but the field is declaredSHORTwith no explicit guard). The earlier checkif (m_viewportHeight == 0)sets it only when zero, so a degenerate console where the snapshot returns 0 would result intailCountunderflowing toSIZE_MAX. Consider clampingm_viewportHeightto at least 2 after the snapshot.
// Snapshot viewport height on first render.
if (m_viewportHeight == 0)
{
m_viewportHeight = info.srWindow.Bottom - info.srWindow.Top + 1;
}
auto allLines = BuildFrameLines(consoleWidth);
auto totalLines = static_cast<SHORT>(allLines.size());
std::wstring buffer = L"\033[?25l"; // Hide cursor during redraw.
if (totalLines < m_viewportHeight)
{
// Append mode: content fits in viewport.
if (m_linesWrittenLastFrame > 0)
{
buffer += std::format(L"\033[{}A\r", m_linesWrittenLastFrame);
}
for (const auto& line : allLines)
{
buffer += line;
buffer += L"\033[K\n";
}
buffer += L"\033[J";
m_linesWrittenLastFrame = totalLines;
}
else
{
// Fixed mode: fill the entire viewport.
if (m_linesWrittenLastFrame >= m_viewportHeight)
{
// Already in fixed mode: cursor home to viewport top.
buffer += L"\033[H";
}
else if (m_linesWrittenLastFrame > 0)
{
// Transition from append mode: go back to start of old frame.
buffer += std::format(L"\033[{}A\r", m_linesWrittenLastFrame);
}
// Header line
buffer += allLines[0];
buffer += L"\033[K\n";
// Tail: fill the rest of the viewport with the most recent content.
auto tailCount = static_cast<size_t>(m_viewportHeight - 1);
auto tailStart = allLines.size() - tailCount;
|
Do we want to keep the |
dkbennett
left a comment
There was a problem hiding this comment.
Output looks good, I'm not entirely sure what it should look like, but I'd just normally ask Craig if it is good, and since this is Craig's PR I'm assuming it is correct. :)
|
Hi! This PR is approved but has merge conflicts with |
| static std::wstring FormatBytes(int64_t b) | ||
| { | ||
| if (b <= 0) | ||
| { | ||
| return L"0B"; | ||
| } | ||
|
|
||
| constexpr double c_kB = 1024.0; | ||
| constexpr double c_MB = 1024.0 * 1024.0; | ||
| constexpr double c_GB = 1024.0 * 1024.0 * 1024.0; | ||
| auto val = static_cast<double>(b); | ||
|
|
||
| if (val >= c_GB) | ||
| { | ||
| return std::format(L"{:.2f}GB", val / c_GB); | ||
| } | ||
| if (val >= c_MB) | ||
| { | ||
| return std::format(L"{:.2f}MB", val / c_MB); | ||
| } | ||
| if (val >= c_kB) | ||
| { | ||
| return std::format(L"{:.2f}kB", val / c_kB); | ||
| } | ||
| return std::format(L"{}B", b); | ||
| } |
There was a problem hiding this comment.
Just a thought, have you considered StrFormatByteSizeEx ? It doesn't exactly match the docker style format, so I understand why you might not choose it. But it is more consistent with other windows components, and it has built-in localization for the decimal seperator.
| CONSOLE_SCREEN_BUFFER_INFO info{}; | ||
| THROW_IF_WIN32_BOOL_FALSE(GetConsoleScreenBufferInfo(m_console, &info)); | ||
| // Use the visible window width (not buffer width), minus one column to avoid the | ||
| // deferred-wrap edge case when a line is exactly the window width. Clamp to at | ||
| // least zero so the value never goes negative (which would underflow when passed | ||
| // to std::wstring::resize). | ||
| const SHORT consoleWidth = std::max<SHORT>(0, info.srWindow.Right - info.srWindow.Left); | ||
| const SHORT consoleWidth = std::max<SHORT>(40, info.srWindow.Right - info.srWindow.Left); |
There was a problem hiding this comment.
This is computed twice here and I think once in TableOutput::GetConsoleWidth(), we could probably create a hellper GetConsoleWidth that can be used in this file and in TableOutput.
| buffer += std::format(L"\n\033[31m Error: {}\033[0m\n", MultiByteToWide(step.error)); | ||
| if (!step.logOutput.empty()) |
There was a problem hiding this comment.
Minor: each std::format(...) here allocates a temporary std::wstring that's immediately copied into buffer. Consider std::format_to(std::back_inserter(buffer), L"...", ...) to write directly into the buffer and skip the intermediate allocation. Same pattern shows up in RenderFrame() and BuildFrameLines() if you want to apply it more broadly — not blocking, just a small perf/cleanliness nit.
| if (wtext.size() > maxTextWidth && maxTextWidth > 3) | ||
| { | ||
| if (*p == '\n') | ||
| wtext.resize(maxTextWidth - 3); | ||
| wtext += L"..."; | ||
| } |
There was a problem hiding this comment.
This pattern is repeated quite a bit. Would be nice to add a TruncateWithEllipsis helper like:
static void TruncateWithEllipsis(std::wstring& s, size_t maxWidth)
{
if (s.size() > maxWidth && maxWidth > 3)
{
s.resize(maxWidth - 3);
s += L"...";
}
}
| auto elapsed = FormatElapsed(m_buildStartTime); | ||
| auto label = std::format(L"Building {}", elapsed); | ||
| auto pad = static_cast<size_t>(std::max<SHORT>(0, consoleWidth - static_cast<SHORT>(label.size()))); | ||
| lines.push_back(std::format(L"{}\033[2m{}\033[0m", std::wstring(pad, L' '), label)); |
There was a problem hiding this comment.
std::format supports dynamic width and alignment: {:<{}} (left-pad) and {:>{}} (right-pad). Several spots build a std::wstring(pad, L' ') just to splice in — could be folded into the format string. E.g. the header line:
lines.push_back(std::format(L"\033[2m{:>{}}\033[0m", label, consoleWidth));
Same applies to FormatStepLine and AppendCachedFromLines.
| for (const auto& step : target.steps) | ||
| { | ||
| if (step.started || step.completed) | ||
| { | ||
| m_lines.pop_front(); | ||
| hasVisibleSteps = true; | ||
| break; | ||
| } | ||
| } |
There was a problem hiding this comment.
nit: std::ranges::any_of(target.steps, [](const auto& s) { return s.started || s.completed; })
| if (inputStep && inputStep->stepLabel.find("] FROM ") != std::string::npos) | ||
| { |
There was a problem hiding this comment.
The string is searched twice. Once in the if condition, once to get pos. The second find is pure waste — its result is guaranteed to equal the first. Do one find, then test its result. Also the + 6 is a magic number. It's "skip past ] FROM" (6 characters). If someone changes the search string to "] FROM:" or "] FROM " (extra space), they have to remember to update the 6 — and the compiler won't catch the mistake.
auto* inputStep = m_buildView.StepByDigest(digest);
if (inputStep)
{
auto pos = inputStep->stepLabel.find(c_fromMarker);
if (pos != std::string::npos)
{
fromImage = inputStep->stepLabel.substr(pos + c_fromMarker.size());
break;
}
}
|
|
||
| // Trim trailing whitespace. | ||
| auto endPos = text.find_last_not_of(" \n\r\t"); | ||
| text = (endPos != std::string::npos) ? text.substr(0, endPos + 1) : ""; | ||
|
|
||
| // Trim leading whitespace. | ||
| auto startPos = text.find_first_not_of(" \n\r\t"); | ||
| text = (startPos != std::string::npos) ? text.substr(startPos) : ""; |
There was a problem hiding this comment.
This pattern is repeated quite a bit. There is a StripLeadingWhitespace in wsl::windows::common::string, I think adding a StripWhitespace or Trim() is fitting.
There was a problem hiding this comment.
Or just add a StripTrailingWhiteSpace and do both.
| step.logOutput.push_back(std::move(line)); | ||
| if (step.logOutput.size() > c_maxLogLinesPerStep) | ||
| { | ||
| step.logOutput.erase(step.logOutput.begin()); |
There was a problem hiding this comment.
Erasing from the front of std::vector is O(n) — and you do it on every overflow log line, so writing N lines into a step is O(N · c_maxLogLinesPerStep). I think you can switch logOutput to std::dequestd::stringstd::string — pop_front() is O(1).
Realistically, the perf hit is probably not very big, so it shouldn't be a huge issue, but its worth considering.
| std::string ShortDigest(const std::string& id) | ||
| { | ||
| constexpr auto prefix = "sha256:"; | ||
| constexpr size_t prefixLen = 7; |
There was a problem hiding this comment.
Nit: magic number here. use constexpr std::string_view prefix = "sha256:"; and prefix.size() or strlen.
| // Snapshot viewport height on first render. | ||
| if (m_viewportHeight == 0) | ||
| { | ||
| completedCount = c_maxDisplayLines - 1; | ||
| m_viewportHeight = info.srWindow.Bottom - info.srWindow.Top + 1; | ||
| } |
There was a problem hiding this comment.
Viewport height is captured once on the first render and never updated. If the user resizes their terminal mid-build (especially shrinks it), the fixed-mode layout will render past the visible area or leave stale content. Width is re-read every frame, so this is the only resize blind spot. Probably not worth fixing now — just worth a comment here noting the limitation, or re-querying m_viewportHeight each frame.
kvega005
left a comment
There was a problem hiding this comment.
Nit: Might make sense to add unit tests for BuildView::ProcessMessage. See test/wslc/*UnitTests.cpp
| return &m_targets[it->second.first].steps[it->second.second]; | ||
| } | ||
|
|
||
| bool BuildView::HasErrors() const |
There was a problem hiding this comment.
I don't see any callers for this. Did I miss something? If its not being used, best to remove.
Summary of the Pull Request
Changed
wslc buildto give more detailed build output.PR Checklist
Detailed Description of the Pull Request / Additional comments
Summary of changes:
Validation Steps Performed
Tested with various Containerfiles some simple and some complex. Here is a sufficiently complex one that you can use to test yourself:
View file
This containerfile does multi target builds that are copying from eachother and then finally gives an error right at the end.I notice that we don't get all the data coming in - so we don't get signals of every layer extraction completion. I've left the PR as is which just does best effort on showing it.
I also didn't program in support for changing the Terminal Window while it's running, which I think is OK.