From 66d2c9d4d2bb30813105f555e5433c1de68994b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Wed, 7 Oct 2026 02:27:25 -0300 Subject: [PATCH] eproc: improve startup memory collection and tab navigation Publish the base process snapshot without waiting for PSS collection. Leave Family Memory blank until startup accounting completes. - Derive PSS policy from saved column visibility before startup. - Collect Linux startup PSS with four temporary workers and seed the cache. - Apply completed samples only to matching process identities without advancing performance history or aging ended processes. - Preserve live cache entries while memory columns are hidden and invalidate entries on PID reuse. - Share table-state parsing and validation; remove unused migrations. - Add next-tab and previous-tab commands with Mod+Tab and Mod+Shift+Tab. - Remember and restore each tab's last focused widget. - Add coverage for startup collection, cache lifetime, saved visibility, and asynchronous family-memory updates. --- premake4.lua | 12 + premake5.lua | 12 + .../eproc_linux_collector_tests.cpp | 155 ++++++ .../eproc_process_table_state_tests.cpp | 80 ++++ .../unit_tests/eproc_process_tree_tests.cpp | 102 ++++ src/tools/eproc/eproc.cpp | 445 ++++++------------ src/tools/eproc/eproc.hpp | 24 +- .../linux/process_collector_linux.cpp | 211 ++++++++- .../linux/process_collector_linux.hpp | 11 +- src/tools/eproc/process_collector.hpp | 18 + src/tools/eproc/process_model.cpp | 48 +- src/tools/eproc/process_model.hpp | 11 +- src/tools/eproc/process_table_state.cpp | 58 +++ src/tools/eproc/process_table_state.hpp | 101 ++++ 14 files changed, 964 insertions(+), 324 deletions(-) create mode 100644 src/tests/unit_tests/eproc_linux_collector_tests.cpp create mode 100644 src/tests/unit_tests/eproc_process_table_state_tests.cpp create mode 100644 src/tools/eproc/process_table_state.cpp create mode 100644 src/tools/eproc/process_table_state.hpp diff --git a/premake4.lua b/premake4.lua index d0af7cf61..c37488ce1 100644 --- a/premake4.lua +++ b/premake4.lua @@ -2034,6 +2034,7 @@ solution "eepp" "src/tools/eproc/process_collector.cpp", "src/tools/eproc/process_info.cpp", "src/tools/eproc/process_model.cpp", + "src/tools/eproc/process_table_state.cpp", "src/tools/eproc/settingspanel.cpp", "src/tools/eproc/window_icon.cpp", } @@ -2111,6 +2112,7 @@ solution "eepp" files { "src/tests/unit_tests/*.cpp", "src/tools/eproc/process_info.cpp", "src/tools/eproc/process_model.cpp", + "src/tools/eproc/process_table_state.cpp", "src/tools/eproc/window_icon.cpp", "src/tools/ecode/ignorematcher.cpp", "src/tools/ecode/jsonhelper.cpp", @@ -2120,6 +2122,16 @@ solution "eepp" "src/tools/ecode/plugins/autocomplete/snippetparser.cpp", "src/tools/ecode/plugins/autocomplete/usersnippetstore.cpp" } eepp_module_backward_add( false ) + + configuration { "linux" } + files { "src/tools/eproc/process_collector.cpp", + "src/tools/eproc/platform/linux/process_collector_linux.cpp", + "src/tools/eproc/platform/linux/gpu_reader_nvidia.cpp", + "src/tools/eproc/platform/linux/gpu_reader_drm.cpp", + "src/tools/eproc/platform/linux/process_network_monitor.cpp", + "src/tools/eproc/platform/posix/process_icon_resolver.cpp" } + configuration {} + build_link_configuration( "eepp-unit_tests", true ) if os.isfile("external_projects.lua") then diff --git a/premake5.lua b/premake5.lua index 18a4044ec..d3116a8b1 100644 --- a/premake5.lua +++ b/premake5.lua @@ -2057,6 +2057,7 @@ workspace "eepp" "src/tools/eproc/process_collector.cpp", "src/tools/eproc/process_info.cpp", "src/tools/eproc/process_model.cpp", + "src/tools/eproc/process_table_state.cpp", "src/tools/eproc/settingspanel.cpp", "src/tools/eproc/window_icon.cpp", } @@ -2115,6 +2116,7 @@ workspace "eepp" files { "src/tests/unit_tests/*.cpp", "src/tools/eproc/process_info.cpp", "src/tools/eproc/process_model.cpp", + "src/tools/eproc/process_table_state.cpp", "src/tools/eproc/window_icon.cpp", "src/tools/ecode/ignorematcher.cpp", "src/tools/ecode/jsonhelper.cpp", @@ -2136,6 +2138,16 @@ workspace "eepp" links { "util" } filter {} eepp_module_backward_add( false ) + + filter { "system:linux" } + files { "src/tools/eproc/process_collector.cpp", + "src/tools/eproc/platform/linux/process_collector_linux.cpp", + "src/tools/eproc/platform/linux/gpu_reader_nvidia.cpp", + "src/tools/eproc/platform/linux/gpu_reader_drm.cpp", + "src/tools/eproc/platform/linux/process_network_monitor.cpp", + "src/tools/eproc/platform/posix/process_icon_resolver.cpp" } + filter {} + build_link_configuration( "eepp-unit_tests", true ) if os.isfile("external_projects.lua") then diff --git a/src/tests/unit_tests/eproc_linux_collector_tests.cpp b/src/tests/unit_tests/eproc_linux_collector_tests.cpp new file mode 100644 index 000000000..2e979a82a --- /dev/null +++ b/src/tests/unit_tests/eproc_linux_collector_tests.cpp @@ -0,0 +1,155 @@ +#include + +#if EE_PLATFORM == EE_PLATFORM_LINUX +#include "../../tools/eproc/platform/linux/process_collector_linux.hpp" +#include "utest.hpp" +#include + +namespace eproc { + +struct ProcessCollectorLinuxTestAccess { + static void sample( ProcessCollectorLinux& collector, ProcessInfo& proc, Uint32 pass, + bool enabled ) { + collector.mPass = pass; + collector.setCollectProportionalMemory( enabled ); + collector.collectProcessPss( proc ); + } + + static void seed( ProcessCollectorLinux& collector, const ProcessInfo& proc, Int64 value, + Uint32 pass ) { + collector.mProcessPss[proc.pid] = { value, proc.startTime, pass }; + } + + static bool cached( const ProcessCollectorLinux& collector, Int64 pid ) { + return collector.mProcessPss.find( pid ) != collector.mProcessPss.end(); + } + + static void prune( ProcessCollectorLinux& collector ) { collector.pruneCaches(); } +}; + +} // namespace eproc + +using namespace eproc; +using Access = ProcessCollectorLinuxTestAccess; + +UTEST( EProcLinuxCollector, HiddenPassesPreserveOnlyLiveProcessIncarnations ) { + ProcessCollectorLinux collector; + ProcessInfo proc; + proc.pid = 1000000000; // Outside Linux's PID range; an accidental procfs read must fail. + proc.startTime = 42; + proc.vmRSS = 100; + EXPECT_FALSE( Access::cached( collector, proc.pid ) ); + Access::seed( collector, proc, 123, 1 ); + for ( Uint32 pass = 2; pass <= 60; ++pass ) + Access::sample( collector, proc, pass, false ); + Access::prune( collector ); + ASSERT_TRUE( Access::cached( collector, proc.pid ) ); + Access::sample( collector, proc, 61, true ); // Different refresh bucket from PID. + EXPECT_EQ( proc.vmPSS, 123 ); + ++proc.startTime; + Access::sample( collector, proc, 62, false ); + EXPECT_FALSE( Access::cached( collector, proc.pid ) ); + Access::sample( collector, proc, 63, true ); + EXPECT_EQ( proc.vmPSS, -1 ); + Access::seed( collector, proc, 123, 63 ); + ProcessInfo other; + other.pid = proc.pid + 1; + other.startTime = 43; + other.vmRSS = 100; + Access::sample( collector, other, 120, false ); + Access::prune( collector ); + EXPECT_FALSE( Access::cached( collector, proc.pid ) ); +} + +UTEST( EProcLinuxCollector, ZeroRssAvoidsProcfsAndBucketRefreshStillRuns ) { + ProcessCollectorLinux collector; + ProcessInfo proc; + proc.pid = 1000000000; + proc.startTime = 42; + proc.vmRSS = 0; + Access::sample( collector, proc, 1, true ); + EXPECT_EQ( proc.vmPSS, 0 ); + EXPECT_TRUE( Access::cached( collector, proc.pid ) ); + proc.vmRSS = 100; + Access::seed( collector, proc, 123, 1 ); + Access::sample( collector, proc, 2, true ); + EXPECT_EQ( proc.vmPSS, 123 ); + Access::sample( collector, proc, 5, true ); + EXPECT_EQ( proc.vmPSS, -1 ); // Scheduled refresh fails for this nonexistent PID. +} + +UTEST( EProcLinuxCollector, FirstEnabledSnapshotIncludesCurrentProcessPss ) { + ProcessCollectorLinux collector; + collector.setCollectProportionalMemory( true ); + std::vector processes; + SystemInfo system; + ASSERT_TRUE( collector.collectInitial( processes, system ) ); + const auto current = + std::find_if( processes.begin(), processes.end(), + []( const ProcessInfo& proc ) { return proc.pid == getpid(); } ); + ASSERT_TRUE( current != processes.end() ); + EXPECT_TRUE( current->vmPSS > 0 ); + EXPECT_TRUE( current->startTime > 0 ); + ASSERT_TRUE( Access::cached( collector, current->pid ) ); + ProcessInfo cached; + cached.pid = current->pid; + cached.startTime = current->startTime; + cached.vmRSS = current->vmRSS; + // Stay outside this PID's refresh bucket: a miss would read procfs again. + const Uint32 pass = static_cast( current->pid ) % 5 == 2 ? 3 : 2; + Access::sample( collector, cached, pass, true ); + EXPECT_EQ( cached.vmPSS, current->vmPSS ); +} +UTEST( EProcLinuxCollector, PublishesBaseBeforeReturningCompletedMemorySamples ) { + ProcessCollectorLinux collector; + collector.setCollectProportionalMemory( true ); + std::vector processes, base; + std::vector memory; + SystemInfo system, baseSystem; + bool published = false; + ASSERT_TRUE( collector.collectInitial( + processes, system, + [&]( std::vector&& snapshot, SystemInfo&& info ) { + published = true; + base = std::move( snapshot ); + baseSystem = std::move( info ); + }, + &memory ) ); + ASSERT_TRUE( published ); + EXPECT_TRUE( processes.empty() ); + EXPECT_TRUE( baseSystem.totalMemory > 0 ); + const auto current = std::find_if( + base.begin(), base.end(), []( const ProcessInfo& proc ) { return proc.pid == getpid(); } ); + ASSERT_TRUE( current != base.end() ); + EXPECT_EQ( current->vmPSS, -1 ); + ASSERT_TRUE( + std::is_sorted( memory.begin(), memory.end(), + []( const auto& lhs, const auto& rhs ) { return lhs.pid < rhs.pid; } ) ); + const auto sample = std::find_if( memory.begin(), memory.end(), + []( const auto& sample ) { return sample.pid == getpid(); } ); + ASSERT_TRUE( sample != memory.end() ); + EXPECT_EQ( sample->startTime, current->startTime ); + EXPECT_TRUE( sample->valueKB > 0 ); + EXPECT_TRUE( Access::cached( collector, current->pid ) ); +} + +UTEST( EProcLinuxCollector, HiddenStartupDoesNotPublishPartialSnapshotOrCollectPss ) { + ProcessCollectorLinux collector; + std::vector processes; + std::vector memory; + SystemInfo system; + bool published = false; + ASSERT_TRUE( collector.collectInitial( + processes, system, [&]( std::vector&&, SystemInfo&& ) { published = true; }, + &memory ) ); + EXPECT_FALSE( published ); + EXPECT_TRUE( memory.empty() ); + const auto current = + std::find_if( processes.begin(), processes.end(), + []( const ProcessInfo& proc ) { return proc.pid == getpid(); } ); + ASSERT_TRUE( current != processes.end() ); + EXPECT_EQ( current->vmPSS, -1 ); + EXPECT_FALSE( Access::cached( collector, current->pid ) ); +} + +#endif diff --git a/src/tests/unit_tests/eproc_process_table_state_tests.cpp b/src/tests/unit_tests/eproc_process_table_state_tests.cpp new file mode 100644 index 000000000..b155be46a --- /dev/null +++ b/src/tests/unit_tests/eproc_process_table_state_tests.cpp @@ -0,0 +1,80 @@ +#include "../../tools/eproc/process_table_state.hpp" +#include "utest.hpp" + +using namespace eproc; +using namespace eproc::ProcessTableState; + +namespace { + +nlohmann::json tableState() { + nlohmann::json state; + state["version"] = kProcessTableStateVersion; + state["widths"]["mode"] = "pixels"; + state["widths"]["widths"] = std::vector( ProcessModel::ColCount, 100 ); + state["hidden_columns"] = kOptionalProcessColumns; + return state; +} + +void showColumn( nlohmann::json& state, size_t column ) { + auto& hidden = state["hidden_columns"]; + hidden.erase( std::remove( hidden.begin(), hidden.end(), nlohmann::json( column ) ), + hidden.end() ); +} + +} // namespace + +UTEST( EProcProcessTableState, DefaultAndInvalidStatesDoNotRequestPss ) { + for ( const auto& saved : { "", "{", "null", "[]", "{}", "{\"version\":999}", + "{\"version\":18446744073709551615}" } ) { + EXPECT_FALSE( showsFamilyMemory( parse( saved ), false ) ); + EXPECT_FALSE( showsFamilyMemory( parse( saved ), true ) ); + } + auto state = tableState(); + EXPECT_FALSE( showsFamilyMemory( parse( state.dump() ), false ) ); + state.erase( "hidden_columns" ); + EXPECT_FALSE( showsFamilyMemory( parse( state.dump() ), false ) ); + state["hidden_columns"] = "invalid"; + EXPECT_FALSE( showsFamilyMemory( parse( state.dump() ), false ) ); +} + +UTEST( EProcProcessTableState, EitherFamilyColumnRequestsPss ) { + auto state = tableState(); + showColumn( state, ProcessModel::ColFamilyMemory ); + EXPECT_TRUE( showsFamilyMemory( parse( state.dump() ), false ) ); + EXPECT_FALSE( showsFamilyMemory( parse( state.dump() ), true ) ); + state = tableState(); + showColumn( state, ProcessModel::ColFamilyMemoryPercent ); + EXPECT_TRUE( showsFamilyMemory( parse( state.dump() ), false ) ); + showColumn( state, ProcessModel::ColFamilyMemory ); + EXPECT_TRUE( showsFamilyMemory( parse( state.dump() ), false ) ); + state["hidden_columns"] = + nlohmann::json::array( { -1, 999, "invalid", 18446744073709551615ull } ); + EXPECT_TRUE( showsFamilyMemory( parse( state.dump() ), false ) ); +} + +UTEST( EProcProcessTableState, UsesOnlyTheActiveViewsColumns ) { + auto state = tableState(); + state["tree"] = tableState(); + showColumn( state["tree"], ProcessModel::ColFamilyMemoryPercent ); + EXPECT_FALSE( showsFamilyMemory( parse( state.dump() ), false ) ); + EXPECT_TRUE( showsFamilyMemory( parse( state.dump() ), true ) ); + state["tree"] = tableState(); + showColumn( state, ProcessModel::ColFamilyMemory ); + EXPECT_TRUE( showsFamilyMemory( parse( state.dump() ), false ) ); + EXPECT_FALSE( showsFamilyMemory( parse( state.dump() ), true ) ); +} + +UTEST( EProcProcessTableState, RejectedWidthsKeepDefaultVisibility ) { + auto state = tableState(); + showColumn( state, ProcessModel::ColFamilyMemory ); + state["widths"]["widths"][ProcessModel::ColName] = 0; + EXPECT_FALSE( showsFamilyMemory( parse( state.dump() ), false ) ); + state["widths"]["mode"] = "percentage"; + EXPECT_TRUE( showsFamilyMemory( parse( state.dump() ), false ) ); + state["widths"]["widths"][0] = -1; + EXPECT_FALSE( showsFamilyMemory( parse( state.dump() ), false ) ); + state["widths"]["widths"] = nlohmann::json::array(); + EXPECT_FALSE( showsFamilyMemory( parse( state.dump() ), false ) ); + state.erase( "widths" ); + EXPECT_FALSE( showsFamilyMemory( parse( state.dump() ), false ) ); +} diff --git a/src/tests/unit_tests/eproc_process_tree_tests.cpp b/src/tests/unit_tests/eproc_process_tree_tests.cpp index 5a17cf804..c6c0356f4 100644 --- a/src/tests/unit_tests/eproc_process_tree_tests.cpp +++ b/src/tests/unit_tests/eproc_process_tree_tests.cpp @@ -217,3 +217,105 @@ UTEST( EProcProcessTree, FamilyMemoryCountsZeroRssWithoutPss ) { .empty() ); } #endif + +UTEST( EProcProcessTree, FamilyMemoryPropagatesUnavailableGrandchildrenAndRejectsCycles ) { + UIApplication app( + WindowSettings( 600, 300, "eepp - unit tests" ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + auto source = ProcessModel::create( app.getUI() ); + auto parent = process( 1, 0, "parent" ); + auto child = process( 2, 1, "child" ); + auto grandchild = process( 3, 2, "grandchild" ); + auto zero = process( 4, 1, "zero" ); + parent.vmRSS = parent.vmPSS = 100; + child.vmRSS = child.vmPSS = 40; + grandchild.vmRSS = grandchild.vmPSS = 60; + zero.vmRSS = zero.vmPSS = 0; + source->applySnapshot( { parent, child, grandchild, zero }, {} ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 1 ) )->familyMemoryKB, 200 ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 2 ) )->familyMemoryKB, 100 ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 3 ) )->familyMemoryKB, 60 ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 4 ) )->familyMemoryKB, 0 ); + grandchild.vmRSS = grandchild.vmPSS = -1; + source->applySnapshot( { parent, child, grandchild, zero }, {} ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 1 ) )->familyMemoryKB, -1 ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 2 ) )->familyMemoryKB, -1 ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 4 ) )->familyMemoryKB, 0 ); + parent.parentPid = 2; + child.parentPid = 1; + grandchild.parentPid = 3; // A self-parent is ignored; it cannot contribute twice. + grandchild.vmRSS = grandchild.vmPSS = 60; + source->applySnapshot( { parent, child, grandchild, zero }, {} ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 1 ) )->familyMemoryKB, -1 ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 2 ) )->familyMemoryKB, -1 ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 3 ) )->familyMemoryKB, 60 ); +} + +#if EE_PLATFORM == EE_PLATFORM_LINUX +UTEST( EProcProcessTree, PendingFamilyMemoryStaysBlankUntilCompletedSamplesArrive ) { + UIApplication app( + WindowSettings( 600, 300, "eepp - unit tests" ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + auto source = ProcessModel::create( app.getUI() ); + auto parent = process( 1, 0, "parent" ); + auto child = process( 2, 1, "child" ); + auto zero = process( 3, 1, "zero" ); + parent.startTime = 100; + child.startTime = 200; + zero.startTime = 300; + parent.vmRSS = 100; + child.vmRSS = 40; + zero.vmRSS = 0; + SystemInfo system; + system.totalMemory = 1000; + source->applySnapshot( { parent, child, zero }, system, false ); + EXPECT_EQ( source->visibleCount(), 3u ); + for ( long pid : { 1, 2, 3 } ) { + EXPECT_EQ( source->getProcessByRow( source->rowForPid( pid ) )->familyMemoryKB, -1 ); + EXPECT_TRUE( + source->data( source->index( source->rowForPid( pid ), ProcessModel::ColFamilyMemory ) ) + .toString() + .empty() ); + EXPECT_TRUE( source + ->data( source->index( source->rowForPid( pid ), + ProcessModel::ColFamilyMemoryPercent ) ) + .toString() + .empty() ); + } + const std::vector memory = { + { 1, 100, 100 }, { 2, 200, 40 }, { 3, 300, 0 } }; + source->applyProportionalMemory( memory ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 1 ) )->familyMemoryKB, 140 ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 3 ) )->familyMemoryKB, 0 ); + EXPECT_TRUE( + source + ->data( source->index( source->rowForPid( 1 ), ProcessModel::ColFamilyMemoryPercent ) ) + .toString() == "14.0%" ); + // Completion before publication follows the same merge without an intermediate blank model. + source->applySnapshot( { parent, child, zero }, system, true, memory ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 1 ) )->familyMemoryKB, 140 ); +} + +UTEST( EProcProcessTree, LatePssRejectsReusedPidsAndDoesNotAgeEndedRows ) { + UIApplication app( + WindowSettings( 600, 300, "eepp - unit tests" ), + UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) ); + auto source = ProcessModel::create( app.getUI() ); + auto parent = process( 1, 0, "parent" ); + auto child = process( 2, 1, "child" ); + parent.startTime = 100; + child.startTime = 200; + parent.vmRSS = child.vmRSS = 100; + source->applySnapshot( { parent, child }, {}, false ); + parent.startTime = 101; + source->applySnapshot( { parent }, {}, false ); + source->applyProportionalMemory( { { 1, 100, 1000 }, { 2, 200, 1000 } } ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 1 ) )->vmPSS, -1 ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 2 ) )->status, ProcessStatus::Ended ); + source->applyProportionalMemory( { { 1, 101, 100 }, { 2, 200, 1000 } } ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 1 ) )->familyMemoryKB, 100 ); + EXPECT_EQ( source->getProcessByRow( source->rowForPid( 2 ) )->status, ProcessStatus::Ended ); + // The previous parent incarnation and child remain as ended rows for one snapshot. + EXPECT_EQ( source->visibleCount(), 3u ); +} +#endif diff --git a/src/tools/eproc/eproc.cpp b/src/tools/eproc/eproc.cpp index f6ac1fd25..c732f831c 100644 --- a/src/tools/eproc/eproc.cpp +++ b/src/tools/eproc/eproc.cpp @@ -1,4 +1,5 @@ #include "eproc.hpp" +#include "process_table_state.hpp" #include "process_view.hpp" #include "settingspanel.hpp" @@ -25,6 +26,8 @@ namespace eproc { +using namespace ProcessTableState; + namespace { constexpr size_t kPerformanceHistorySamples = 600; @@ -32,7 +35,6 @@ constexpr double kPerformanceVisibleSeconds = 60.0; static_assert( kPerformanceHistorySamples >= kPerformanceVisibleSeconds * 1000 / AppConfig::MinRefreshIntervalMs + 2 ); -constexpr int kProcessTableStateVersion = 6; constexpr int kForceKillSignal = 9; // SIGKILL on Linux; TerminateProcess on Windows. #if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_MACOS || \ EE_PLATFORM == EE_PLATFORM_BSD @@ -81,68 +83,6 @@ String formatUptime( UISceneNode* ui, double uptimeSeconds ) { return result; } -constexpr int kPreviousProcessTableStateVersion = 3; -constexpr int kLegacyProcessTableStateVersion = 2; -constexpr int kPriorProcessTableStateVersion = 4; -constexpr int kFamilyProcessTableStateVersion = 5; -constexpr size_t kPreviousProcessColumnCount = 20; -constexpr size_t kPreviousCommandColumn = 11; - -constexpr std::array kOptionalProcessColumns = { { - ProcessModel::ColTotalMemory, - ProcessModel::ColVirtualSize, - ProcessModel::ColCpuTime, - ProcessModel::ColNiceness, - ProcessModel::ColRelativeStartTime, - ProcessModel::ColTty, - ProcessModel::ColIoRead, - ProcessModel::ColIoWrite, - ProcessModel::ColThreads, - ProcessModel::ColMemoryPercent, - ProcessModel::ColFamilyMemory, - ProcessModel::ColFamilyMemoryPercent, -} }; - -#if EE_PLATFORM == EE_PLATFORM_WIN -constexpr std::array kUnavailableProcessColumns = { { - ProcessModel::ColSharedMem, - ProcessModel::ColGpuUsage, - ProcessModel::ColGpuMemory, - ProcessModel::ColDownload, - ProcessModel::ColUpload, - ProcessModel::ColVirtualSize, - ProcessModel::ColNiceness, - ProcessModel::ColTty, -} }; -#elif EE_PLATFORM == EE_PLATFORM_MACOS -constexpr std::array kUnavailableProcessColumns = { { - ProcessModel::ColSharedMem, - ProcessModel::ColGpuUsage, - ProcessModel::ColGpuMemory, - ProcessModel::ColDownload, - ProcessModel::ColUpload, - ProcessModel::ColTty, -} }; -#elif EE_PLATFORM == EE_PLATFORM_BSD -constexpr std::array kUnavailableProcessColumns = { { - ProcessModel::ColSharedMem, - ProcessModel::ColGpuUsage, - ProcessModel::ColGpuMemory, - ProcessModel::ColDownload, - ProcessModel::ColUpload, - ProcessModel::ColTty, - ProcessModel::ColIoRead, - ProcessModel::ColIoWrite, -} }; -#else -constexpr std::array kUnavailableProcessColumns{}; -#endif - -bool isProcessColumnSupported( size_t column ) { - return std::find( kUnavailableProcessColumns.begin(), kUnavailableProcessColumns.end(), - column ) == kUnavailableProcessColumns.end(); -} - String processColumnTooltip( UISceneNode* ui, size_t column ) { switch ( column ) { case ProcessModel::ColIcon: @@ -256,183 +196,6 @@ bool parseSortOrder( const std::string& value, SortOrder& order ) { return false; } -bool isValidProcessTableWidthState( const nlohmann::json& widths, size_t columnCount, - const UIAbstractTableView& tableView ) { - if ( !widths.is_object() || !widths.contains( "mode" ) || !widths["mode"].is_string() || - !widths.contains( "widths" ) || !widths["widths"].is_array() || - widths["widths"].size() != columnCount ) - return false; - - const std::string mode = widths["mode"].get(); - if ( mode != "pixels" && mode != "percentage" ) - return false; - - bool hasVisibleWidth = false; - for ( size_t column = 0; column < columnCount; ++column ) { - const auto& width = widths["widths"][column]; - const double value = width.is_number() ? width.get() : 0; - if ( !width.is_number() || !std::isfinite( value ) || value < 0 ) - return false; - if ( mode == "pixels" && !tableView.isColumnHidden( column ) && value <= 1.0 ) - return false; - if ( !tableView.isColumnHidden( column ) && value > 0 ) - hasVisibleWidth = true; - } - - return hasVisibleWidth; -} - -size_t remapPreviousProcessTableColumn( size_t column ) { - if ( column == kPreviousCommandColumn ) - return ProcessModel::ColCommand; - if ( column > kPreviousCommandColumn ) - return column - 1; - return column; -} - -bool migratePreviousProcessTableState( nlohmann::json& state ) { - if ( !state.contains( "widths" ) || !state["widths"].is_object() || - !state["widths"].contains( "widths" ) || !state["widths"]["widths"].is_array() || - state["widths"]["widths"].size() != kPreviousProcessColumnCount ) - return false; - - nlohmann::json remappedWidths = nlohmann::json::array(); - for ( size_t column = 0; column < kPreviousProcessColumnCount; ++column ) - remappedWidths.push_back( 0 ); - for ( size_t previousColumn = 0; previousColumn < kPreviousProcessColumnCount; - ++previousColumn ) { - remappedWidths[remapPreviousProcessTableColumn( previousColumn )] = - state["widths"]["widths"][previousColumn]; - } - state["widths"]["widths"] = std::move( remappedWidths ); - - if ( state.contains( "hidden_columns" ) && state["hidden_columns"].is_array() ) { - nlohmann::json remappedHiddenColumns = nlohmann::json::array(); - for ( const auto& column : state["hidden_columns"] ) { - if ( !column.is_number_integer() ) - continue; - const Int64 previousColumn = column.get(); - if ( previousColumn >= 0 && - static_cast( previousColumn ) < kPreviousProcessColumnCount ) - remappedHiddenColumns.push_back( - remapPreviousProcessTableColumn( static_cast( previousColumn ) ) ); - } - state["hidden_columns"] = std::move( remappedHiddenColumns ); - } - - if ( state.contains( "sort" ) && state["sort"].is_object() && - state["sort"].contains( "column" ) && state["sort"]["column"].is_number_integer() ) { - const Int64 previousColumn = state["sort"]["column"].get(); - if ( previousColumn >= 0 && - static_cast( previousColumn ) < kPreviousProcessColumnCount ) - state["sort"]["column"] = - remapPreviousProcessTableColumn( static_cast( previousColumn ) ); - } - - state["version"] = kPreviousProcessTableStateVersion; - return true; -} - -bool migrateProcessTableState( nlohmann::json& state ) { - if ( state["version"] == kLegacyProcessTableStateVersion && - !migratePreviousProcessTableState( state ) ) - return false; - if ( state["version"] == kPreviousProcessTableStateVersion ) { - if ( !state.contains( "widths" ) || !state["widths"].is_object() || - !state["widths"].contains( "widths" ) || !state["widths"]["widths"].is_array() || - state["widths"]["widths"].size() != kPreviousProcessColumnCount ) - return false; - state["widths"]["widths"].push_back( 0 ); - state["widths"]["widths"].push_back( 0 ); - if ( !state.contains( "hidden_columns" ) || !state["hidden_columns"].is_array() ) - state["hidden_columns"] = nlohmann::json::array(); - state["hidden_columns"].push_back( ProcessModel::ColThreads ); - state["hidden_columns"].push_back( ProcessModel::ColMemoryPercent ); - if ( state.contains( "column_order" ) && state["column_order"].is_array() && - state["column_order"].size() == kPreviousProcessColumnCount ) { - state["column_order"].push_back( ProcessModel::ColThreads ); - state["column_order"].push_back( ProcessModel::ColMemoryPercent ); - } - state["version"] = kPriorProcessTableStateVersion; - } - if ( state["version"] == kPriorProcessTableStateVersion ) { - const auto addFamilyColumn = []( nlohmann::json& columns ) { - if ( !columns.contains( "widths" ) || !columns["widths"].is_object() || - !columns["widths"].contains( "widths" ) || - !columns["widths"]["widths"].is_array() || - columns["widths"]["widths"].size() != ProcessModel::ColFamilyMemory ) - return false; - columns["widths"]["widths"].push_back( 0 ); - if ( !columns.contains( "hidden_columns" ) || !columns["hidden_columns"].is_array() ) - columns["hidden_columns"] = nlohmann::json::array(); - columns["hidden_columns"].push_back( ProcessModel::ColFamilyMemory ); - if ( columns.contains( "column_order" ) && columns["column_order"].is_array() && - columns["column_order"].size() == ProcessModel::ColFamilyMemory ) - columns["column_order"].push_back( ProcessModel::ColFamilyMemory ); - return true; - }; - if ( !addFamilyColumn( state ) ) - return false; - if ( state.contains( "tree" ) && state["tree"].is_object() && - !addFamilyColumn( state["tree"] ) ) - state.erase( "tree" ); - state["version"] = kFamilyProcessTableStateVersion; - } - if ( state["version"] != kFamilyProcessTableStateVersion ) - return false; - const auto addFamilyPercentColumn = []( nlohmann::json& columns ) { - if ( !columns.contains( "widths" ) || !columns["widths"].is_object() || - !columns["widths"].contains( "widths" ) || !columns["widths"]["widths"].is_array() || - columns["widths"]["widths"].size() != ProcessModel::ColFamilyMemoryPercent ) - return false; - columns["widths"]["widths"].push_back( 0 ); - if ( !columns.contains( "hidden_columns" ) || !columns["hidden_columns"].is_array() ) - columns["hidden_columns"] = nlohmann::json::array(); - columns["hidden_columns"].push_back( ProcessModel::ColFamilyMemoryPercent ); - if ( columns.contains( "column_order" ) && columns["column_order"].is_array() && - columns["column_order"].size() == ProcessModel::ColFamilyMemoryPercent ) { - const auto& oldOrder = columns["column_order"]; - bool naturalOrder = true; - for ( size_t i = 0; i < oldOrder.size(); ++i ) { - if ( !oldOrder[i].is_number_integer() || - oldOrder[i].get() != static_cast( i ) ) { - naturalOrder = false; - break; - } - } - nlohmann::json order = nlohmann::json::array(); - if ( naturalOrder ) { - for ( size_t column = 0; column < ProcessModel::ColFamilyMemoryPercent; ++column ) { - if ( column != ProcessModel::ColCommand ) - order.push_back( column ); - } - order.push_back( ProcessModel::ColFamilyMemoryPercent ); - order.push_back( ProcessModel::ColCommand ); - } else { - bool inserted = false; - for ( const auto& column : oldOrder ) { - if ( column == ProcessModel::ColCommand ) { - order.push_back( ProcessModel::ColFamilyMemoryPercent ); - inserted = true; - } - order.push_back( column ); - } - if ( !inserted ) - order.push_back( ProcessModel::ColFamilyMemoryPercent ); - } - columns["column_order"] = std::move( order ); - } - return true; - }; - if ( !addFamilyPercentColumn( state ) ) - return false; - if ( state.contains( "tree" ) && state["tree"].is_object() && - !addFamilyPercentColumn( state["tree"] ) ) - state.erase( "tree" ); - state["version"] = kProcessTableStateVersion; - return true; -} - const char* usernameClass( const ProcessInfo& process ) { if ( process.status == ProcessStatus::Ended ) return "eproc-process-username-ended"; @@ -866,10 +629,8 @@ bool App::init() { platformMessage->showWhenReady(); } - // The first snapshot was requested before the window existed, so it is normally ready by now. - // Publishing it here (before the loop, so nothing is being drawn yet) means the very first - // frame already shows data instead of flashing an empty table. - waitForFirstSnapshot( 250 ); + // Publish whatever finished during UI initialization. Neither process collection nor PSS + // accounting may delay the first frame; later results arrive through the refresh tick. publishStagedSnapshot(); setupRefreshTimer(); @@ -972,7 +733,8 @@ static void restoreProcessColumns( UIAbstractTableView& view, const nlohmann::js hideUnsupportedProcessColumns( view ); if ( state.contains( "widths" ) && - isValidProcessTableWidthState( state["widths"], columnCount, view ) ) { + isValidWidths( state["widths"], columnCount, + [&view]( size_t column ) { return view.isColumnHidden( column ); } ) ) { // Pixel restoration sets each width separately, so disable automatic sizing first. const bool pixelWidths = state["widths"]["mode"] == "pixels"; if ( pixelWidths ) @@ -1035,26 +797,15 @@ void App::restoreProcessTableState() { if ( !mConfig || mConfig->processTableState.empty() || !mTableView || !mSortProxy ) return; - nlohmann::json state = - nlohmann::json::parse( mConfig->processTableState, nullptr, false, true ); - if ( state.is_discarded() || !state.is_object() || !state.contains( "version" ) || - !state["version"].is_number_integer() ) + nlohmann::json state = parse( mConfig->processTableState ); + if ( state.is_null() ) return; - const int stateVersion = state["version"].get(); - if ( stateVersion == kLegacyProcessTableStateVersion || - stateVersion == kPreviousProcessTableStateVersion || - stateVersion == kPriorProcessTableStateVersion || - stateVersion == kFamilyProcessTableStateVersion ) { - if ( !migrateProcessTableState( state ) ) - return; - } else if ( stateVersion != kProcessTableStateVersion ) { - return; - } - const size_t columnCount = mSortProxy->columnCount(); if ( !state.contains( "widths" ) || - !isValidProcessTableWidthState( state["widths"], columnCount, *mTableView ) ) + !isValidWidths( state["widths"], columnCount, [this]( size_t column ) { + return mTableView->isColumnHidden( column ); + } ) ) return; restoreProcessColumns( *mTableView, state, columnCount ); @@ -1087,6 +838,10 @@ void App::setupUI() { mSwapText = mRoot->find( "swap_text" ); auto* ui = mApp->getUI(); + ui->setKeyBindingCommand( "next-tab", [this] { mTabWidget->focusNextTab(); } ); + ui->setKeyBindingCommand( "previous-tab", [this] { mTabWidget->focusPreviousTab(); } ); + ui->addKeyBindingString( "mod+tab", "next-tab" ); + ui->addKeyBindingString( "mod+shift+tab", "previous-tab" ); mRoot->find( "tab_process_table" ) ->setText( ui->i18n( "eproc_process_table_tab", "Process Table" ) ); mRoot->find( "tab_performance" ) @@ -1099,6 +854,32 @@ void App::setupUI() { mSwapText->setText( ui->i18n( "eproc_swap_status", "Swap: 0 / 0" ) ); // Tabs are declared in the XML layout via Tab elements with owns= attributes. + for ( size_t index = 0; index < mTabFocus.size(); ++index ) { + auto* content = mTabWidget->getTab( index )->getOwnedWidget(); + mTabFocus[index].focusConnection = + content->connect( Event::OnFocusWithin, [this, index]( const Event* event ) { + auto* focused = mApp->getUI()->getEventDispatcher()->getFocusNode(); + // Tab selection briefly focuses the container; preserve its last focused child. + if ( mTabFocusRestorePending || !focused || focused == event->getNode() || + !focused->isWidget() || !event->getNode()->isParentOf( focused ) ) + return; + auto& state = mTabFocus[index]; + if ( state.node == focused && state.closeConnection ) + return; + state.closeConnection = + focused->connect( Event::OnClose, [this, index]( const Event* ) { + mTabFocus[index].node = nullptr; + } ); + state.node = focused; + } ); + } + mTabSelectedConnection = mTabWidget->connect( Event::OnTabSelected, [this]( const Event* ) { + // Mouse-down and Shift+Tab processing can move focus again after selection finishes. + if ( mTabFocusRestorePending ) + return; + mTabFocusRestorePending = true; + mTabWidget->runOnMainThread( [this] { restoreTabFocus(); } ); + } ); // Filter entries mirror the original's ProcessFilter::State order. static const std::pair filters[] = { @@ -1145,6 +926,33 @@ void App::setupUI() { mSearchInput->setFocus(); } +void App::restoreTabFocus() { + mTabFocusRestorePending = false; + const auto index = mTabWidget->getTabSelectedIndex(); + if ( index >= mTabFocus.size() ) + return; + auto* tab = mTabWidget->getTabSelected(); + auto* content = tab->getOwnedWidget(); + const auto& state = mTabFocus[index]; + // Connection expiry also protects against widgets deleted without a close notification. + auto* focused = state.closeConnection ? state.node : nullptr; + if ( focused && content->isParentOf( focused ) ) { + for ( auto* ancestor = focused; ancestor; ancestor = ancestor->getParent() ) { + if ( !ancestor->isVisible() || !ancestor->isEnabled() || ancestor->isClosing() ) { + focused = nullptr; + break; + } + if ( ancestor == content ) + break; + } + } else { + focused = nullptr; + } + if ( !focused ) + focused = tab->getId() == "tab_process_table" ? mSearchInput : content; + focused->setFocus(); +} + void App::setupPerformance() { constexpr std::array ids = { "cpu", "memory", "gpu", "network" }; constexpr std::array names = { "CPU", "Memory", "GPU", "Network" }; @@ -1559,6 +1367,12 @@ void App::startCollection() { // joins the pool before destroying the staging mutex that the worker uses. mThreadPool = ThreadPool::createShared( 1, false ); + // Config is available before any table exists. Include PSS in the first snapshot and + // overlap the cold scan with window/UI initialization when the saved active view needs it. + mCollectProportionalMemory = + showsFamilyMemory( parse( mConfig->processTableState ), + mConfig->filterMode == ProcessModel::AllProcessesInTreeForm ); + // Timed from this first dispatch so the next sample lands a full period later, giving the CPU // deltas a meaningful window instead of the few milliseconds of a back-to-back pair. mDispatchClock.getElapsedTimeAndReset(); @@ -1575,25 +1389,14 @@ void App::setupRefreshTimer() { mRoot->setInterval( [this] { onRefreshTick(); }, Milliseconds( mTickIntervalMs ) ); } -bool App::waitForFirstSnapshot( Uint32 timeoutMs ) { - Clock clock; - while ( clock.getElapsedTime().asMilliseconds() < timeoutMs ) { - { - Lock lock( mStagingMutex ); - if ( mStagedReady ) - return true; - } - Sys::sleep( Milliseconds( 2 ) ); - } - return false; -} - void App::onRefreshTick() { publishStagedSnapshot(); if ( mCollectInFlight.load() ) return; - if ( mCollectFamilyMemoryAfterRestore ) { - mCollectFamilyMemoryAfterRestore = false; + if ( mProcessTableStateRestored ) + reconcileProportionalMemory(); + if ( mCollectionRequested ) { + mCollectionRequested = false; mDispatchClock.getElapsedTimeAndReset(); collectAsync(); return; @@ -1614,20 +1417,44 @@ void App::collectAsync() { // previous sample, so concurrent runs would corrupt it. if ( mCollectInFlight.exchange( true ) ) return; - UIAbstractTableView* view = activeProcessView(); - mCollector->setCollectProportionalMemory( - view && ( !view->isColumnHidden( ProcessModel::ColFamilyMemory ) || - !view->isColumnHidden( ProcessModel::ColFamilyMemoryPercent ) ) ); - - mThreadPool->run( [this] { + const bool collectProportionalMemory = mCollectProportionalMemory; + const bool initialCollection = mInitialCollection; + mInitialCollection = false; + mThreadPool->run( [this, collectProportionalMemory, initialCollection] { + // Policy travels with the job; the collector and its caches stay worker-owned. + mCollector->setCollectProportionalMemory( collectProportionalMemory ); std::vector processes; SystemInfo sysInfo; - if ( mCollector->collect( processes, sysInfo ) ) { + std::vector memory; + bool publishedBase = false; + const auto publishBase = [this, &publishedBase]( std::vector&& processes, + SystemInfo&& sysInfo ) { Lock lock( mStagingMutex ); mStagedProcesses = std::move( processes ); mStagedSystemInfo = std::move( sysInfo ); + mStagedFamilyMemoryReady = false; mStagedReady = true; + publishedBase = true; + }; + const bool collected = + initialCollection + ? mCollector->collectInitial( processes, sysInfo, publishBase, &memory ) + : mCollector->collect( processes, sysInfo ); + if ( collected ) { + Lock lock( mStagingMutex ); + if ( publishedBase ) { + mStagedProportionalMemory = std::move( memory ); + mStagedProportionalMemoryReady = true; + } else { + mStagedProcesses = std::move( processes ); + mStagedSystemInfo = std::move( sysInfo ); + mStagedFamilyMemoryReady = true; + mStagedReady = true; + // A newer ordinary snapshot supersedes any unconsumed startup completion. + mStagedProportionalMemory.clear(); + mStagedProportionalMemoryReady = false; + } } mCollectInFlight.store( false ); @@ -1637,19 +1464,32 @@ void App::collectAsync() { void App::publishStagedSnapshot() { std::vector processes; SystemInfo sysInfo; + std::vector memory; + bool hasSnapshot = false; + bool hasMemory = false; + bool familyMemoryReady = true; { Lock lock( mStagingMutex ); - if ( !mStagedReady ) + if ( !mStagedReady && !mStagedProportionalMemoryReady ) return; - processes = std::move( mStagedProcesses ); - sysInfo = std::move( mStagedSystemInfo ); - mStagedReady = false; + hasSnapshot = mStagedReady; + hasMemory = mStagedProportionalMemoryReady; + familyMemoryReady = mStagedFamilyMemoryReady || hasMemory; + if ( hasSnapshot ) { + processes = std::move( mStagedProcesses ); + sysInfo = std::move( mStagedSystemInfo ); + mStagedReady = false; + } + if ( hasMemory ) { + memory = std::move( mStagedProportionalMemory ); + mStagedProportionalMemoryReady = false; + } } // Window ownership is needed by the Programs Only filter, so it is refreshed on the UI thread // once per published snapshot rather than per tick. - if ( mProcessModel && mCollector && mCollector->supportsProgramsOnly() ) { + if ( hasSnapshot && mProcessModel && mCollector && mCollector->supportsProgramsOnly() ) { mGuiWindows.refresh(); mProcessModel->setGuiWindowPids( UnorderedSet( mGuiWindows.windowPids().begin(), mGuiWindows.windowPids().end() ) ); @@ -1667,10 +1507,16 @@ void App::publishStagedSnapshot() { if ( mTreeView ) mTreeView->clearViewMetadata(); - if ( mPerformanceDetailChart ) + if ( hasSnapshot && mPerformanceDetailChart ) updatePerformance( sysInfo, processes ); - if ( mProcessModel ) - mProcessModel->applySnapshot( std::move( processes ), sysInfo ); + if ( mProcessModel ) { + if ( hasSnapshot ) { + mProcessModel->applySnapshot( std::move( processes ), sysInfo, familyMemoryReady, + memory ); + } else if ( hasMemory ) { + mProcessModel->applyProportionalMemory( memory ); + } + } if ( !mProcessTableStateRestored && !mProcessTableStateRestoreScheduled && mApp && mApp->getUI() ) { @@ -1679,10 +1525,7 @@ void App::publishStagedSnapshot() { if ( !mProcessTableStateRestored ) { restoreProcessTableState(); mProcessTableStateRestored = true; - UIAbstractTableView* view = activeProcessView(); - mCollectFamilyMemoryAfterRestore = - view && ( !view->isColumnHidden( ProcessModel::ColFamilyMemory ) || - !view->isColumnHidden( ProcessModel::ColFamilyMemoryPercent ) ); + reconcileProportionalMemory(); } mProcessTableStateRestoreScheduled = false; } ); @@ -1698,6 +1541,18 @@ UIAbstractTableView* App::activeProcessView() const { : static_cast( mTableView ); } +void App::reconcileProportionalMemory() { + UIAbstractTableView* view = activeProcessView(); + const bool enabled = view && ( !view->isColumnHidden( ProcessModel::ColFamilyMemory ) || + !view->isColumnHidden( ProcessModel::ColFamilyMemoryPercent ) ); + if ( enabled == mCollectProportionalMemory ) + return; + mCollectProportionalMemory = enabled; + // Normally startup prediction matches restoration. Correct a mismatch, or a later + // visibility/view change, as soon as the current collection finishes. + mCollectionRequested = enabled; +} + void App::captureTreeExpansion() { if ( !mTreeMode || !mTreeExpansionInitialized || !mTreeView || !mTreeModel || !mProcessModel || mTreeSearchActive ) diff --git a/src/tools/eproc/eproc.hpp b/src/tools/eproc/eproc.hpp index ffbdb8ad4..427bd7090 100644 --- a/src/tools/eproc/eproc.hpp +++ b/src/tools/eproc/eproc.hpp @@ -50,6 +50,9 @@ class App { bool init(); void setupUI(); + + void restoreTabFocus(); + void showSettings(); void savePreferences(); void setupProcessTable(); @@ -75,10 +78,6 @@ class App { /** Starts the poll/dispatch timer. Requires the UI to exist. */ void setupRefreshTimer(); - /** Blocks up to @p timeoutMs until the first snapshot is staged. Called before the render - * loop, where blocking cannot stall a frame. */ - bool waitForFirstSnapshot( Uint32 timeoutMs ); - /** Collects the current snapshot and publishes it to the UI thread through the staging * buffer. Runs on a worker thread; never touches the model or any Node. */ void collectAsync(); @@ -95,6 +94,8 @@ class App { UIAbstractTableView* activeProcessView() const; + void reconcileProportionalMemory(); + void captureTreeExpansion(); void restoreTreeExpansion(); @@ -135,6 +136,13 @@ class App { std::unique_ptr mApp; UIWidget* mRoot{ nullptr }; UITabWidget* mTabWidget{ nullptr }; + struct TabFocusState { + Node* node{ nullptr }; + EventConnection closeConnection; + EventConnection focusConnection; + }; + std::array mTabFocus; + EventConnection mTabSelectedConnection; UIWindow* mSettingsWindow{ nullptr }; UIWidget* mProcessTableLayout{ nullptr }; @@ -172,6 +180,8 @@ class App { Clock mPerformanceClock; size_t mSelectedPerformanceMetric{ 0 }; bool mPerCoreView{ false }; + bool mTabFocusRestorePending{ false }; + bool mInitialCollection{ true }; std::shared_ptr mProcessModel; std::shared_ptr mTreeModel; @@ -182,7 +192,8 @@ class App { bool mTreeSearchActive{ false }; bool mProcessTableStateRestored{ false }; bool mProcessTableStateRestoreScheduled{ false }; - bool mCollectFamilyMemoryAfterRestore{ false }; + bool mCollectProportionalMemory{ false }; + bool mCollectionRequested{ false }; bool mWindowStateSaved{ false }; // Window ownership backs the Programs Only filter. Refreshed on the UI thread, since Xlib is @@ -196,8 +207,11 @@ class App { std::atomic mCollectInFlight{ false }; Mutex mStagingMutex; std::vector mStagedProcesses; + std::vector mStagedProportionalMemory; SystemInfo mStagedSystemInfo; bool mStagedReady{ false }; + bool mStagedFamilyMemoryReady{ true }; + bool mStagedProportionalMemoryReady{ false }; Uint32 mUpdateIntervalMs{ 1000 }; diff --git a/src/tools/eproc/platform/linux/process_collector_linux.cpp b/src/tools/eproc/platform/linux/process_collector_linux.cpp index 15fdd5049..0daa49616 100644 --- a/src/tools/eproc/platform/linux/process_collector_linux.cpp +++ b/src/tools/eproc/platform/linux/process_collector_linux.cpp @@ -1,4 +1,8 @@ #include "process_collector_linux.hpp" +#include +#include +#include +#include #include #include @@ -33,6 +37,9 @@ constexpr size_t kProcFileCapacity = 8192; /** How often (in passes) the per-pid caches are swept of processes that exited. A sweep walks the * whole map, so it is amortised over many passes instead of running on every one. */ constexpr Uint32 kCacheSweepInterval = 60; +// Four short-lived PSS workers finish the measured cold scan within UI initialization; +// one/two workers exceeded the startup budget. No extra PSS workers survive this scan. +constexpr size_t kStartupPssWorkers = 4; // smaps_rollup walks page tables. Refresh one PID bucket per pass to spread that work across // collections, while reading uncached values immediately. constexpr Uint32 kPssSampleInterval = 5; @@ -153,6 +160,106 @@ std::string readExePath( long pid, char* out, size_t capacity ) { return std::string( out, static_cast( length ) ); } +// Startup workers use only stat and smaps_rollup. They never access collector caches. +struct StartupPssSample { + Int64 startTime{ -1 }; + Int64 rssPages{ 0 }; + Int64 value{ -1 }; + long pid{ 0 }; +}; + +bool readProcessMemoryIdentity( long pid, Int64& startTime, Int64& rssPages ) { + char path[kProcPathCapacity]; + char buffer[kProcFileCapacity]; + size_t length = 0; + if ( 0 == formatProcPath( path, sizeof( path ), pid, "stat" ) || + !readProcFile( path, buffer, sizeof( buffer ), length ) ) + return false; + // comm can contain spaces and parentheses; fields after the final ')' start with state (3). + const char* p = strrchr( buffer, ')' ); + if ( !p || p[1] != ' ' ) + return false; + p += 2; + for ( int field = 3; field < 22; ++field ) { + while ( *p && *p != ' ' ) + ++p; + while ( *p == ' ' ) + ++p; + if ( !*p ) + return false; + } + const char* end = buffer + length; + auto start = std::from_chars( p, end, startTime ); + if ( start.ec != std::errc() || start.ptr == end || *start.ptr != ' ' ) + return false; + p = start.ptr; + while ( *p == ' ' ) + ++p; + // vsize (23) is irrelevant; RSS (24) is the cheap zero-memory shortcut. + while ( *p && *p != ' ' ) + ++p; + while ( *p == ' ' ) + ++p; + auto rss = std::from_chars( p, end, rssPages ); + return rss.ec == std::errc() && startTime >= 0 && rssPages >= 0; +} + +Int64 readProcessPss( long pid ) { + char path[kProcPathCapacity]; + char buffer[kProcFileCapacity]; + size_t length = 0; + if ( 0 == formatProcPath( path, sizeof( path ), pid, "smaps_rollup" ) || + !readProcFile( path, buffer, sizeof( buffer ), length ) ) + return -1; + const char* line = strstr( buffer, "\nPss:" ); + return line ? parseLabeledLong( line + 1 ) : -1; +} + +void collectStartupPss( std::vector& samples ) { + DIR* procDir = opendir( "/proc" ); + if ( !procDir ) + return; + struct dirent* entry; + while ( ( entry = readdir( procDir ) ) != nullptr ) { + char* end = nullptr; + const long pid = strtol( entry->d_name, &end, 10 ); + if ( *end != '\0' || pid <= 0 ) + continue; + StartupPssSample sample; + sample.pid = pid; + if ( readProcessMemoryIdentity( pid, sample.startTime, sample.rssPages ) ) + samples.push_back( sample ); + } + closedir( procDir ); + + // Measured cold scans have a long tail of large resident processes. Start those first so + // their page-table walks overlap the cheap jobs instead of delaying the final join. + std::sort( samples.begin(), samples.end(), + []( const auto& lhs, const auto& rhs ) { return lhs.rssPages > rhs.rssPages; } ); + + std::atomic nextJob{ 0 }; + const auto work = [&] { + for ( ;; ) { + const size_t index = nextJob.fetch_add( 1, std::memory_order_relaxed ); + if ( index >= samples.size() ) + break; + auto& sample = samples[index]; + sample.value = sample.rssPages == 0 ? 0 : readProcessPss( sample.pid ); + // Check again after the accounting read: an exit/reused PID during the read must + // not attach a different incarnation's PSS to this sample. + Int64 startTime = 0, rssPages = 0; + if ( !readProcessMemoryIdentity( sample.pid, startTime, rssPages ) || + startTime != sample.startTime ) + sample.startTime = -1; + } + }; + // The sampler thread is itself one worker. All helpers join before samples are merged. + std::array workers; + for ( auto& worker : workers ) + worker = std::jthread( work ); + work(); +} + // ksysguard treats an account as a system account when its shell cannot be used to log in. bool isLoginShell( const char* shell ) { if ( !shell || !*shell ) @@ -532,6 +639,62 @@ const ProcessCollectorLinux::UserInfo& ProcessCollectorLinux::userInfo( long uid return mUserCache.emplace( uid, std::move( info ) ).first->second; } +bool ProcessCollectorLinux::collectInitial( std::vector& processes, + SystemInfo& sysInfo, + const InitialSnapshotCallback& publishBase, + std::vector* memory ) { + if ( !mCollectProportionalMemory ) + return collect( processes, sysInfo ); + + // Overlap the cold PSS scan with the normal snapshot and with UI initialization. Workers + // write only private samples; the collector cache remains exclusively owned by this thread. + std::vector samples; + std::jthread sampler( [&samples] { collectStartupPss( samples ); } ); + mCollectProportionalMemory = false; + const bool collected = collect( processes, sysInfo ); + mCollectProportionalMemory = true; + if ( !collected ) + return false; // sampler's destructor still joins all startup workers. + + const bool publishEarly = publishBase && memory; + if ( publishEarly ) { + // Keep only numeric identities/RSS-zero markers for the later merge. The large process + // snapshot and its strings move directly to the UI; no snapshot copy is needed. + memory->clear(); + memory->reserve( processes.size() ); + for ( const auto& proc : processes ) + memory->push_back( { proc.pid, proc.startTime, proc.vmRSS == 0 ? 0 : -1 } ); + publishBase( std::move( processes ), std::move( sysInfo ) ); + } + sampler.join(); + + std::sort( samples.begin(), samples.end(), + []( const auto& lhs, const auto& rhs ) { return lhs.pid < rhs.pid; } ); + const auto merge = [&]( Int64 pid, Int64 startTime, bool zeroRss ) -> Int64 { + const auto sample = + std::lower_bound( samples.begin(), samples.end(), pid, + []( const auto& sample, Int64 pid ) { return sample.pid < pid; } ); + if ( sample == samples.end() || sample->pid != pid || sample->startTime != startTime ) + return -1; + // Seed the ordinary bucket cache, so the next pass does not repeat the full scan. + PssEntry& pss = mProcessPss[pid]; + pss.value = zeroRss ? 0 : sample->value; + pss.startTime = startTime; + pss.alivePass = mPass; + return pss.value; + }; + if ( publishEarly ) { + for ( auto& sample : *memory ) + sample.valueKB = merge( sample.pid, sample.startTime, sample.valueKB == 0 ); + std::sort( memory->begin(), memory->end(), + []( const auto& lhs, const auto& rhs ) { return lhs.pid < rhs.pid; } ); + } else { + for ( auto& proc : processes ) + proc.vmPSS = merge( proc.pid, proc.startTime, proc.vmRSS == 0 ); + } + return true; +} + bool ProcessCollectorLinux::collect( std::vector& processes, SystemInfo& sysInfo ) { // 0 is the "never seen" sentinel for the per-pid caches below. ++mPass; @@ -627,22 +790,7 @@ bool ProcessCollectorLinux::collect( std::vector& processes, System if ( proc.hasSharedInfo && proc.vmRSS >= 0 ) proc.vmURSS = proc.vmRSS - proc.sharedMem; - if ( mCollectProportionalMemory ) { - PssEntry& pss = mProcessPss[pid]; - if ( pss.startTime != proc.startTime || pss.value < 0 || - static_cast( pid ) % kPssSampleInterval == mPass % kPssSampleInterval ) { - pss.value = -1; - if ( 0 != formatProcPath( path, sizeof( path ), pid, "smaps_rollup" ) && - readProcFile( path, buffer, sizeof( buffer ), length ) ) { - const char* line = strstr( buffer, "\nPss:" ); - if ( line ) - pss.value = parseLabeledLong( line + 1 ); - } - pss.startTime = proc.startTime; - } - pss.seenPass = mPass; - proc.vmPSS = pss.value; - } + collectProcessPss( proc ); // CPU usage delta against the previous pass for this PID. Entries are updated in place and // swept periodically, so the steady state does not allocate. A pid whose start time @@ -724,6 +872,35 @@ bool ProcessCollectorLinux::collect( std::vector& processes, System return true; } +void ProcessCollectorLinux::collectProcessPss( ProcessInfo& proc ) { + auto pssIt = mProcessPss.find( proc.pid ); + if ( pssIt != mProcessPss.end() ) { + if ( pssIt->second.startTime != proc.startTime ) { + // A reused PID must never inherit the previous incarnation's cached PSS, + // including when collection is disabled. + mProcessPss.erase( pssIt ); + } else { + // Liveness is independent of refresh policy. Keep live values while hidden. + pssIt->second.alivePass = mPass; + } + } + if ( mCollectProportionalMemory ) { + PssEntry& pss = mProcessPss[proc.pid]; + if ( proc.vmRSS == 0 ) { + // Zombies/kernel threads have no resident userspace memory to account for. + pss.value = 0; + pss.startTime = proc.startTime; + } else if ( pss.startTime != proc.startTime || pss.value < 0 || + static_cast( proc.pid ) % kPssSampleInterval == + mPass % kPssSampleInterval ) { + pss.value = readProcessPss( proc.pid ); + pss.startTime = proc.startTime; + } + pss.alivePass = mPass; + proc.vmPSS = pss.value; + } +} + void ProcessCollectorLinux::pruneCaches() { for ( auto it = mProcessTicks.begin(); it != mProcessTicks.end(); ) { if ( it->second.pass != mPass ) @@ -739,7 +916,7 @@ void ProcessCollectorLinux::pruneCaches() { ++it; } for ( auto it = mProcessPss.begin(); it != mProcessPss.end(); ) { - if ( it->second.seenPass != mPass ) + if ( it->second.alivePass != mPass ) it = mProcessPss.erase( it ); else ++it; diff --git a/src/tools/eproc/platform/linux/process_collector_linux.hpp b/src/tools/eproc/platform/linux/process_collector_linux.hpp index 75e363b17..e3fe8f951 100644 --- a/src/tools/eproc/platform/linux/process_collector_linux.hpp +++ b/src/tools/eproc/platform/linux/process_collector_linux.hpp @@ -16,6 +16,10 @@ class ProcessCollectorLinux : public ProcessCollector { bool collect( std::vector& processes, SystemInfo& sysInfo ) override; + bool collectInitial( std::vector& processes, SystemInfo& sysInfo, + const InitialSnapshotCallback& publishBase = {}, + std::vector* memory = nullptr ) override; + void setCollectProportionalMemory( bool enabled ) override { mCollectProportionalMemory = enabled; } @@ -23,6 +27,8 @@ class ProcessCollectorLinux : public ProcessCollector { bool supportsProgramsOnly() const override { return true; } private: + friend struct ProcessCollectorLinuxTestAccess; + // Cached per-uid identity, since passwd lookups can hit NSS. struct UserInfo { std::string name; @@ -48,7 +54,8 @@ class ProcessCollectorLinux : public ProcessCollector { struct PssEntry { Int64 value{ -1 }; long long startTime{ 0 }; - Uint32 seenPass{ 0 }; + // Last pass observing this incarnation, even when PSS collection is disabled. + Uint32 alivePass{ 0 }; }; // KiB per memory page (e.g. 4 for a 4096-byte page size) @@ -88,6 +95,8 @@ class ProcessCollectorLinux : public ProcessCollector { void readSystemMemory( SystemInfo& sysInfo ); void readSystemUptime( SystemInfo& sysInfo ); void resolveUser( ProcessInfo& proc ); + void collectProcessPss( ProcessInfo& proc ); + void pruneCaches(); /** Cached passwd lookup for a uid (name + login-shell capability). */ diff --git a/src/tools/eproc/process_collector.hpp b/src/tools/eproc/process_collector.hpp index 51e809876..4ec75b82d 100644 --- a/src/tools/eproc/process_collector.hpp +++ b/src/tools/eproc/process_collector.hpp @@ -2,6 +2,7 @@ #define EPROC_PROCESS_COLLECTOR_HPP #include "process_info.hpp" +#include #include #include @@ -26,6 +27,12 @@ struct SystemInfo { Int64 getUsedSwapKB() const { return totalSwap - freeSwap; } }; +struct ProportionalMemorySample { + Int64 pid{ 0 }; + Int64 startTime{ 0 }; + Int64 valueKB{ -1 }; +}; + class ProcessCollector { public: virtual ~ProcessCollector() = default; @@ -36,6 +43,17 @@ class ProcessCollector { * @return true on success. */ virtual bool collect( std::vector& processes, SystemInfo& sysInfo ) = 0; + using InitialSnapshotCallback = std::function&&, SystemInfo&& )>; + + /** Startup snapshot. Platforms may publish the base snapshot before expensive accounting + * completes. In that case processes/system data move into publishBase and the completed, + * PID-sorted memory samples are returned separately. All callbacks run on the owner thread. */ + virtual bool collectInitial( std::vector& processes, SystemInfo& sysInfo, + const InitialSnapshotCallback& = {}, + std::vector* = nullptr ) { + return collect( processes, sysInfo ); + } + /** Enables the optional, more expensive proportional-memory reading where available. */ virtual void setCollectProportionalMemory( bool ) {} diff --git a/src/tools/eproc/process_model.cpp b/src/tools/eproc/process_model.cpp index 8f5321eca..41371b9b2 100644 --- a/src/tools/eproc/process_model.cpp +++ b/src/tools/eproc/process_model.cpp @@ -289,6 +289,31 @@ ModelIndex ProcessModel::index( int row, int column, const ModelIndex& ) const { return createIndex( row, column ); } +bool ProcessModel::mergeProportionalMemory( std::vector& processes, + const std::vector& memory ) { + bool updated = false; + for ( auto& process : processes ) { + if ( process.status == ProcessStatus::Ended ) + continue; + const auto sample = + std::lower_bound( memory.begin(), memory.end(), process.pid, + []( const auto& sample, Int64 pid ) { return sample.pid < pid; } ); + if ( sample != memory.end() && sample->pid == process.pid && + sample->startTime == process.startTime ) { + process.vmPSS = sample->valueKB; + updated = true; + } + } + return updated; +} + +void ProcessModel::applyProportionalMemory( const std::vector& memory ) { + if ( !mergeProportionalMemory( mProcesses, memory ) ) + return; + buildFamilyMemory( mProcesses ); + onModelUpdate(); +} + void ProcessModel::buildFamilyMemory( std::vector& processes ) { const size_t count = processes.size(); mFamilyRowForPid.clear(); @@ -299,6 +324,8 @@ void ProcessModel::buildFamilyMemory( std::vector& processes ) { mFamilyQueue.reserve( count ); for ( size_t row = 0; row < count; ++row ) { auto& process = processes[row]; + if ( process.status == ProcessStatus::Ended ) + continue; mFamilyRowForPid.emplace( process.pid, static_cast( row ) ); #if EE_PLATFORM == EE_PLATFORM_LINUX // Zombies have no address space, so smaps_rollup is unavailable but their PSS is zero. @@ -309,7 +336,8 @@ void ProcessModel::buildFamilyMemory( std::vector& processes ) { } for ( size_t row = 0; row < count; ++row ) { const auto& process = processes[row]; - if ( process.parentPid <= 0 || process.parentPid == process.pid ) + if ( process.status == ProcessStatus::Ended || process.parentPid <= 0 || + process.parentPid == process.pid ) continue; auto parent = mFamilyRowForPid.find( process.parentPid ); if ( parent == mFamilyRowForPid.end() ) @@ -318,7 +346,7 @@ void ProcessModel::buildFamilyMemory( std::vector& processes ) { ++mFamilyPendingChildren[parent->second]; } for ( size_t row = 0; row < count; ++row ) { - if ( mFamilyPendingChildren[row] == 0 ) + if ( processes[row].status != ProcessStatus::Ended && mFamilyPendingChildren[row] == 0 ) mFamilyQueue.push_back( row ); } for ( size_t head = 0; head < mFamilyQueue.size(); ++head ) { @@ -339,9 +367,19 @@ void ProcessModel::buildFamilyMemory( std::vector& processes ) { } } -void ProcessModel::applySnapshot( std::vector&& processes, - const SystemInfo& sysInfo ) { - buildFamilyMemory( processes ); +void ProcessModel::applySnapshot( std::vector&& processes, const SystemInfo& sysInfo, + bool familyMemoryReady, + const std::vector& memory ) { + if ( !memory.empty() ) + mergeProportionalMemory( processes, memory ); + if ( familyMemoryReady ) { + buildFamilyMemory( processes ); + } else { + // A base snapshot is usable immediately, but partial family totals (including zero-RSS + // members) stay blank until the startup accounting scan has completed. + for ( auto& process : processes ) + process.familyMemoryKB = -1; + } // Keep processes that disappeared from the latest snapshot for one more update. This mirrors // ksysguard's Ended state and is especially useful when a process exits between two refreshes: // its last known row remains visible, but is marked as ended by the view. diff --git a/src/tools/eproc/process_model.hpp b/src/tools/eproc/process_model.hpp index c82912497..440dbe52e 100644 --- a/src/tools/eproc/process_model.hpp +++ b/src/tools/eproc/process_model.hpp @@ -83,7 +83,13 @@ class ProcessModel : public Model { const ModelIndex& parent = ModelIndex() ) const override; /** Replaces the snapshot and notifies the views. UI thread only. */ - void applySnapshot( std::vector&& processes, const SystemInfo& sysInfo ); + void applySnapshot( std::vector&& processes, const SystemInfo& sysInfo, + bool familyMemoryReady = true, + const std::vector& memory = {} ); + + /** Applies completed, PID-sorted startup PSS samples to matching live incarnations without + * advancing CPU/performance history or aging ended processes. UI thread only. */ + void applyProportionalMemory( const std::vector& memory ); void setFilter( FilterMode mode ); @@ -131,6 +137,9 @@ class ProcessModel : public Model { void applyFilters(); + static bool mergeProportionalMemory( std::vector& processes, + const std::vector& memory ); + void buildFamilyMemory( std::vector& processes ); /** Applies the active filter mode to a single process, mirroring the original's predicates. */ diff --git a/src/tools/eproc/process_table_state.cpp b/src/tools/eproc/process_table_state.cpp new file mode 100644 index 000000000..bb5801306 --- /dev/null +++ b/src/tools/eproc/process_table_state.cpp @@ -0,0 +1,58 @@ +#include "process_table_state.hpp" + +namespace eproc::ProcessTableState { + +namespace { + +bool defaultColumnHidden( size_t column ) { +#if EE_PLATFORM == EE_PLATFORM_MACOS + if ( column == ProcessModel::ColTotalMemory ) + return false; +#endif + return !isProcessColumnSupported( column ) || + std::find( kOptionalProcessColumns.begin(), kOptionalProcessColumns.end(), column ) != + kOptionalProcessColumns.end(); +} + +} // namespace + +bool isProcessColumnSupported( size_t column ) { + return std::find( kUnavailableProcessColumns.begin(), kUnavailableProcessColumns.end(), + column ) == kUnavailableProcessColumns.end(); +} + +nlohmann::json parse( const std::string& savedState ) { + if ( savedState.empty() ) + return nullptr; + auto state = nlohmann::json::parse( savedState, nullptr, false, true ); + if ( !state.is_object() || !state.contains( "version" ) || + !state["version"].is_number_integer() ) + return nullptr; + if ( state["version"] != kProcessTableStateVersion ) + return nullptr; + return state; +} + +bool showsFamilyMemory( const nlohmann::json& state, bool treeMode ) { + // Restoration rejects the whole state when root widths are invalid for the default table. + if ( !state.is_object() || !state.contains( "widths" ) || + !isValidWidths( state["widths"], ProcessModel::ColCount, defaultColumnHidden ) ) + return false; + // The tree has independent defaults when no valid tree state was saved. + if ( treeMode && ( !state.contains( "tree" ) || !state["tree"].is_object() ) ) + return false; + const auto& columns = treeMode ? state["tree"] : state; + if ( !columns.contains( "hidden_columns" ) || !columns["hidden_columns"].is_array() ) + return false; + bool memoryHidden = false; + bool percentHidden = false; + for ( const auto& column : columns["hidden_columns"] ) { + if ( !column.is_number_integer() ) + continue; + memoryHidden |= column == ProcessModel::ColFamilyMemory; + percentHidden |= column == ProcessModel::ColFamilyMemoryPercent; + } + return !memoryHidden || !percentHidden; +} + +} // namespace eproc::ProcessTableState diff --git a/src/tools/eproc/process_table_state.hpp b/src/tools/eproc/process_table_state.hpp new file mode 100644 index 000000000..1c7a23a1b --- /dev/null +++ b/src/tools/eproc/process_table_state.hpp @@ -0,0 +1,101 @@ +#ifndef EPROC_PROCESS_TABLE_STATE_HPP +#define EPROC_PROCESS_TABLE_STATE_HPP + +#include "process_model.hpp" +#include +#include +#include +#include + +namespace eproc::ProcessTableState { + +inline constexpr int kProcessTableStateVersion = 6; + +constexpr std::array kOptionalProcessColumns = { { + ProcessModel::ColTotalMemory, + ProcessModel::ColVirtualSize, + ProcessModel::ColCpuTime, + ProcessModel::ColNiceness, + ProcessModel::ColRelativeStartTime, + ProcessModel::ColTty, + ProcessModel::ColIoRead, + ProcessModel::ColIoWrite, + ProcessModel::ColThreads, + ProcessModel::ColMemoryPercent, + ProcessModel::ColFamilyMemory, + ProcessModel::ColFamilyMemoryPercent, +} }; + +#if EE_PLATFORM == EE_PLATFORM_WIN +constexpr std::array kUnavailableProcessColumns = { { + ProcessModel::ColSharedMem, + ProcessModel::ColGpuUsage, + ProcessModel::ColGpuMemory, + ProcessModel::ColDownload, + ProcessModel::ColUpload, + ProcessModel::ColVirtualSize, + ProcessModel::ColNiceness, + ProcessModel::ColTty, +} }; +#elif EE_PLATFORM == EE_PLATFORM_MACOS +constexpr std::array kUnavailableProcessColumns = { { + ProcessModel::ColSharedMem, + ProcessModel::ColGpuUsage, + ProcessModel::ColGpuMemory, + ProcessModel::ColDownload, + ProcessModel::ColUpload, + ProcessModel::ColTty, +} }; +#elif EE_PLATFORM == EE_PLATFORM_BSD +constexpr std::array kUnavailableProcessColumns = { { + ProcessModel::ColSharedMem, + ProcessModel::ColGpuUsage, + ProcessModel::ColGpuMemory, + ProcessModel::ColDownload, + ProcessModel::ColUpload, + ProcessModel::ColTty, + ProcessModel::ColIoRead, + ProcessModel::ColIoWrite, +} }; +#else +constexpr std::array kUnavailableProcessColumns{}; +#endif + +bool isProcessColumnSupported( size_t column ); + +/** Parses the current saved state format. Invalid/unsupported state returns null. */ +nlohmann::json parse( const std::string& savedState ); + +/** Predicts the restored active view's Family Memory visibility without constructing widgets. */ +bool showsFamilyMemory( const nlohmann::json& state, bool treeMode ); + +/** Shared width validation; visibility comes from either defaults or the actual view. */ +template +bool isValidWidths( const nlohmann::json& widths, size_t columnCount, IsHidden isHidden ) { + if ( !widths.is_object() || !widths.contains( "mode" ) || !widths["mode"].is_string() || + !widths.contains( "widths" ) || !widths["widths"].is_array() || + widths["widths"].size() != columnCount ) + return false; + + const auto& mode = widths["mode"].get_ref(); + if ( mode != "pixels" && mode != "percentage" ) + return false; + + bool hasVisibleWidth = false; + for ( size_t column = 0; column < columnCount; ++column ) { + const auto& width = widths["widths"][column]; + const double value = width.is_number() ? width.get() : 0; + if ( !width.is_number() || !std::isfinite( value ) || value < 0 ) + return false; + if ( mode == "pixels" && !isHidden( column ) && value <= 1.0 ) + return false; + if ( !isHidden( column ) && value > 0 ) + hasVisibleWidth = true; + } + + return hasVisibleWidth; +} + +} // namespace eproc::ProcessTableState + +#endif