aboutsummaryrefslogtreecommitdiff
path: root/src/zen/consoleprogress.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'src/zen/consoleprogress.cpp')
-rw-r--r--src/zen/consoleprogress.cpp582
1 files changed, 582 insertions, 0 deletions
diff --git a/src/zen/consoleprogress.cpp b/src/zen/consoleprogress.cpp
new file mode 100644
index 000000000..1726a08aa
--- /dev/null
+++ b/src/zen/consoleprogress.cpp
@@ -0,0 +1,582 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+// Zen command line client utility
+//
+
+#include "consoleprogress.h"
+
+#include <zencore/fmtutils.h>
+#include <zencore/logging.h>
+#include <zencore/timer.h>
+#include <zencore/windows.h>
+#include <zenutil/consoletui.h>
+#include <zenutil/progress.h>
+
+#if !ZEN_PLATFORM_WINDOWS
+# include <csignal>
+#endif
+
+ZEN_THIRD_PARTY_INCLUDES_START
+#include <gsl/gsl-lite.hpp>
+ZEN_THIRD_PARTY_INCLUDES_END
+
+//////////////////////////////////////////////////////////////////////////
+
+namespace zen {
+
+// Global tracking for scroll region cleanup on abnormal termination (Ctrl+C etc.)
+// Only one ProgressBar can own a scroll region at a time.
+static std::atomic<class ConsoleProgressBar*> g_ActiveScrollRegionOwner{nullptr};
+static std::atomic<uint32_t> g_ActiveScrollRegionRows{0};
+
+static void
+ResetScrollRegionRaw()
+{
+ // Signal-safe: emit raw escape sequences to restore terminal state.
+ // These are async-signal-safe on POSIX (write()) and safe in console
+ // ctrl handlers on Windows (WriteConsole is allowed).
+ uint32_t Rows = g_ActiveScrollRegionRows.load(std::memory_order_acquire);
+ if (Rows >= 3)
+ {
+ // Move to status line, erase it, reset scroll region, move cursor to end of content
+ TuiMoveCursor(Rows, 1);
+ TuiEraseLine();
+ TuiResetScrollRegion();
+ TuiMoveCursor(Rows - 1, 1);
+ }
+ else
+ {
+ TuiResetScrollRegion();
+ }
+ TuiShowCursor(true);
+ TuiFlush();
+}
+
+#if ZEN_PLATFORM_WINDOWS
+static BOOL WINAPI
+ScrollRegionCtrlHandler(DWORD CtrlType)
+{
+ if (CtrlType == CTRL_C_EVENT || CtrlType == CTRL_BREAK_EVENT)
+ {
+ ResetScrollRegionRaw();
+ }
+ // Return FALSE so the default handler (process termination) still runs
+ return FALSE;
+}
+#else
+static struct sigaction s_PrevSigIntAction;
+static struct sigaction s_PrevSigTermAction;
+
+static void
+ScrollRegionSignalHandler(int Signal)
+{
+ ResetScrollRegionRaw();
+
+ // Re-raise with the previous handler
+ struct sigaction* PrevAction = (Signal == SIGINT) ? &s_PrevSigIntAction : &s_PrevSigTermAction;
+ sigaction(Signal, PrevAction, nullptr);
+ raise(Signal);
+}
+#endif
+
+static void
+InstallScrollRegionCleanupHandler()
+{
+#if ZEN_PLATFORM_WINDOWS
+ SetConsoleCtrlHandler(ScrollRegionCtrlHandler, TRUE);
+#else
+ struct sigaction Action = {};
+ Action.sa_handler = ScrollRegionSignalHandler;
+ Action.sa_flags = SA_RESETHAND; // one-shot
+ sigemptyset(&Action.sa_mask);
+ sigaction(SIGINT, &Action, &s_PrevSigIntAction);
+ sigaction(SIGTERM, &Action, &s_PrevSigTermAction);
+#endif
+}
+
+static void
+RemoveScrollRegionCleanupHandler()
+{
+#if ZEN_PLATFORM_WINDOWS
+ SetConsoleCtrlHandler(ScrollRegionCtrlHandler, FALSE);
+#else
+ sigaction(SIGINT, &s_PrevSigIntAction, nullptr);
+ sigaction(SIGTERM, &s_PrevSigTermAction, nullptr);
+#endif
+}
+
+#if ZEN_PLATFORM_WINDOWS
+static HANDLE
+GetConsoleHandle()
+{
+ static HANDLE hStdOut = GetStdHandle(STD_OUTPUT_HANDLE);
+ return hStdOut;
+}
+#endif
+
+static void
+OutputToConsoleRaw(const char* String, size_t Length)
+{
+#if ZEN_PLATFORM_WINDOWS
+ HANDLE hStdOut = GetConsoleHandle();
+ if (TuiIsStdoutTty())
+ {
+ WriteConsoleA(hStdOut, String, (DWORD)Length, 0, 0);
+ }
+ else
+ {
+ ::WriteFile(hStdOut, (LPCVOID)String, (DWORD)Length, 0, 0);
+ }
+#else
+ fwrite(String, 1, Length, stdout);
+#endif
+}
+
+static void
+OutputToConsoleRaw(const std::string& String)
+{
+ OutputToConsoleRaw(String.c_str(), String.length());
+}
+
+static void
+OutputToConsoleRaw(const StringBuilderBase& SB)
+{
+ OutputToConsoleRaw(SB.c_str(), SB.Size());
+}
+
+static uint32_t
+GetUpdateDelayMS(ConsoleProgressMode InMode)
+{
+ switch (InMode)
+ {
+ case ConsoleProgressMode::Plain:
+ return 5000;
+ case ConsoleProgressMode::Pretty:
+ return 200;
+ case ConsoleProgressMode::Log:
+ return 2000;
+ case ConsoleProgressMode::Quiet:
+ return 5000;
+ default:
+ ZEN_ASSERT(false);
+ return 0;
+ }
+}
+
+class ConsoleProgressBar : public ProgressBase::ProgressBar
+{
+public:
+ explicit ConsoleProgressBar(ConsoleProgressMode InMode, std::string_view InSubTask);
+ ~ConsoleProgressBar();
+
+ void UpdateState(const State& NewState, bool DoLinebreak) override;
+ void ForceLinebreak() override;
+ void Finish() override;
+
+private:
+ void SetupScrollRegion();
+ void TeardownScrollRegion();
+ void RenderStatusLine(std::string_view Line);
+
+ const ConsoleProgressMode m_Mode;
+ Stopwatch m_SW;
+ uint64_t m_LastUpdateMS;
+ uint64_t m_PausedMS;
+ State m_State;
+ const std::string m_SubTask;
+ size_t m_LastOutputLength = 0;
+ bool m_ScrollRegionActive = false;
+ uint32_t m_ScrollRegionRows = 0;
+};
+
+ConsoleProgressBar::ConsoleProgressBar(ConsoleProgressMode InMode, std::string_view InSubTask)
+: m_Mode((!TuiIsStdoutTty() && InMode == ConsoleProgressMode::Pretty) ? ConsoleProgressMode::Plain : InMode)
+, m_LastUpdateMS((uint64_t)-1)
+, m_PausedMS(0)
+, m_SubTask(InSubTask)
+{
+ ZEN_ASSERT(InSubTask.find('\"') == std::string_view::npos);
+ if (!m_SubTask.empty())
+ {
+ if (m_Mode == ConsoleProgressMode::Log)
+ {
+ std::string String = fmt::format("@progress push \"{}\"\n", m_SubTask);
+ OutputToConsoleRaw(String);
+ }
+ }
+
+ if (m_Mode == ConsoleProgressMode::Pretty)
+ {
+ SetupScrollRegion();
+ }
+}
+
+ConsoleProgressBar::~ConsoleProgressBar()
+{
+ try
+ {
+ TeardownScrollRegion();
+ ForceLinebreak();
+ if (!m_SubTask.empty())
+ {
+ if (m_Mode == ConsoleProgressMode::Log)
+ {
+ const std::string String("@progress pop\n");
+ OutputToConsoleRaw(String);
+ }
+ }
+ }
+ catch (const std::exception& Ex)
+ {
+ ZEN_ERROR("ConsoleProgressBar::~ConsoleProgressBar() failed with {}", Ex.what());
+ }
+}
+
+void
+ConsoleProgressBar::SetupScrollRegion()
+{
+ // Only one scroll region owner at a time; nested bars fall back to the inline \r path.
+ if (g_ActiveScrollRegionOwner.load(std::memory_order_acquire) != nullptr)
+ {
+ return;
+ }
+
+ uint32_t Rows = TuiConsoleRows(0);
+ if (Rows < 3)
+ {
+ return;
+ }
+
+ TuiEnableOutput();
+
+ // Ensure cursor is not on the last row before we install the region.
+ // Print a newline to push content up if needed, then set the region.
+ OutputToConsoleRaw("\n", 1);
+ TuiSetScrollRegion(1, Rows - 1);
+
+ // Move cursor into the scroll region so normal output stays there
+ TuiMoveCursor(Rows - 1, 1);
+
+ m_ScrollRegionActive = true;
+ m_ScrollRegionRows = Rows;
+
+ g_ActiveScrollRegionRows.store(Rows, std::memory_order_release);
+ g_ActiveScrollRegionOwner.store(this, std::memory_order_release);
+ InstallScrollRegionCleanupHandler();
+}
+
+void
+ConsoleProgressBar::TeardownScrollRegion()
+{
+ if (!m_ScrollRegionActive)
+ {
+ return;
+ }
+ m_ScrollRegionActive = false;
+
+ RemoveScrollRegionCleanupHandler();
+ g_ActiveScrollRegionOwner.store(nullptr, std::memory_order_release);
+ g_ActiveScrollRegionRows.store(0, std::memory_order_release);
+
+ // Emit all teardown escape sequences as a single atomic write
+ ExtendableStringBuilder<128> Buf;
+ Buf << fmt::format("\x1b[{};1H", m_ScrollRegionRows) // move to status line
+ << "\x1b[2K" // erase it
+ << "\x1b[r" // reset scroll region
+ << fmt::format("\x1b[{};1H", m_ScrollRegionRows - 1); // move to end of content
+ OutputToConsoleRaw(Buf);
+ TuiFlush();
+}
+
+void
+ConsoleProgressBar::RenderStatusLine(std::string_view Line)
+{
+ // Handle terminal resizes by re-querying row count
+ uint32_t CurrentRows = TuiConsoleRows(0);
+ if (CurrentRows >= 3 && CurrentRows != m_ScrollRegionRows)
+ {
+ // Terminal was resized - reinstall scroll region
+ TuiSetScrollRegion(1, CurrentRows - 1);
+ m_ScrollRegionRows = CurrentRows;
+ }
+
+ // Build the entire escape sequence as a single string so the console write
+ // is atomic and log output from other threads cannot interleave.
+ ExtendableStringBuilder<512> Buf;
+ Buf << "\x1b"
+ "7" // ESC 7 - save cursor
+ << fmt::format("\x1b[{};1H", m_ScrollRegionRows) // move to bottom row
+ << "\x1b[2K" // erase entire line
+ << Line // progress bar content
+ << "\x1b"
+ "8"; // ESC 8 - restore cursor
+ OutputToConsoleRaw(Buf);
+}
+
+void
+ConsoleProgressBar::UpdateState(const State& NewState, bool DoLinebreak)
+{
+ ZEN_ASSERT(NewState.TotalCount >= NewState.RemainingCount);
+ ZEN_ASSERT(NewState.Task.find('\"') == std::string::npos);
+ if (DoLinebreak == false && m_State == NewState)
+ {
+ return;
+ }
+
+ uint64_t ElapsedTimeMS = NewState.OptionalElapsedTime == (uint64_t)-1 ? m_SW.GetElapsedTimeMs() : NewState.OptionalElapsedTime;
+ if (m_LastUpdateMS != (uint64_t)-1)
+ {
+ if (!DoLinebreak && (NewState.Status == m_State.Status) && (NewState.Task == m_State.Task) &&
+ ((m_LastUpdateMS + 200) > ElapsedTimeMS))
+ {
+ return;
+ }
+ if (m_State.Status == State::EStatus::Paused)
+ {
+ uint64_t ElapsedSinceLast = ElapsedTimeMS - m_LastUpdateMS;
+ m_PausedMS += ElapsedSinceLast;
+ }
+ }
+
+ m_LastUpdateMS = ElapsedTimeMS;
+
+ std::string Task = NewState.Task;
+ switch (NewState.Status)
+ {
+ case State::EStatus::Aborted:
+ Task = "Aborting";
+ break;
+ case State::EStatus::Paused:
+ Task = "Paused";
+ break;
+ default:
+ break;
+ }
+ if (NewState.Task.length() > Task.length())
+ {
+ Task += std::string(NewState.Task.length() - Task.length(), ' ');
+ }
+
+ const size_t PercentDone =
+ NewState.TotalCount > 0u ? gsl::narrow<uint8_t>((100 * (NewState.TotalCount - NewState.RemainingCount)) / NewState.TotalCount) : 0u;
+
+ uint64_t Completed = NewState.TotalCount - NewState.RemainingCount;
+ uint64_t ETAElapsedMS = ElapsedTimeMS - m_PausedMS;
+ uint64_t ETAMS = ((m_State.TotalCount == NewState.TotalCount) && (NewState.Status == State::EStatus::Running)) && (PercentDone > 5)
+ ? (ETAElapsedMS * NewState.RemainingCount) / Completed
+ : 0;
+ const std::string ETAString = (ETAMS > 0) ? fmt::format(" ETA {}", NiceTimeSpanMs(ETAMS)) : "";
+
+ if (m_Mode == ConsoleProgressMode::Plain)
+ {
+ const std::string Details = (!NewState.Details.empty()) ? fmt::format(": {}", NewState.Details) : "";
+ const std::string Output = fmt::format("{} {}% {}{}{}\n", Task, PercentDone, NiceTimeSpanMs(ElapsedTimeMS), ETAString, Details);
+ OutputToConsoleRaw(Output);
+ m_State = NewState;
+ }
+ else if (m_Mode == ConsoleProgressMode::Pretty)
+ {
+ size_t ProgressBarSize = 20;
+
+ size_t ProgressBarCount = (ProgressBarSize * PercentDone) / 100;
+
+ uint32_t ConsoleColumns = TuiConsoleColumns(1024);
+
+ const std::string PercentString = fmt::format("{:#3}%", PercentDone);
+
+ const std::string ProgressBarString =
+ fmt::format(": |{}{}|", std::string(ProgressBarCount, '#'), std::string(ProgressBarSize - ProgressBarCount, ' '));
+
+ const std::string ElapsedString = fmt::format(": {}", NiceTimeSpanMs(ElapsedTimeMS));
+
+ const std::string DetailsString = (!NewState.Details.empty()) ? fmt::format(". {}", NewState.Details) : "";
+
+ ExtendableStringBuilder<256> OutputBuilder;
+
+ OutputBuilder << Task << " " << PercentString;
+ if (OutputBuilder.Size() + 1 < ConsoleColumns)
+ {
+ size_t RemainingSpace = ConsoleColumns - (OutputBuilder.Size() + 1);
+ bool ElapsedFits = RemainingSpace >= ElapsedString.length();
+ RemainingSpace -= ElapsedString.length();
+ bool ETAFits = ElapsedFits && RemainingSpace >= ETAString.length();
+ RemainingSpace -= ETAString.length();
+ bool DetailsFits = ETAFits && RemainingSpace >= DetailsString.length();
+ RemainingSpace -= DetailsString.length();
+ bool ProgressBarFits = DetailsFits && RemainingSpace >= ProgressBarString.length();
+ RemainingSpace -= ProgressBarString.length();
+
+ if (ProgressBarFits)
+ {
+ OutputBuilder << ProgressBarString;
+ }
+ if (ElapsedFits)
+ {
+ OutputBuilder << ElapsedString;
+ }
+ if (ETAFits)
+ {
+ OutputBuilder << ETAString;
+ }
+ if (DetailsFits)
+ {
+ OutputBuilder << DetailsString;
+ }
+ }
+
+ if (m_ScrollRegionActive)
+ {
+ // Render on the pinned bottom status line
+ RenderStatusLine(OutputBuilder.ToView());
+ }
+ else
+ {
+ // Fallback: inline \r-based overwrite (terminal too small for scroll region)
+ std::string_view Output = OutputBuilder.ToView();
+ std::string::size_type EraseLength =
+ m_LastOutputLength > (Output.length() + 1) ? (m_LastOutputLength - Output.length() - 1) : 0;
+ ExtendableStringBuilder<256> LineToPrint;
+
+ if (Output.length() + 1 + EraseLength >= ConsoleColumns)
+ {
+ if (m_LastOutputLength > 0)
+ {
+ LineToPrint << "\n";
+ }
+ LineToPrint << Output;
+ DoLinebreak = true;
+ }
+ else
+ {
+ LineToPrint << "\r" << Output << std::string(EraseLength, ' ');
+ }
+
+ if (DoLinebreak)
+ {
+ LineToPrint << "\n";
+ }
+
+ OutputToConsoleRaw(LineToPrint);
+ m_LastOutputLength = DoLinebreak ? 0 : (Output.length() + 1); // +1 for \r prefix
+ }
+
+ m_State = NewState;
+ }
+ else if (m_Mode == ConsoleProgressMode::Log)
+ {
+ if (m_State.Task != NewState.Task ||
+ m_State.Details != NewState.Details) // TODO: Should we output just because details change? Will this spam the log collector?
+ {
+ std::string Details = (!NewState.Details.empty()) ? fmt::format(": {}", NewState.Details) : "";
+ for (std::string::value_type& Char : Details)
+ {
+ if (Char == '"')
+ {
+ Char = '\'';
+ }
+ }
+ const std::string Message =
+ fmt::format("@progress \"{} {}{}{}\"\n", NewState.Task, NiceTimeSpanMs(ElapsedTimeMS), ETAString, Details);
+ OutputToConsoleRaw(Message);
+ }
+
+ const size_t OldPercentDone =
+ m_State.TotalCount > 0u ? gsl::narrow<uint8_t>((100 * (m_State.TotalCount - m_State.RemainingCount)) / m_State.TotalCount) : 0u;
+
+ if (OldPercentDone != PercentDone)
+ {
+ const std::string Progress = fmt::format("@progress {}%\n", PercentDone);
+ OutputToConsoleRaw(Progress);
+ }
+ m_State = NewState;
+ }
+}
+
+void
+ConsoleProgressBar::ForceLinebreak()
+{
+ if (m_LastOutputLength > 0)
+ {
+ State NewState = m_State;
+ UpdateState(NewState, /*DoLinebreak*/ true);
+ }
+}
+
+void
+ConsoleProgressBar::Finish()
+{
+ TeardownScrollRegion();
+
+ if (m_LastOutputLength > 0 || m_State.RemainingCount > 0)
+ {
+ State NewState = m_State;
+ NewState.RemainingCount = 0;
+ NewState.Details = "";
+ UpdateState(NewState, /*DoLinebreak*/ true);
+ }
+ m_State = State{};
+ m_LastOutputLength = 0;
+ m_SW.Reset();
+}
+
+class ConsoleProgress : public ProgressBase
+{
+public:
+ ConsoleProgress(ConsoleProgressMode InMode) : m_Mode(InMode) {}
+
+ virtual void SetLogOperationName(std::string_view Name) override
+ {
+ ZEN_ASSERT(Name.find('\"') == std::string_view::npos);
+ if (m_Mode == ConsoleProgressMode::Log)
+ {
+ std::string String = fmt::format("@progress \"{}\"\n", Name);
+ OutputToConsoleRaw(String);
+ }
+ }
+
+ virtual void SetLogOperationProgress(uint32_t StepIndex, uint32_t StepCount) override
+ {
+ if (m_Mode == ConsoleProgressMode::Log)
+ {
+ const size_t PercentDone = StepCount > 0u ? gsl::narrow<uint8_t>((100 * StepIndex) / StepCount) : 0u;
+ std::string String = fmt::format("@progress {}%\n", PercentDone);
+ OutputToConsoleRaw(String);
+ }
+ }
+ virtual void PushLogOperation(std::string_view Name) override
+ {
+ ZEN_ASSERT(Name.find('\"') == std::string_view::npos);
+ if (m_Mode == ConsoleProgressMode::Log)
+ {
+ std::string String = fmt::format("@progress push \"{}\"\n", Name);
+ OutputToConsoleRaw(String);
+ }
+ }
+
+ virtual void PopLogOperation() override
+ {
+ if (m_Mode == ConsoleProgressMode::Log)
+ {
+ const std::string String("@progress pop\n");
+ OutputToConsoleRaw(String);
+ }
+ }
+
+ virtual uint32_t GetProgressUpdateDelayMS() const override { return GetUpdateDelayMS(m_Mode); }
+
+ virtual std::unique_ptr<ProgressBase::ProgressBar> CreateProgressBar(std::string_view InSubTask) override
+ {
+ return std::make_unique<ConsoleProgressBar>(m_Mode, InSubTask);
+ }
+
+private:
+ ConsoleProgressMode m_Mode;
+};
+
+ProgressBase*
+CreateConsoleProgress(ConsoleProgressMode InMode)
+{
+ return new ConsoleProgress(InMode);
+}
+
+} // namespace zen