diff --git a/.ecode/project_build.json b/.ecode/project_build.json index f17860032..53912442f 100644 --- a/.ecode/project_build.json +++ b/.ecode/project_build.json @@ -417,6 +417,12 @@ "command": "${project_root}/bin/eepp-ui-application-multi-window-debug", "name": "eepp-ui-application-multi-window-debug", "working_dir": "${project_root}/bin" + }, + { + "args": "", + "command": "${project_root}/bin/eproc-debug", + "name": "eproc-debug", + "working_dir": "${project_root}/bin" } ], "var": { diff --git a/.gitignore b/.gitignore index 76bf7b208..4c1fb6ad1 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ src/thirdparty/SDL3-* bin/ee* bin/eepp-* bin/ecode* +bin/eproc* ee.tag log.log /external_projects*.lua @@ -86,4 +87,4 @@ ecode.dmg /projects/android-project/.project /projects/android-project/app/.project /design/ -/projects/haiku/ecode/ecode.app/* \ No newline at end of file +/projects/haiku/ecode/ecode.app/* diff --git a/bin/assets/ui/breeze.css b/bin/assets/ui/breeze.css index d88c4d628..7c6ca0ebf 100644 --- a/bin/assets/ui/breeze.css +++ b/bin/assets/ui/breeze.css @@ -13,6 +13,7 @@ --base-vertical-padding: 5dp; --border-width: 1dp; --list-back: #232629; + --list-back-alt: #1b1e20; --separator: #31363b; --item-hover: #284150; --slider-back: #676a6e; @@ -1215,6 +1216,7 @@ DiffView > SelectButton:focus { --base-vertical-padding: 5dp; --border-width: 1dp; --list-back: #ffffff; + --list-back-alt: #efefef; --separator: #cbcdcd; --item-hover: #93cee9; --slider-back: #e9e9e9; diff --git a/premake4.lua b/premake4.lua index 3b74bcbb4..b278172a1 100644 --- a/premake4.lua +++ b/premake4.lua @@ -1953,6 +1953,29 @@ solution "eepp" files { "src/tools/eeiv/*.cpp" } build_link_configuration( "eeiv", true ) + project "eproc" + set_kind() + language "C++" + files { + "src/tools/eproc/appconfig.cpp", + "src/tools/eproc/eproc.cpp", + "src/tools/eproc/gui_window_tracker.cpp", + "src/tools/eproc/process_collector.cpp", + "src/tools/eproc/process_info.cpp", + "src/tools/eproc/process_model.cpp", + } + if os.is_real("linux") then + files { + "src/tools/eproc/platform/linux/gpu_reader_nvidia.cpp", + "src/tools/eproc/platform/linux/process_collector_linux.cpp", + "src/tools/eproc/platform/linux/process_icon_resolver.cpp", + "src/tools/eproc/platform/linux/process_network_monitor.cpp", + } + -- The Programs Only view reads window ownership from the X11 root window. + links { "pcap" } + end + build_link_configuration( "eproc", true ) + -- Tests project "eepp-test" set_kind() diff --git a/premake5.lua b/premake5.lua index a83ffea23..929f6aa20 100644 --- a/premake5.lua +++ b/premake5.lua @@ -1975,6 +1975,29 @@ workspace "eepp" files { "src/tools/eeiv/*.cpp" } build_link_configuration( "eeiv", true ) + project "eproc" + set_kind() + language "C++" + files { + "src/tools/eproc/appconfig.cpp", + "src/tools/eproc/eproc.cpp", + "src/tools/eproc/gui_window_tracker.cpp", + "src/tools/eproc/process_collector.cpp", + "src/tools/eproc/process_info.cpp", + "src/tools/eproc/process_model.cpp", + } + filter "system:linux" + files { + "src/tools/eproc/platform/linux/gpu_reader_nvidia.cpp", + "src/tools/eproc/platform/linux/process_collector_linux.cpp", + "src/tools/eproc/platform/linux/process_icon_resolver.cpp", + "src/tools/eproc/platform/linux/process_network_monitor.cpp", + } + -- The Programs Only filter reads window ownership from the X11 root window. + links { "pcap" } + filter {} + build_link_configuration( "eproc", true ) + -- Tests project "eepp-test" set_kind() diff --git a/src/eepp/ui/abstract/uiabstracttableview.cpp b/src/eepp/ui/abstract/uiabstracttableview.cpp index cdc5a2ce3..5f22d5c12 100644 --- a/src/eepp/ui/abstract/uiabstracttableview.cpp +++ b/src/eepp/ui/abstract/uiabstracttableview.cpp @@ -354,7 +354,8 @@ void UIAbstractTableView::createOrUpdateColumns( bool resetColumnData ) { col.minHeight = col.widget->getPixelsSize().getHeight(); } col.setWidth( eeceil( col.maxWidth != 0 ? eeclamp( col.width, col.minWidth, col.maxWidth ) - : eemax( col.width, col.minWidth ) ) ); + : eemax( col.width, col.minWidth ) ), + col.manuallySet ); col.widget->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); col.widget->setPixelsSize( col.width, getHeaderHeight() ); } @@ -374,6 +375,10 @@ void UIAbstractTableView::createOrUpdateColumns( bool resetColumnData ) { if ( colIdx != mMainColumn && !isColumnHidden( colIdx ) ) { Float colWidth = getMaxColumnContentWidth( colIdx, true ); auto& col = columnData( colIdx ); + if ( col.manuallySet ) { + usedWidth += col.width; + continue; + } if ( col.widget ) colWidth = eemax( colWidth, col.widget->getPixelsSize().getWidth() ); usedWidth += colWidth; @@ -413,7 +418,8 @@ void UIAbstractTableView::createOrUpdateColumns( bool resetColumnData ) { if ( !col.visible ) continue; col.setWidth( eeceil( col.maxWidth != 0 ? eeclamp( col.width, col.minWidth, col.maxWidth ) - : eemax( col.width, col.minWidth ) ) ); + : eemax( col.width, col.minWidth ) ), + col.manuallySet ); if ( col.widget ) { col.widget->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed ); col.widget->setPixelsSize( col.width, getHeaderHeight() ); diff --git a/src/eepp/ui/uitableheadercolumn.cpp b/src/eepp/ui/uitableheadercolumn.cpp index a09452a3d..a3036f8c0 100644 --- a/src/eepp/ui/uitableheadercolumn.cpp +++ b/src/eepp/ui/uitableheadercolumn.cpp @@ -80,8 +80,45 @@ Uint32 UITableHeaderColumn::onMouseClick( const Vector2i& position, const Uint32 } Uint32 UITableHeaderColumn::onMouseUp( const Vector2i& position, const Uint32& flags ) { - if ( ( flags & EE_BUTTON_RMASK ) && mView->isColumnWidthModeMenuEnabled() ) { + if ( ( flags & EE_BUTTON_RMASK ) && mView->isColumnWidthModeMenuEnabled() && + mView->getModel() ) { auto* menu = UIPopUpMenu::New(); + const Model* model = mView->getModel(); + const size_t columnCount = model->columnCount(); + const auto columnLabel = [this, model]( size_t column ) { + std::string label = model->columnName( column ); + if ( label.empty() ) + label = String::format( i18n( "uitable_column_number", "Column %zu" ).toUtf8(), + column + 1 ); + return label; + }; + bool hasColumnVisibilityItems = false; + bool hasHiddenColumnItems = false; + + if ( mView->visibleColumnCount() > 1 ) { + const std::string title = + String::format( i18n( "uitable_hide_column", "Hide Column '%s'" ).toUtf8(), + columnLabel( mColIndex ).c_str() ); + menu->add( title )->setId( "hide-column" )->setData( mColIndex ); + hasColumnVisibilityItems = true; + } + + for ( size_t col = 0; col < columnCount; ++col ) { + if ( !mView->isColumnHidden( col ) ) + continue; + if ( hasColumnVisibilityItems && !hasHiddenColumnItems ) + menu->addSeparator(); + const std::string title = + String::format( i18n( "uitable_show_column", "Show Column '%s'" ).toUtf8(), + columnLabel( col ).c_str() ); + menu->add( title )->setId( "show-column" )->setData( col ); + hasColumnVisibilityItems = true; + hasHiddenColumnItems = true; + } + + if ( hasColumnVisibilityItems ) + menu->addSeparator(); + menu->addRadioButton( i18n( "uitable_fit_columns_to_view", "Fit Columns to View" ), mView->getColumnWidthMode() == UIAbstractTableView::ColumnWidthMode::Percentage ) @@ -90,12 +127,34 @@ Uint32 UITableHeaderColumn::onMouseUp( const Vector2i& position, const Uint32& f mView->getColumnWidthMode() == UIAbstractTableView::ColumnWidthMode::Pixels ) ->setId( "pixels" ); - menu->on( Event::OnItemClicked, [view = mView]( const Event* event ) { + menu->on( Event::OnItemClicked, [view = mView, columnCount]( const Event* event ) { if ( !event->getNode()->isType( UI_TYPE_MENUITEM ) ) return; - view->setColumnWidthMode( event->getNode()->getId() == "percentage" - ? UIAbstractTableView::ColumnWidthMode::Percentage - : UIAbstractTableView::ColumnWidthMode::Pixels ); + + const auto* item = event->getNode(); + const std::string id( item->getId() ); + const size_t column = static_cast( item->getData() ); + if ( id == "hide-column" ) { + if ( column < columnCount && view->visibleColumnCount() > 1 ) { + if ( view->getMainColumn() == column ) { + for ( size_t next = 0; next < columnCount; ++next ) { + if ( next != column && !view->isColumnHidden( next ) ) { + view->setMainColumn( next ); + break; + } + } + } + view->setColumnHidden( column, true ); + } + } else if ( id == "show-column" ) { + if ( column < columnCount ) + view->setColumnHidden( column, false ); + } else if ( id == "percentage" ) { + view->setColumnWidthMode( UIAbstractTableView::ColumnWidthMode::Percentage ); + } else if ( id == "pixels" ) { + view->setAutoColumnsWidth( false ); + view->setColumnWidthMode( UIAbstractTableView::ColumnWidthMode::Pixels ); + } } ); menu->setCloseOnHide( true ); menu->showAtScreenPosition( position.asFloat() ); @@ -107,7 +166,8 @@ Uint32 UITableHeaderColumn::onDrag( const Vector2f& position, const Uint32&, const Sizef& dragDiff ) { Vector2f localPos( convertToNodeSpace( position ) ); if ( isDragging() || localPos.x >= mSize.getWidth() - mView->getDragBorderDistance() ) { - setPixelsSize( mSize.x - dragDiff.x, mSize.getHeight() ); + const Float width = eemax( mSize.x - dragDiff.x, mView->columnData( mColIndex ).minWidth ); + setPixelsSize( width, mSize.getHeight() ); if ( mSize.getWidth() != mView->columnData( mColIndex ).width ) { mView->columnData( mColIndex ).setWidth( mSize.getWidth(), true ); mView->updateHeaderSize(); diff --git a/src/tools/eproc/appconfig.cpp b/src/tools/eproc/appconfig.cpp new file mode 100644 index 000000000..f30130e9e --- /dev/null +++ b/src/tools/eproc/appconfig.cpp @@ -0,0 +1,57 @@ +#include "appconfig.hpp" + +#include + +using namespace EE; +using namespace EE::System; + +namespace eproc { + +AppConfig::AppConfig( std::string configPath ) : mConfigPath( std::move( configPath ) ) { + FileSystem::dirAddSlashAtEnd( mConfigPath ); + mState.path( mConfigPath + "state.cfg" ); +} + +void AppConfig::load() { + mState.loadFromFile( mState.path() ); + + windowState.size.setWidth( mState.getValueI( "window", "width", windowState.size.getWidth() ) ); + windowState.size.setHeight( + mState.getValueI( "window", "height", windowState.size.getHeight() ) ); + windowState.position.x = mState.getValueI( "window", "x", windowState.position.x ); + windowState.position.y = mState.getValueI( "window", "y", windowState.position.y ); + windowState.displayIndex = + mState.getValueI( "window", "display_index", windowState.displayIndex ); + windowState.maximized = mState.getValueB( "window", "maximized", windowState.maximized ); + processTableState = mState.getValue( "process_table", "state", processTableState ); +} + +bool AppConfig::saveWindowState() { + if ( !FileSystem::isDirectory( mConfigPath ) && !FileSystem::makeDir( mConfigPath, true ) ) + return false; + + mState.setValueI( "window", "width", windowState.size.getWidth() ); + mState.setValueI( "window", "height", windowState.size.getHeight() ); + mState.setValueI( "window", "x", windowState.position.x ); + mState.setValueI( "window", "y", windowState.position.y ); + mState.setValueI( "window", "display_index", windowState.displayIndex ); + mState.setValueB( "window", "maximized", windowState.maximized ); + mState.setValue( "process_table", "state", processTableState ); + return mState.writeFile(); +} + +void AppConfig::captureWindowState( EE::Window::Window* window ) { + if ( !window ) + return; + + windowState.size = Sys::getPlatformType() == Sys::PlatformType::macOS + ? window->getSizeInScreenCoordinates() + : window->getLastWindowedSizeInScreenCoordinates(); + windowState.position = window->getPosition(); + windowState.position.x = eemax( 0, windowState.position.x ); + windowState.position.y = eemax( 0, windowState.position.y ); + windowState.displayIndex = window->getCurrentDisplayIndex(); + windowState.maximized = window->isMaximized(); +} + +} // namespace eproc diff --git a/src/tools/eproc/appconfig.hpp b/src/tools/eproc/appconfig.hpp new file mode 100644 index 000000000..246d0c2e7 --- /dev/null +++ b/src/tools/eproc/appconfig.hpp @@ -0,0 +1,46 @@ +#ifndef EPROC_APPCONFIG_HPP +#define EPROC_APPCONFIG_HPP + +#include +#include +#include +#include + +#include + +using namespace EE; +using namespace EE::Math; +using namespace EE::System; +using namespace EE::Window; + +namespace eproc { + +struct WindowStateConfig { + Sizei size{ 1280, 720 }; + Vector2i position{ -1, -1 }; + int displayIndex{ 0 }; + bool maximized{ false }; +}; + +class AppConfig { + public: + explicit AppConfig( std::string configPath ); + + void load(); + bool saveWindowState(); + void captureWindowState( EE::Window::Window* window ); + + const std::string& getConfigPath() const { return mConfigPath; } + + WindowStateConfig windowState; + // Serialized process table columns, widths, and sorting state. + std::string processTableState; + + private: + std::string mConfigPath; + IniFile mState; +}; + +} // namespace eproc + +#endif // EPROC_APPCONFIG_HPP diff --git a/src/tools/eproc/eproc.cpp b/src/tools/eproc/eproc.cpp new file mode 100644 index 000000000..0fc6a0e2c --- /dev/null +++ b/src/tools/eproc/eproc.cpp @@ -0,0 +1,963 @@ +#include "eproc.hpp" + +#include +#include +#include +#include + +#include +#include +#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_MACOS || \ + EE_PLATFORM == EE_PLATFORM_BSD +#include +#endif +#if EE_PLATFORM == EE_PLATFORM_LINUX +#include +#endif + +namespace eproc { + +namespace { + +constexpr int kProcessTableStateVersion = 3; +constexpr int kPreviousProcessTableStateVersion = 2; +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, +} }; + +const char* sortOrderName( SortOrder order ) { + switch ( order ) { + case SortOrder::Ascending: + return "ascending"; + case SortOrder::Descending: + return "descending"; + default: + return "none"; + } +} + +bool parseSortOrder( const std::string& value, SortOrder& order ) { + if ( value == "ascending" ) { + order = SortOrder::Ascending; + return true; + } + if ( value == "descending" ) { + order = SortOrder::Descending; + return true; + } + 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() != ProcessModel::ColCount ) + return false; + + nlohmann::json remappedWidths = nlohmann::json::array(); + for ( size_t column = 0; column < ProcessModel::ColCount; ++column ) + remappedWidths.push_back( 0 ); + for ( size_t previousColumn = 0; previousColumn < ProcessModel::ColCount; ++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 ) < ProcessModel::ColCount ) + 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 ) < ProcessModel::ColCount ) + state["sort"]["column"] = + remapPreviousProcessTableColumn( static_cast( previousColumn ) ); + } + + state["version"] = kProcessTableStateVersion; + return true; +} + +bool isCurrentUser( const ProcessInfo& process ) { +#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_MACOS || \ + EE_PLATFORM == EE_PLATFORM_BSD + return process.uid == static_cast( getuid() ); +#else + return false; +#endif +} + +const char* usernameClass( const ProcessInfo& process ) { + if ( process.status == ProcessStatus::Ended ) + return "eproc-process-username-ended"; + if ( process.tracerPid > 0 ) + return "eproc-process-username-traced"; + if ( isCurrentUser( process ) ) + return "eproc-process-username-own"; + if ( process.uid < 100 || !process.canLogin ) + return "eproc-process-username-system"; + return "eproc-process-username-other"; +} + +} // namespace + +App::App() { + mConfig = std::make_unique( Sys::getConfigPath( "eproc" ) ); + mConfig->load(); + + // Scanning /proc takes long enough to race the first frame, which would leave the table briefly + // empty. Kick the worker off before the window exists so the first snapshot is already staged + // by the time the scene is rendered. + startCollection(); + + const Sizei storedSize = mConfig->windowState.size; + const Uint32 windowWidth = + storedSize.getWidth() > 0 ? static_cast( storedSize.getWidth() ) : 1280; + const Uint32 windowHeight = + storedSize.getHeight() > 0 ? static_cast( storedSize.getHeight() ) : 720; + WindowSettings ws( windowWidth, windowHeight, "", WindowStyle::Default, WindowBackend::Default, + 32, Sys::getProcessPath() + "assets/icon/ee.png" ); + mApp = std::make_unique( ws ); + if ( mApp->getUI() && mApp->getWindow() ) + mApp->getWindow()->setTitle( + mApp->getUI()->i18n( "eproc_window_title", "System Monitor" ) ); +} + +App::~App() {} + +int App::run() { + if ( !init() ) + return EXIT_FAILURE; + const int result = mApp->run(); + if ( !mWindowStateSaved ) + saveWindowState(); + return result; +} + +bool App::init() { + auto* ui = mApp->getUI(); + if ( !ui ) + return false; + + restoreWindowState(); + if ( mApp->getWindow() ) { + mApp->getWindow()->setCloseRequestCallback( + [this]( EE::Window::Window* window ) { return closeWindow( window ); } ); + mApp->getWindow()->setQuitCallback( [this]( EE::Window::Window* window ) { + if ( window->isOpen() && closeWindow( window ) ) + window->close(); + } ); + } + + mRoot = ui->loadLayoutFromString( R"xml( + + + + + + + + + + + + + + + + + + + + + + + + + + )xml" ); + + if ( !mRoot ) { + return false; + } + + setupUI(); + setupProcessTable(); + +#if EE_PLATFORM != EE_PLATFORM_LINUX && EE_PLATFORM != EE_PLATFORM_BSD + UIMessageBox* platformMessage = UIMessageBox::New( + UIMessageBox::OK, + ui->i18n( "eproc_platform_wip_message", + "eproc is currently a work in progress. This operating system is not implemented " + "yet." ) ); + platformMessage->setTitle( ui->i18n( "eproc_platform_wip_title", "Work in Progress" ) ); + platformMessage->center(); + platformMessage->showWhenReady(); +#endif + + // 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 ); + publishStagedSnapshot(); + + setupRefreshTimer(); + + return true; +} + +void App::restoreWindowState() { + if ( !mConfig || !mApp || !mApp->getWindow() ) + return; + + EE::Window::Window* window = mApp->getWindow(); + DisplayManager* displayManager = Engine::instance()->getDisplayManager(); + const auto& state = mConfig->windowState; + if ( state.position != Vector2i( -1, -1 ) && displayManager && state.displayIndex >= 0 && + state.displayIndex < displayManager->getDisplayCount() ) { + window->setPosition( state.position.x + ( state.maximized ? -1 : 0 ), state.position.y ); + } + +#if EE_PLATFORM != EE_PLATFORM_EMSCRIPTEN + if ( state.maximized ) { +#if EE_PLATFORM == EE_PLATFORM_LINUX + mApp->getUI()->runOnMainThread( [window] { window->maximize(); } ); +#elif EE_PLATFORM != EE_PLATFORM_MACOS + window->maximize(); +#endif + } +#endif +} + +void App::saveWindowState() { + if ( !mConfig || !mApp ) + return; + + if ( mTableView && mSortProxy ) + mConfig->processTableState = serializeProcessTableState(); + mConfig->captureWindowState( mApp->getWindow() ); + if ( !mConfig->saveWindowState() ) + Log::error( "Could not save eproc window state to %s", mConfig->getConfigPath() ); + else + mWindowStateSaved = true; +} + +std::string App::serializeProcessTableState() const { + if ( !mTableView || !mSortProxy ) + return {}; + + nlohmann::json state; + state["version"] = kProcessTableStateVersion; + state["widths"] = mTableView->serializeColumnWidths(); + state["hidden_columns"] = nlohmann::json::array(); + for ( size_t column = 0; column < mSortProxy->columnCount(); ++column ) { + if ( mTableView->isColumnHidden( column ) ) + state["hidden_columns"].push_back( column ); + } + state["sort"]["column"] = mSortProxy->keyColumn(); + state["sort"]["order"] = sortOrderName( mSortProxy->sortOrder() ); + return state.dump(); +} + +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() ) + return; + + const int stateVersion = state["version"].get(); + if ( stateVersion == kPreviousProcessTableStateVersion ) { + if ( !migratePreviousProcessTableState( state ) ) + return; + } else if ( stateVersion != kProcessTableStateVersion ) { + return; + } + + const size_t columnCount = mSortProxy->columnCount(); + if ( !state.contains( "widths" ) || + !isValidProcessTableWidthState( state["widths"], columnCount, *mTableView ) ) + return; + + if ( state.contains( "hidden_columns" ) && state["hidden_columns"].is_array() ) { + std::vector hidden( columnCount, false ); + for ( const auto& column : state["hidden_columns"] ) { + if ( !column.is_number_integer() ) + continue; + const Int64 index = column.get(); + if ( index >= 0 && static_cast( index ) < columnCount ) + hidden[static_cast( index )] = true; + } + + std::vector visible; + visible.reserve( columnCount ); + for ( size_t column = 0; column < columnCount; ++column ) { + if ( !hidden[column] ) + visible.push_back( column ); + } + // Always leave one column visible, even if a malformed or old state hides everything. + if ( !visible.empty() ) + mTableView->setColumnsVisible( visible ); + } + + // Pixel restoration calls setColumnWidth() once per column. Automatic sizing must already be + // disabled, otherwise each call immediately recalculates all columns and overwrites the saved + // width with content-based sizing. + const bool pixelWidths = state["widths"]["mode"] == "pixels"; + if ( pixelWidths ) + mTableView->setAutoColumnsWidth( false ); + if ( !mTableView->unserializeColumnWidths( state["widths"] ) && pixelWidths ) + mTableView->setAutoColumnsWidth( true ); + + if ( state.contains( "sort" ) && state["sort"].is_object() ) { + const auto& sort = state["sort"]; + const int column = sort.contains( "column" ) && sort["column"].is_number_integer() + ? sort["column"].get() + : -1; + const std::string orderName = sort.contains( "order" ) && sort["order"].is_string() + ? sort["order"].get() + : "none"; + SortOrder order = SortOrder::None; + if ( column >= 0 && static_cast( column ) < columnCount && + parseSortOrder( orderName, order ) && order != SortOrder::None && + mSortProxy->isColumnSortable( column ) ) + mTableView->sortByColumn( static_cast( column ), order ); + } +} + +bool App::closeWindow( EE::Window::Window* ) { + saveWindowState(); + return true; +} + +void App::setupUI() { + mTabWidget = mRoot->find( "tab_widget" ); + mEndProcessBtn = mRoot->find( "end_process_btn" ); + mSearchInput = mRoot->find( "search_input" ); + mFilterDropdown = mRoot->find( "filter_dropdown" ); + mTableView = mRoot->find( "process_table" ); + mStatusText = mRoot->find( "process_count" ); + mCpuText = mRoot->find( "cpu_text" ); + mMemText = mRoot->find( "mem_text" ); + mSwapText = mRoot->find( "swap_text" ); + + auto* ui = mApp->getUI(); + mRoot->find( "tab_process_table" ) + ->setText( ui->i18n( "eproc_process_table_tab", "Process Table" ) ); + mEndProcessBtn->setText( ui->i18n( "eproc_end_process_button", "End Process..." ) ); + mSearchInput->setHint( ui->i18n( "eproc_quick_search_hint", "Quick search" ) ); + mStatusText->setText( ui->i18n( "eproc_process_count", "0 processes" ) ); + mCpuText->setText( ui->i18n( "eproc_cpu_status", "CPU: 0%" ) ); + mMemText->setText( ui->i18n( "eproc_memory_status", "Memory: 0 / 0" ) ); + mSwapText->setText( ui->i18n( "eproc_swap_status", "Swap: 0 / 0" ) ); + + // Tabs are declared in the XML layout via Tab elements with owns= attributes. + + // Filter entries mirror the original's ProcessFilter::State order (flat variants only; the + // tree variants need a hierarchical model). + static const std::pair filters[] = { + { "eproc_filter_all_processes", "All Processes" }, + { "eproc_filter_system_processes", "System Processes" }, + { "eproc_filter_user_processes", "User Processes" }, + { "eproc_filter_own_processes", "Own Processes" }, + { "eproc_filter_programs_only", "Programs Only" }, + }; + auto* filterListBox = mFilterDropdown->getListBox(); + for ( const auto& filter : filters ) + filterListBox->addListBoxItem( ui->i18n( filter.first, filter.second ) ); + filterListBox->setSelected( 0 ); + + // Connect events + if ( mEndProcessBtn ) + mEndProcessBtn->onClick( [this]( const MouseEvent* ) { onEndProcess(); } ); + + if ( mSearchInput ) + mSearchInput->on( Event::OnTextChanged, [this]( const Event* ) { onSearchChanged(); } ); + + if ( mFilterDropdown ) + mFilterDropdown->on( Event::OnItemSelected, [this]( const Event* ) { onFilterChanged(); } ); + + mApp->getUI()->on( Event::KeyUp, [this]( const Event* event ) { + if ( event->asKeyEvent()->getKeyCode() == KEY_F11 ) { + UIWidgetInspector::create( mApp->getUI() ); + } + } ); +} + +void App::setupProcessTable() { + mProcessModel = ProcessModel::create( mApp->getUI() ); + mSortProxy = SortingProxyModel::New( mProcessModel ); + + if ( mTableView ) { + mTableView->setModel( mSortProxy ); + mTableView->setColumnsHidden( + std::vector( kOptionalProcessColumns.begin(), kOptionalProcessColumns.end() ), + true ); + mTableView->setOnUpdateCellCb( [this]( UITableCell* cell, Model* model ) { + if ( !cell || !model ) + return; + + const ModelIndex index = cell->getCurIndex(); + const ProcessInfo* process = processForProxyIndex( index ); + const Variant columnClass = model->data( index, ModelRole::Class ); + std::vector classes; + if ( columnClass.isValid() ) + classes.emplace_back( columnClass.toString() ); + if ( process ) { + if ( process->status == ProcessStatus::Ended ) + classes.emplace_back( "eproc-process-ended" ); + if ( index.column() == ProcessModel::ColUsername ) + classes.emplace_back( usernameClass( *process ) ); + } + cell->setClasses( classes ); + } ); + mTableView->setRowHeight( 28 ); + // The flexible column is Name; the icon column is fixed so every row lines up. + mTableView->setMainColumn( ProcessModel::ColName ); + mTableView->setSortIconSize( 12 ); + mTableView->setIconSize( PixelDensity::dpToPxI( 16 ) ); + mTableView->setColumnWidth( ProcessModel::ColIcon, PixelDensity::dpToPx( 26 ) ); + + mTableView->setOnSelectionChange( [this]() { onSelectionChange(); } ); + mTableView->onModelEvent( [this]( const ModelEvent* event ) { + if ( event->getModelEventType() == ModelEventType::OpenMenu ) + showProcessContextMenu( event->getModelIndex() ); + } ); + } + + // The original opens sorted by memory usage, descending, which surfaces the heavy processes + // instead of the kernel threads that /proc happens to enumerate first. Sorting through the + // view (not the model directly) also renders the sort indicator in the header. + mTableView->sortByColumn( ProcessModel::ColMemory, SortOrder::Descending ); +} + +void App::startCollection() { + mCollector = ProcessCollector::create(); + if ( !mCollector ) { + Log::error( "eproc: no process collector for this platform, the table will stay empty" ); + return; + } + + // A single worker keeps the collector's previous-sample state exclusive to one thread, and + // keeps /proc walking off the UI thread entirely. + // + // terminateOnClose must stay false: the pool destructor then joins the worker, which + // guarantees no in-flight collect() can outlive mCollector (destroyed after mThreadPool, + // since members are destroyed in reverse declaration order). + mThreadPool = ThreadPool::createShared( 1, false ); + + // 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(); + collectAsync(); +} + +void App::setupRefreshTimer() { + if ( !mCollector ) + return; + + // The worker finishes a snapshot long before the next sample is due, so the tick only polls + // for staged results and dispatches a new sample once the refresh period has elapsed. + // Publishing on the same 2s cadence would leave the table empty until the second tick. + 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 ( mDispatchClock.getElapsedTime().asMilliseconds() < mUpdateIntervalMs ) + return; + + mDispatchClock.getElapsedTimeAndReset(); + collectAsync(); +} + +void App::collectAsync() { + if ( !mCollector || !mThreadPool ) + return; + + // Never overlap collections: CPU usage is computed from the delta against the instance's + // previous sample, so concurrent runs would corrupt it. + if ( mCollectInFlight.exchange( true ) ) + return; + + mThreadPool->run( [this] { + std::vector processes; + SystemInfo sysInfo; + + if ( mCollector->collect( processes, sysInfo ) ) { + Lock lock( mStagingMutex ); + mStagedProcesses = std::move( processes ); + mStagedSystemInfo = sysInfo; + mStagedReady = true; + } + + mCollectInFlight.store( false ); + } ); +} + +void App::publishStagedSnapshot() { + std::vector processes; + SystemInfo sysInfo; + + { + Lock lock( mStagingMutex ); + if ( !mStagedReady ) + return; + processes = std::move( mStagedProcesses ); + sysInfo = mStagedSystemInfo; + mStagedReady = 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 ) { + mGuiWindows.refresh(); + mProcessModel->setGuiWindowPids( UnorderedSet( mGuiWindows.windowPids() ) ); + } + + // A full model reset clears the view's selection (SortingProxyModel drops it on every + // invalidation), so the chosen process is remembered by PID and re-selected afterwards. PIDs + // are stable across snapshots while row indexes are not: the table re-sorts on every update. + long selectedPid = getSelectedPid(); + + if ( mProcessModel ) + mProcessModel->applySnapshot( std::move( processes ), sysInfo ); + + if ( !mProcessTableStateRestored && !mProcessTableStateRestoreScheduled && mApp && + mApp->getUI() ) { + mProcessTableStateRestoreScheduled = true; + mApp->getUI()->runOnMainThread( [this] { + if ( !mProcessTableStateRestored ) { + restoreProcessTableState(); + mProcessTableStateRestored = true; + } + mProcessTableStateRestoreScheduled = false; + } ); + } + + updateStatusBar(); + restoreSelection( selectedPid ); +} + +long App::getSelectedPid() const { + if ( !mTableView || !mProcessModel ) + return -1; + + ModelIndex proxyIndex = mTableView->getSelection().first(); + if ( !proxyIndex.isValid() ) + return -1; + + ModelIndex sourceIndex = mSortProxy ? mSortProxy->mapToSource( proxyIndex ) : proxyIndex; + const ProcessInfo* proc = mProcessModel->getProcessByRow( sourceIndex.row() ); + return proc ? proc->pid : -1; +} + +void App::restoreSelection( long pid ) { + if ( pid < 0 || !mTableView || !mProcessModel || !mSortProxy ) + return; + + int row = mProcessModel->rowForPid( pid ); + if ( row < 0 ) + return; // the process exited, or the filter no longer matches it + + ModelIndex proxyIndex = mSortProxy->mapToProxy( mProcessModel->index( row, 0 ) ); + if ( proxyIndex.isValid() ) + mTableView->setSelection( proxyIndex, false ); +} + +void App::updateStatusBar() { + if ( !mProcessModel ) + return; + + const auto& sys = mProcessModel->getSystemInfo(); + + if ( mStatusText ) + mStatusText->setText( String::format( + mApp->getUI()->i18n( "eproc_process_count_format", "%zu processes" ).toUtf8(), + mProcessModel->visibleCount() ) ); + + if ( mCpuText ) + mCpuText->setText( + String::format( mApp->getUI()->i18n( "eproc_cpu_status_format", "CPU: %d%%" ).toUtf8(), + static_cast( sys.cpuUsage ) ) ); + + if ( mMemText ) + mMemText->setText( String::format( + mApp->getUI()->i18n( "eproc_memory_status_format", "Memory: %s / %s" ).toUtf8(), + formatKiBIEC( sys.getUsedMemoryKB() ).c_str(), + formatKiBIEC( sys.getTotalMemoryKB() ).c_str() ) ); + + if ( mSwapText ) + mSwapText->setText( String::format( + mApp->getUI()->i18n( "eproc_swap_status_format", "Swap: %s / %s" ).toUtf8(), + formatKiBIEC( sys.getUsedSwapKB() ).c_str(), formatKiBIEC( sys.totalSwap ).c_str() ) ); +} + +void App::onEndProcess() { +#if EE_PLATFORM == EE_PLATFORM_LINUX + requestSignal( selectedPids(), SIGTERM, + mApp->getUI()->i18n( "eproc_end_process", "End Process" ).toUtf8(), true ); +#endif +} + +void App::onSearchChanged() { + if ( mSearchInput && mProcessModel ) { + mProcessModel->setTextFilter( mSearchInput->getText().toUtf8() ); + updateStatusBar(); + } +} + +void App::onFilterChanged() { + if ( mFilterDropdown && mProcessModel ) { + // The dropdown is built in the same order as the enum, so the index maps directly. + Uint32 selected = mFilterDropdown->getListBox()->getItemSelectedIndex(); + if ( selected < ProcessModel::FilterModeCount ) + mProcessModel->setFilter( static_cast( selected ) ); + updateStatusBar(); + } +} + +void App::onSelectionChange() { + // Update End Process button state + if ( mEndProcessBtn && mTableView ) { + mEndProcessBtn->setEnabled( !selectedPids().empty() ); + } +} + +const ProcessInfo* App::processForProxyIndex( const ModelIndex& proxyIndex ) const { + if ( !mProcessModel || !mSortProxy || !proxyIndex.isValid() ) + return nullptr; + + return mProcessModel->getProcessByRow( mSortProxy->mapToSource( proxyIndex ).row() ); +} + +std::vector App::selectedPids() const { + std::vector pids; + + if ( !mProcessModel || !mTableView ) + return pids; + + for ( const auto& proxyIndex : mTableView->getSelection().indexes() ) { + const ProcessInfo* proc = processForProxyIndex( proxyIndex ); + if ( proc && proc->status != ProcessStatus::Ended ) + pids.push_back( proc->pid ); + } + + return pids; +} + +void App::selectProcess( long pid ) { + if ( pid <= 0 || !mProcessModel || !mSortProxy || !mTableView ) + return; + + int row = mProcessModel->rowForPid( pid ); + + // The active filter may be hiding the target, so drop it instead of silently doing nothing + // (the original clears its text filter for the same reason). + if ( row < 0 && mSearchInput && !mSearchInput->getText().empty() ) { + mProcessModel->setTextFilter( "" ); + mSearchInput->setText( "" ); + row = mProcessModel->rowForPid( pid ); + } + + if ( row < 0 ) + return; + + ModelIndex proxyIndex = mSortProxy->mapToProxy( mProcessModel->index( row, 0 ) ); + if ( proxyIndex.isValid() ) + mTableView->setSelection( proxyIndex ); +} + +void App::requestSignal( std::vector pids, int signal, const std::string& actionLabel, + bool confirm ) { + if ( pids.empty() ) + return; + + auto send = [pids, signal]() { + for ( long pid : pids ) + sendProcessSignal( pid, signal ); + }; + + if ( !confirm ) { + send(); + return; + } + + const std::string target = + pids.size() == 1 + ? String::format( mApp->getUI()->i18n( "eproc_process_target", "process %ld" ).toUtf8(), + pids.front() ) + : String::format( + mApp->getUI()->i18n( "eproc_processes_target", "%zu processes" ).toUtf8(), + pids.size() ); + const std::string message = + String::format( mApp->getUI()->i18n( "eproc_confirm_action", "%s %s?" ).toUtf8(), + actionLabel.c_str(), target.c_str() ); + + UIMessageBox* box = UIMessageBox::New( UIMessageBox::OK_CANCEL, message ); + box->setTitle( actionLabel ); + box->on( Event::OnConfirm, [send]( const Event* ) { send(); } ); + box->center(); + box->showWhenReady(); +} + +void App::showProcessContextMenu( const ModelIndex& proxyIndex ) { + if ( !mTableView ) + return; + + // Right-clicking outside the selection moves the selection to the clicked row, as the + // original does, so the menu always acts on what the user pointed at. + if ( proxyIndex.isValid() && !mTableView->getSelection().contains( proxyIndex ) ) + mTableView->setSelection( proxyIndex, false ); + + const std::vector pids = selectedPids(); + if ( pids.empty() ) + return; + + const ProcessInfo* proc = processForProxyIndex( proxyIndex ); + const long parentPid = proc ? proc->parentPid : 0; + const long tracerPid = proc ? proc->tracerPid : 0; + std::string copyCommandLine = proc ? proc->commandLine : std::string(); + + struct SignalItem { + const char* id; + const char* key; + const char* label; + int signal; + }; + +// The same signal set the original offers, in the same order. +#if EE_PLATFORM == EE_PLATFORM_LINUX + static const std::array signalItems = { { + { "signal-stop", "eproc_signal_suspend", "Suspend (STOP)", SIGSTOP }, + { "signal-cont", "eproc_signal_continue", "Continue (CONT)", SIGCONT }, + { "signal-hup", "eproc_signal_hangup", "Hangup (HUP)", SIGHUP }, + { "signal-int", "eproc_signal_interrupt", "Interrupt (INT)", SIGINT }, + { "signal-term", "eproc_signal_terminate", "Terminate (TERM)", SIGTERM }, + { "signal-kill", "eproc_signal_kill", "Kill (KILL)", SIGKILL }, + { "signal-usr1", "eproc_signal_user1", "User 1 (USR1)", SIGUSR1 }, + { "signal-usr2", "eproc_signal_user2", "User 2 (USR2)", SIGUSR2 }, + } }; +#else + static const std::array signalItems{}; +#endif + + UIPopUpMenu* signalMenu = UIPopUpMenu::New(); + signalMenu->setId( "process_signal_menu" ); + for ( const auto& item : signalItems ) + signalMenu->add( mApp->getUI()->i18n( item.key, item.label ) )->setId( item.id ); + + UIPopUpMenu* menu = UIPopUpMenu::New(); + menu->setId( "process_context_menu" ); + menu->addSubMenu( mApp->getUI()->i18n( "eproc_send_signal", "Send Signal" ), nullptr, + signalMenu ) + ->setId( "send-signal" ); + menu->add( mApp->getUI()->i18n( "eproc_jump_to_parent", "Jump to Parent Process" ) ) + ->setId( "jump-parent" ); + + if ( tracerPid > 0 ) + menu->add( mApp->getUI()->i18n( "eproc_jump_to_tracer", + "Jump to Process Debugging This One" ) ) + ->setId( "jump-tracer" ); + + menu->add( mApp->getUI()->i18n( "eproc_copy_command_line", "Copy Command Line" ) ) + ->setId( "copy-command-line" ); + +#if EE_PLATFORM == EE_PLATFORM_LINUX + menu->addSeparator(); + menu->add( mApp->getUI()->i18n( "eproc_end_process", "End Process" ) )->setId( "end-process" ); + menu->add( mApp->getUI()->i18n( "eproc_forcibly_kill_process", "Forcibly Kill Process" ) ) + ->setId( "kill-process" ); +#endif + + menu->on( Event::OnItemClicked, [this, pids, parentPid, tracerPid, + copyCommandLine = + std::move( copyCommandLine )]( const Event* event ) { + UIMenuItem* item = event->getNode()->asType(); + if ( !item ) + return; + + const std::string id( item->getId() ); + + if ( id == "jump-parent" ) { + selectProcess( parentPid ); + } else if ( id == "jump-tracer" ) { + selectProcess( tracerPid ); + } else if ( id == "copy-command-line" ) { + if ( !copyCommandLine.empty() && mApp->getWindow()->getClipboard() ) + mApp->getWindow()->getClipboard()->setText( copyCommandLine ); +#if EE_PLATFORM == EE_PLATFORM_LINUX + } else if ( id == "end-process" ) { + requestSignal( pids, SIGTERM, + mApp->getUI()->i18n( "eproc_end_process", "End Process" ).toUtf8(), + true ); + } else if ( id == "kill-process" ) { + requestSignal( pids, SIGKILL, + mApp->getUI() + ->i18n( "eproc_forcibly_kill_process", "Forcibly Kill Process" ) + .toUtf8(), + true ); +#endif + } else { + // Signals picked explicitly from the submenu are sent straight away: the original + // only asks for confirmation on End Process and Forcibly Kill. + for ( const auto& sig : signalItems ) { + if ( id == sig.id ) { + requestSignal( + pids, sig.signal, + mApp->getUI()->i18n( "eproc_send_signal", "Send Signal" ).toUtf8(), false ); + break; + } + } + } + } ); + + Vector2f pos( mApp->getWindow()->getInput()->getMousePos().asFloat() ); + menu->nodeToWorldTranslation( pos ); + UIMenu::findBestMenuPos( pos, menu ); + menu->setPixelsPosition( pos ); + menu->show(); +} + +} // namespace eproc + +EE_MAIN_FUNC int main( int, char*[] ) { + eproc::App app; + return app.run(); +} diff --git a/src/tools/eproc/eproc.hpp b/src/tools/eproc/eproc.hpp new file mode 100644 index 000000000..fdcb7f8fc --- /dev/null +++ b/src/tools/eproc/eproc.hpp @@ -0,0 +1,157 @@ +#ifndef EPROC_HPP +#define EPROC_HPP + +#include "appconfig.hpp" +#include "gui_window_tracker.hpp" +#include "process_model.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::System; +using namespace EE::UI; +using namespace EE::UI::Models; + +namespace eproc { + +class App { + public: + App(); + ~App(); + + int run(); + + private: + bool init(); + void setupUI(); + void setupProcessTable(); + + /** Restores the saved display, position, size, and maximized state. */ + void restoreWindowState(); + + /** Captures and writes the current window state. */ + void saveWindowState(); + + /** Restores the process table's columns, widths, and sorting state. */ + void restoreProcessTableState(); + + /** Serializes the process table's columns, widths, and sorting state. */ + std::string serializeProcessTableState() const; + + /** Saves the state before the primary window is closed. */ + bool closeWindow( EE::Window::Window* window ); + + /** Creates the collector and worker, and dispatches the first sample. Called from the + * constructor so collection overlaps window creation. */ + void startCollection(); + + /** 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(); + + /** UI-thread tick: publishes any staged snapshot, then schedules the next collection. */ + void onRefreshTick(); + + /** Moves a staged snapshot into the model, preserving the selected process by PID. UI thread + * only. */ + void publishStagedSnapshot(); + + /** PID of the currently selected process, or -1. */ + long getSelectedPid() const; + + /** Re-selects the process with @p pid after a snapshot, so a refresh is invisible to the + * user. Does nothing when that process is gone. */ + void restoreSelection( long pid ); + + void updateStatusBar(); + void onEndProcess(); + void onSearchChanged(); + void onFilterChanged(); + void onSelectionChange(); + + /** Builds and shows the process context menu for a right-clicked row. */ + void showProcessContextMenu( const ModelIndex& proxyIndex ); + + /** PIDs of the currently selected rows. */ + std::vector selectedPids() const; + + /** Maps a proxy index to the process behind it, or nullptr. */ + const ProcessInfo* processForProxyIndex( const ModelIndex& proxyIndex ) const; + + /** Sends @p signal to every pid in @p pids, asking for confirmation first when @p confirm. */ + void requestSignal( std::vector pids, int signal, const std::string& actionLabel, + bool confirm ); + + /** Selects the row holding @p pid and scrolls it into view. */ + void selectProcess( long pid ); + + std::unique_ptr mConfig; + std::unique_ptr mApp; + UIWidget* mRoot{ nullptr }; + UITabWidget* mTabWidget{ nullptr }; + + UIWidget* mProcessTableLayout{ nullptr }; + UIPushButton* mEndProcessBtn{ nullptr }; + UITextInput* mSearchInput{ nullptr }; + UIDropDownList* mFilterDropdown{ nullptr }; + UITableView* mTableView{ nullptr }; + UITextView* mStatusText{ nullptr }; + UITextView* mCpuText{ nullptr }; + UITextView* mMemText{ nullptr }; + UITextView* mSwapText{ nullptr }; + + std::shared_ptr mProcessModel; + std::shared_ptr mSortProxy; + bool mProcessTableStateRestored{ false }; + bool mProcessTableStateRestoreScheduled{ false }; + bool mWindowStateSaved{ false }; + + // Window ownership backs the Programs Only filter. Refreshed on the UI thread, since Xlib is + // not safe to drive from the collection worker. + GuiWindowTracker mGuiWindows; + + // Collection runs on a worker thread so the render loop never blocks on /proc. The worker + // owns mCollector exclusively; the UI thread only ever reads staged snapshots. + std::unique_ptr mCollector; + std::shared_ptr mThreadPool; + std::atomic mCollectInFlight{ false }; + Mutex mStagingMutex; + std::vector mStagedProcesses; + SystemInfo mStagedSystemInfo; + bool mStagedReady{ false }; + + Uint32 mUpdateIntervalMs{ 1000 }; + + // The publish poll runs much faster than the sampling cadence so a finished snapshot reaches + // the table immediately instead of waiting for the next sample tick. + Uint32 mTickIntervalMs{ 100 }; + Clock mDispatchClock; +}; + +} // namespace eproc + +#endif // EPROC_HPP diff --git a/src/tools/eproc/gui_window_tracker.cpp b/src/tools/eproc/gui_window_tracker.cpp new file mode 100644 index 000000000..99f110d33 --- /dev/null +++ b/src/tools/eproc/gui_window_tracker.cpp @@ -0,0 +1,242 @@ +#include "gui_window_tracker.hpp" + +#include +#include + +// Which processes own a top-level window is read through a backend selected at runtime, so the +// class keeps the very same interface whether or not X11 is available on the platform. +// +// - X11: the only backend implemented today. It relies on an X11 session, reading the EWMH +// properties the running window manager publishes on the root window. On Wayland it therefore +// only ever sees XWayland clients, since a native Wayland compositor does not expose its +// toplevels over X11. A Wayland backend would have to speak a compositor protocol instead +// (wlr-foreign-toplevel-management, or KDE's plasma-window-management) and is not implemented +// yet. +// - Anywhere else: the stub backend at the bottom of this file, which no X11 header or library is +// needed for. +// +// The X11 backend is compiled where eepp's config found the X11 headers and loads libX11 at +// runtime, which keeps the eproc project independent of an installed X11 runtime. +#if EE_PLATFORM == EE_PLATFORM_LINUX && defined( EE_X11_PLATFORM ) + +#include +#include + +#endif // EE_PLATFORM == EE_PLATFORM_LINUX && defined( EE_X11_PLATFORM ) + +using namespace EE::System; + +namespace eproc { + +#if EE_PLATFORM == EE_PLATFORM_LINUX && defined( EE_X11_PLATFORM ) + +// X11 backend ----------------------------------------------------------------------------------- + +struct GuiWindowTracker::X11Api { + using OpenDisplayFn = Display* ( * )( const char* ); + using CloseDisplayFn = int ( * )( Display* ); + using DefaultRootWindowFn = Window ( * )( Display* ); + using InternAtomFn = Atom ( * )( Display*, const char*, Bool ); + using GetWindowPropertyFn = int ( * )( Display*, Window, Atom, long, long, Bool, Atom, Atom*, + int*, unsigned long*, unsigned long*, unsigned char** ); + using FreeFn = int ( * )( void* ); + using SyncFn = int ( * )( Display*, Bool ); + using SetErrorHandlerFn = XErrorHandler ( * )( XErrorHandler ); + + void* library{ nullptr }; + OpenDisplayFn openDisplay{ nullptr }; + CloseDisplayFn closeDisplay{ nullptr }; + DefaultRootWindowFn defaultRootWindow{ nullptr }; + InternAtomFn internAtom{ nullptr }; + GetWindowPropertyFn getWindowProperty{ nullptr }; + FreeFn freeMemory{ nullptr }; + SyncFn sync{ nullptr }; + SetErrorHandlerFn setErrorHandler{ nullptr }; + + ~X11Api() { + if ( library ) + Sys::unloadObject( library ); + } + + template Function resolve( const char* name ) { + return reinterpret_cast( Sys::loadFunction( library, name ) ); + } + + bool load() { + library = Sys::loadObject( "libX11.so.6" ); + if ( !library ) + library = Sys::loadObject( "libX11.so" ); + + if ( !library ) + return false; + + openDisplay = resolve( "XOpenDisplay" ); + closeDisplay = resolve( "XCloseDisplay" ); + defaultRootWindow = resolve( "XDefaultRootWindow" ); + internAtom = resolve( "XInternAtom" ); + getWindowProperty = resolve( "XGetWindowProperty" ); + freeMemory = resolve( "XFree" ); + sync = resolve( "XSync" ); + setErrorHandler = resolve( "XSetErrorHandler" ); + + return openDisplay && closeDisplay && defaultRootWindow && internAtom && + getWindowProperty && freeMemory && sync && setErrorHandler; + } +}; + +// A window list holds a handful of entries in practice. The request length is capped so that a +// malformed or hostile property can never make the monitor allocate an unbounded amount of memory. +static constexpr long MAX_PROPERTY_ITEMS = 1 << 16; + +// Xlib terminates the process on any X error it is not told about, and a window listed in +// _NET_CLIENT_LIST can be destroyed by its owner between reading the list and reading that +// window's PID, in which case XGetWindowProperty reports BadWindow for a window that no longer +// exists. That race is expected here, so BadWindow is ignored, while any other error keeps the +// behaviour of the handler installed by the application (libX11's own handler when there is none). +static XErrorHandler sPreviousErrorHandler = nullptr; + +static int ignoreMissingWindowError( Display* display, XErrorEvent* event ) { + if ( event && event->error_code == BadWindow ) + return 0; + + if ( sPreviousErrorHandler ) + return sPreviousErrorHandler( display, event ); + + return 0; +} + +// Fetches a 32-bit X property from @p window. On success returns the raw property data, to be +// released by the caller with XFree, and stores its item count in @p itemCount. Returns null - +// leaving @p itemCount at zero - when the property is missing or empty, has another type or +// format, or is longer than MAX_PROPERTY_ITEMS, in which case the reply would be truncated into an +// incomplete list. +template +static unsigned char* fetchProperty( X11Api& api, Display* display, Window window, Atom property, + Atom expectedType, unsigned long& itemCount ) { + itemCount = 0; + + Atom actualType = None; + int actualFormat = 0; + unsigned long bytesAfter = 0; + unsigned char* data = nullptr; + + if ( api.getWindowProperty( display, window, property, 0, MAX_PROPERTY_ITEMS, False, + expectedType, &actualType, &actualFormat, &itemCount, &bytesAfter, + &data ) != Success || + !data ) { + itemCount = 0; + if ( data ) + api.freeMemory( data ); + return nullptr; + } + + // A property stored under another type or format cannot be read as a list of ids. + if ( actualType != expectedType || actualFormat != 32 || itemCount == 0 || bytesAfter != 0 ) { + itemCount = 0; + api.freeMemory( data ); + return nullptr; + } + + return data; +} + +GuiWindowTracker::GuiWindowTracker() { + mX11 = new X11Api(); + if ( !mX11->load() ) { + delete mX11; + mX11 = nullptr; + return; + } + + Display* display = mX11->openDisplay( nullptr ); + if ( !display ) { + Log::warning( + "eproc: could not open an X11 display, no process will be reported as having a GUI " + "window" ); + return; + } + + mDisplay = display; + mRootWindow = mX11->defaultRootWindow( display ); +} + +GuiWindowTracker::~GuiWindowTracker() { + if ( mDisplay ) + mX11->closeDisplay( static_cast( mDisplay ) ); + + delete mX11; +} + +void GuiWindowTracker::refresh() { + mPids.clear(); + + Display* display = static_cast( mDisplay ); + if ( !display ) + return; + + Atom clientListAtom = mX11->internAtom( display, "_NET_CLIENT_LIST", True ); + Atom pidAtom = mX11->internAtom( display, "_NET_WM_PID", True ); + if ( clientListAtom == None || pidAtom == None ) + return; + + XErrorHandler previousHandler = mX11->setErrorHandler( ignoreMissingWindowError ); + + unsigned long windowCount = 0; + unsigned long* windows = reinterpret_cast( + fetchProperty( *mX11, display, mRootWindow, clientListAtom, XA_WINDOW, windowCount ) ); + + if ( windows ) { + for ( unsigned long i = 0; i < windowCount; i++ ) { + if ( windows[i] == None ) + continue; + + unsigned long pidCount = 0; + unsigned long* pid = reinterpret_cast( + fetchProperty( *mX11, display, windows[i], pidAtom, XA_CARDINAL, pidCount ) ); + + if ( pid ) { + if ( pid[0] > 0 ) + mPids.insert( static_cast( pid[0] ) ); + mX11->freeMemory( pid ); + } + } + + mX11->freeMemory( windows ); + } + + // Flush the requests issued above so that the errors they produced are handled here instead of + // reaching the application's handler once the previous one is restored. + mX11->sync( display, False ); + mX11->setErrorHandler( previousHandler ); +} + +bool GuiWindowTracker::isAvailable() const { + return mDisplay != nullptr; +} + +#else // X11 backend + +// Stub backend --------------------------------------------------------------------------------- + +// No window list can be read on this platform, so a tracker always reports itself unavailable and +// never lists a window. The bodies below exist to keep every method defined for every build. +GuiWindowTracker::GuiWindowTracker() {} + +GuiWindowTracker::~GuiWindowTracker() {} + +void GuiWindowTracker::refresh() { + mPids.clear(); +} + +bool GuiWindowTracker::isAvailable() const { + return false; +} + +#endif // X11 backend + +// Shared by every backend: with no backend built, mPids is always empty. +bool GuiWindowTracker::hasWindowForPid( long pid ) const { + return mPids.find( pid ) != mPids.end(); +} + +} // namespace eproc diff --git a/src/tools/eproc/gui_window_tracker.hpp b/src/tools/eproc/gui_window_tracker.hpp new file mode 100644 index 000000000..034016217 --- /dev/null +++ b/src/tools/eproc/gui_window_tracker.hpp @@ -0,0 +1,57 @@ +#ifndef EPROC_GUI_WINDOW_TRACKER_HPP +#define EPROC_GUI_WINDOW_TRACKER_HPP + +#include +#include + +using namespace EE; + +namespace eproc { + +/** @brief Tracks which processes own a top-level GUI window on the current display. + * + * This answers the "does this process have a GUI window?" question used by the process list + * filters (the equivalent of ksysguard's hasGUIWindow()). The X11 backend reads the + * _NET_CLIENT_LIST property of the root window and the owner of each window from its _NET_WM_PID + * property. Both are EWMH extensions published by the running window manager, so on a display + * without a window manager, or without an X display at all, the tracker simply reports that no + * process owns a window. X11 is loaded at runtime when available, keeping it optional for + * Wayland and headless systems. Where no backend is built, the tracker compiles to a stub that + * reports itself unavailable and lists no window PIDs. + * + * The Xlib display is held as an opaque pointer, keeping this header free of X11 headers. */ +class GuiWindowTracker { + public: + GuiWindowTracker(); + ~GuiWindowTracker(); + GuiWindowTracker( const GuiWindowTracker& ) = delete; + GuiWindowTracker& operator=( const GuiWindowTracker& ) = delete; + + /** Re-reads the window list and the PIDs owning windows. MAIN THREAD ONLY. */ + void refresh(); + + /** True when the last refresh() saw a top-level window owned by @p pid. */ + bool hasWindowForPid( long pid ) const; + + /** PIDs that owned a top-level window at the last refresh(). */ + const UnorderedSet& windowPids() const { return mPids; } + + /** True when the backend connected to a display and can list the windows it owns. */ + bool isAvailable() const; + + private: + UnorderedSet mPids; + + // State of the X11 backend. Builds without X11 headers carry no state no backend could fill and + // are still fully usable: they report no window for any process. +#if EE_PLATFORM == EE_PLATFORM_LINUX && defined( EE_X11_PLATFORM ) + struct X11Api; + X11Api* mX11{ nullptr }; + void* mDisplay{ nullptr }; // Display* + unsigned long mRootWindow{ 0 }; +#endif +}; + +} // namespace eproc + +#endif // EPROC_GUI_WINDOW_TRACKER_HPP diff --git a/src/tools/eproc/platform/linux/gpu_reader_nvidia.cpp b/src/tools/eproc/platform/linux/gpu_reader_nvidia.cpp new file mode 100644 index 000000000..8c412376d --- /dev/null +++ b/src/tools/eproc/platform/linux/gpu_reader_nvidia.cpp @@ -0,0 +1,211 @@ +#include "gpu_reader_nvidia.hpp" + +#include + +#include + +using namespace EE::System; + +namespace eproc { + +namespace { + +// Return codes and sentinels mirrored from nvml.h. +constexpr int NvmlSuccess = 0; +constexpr int NvmlErrorInsufficientSize = 7; +constexpr unsigned long long NvmlValueNotAvailable = ~0ULL; + +// GPU memory is reported in bytes, the readers of this tool work in KiB. +constexpr unsigned long long BytesPerKiB = 1024; + +// Bounds the retries of the two call queries, in case a device reports more entries than the +// previous call reserved room for. +constexpr int MaxQueryAttempts = 4; + +template Fn resolveSymbol( void* lib, const char* name ) { + return reinterpret_cast( Sys::loadFunction( lib, name ) ); +} + +} // namespace + +NvidiaGpuReader::NvidiaGpuReader() { + mLib = Sys::loadObject( "libnvidia-ml.so.1" ); + if ( !mLib ) + mLib = Sys::loadObject( "libnvidia-ml.so" ); + + // No driver installed: stay unavailable, the tool keeps working without GPU information. + if ( !mLib ) + return; + + mNvmlInit = resolveSymbol( mLib, "nvmlInit" ); + mNvmlShutdown = resolveSymbol( mLib, "nvmlShutdown" ); + mNvmlDeviceGetCount = resolveSymbol( mLib, "nvmlDeviceGetCount" ); + mNvmlDeviceGetHandleByIndex = + resolveSymbol( mLib, "nvmlDeviceGetHandleByIndex" ); + mNvmlDeviceGetProcessUtilization = + resolveSymbol( mLib, "nvmlDeviceGetProcessUtilization" ); + mNvmlComputeProcesses = resolveSymbol( + mLib, "nvmlDeviceGetComputeRunningProcesses" ); + mNvmlGraphicsProcesses = resolveSymbol( + mLib, "nvmlDeviceGetGraphicsRunningProcesses" ); + // Optional entry point, drivers that do not support MPS do not export it. + mNvmlMpsComputeProcesses = resolveSymbol( + mLib, "nvmlDeviceGetMPSComputeRunningProcesses" ); + + if ( !mNvmlInit || !mNvmlShutdown || !mNvmlDeviceGetCount || !mNvmlDeviceGetHandleByIndex || + !mNvmlDeviceGetProcessUtilization || !mNvmlComputeProcesses || !mNvmlGraphicsProcesses ) + return; + + if ( mNvmlInit() != NvmlSuccess ) + return; + + mInitialized = true; +} + +NvidiaGpuReader::~NvidiaGpuReader() { + if ( mInitialized && mNvmlShutdown ) + mNvmlShutdown(); + + if ( mLib ) + Sys::unloadObject( mLib ); +} + +bool NvidiaGpuReader::isAvailable() const { + return mInitialized; +} + +void NvidiaGpuReader::query( UnorderedMap& usagePercent, + UnorderedMap& memoryKiB ) { + usagePercent.clear(); + memoryKiB.clear(); + + if ( !mInitialized ) + return; + + // The device count is read on every query, GPUs can come and go while the tool runs. + unsigned int deviceCount = 0; + if ( mNvmlDeviceGetCount( &deviceCount ) != NvmlSuccess ) + return; + + unsigned long long newestTimestamp = mLastTimestamp; + for ( unsigned int index = 0; index < deviceCount; index++ ) { + NvmlDevice device = nullptr; + // A device that cannot be queried is skipped, the remaining ones are still reported. + if ( mNvmlDeviceGetHandleByIndex( index, &device ) != NvmlSuccess || !device ) + continue; + + collectUtilization( device, usagePercent, newestTimestamp ); + collectMemory( device, memoryKiB ); + } + + // Only samples newer than this timestamp are requested on the next query. + mLastTimestamp = newestTimestamp; +} + +void NvidiaGpuReader::collectUtilization( NvmlDevice device, UnorderedMap& usagePercent, + unsigned long long& newestTimestamp ) { + // The first call only asks for the number of samples the device has since mLastTimestamp. + unsigned int count = 0; + if ( mNvmlDeviceGetProcessUtilization( device, nullptr, &count, mLastTimestamp ) != + NvmlErrorInsufficientSize || + count == 0 ) + return; + + int result = NvmlErrorInsufficientSize; + unsigned int filled = 0; + for ( int attempt = 0; attempt < MaxQueryAttempts; attempt++ ) { + mSampleBuffer.resize( count ); + filled = count; + result = mNvmlDeviceGetProcessUtilization( device, mSampleBuffer.data(), &filled, + mLastTimestamp ); + if ( result != NvmlErrorInsufficientSize ) + break; + + // The device produced even more samples in the meantime: retry with the reported size. + if ( filled <= count ) + return; + + count = filled; + } + + if ( result != NvmlSuccess ) + return; + + const unsigned int samples = + std::min( filled, static_cast( mSampleBuffer.size() ) ); + for ( unsigned int i = 0; i < samples; i++ ) { + const ProcessUtilizationSample& sample = mSampleBuffer[i]; + if ( sample.timeStamp > newestTimestamp ) + newestTimestamp = sample.timeStamp; + + const long pid = static_cast( sample.pid ); + const int utilization = static_cast( sample.smUtil ); + auto found = usagePercent.find( pid ); + if ( found == usagePercent.end() ) + usagePercent[pid] = utilization; + else if ( utilization > found->second ) + found->second = utilization; + } +} + +void NvidiaGpuReader::collectMemory( NvmlDevice device, UnorderedMap& memoryKiB ) { + collectRunningProcesses( device, mNvmlComputeProcesses, memoryKiB ); + collectRunningProcesses( device, mNvmlGraphicsProcesses, memoryKiB ); + // MPS compute processes are only reported by their own query, and the entry point is missing on + // drivers without MPS support. + if ( mNvmlMpsComputeProcesses ) + collectRunningProcesses( device, mNvmlMpsComputeProcesses, memoryKiB ); +} + +void NvidiaGpuReader::collectRunningProcesses( NvmlDevice device, + NvmlDeviceGetRunningProcessesFn entryPoint, + UnorderedMap& memoryKiB ) { + if ( !entryPoint ) + return; + + // The first call only asks for the number of processes the device reports. + unsigned int count = 0; + if ( entryPoint( device, &count, nullptr ) != NvmlErrorInsufficientSize || count == 0 ) + return; + + int result = NvmlErrorInsufficientSize; + unsigned int filled = 0; + for ( int attempt = 0; attempt < MaxQueryAttempts; attempt++ ) { + mProcessBuffer.resize( count ); + filled = count; + result = entryPoint( device, &filled, mProcessBuffer.data() ); + if ( result != NvmlErrorInsufficientSize ) + break; + + // More processes appeared in the meantime: retry with the reported size. + if ( filled <= count ) + return; + + count = filled; + } + + if ( result != NvmlSuccess ) + return; + + const unsigned int processes = + std::min( filled, static_cast( mProcessBuffer.size() ) ); + for ( unsigned int i = 0; i < processes; i++ ) { + const RunningProcessInfo& process = mProcessBuffer[i]; + // Windows and some of the older drivers do not report the used memory of a process. + if ( process.usedGpuMemory == NvmlValueNotAvailable ) + continue; + + // A process can be listed by more than one query (compute, graphics and MPS) and by more + // than one device; the reported usage is accumulated because that sum is the process's + // overall GPU memory, which is what nvtop reports. + // + // Do NOT "de-duplicate" this with a per-query maximum: a process that appears in both the + // compute and graphics lists genuinely uses the memory reported by each, and taking the + // maximum would halve the figure for exactly those processes. ksysguard6 accumulates the + // same way. + memoryKiB[static_cast( process.pid )] += + static_cast( process.usedGpuMemory / BytesPerKiB ); + } +} + +} // namespace eproc diff --git a/src/tools/eproc/platform/linux/gpu_reader_nvidia.hpp b/src/tools/eproc/platform/linux/gpu_reader_nvidia.hpp new file mode 100644 index 000000000..0fa60a712 --- /dev/null +++ b/src/tools/eproc/platform/linux/gpu_reader_nvidia.hpp @@ -0,0 +1,98 @@ +#ifndef EPROC_GPU_READER_NVIDIA_HPP +#define EPROC_GPU_READER_NVIDIA_HPP + +#include +#include + +using namespace EE; + +namespace eproc { + +/** Per-process GPU utilization and GPU memory of the NVIDIA devices in the system. + * + * The NVIDIA Management Library is loaded at run time, so the tool builds and runs on machines + * without an NVIDIA driver: an unavailable reader simply reports no GPU information. */ +class NvidiaGpuReader { + public: + NvidiaGpuReader(); + ~NvidiaGpuReader(); + NvidiaGpuReader( const NvidiaGpuReader& ) = delete; + NvidiaGpuReader& operator=( const NvidiaGpuReader& ) = delete; + + /** True when libnvidia-ml loaded and nvmlInit() succeeded. */ + bool isAvailable() const; + + /** Fills per-PID GPU utilization in percent (0..100) and per-PID GPU memory in KiB. + * Entries are only present for processes actually using the GPU. */ + void query( UnorderedMap& usagePercent, UnorderedMap& memoryKiB ); + + private: + // Only the NVML declarations this reader uses are mirrored here, the rest of the API lives in + // the driver's own nvml.h which is deliberately not vendored. + using NvmlDevice = void*; + using NvmlReturn = int; + + // nvmlProcessUtilizationSample_t: process id, sample timestamp in microseconds and the + // per-engine utilization values in percent. + struct ProcessUtilizationSample { + unsigned int pid; + unsigned long long timeStamp; + unsigned int smUtil; + unsigned int memUtil; + unsigned int encUtil; + unsigned int decUtil; + }; + static_assert( sizeof( ProcessUtilizationSample ) == 32, + "must match nvmlProcessUtilizationSample_t" ); + + // nvmlProcessInfo_v1_t: the structure filled by the version-less running-process queries, a + // process id followed by the device memory that process uses, in bytes. + struct RunningProcessInfo { + unsigned int pid; + unsigned long long usedGpuMemory; + }; + static_assert( sizeof( RunningProcessInfo ) == 16, "must match nvmlProcessInfo_v1_t" ); + + using NvmlInitFn = NvmlReturn ( * )(); + using NvmlShutdownFn = NvmlReturn ( * )(); + using NvmlDeviceGetCountFn = NvmlReturn ( * )( unsigned int* ); + using NvmlDeviceGetHandleByIndexFn = NvmlReturn ( * )( unsigned int, NvmlDevice* ); + using NvmlDeviceGetProcessUtilizationFn = NvmlReturn ( * )( NvmlDevice, + ProcessUtilizationSample*, + unsigned int*, unsigned long long ); + using NvmlDeviceGetRunningProcessesFn = NvmlReturn ( * )( NvmlDevice, unsigned int*, + RunningProcessInfo* ); + + /** Adds the utilization of the processes sampled on one device, keeping the highest value seen + * per process and refreshing newestTimestamp with the newest sample returned. */ + void collectUtilization( NvmlDevice device, UnorderedMap& usagePercent, + unsigned long long& newestTimestamp ); + + /** Adds the GPU memory of the processes running on one device, summed per process. */ + void collectMemory( NvmlDevice device, UnorderedMap& memoryKiB ); + + void collectRunningProcesses( NvmlDevice device, NvmlDeviceGetRunningProcessesFn entryPoint, + UnorderedMap& memoryKiB ); + + void* mLib{ nullptr }; + bool mInitialized{ false }; + unsigned long long mLastTimestamp{ 0 }; + // resolved function pointers + NvmlInitFn mNvmlInit{ nullptr }; + NvmlShutdownFn mNvmlShutdown{ nullptr }; + NvmlDeviceGetCountFn mNvmlDeviceGetCount{ nullptr }; + NvmlDeviceGetHandleByIndexFn mNvmlDeviceGetHandleByIndex{ nullptr }; + NvmlDeviceGetProcessUtilizationFn mNvmlDeviceGetProcessUtilization{ nullptr }; + NvmlDeviceGetRunningProcessesFn mNvmlComputeProcesses{ nullptr }; + NvmlDeviceGetRunningProcessesFn mNvmlGraphicsProcesses{ nullptr }; + // Optional: drivers without MPS support do not export this entry point. + NvmlDeviceGetRunningProcessesFn mNvmlMpsComputeProcesses{ nullptr }; + + // Reused across queries, so polling does not allocate per device. + std::vector mSampleBuffer; + std::vector mProcessBuffer; +}; + +} // namespace eproc + +#endif // EPROC_GPU_READER_NVIDIA_HPP diff --git a/src/tools/eproc/platform/linux/process_collector_linux.cpp b/src/tools/eproc/platform/linux/process_collector_linux.cpp new file mode 100644 index 000000000..db5728d4f --- /dev/null +++ b/src/tools/eproc/platform/linux/process_collector_linux.cpp @@ -0,0 +1,662 @@ +#include "process_collector_linux.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace EE::System; + +namespace eproc { + +namespace { + +// /proc pseudo-files are read several times per process on every pass. Building the paths with +// string concatenation and reading them through std::ifstream allocated a path string plus the +// stream's own multi-kilobyte buffer per file; formatting into a stack buffer and issuing a single +// read(2) keeps the heap untouched. + +constexpr size_t kProcPathCapacity = 64; +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; + +/** Formats "/proc//" into @p out. Returns the length, or 0 on overflow. */ +inline size_t formatProcPath( char* out, size_t capacity, long pid, const char* leaf ) { + int written = snprintf( out, capacity, "/proc/%ld/%s", pid, leaf ); + return written > 0 && static_cast( written ) < capacity ? static_cast( written ) + : 0; +} + +/** Reads a small pseudo-file into @p out, NUL-terminated. Larger files are truncated, which is + * fine for the fields parsed here. */ +inline bool readProcFile( const char* path, char* out, size_t capacity, size_t& length ) { + length = 0; + + int fd = ::open( path, O_RDONLY | O_CLOEXEC ); + if ( fd < 0 ) + return false; + + ssize_t readBytes = ::read( fd, out, capacity - 1 ); + ::close( fd ); + + if ( readBytes <= 0 ) + return false; + + length = static_cast( readBytes ); + out[length] = '\0'; + return true; +} + +/** Skips the whitespace that follows a "Label:" separator. */ +inline const char* skipSeparator( std::string_view line ) { + size_t colon = line.find( ':' ); + if ( colon == std::string_view::npos ) + return nullptr; + + const char* p = line.data() + colon + 1; + const char* end = line.data() + line.size(); + while ( p < end && ( *p == ' ' || *p == '\t' ) ) + ++p; + return p; +} + +// /proc files pad values with spaces (meminfo) or tabs (status); the parsers below tolerate either +// separator so one implementation reads both formats. + +/** Returns the first integer following the ':' of a "Label: value" line, or 0 when absent. */ +inline long parseLabeledLong( std::string_view line ) { + const char* p = skipSeparator( line ); + return p ? strtol( p, nullptr, 10 ) : 0; +} + +/** Reads up to @p count whitespace-separated integers following the ':' of a "Label: v v v" line. + * Used for the status "Uid:" line, which carries the real, effective, saved and fs ids. */ +inline void parseLabeledLongs( std::string_view line, long* out, int count ) { + const char* p = skipSeparator( line ); + if ( !p ) + return; + + const char* end = line.data() + line.size(); + for ( int i = 0; i < count; ++i ) { + while ( p < end && ( *p == ' ' || *p == '\t' ) ) + ++p; + if ( p >= end ) + return; + + long value = 0; + auto result = std::from_chars( p, end, value ); + if ( result.ec != std::errc() ) + return; + + out[i] = value; + p = result.ptr; + } +} + +/** Returns the trimmed text following the ':' of a "Label: value" line, as a view into it. */ +inline std::string_view parseLabeledString( std::string_view line ) { + const char* p = skipSeparator( line ); + if ( !p ) + return {}; + + std::string_view value( p, line.data() + line.size() - p ); + return String::trim( value, " \t\r\n" ); +} + +std::string ttyName( long ttyNumber ) { + if ( ttyNumber == 0 ) + return {}; + + const unsigned long device = static_cast( ttyNumber ); + const unsigned int major = static_cast( ( device >> 8 ) & 0xff ); + const unsigned int minor = static_cast( device & 0xff ); + char buffer[32]; + if ( major == 136 ) + snprintf( buffer, sizeof( buffer ), "pts/%u", minor ); + else if ( major == 4 ) + snprintf( buffer, sizeof( buffer ), minor < 64 ? "tty/%u" : "ttyS/%u", + minor < 64 ? minor : minor - 64 ); + else + return {}; + return buffer; +} + +/** Resolves the executable behind a pid into @p out. Returns an empty string for kernel threads + * and for processes the caller may not inspect. The " (deleted)" suffix readlink adds for an + * unlinked executable is left in place: ProcessIconResolver strips it when matching names. */ +std::string readExePath( long pid, char* out, size_t capacity ) { + char linkPath[kProcPathCapacity]; + if ( 0 == formatProcPath( linkPath, sizeof( linkPath ), pid, "exe" ) ) + return {}; + + ssize_t length = readlink( linkPath, out, capacity - 1 ); + if ( length <= 0 ) + return {}; + + return std::string( out, static_cast( length ) ); +} + +// 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 ) + return false; + const char* base = strrchr( shell, '/' ); + base = base ? base + 1 : shell; + return strcmp( base, "nologin" ) != 0 && strcmp( base, "false" ) != 0; +} + +} // namespace + +ProcessCollectorLinux::ProcessCollectorLinux() { + mPageSizeKb = sysconf( _SC_PAGESIZE ) / 1024; + mProcessorCount = sysconf( _SC_NPROCESSORS_ONLN ); + if ( mProcessorCount < 1 ) + mProcessorCount = 1; + + mJiffiesPerSecond = sysconf( _SC_CLK_TCK ); + if ( mJiffiesPerSecond < 1 ) + mJiffiesPerSecond = 100; + + // Require roughly a fifth of a second of machine-wide time before trusting a CPU delta. + mMinSampleJiffies = static_cast( mProcessorCount ) * mJiffiesPerSecond / 5; +} + +ProcessCollectorLinux::~ProcessCollectorLinux() {} + +bool ProcessCollectorLinux::readCpuTimes( long long& idle, long long& total ) { + idle = 0; + total = 0; + + char buffer[kProcFileCapacity]; + size_t length = 0; + if ( !readProcFile( "/proc/stat", buffer, sizeof( buffer ), length ) ) + return false; + + long long user = 0, nice = 0, system = 0, idleVal = 0, iowait = 0, irq = 0, softirq = 0, + steal = 0; + // The leading "cpu" aggregate line has at least the first four counters on every kernel. + if ( sscanf( buffer, "cpu %lld %lld %lld %lld %lld %lld %lld %lld", &user, &nice, &system, + &idleVal, &iowait, &irq, &softirq, &steal ) < 4 ) + return false; + + idle = idleVal + iowait; + total = user + nice + system + idleVal + iowait + irq + softirq + steal; + return true; +} + +// Parses /proc/[pid]/stat — extracts: name, state, ppid, utime, stime, nice, num_threads, +// vsize (bytes), rss (pages). Returns false if the line can't be parsed. +bool ProcessCollectorLinux::readProcessStat( ProcessInfo& proc, const char* statLine ) { + const char* p = statLine; + + // pid + while ( *p && *p != ' ' ) + p++; + if ( !*p ) + return false; + p++; // skip space + + // comm — may contain spaces/parens, so find the last ')' + if ( *p != '(' ) + return false; + p++; + const char* end = strrchr( p, ')' ); + if ( !end || *( end + 1 ) != ' ' ) + return false; + proc.name.assign( p, end - p ); + p = end + 2; // skip ') ' + + // state + if ( !*p ) + return false; + switch ( *p ) { + case 'R': + proc.status = ProcessStatus::Running; + break; + case 'S': + proc.status = ProcessStatus::Sleeping; + break; + case 'D': + proc.status = ProcessStatus::DiskSleep; + break; + case 'Z': + proc.status = ProcessStatus::Zombie; + break; + case 'T': + case 't': + proc.status = ProcessStatus::Stopped; + break; + case 'W': + proc.status = ProcessStatus::Paging; + break; + default: + proc.status = ProcessStatus::Other; + break; + } + p++; // past state char + if ( *p ) + p++; // past space + + // Now we're at field 4 (1-indexed): ppid + // Fields: ppid(4) pgrp(5) session(6) tty_nr(7) tpgid(8) flags(9) + // minflt(10) cminflt(11) majflt(12) cmajflt(13) utime(14) stime(15) + // cutime(16) cstime(17) priority(18) nice(19) num_threads(20) + // itrealvalue(21) starttime(22) vsize(23) rss(24) + auto skipField = [&]() { + while ( *p && *p != ' ' ) + p++; + if ( *p ) + p++; + }; + + // ppid (field 4) + proc.parentPid = strtol( p, nullptr, 10 ); + skipField(); + + // pgrp(5), session(6) + skipField(); + skipField(); + + // tty_nr (field 7) — the controlling terminal; 0 means none. Used by the "Programs Only" + // filter, which mirrors ksysguard's tty test. + proc.ttyNr = strtol( p, nullptr, 10 ); + skipField(); + + // tpgid(8), flags(9) + skipField(); + skipField(); + + // minflt(10), cminflt(11), majflt(12), cmajflt(13) — skip 4 fields + for ( int i = 0; i < 4; i++ ) + skipField(); + + // utime (field 14) + proc.userTime = strtol( p, nullptr, 10 ); + skipField(); + + // stime (field 15) + proc.sysTime = strtol( p, nullptr, 10 ); + skipField(); + + // cutime(16), cstime(17), priority(18) — skip 3 fields + for ( int i = 0; i < 3; i++ ) + skipField(); + + // nice (field 19) + proc.niceLevel = strtol( p, nullptr, 10 ); + skipField(); + + // num_threads (field 20) + proc.numThreads = strtol( p, nullptr, 10 ); + skipField(); + + // itrealvalue (field 21) — skip + skipField(); + + // starttime (field 22) — the process's start, in clock ticks since boot + proc.startTime = strtoll( p, nullptr, 10 ); + skipField(); + + // vsize (field 23) — bytes + proc.vmSize = strtol( p, nullptr, 10 ) / 1024; + skipField(); + + // rss (field 24) — pages; mPageSizeKb is already KiB per page + if ( *p ) + proc.vmRSS = strtol( p, nullptr, 10 ) * mPageSizeKb; + + proc.tty = ttyName( proc.ttyNr ); + + return true; +} + +void ProcessCollectorLinux::readProcessStatus( ProcessInfo& proc, const char* content, + size_t length ) { + String::readBySeparator( std::string_view( content, length ), [&proc]( std::string_view line ) { + if ( String::startsWith( line, "Uid:" ) ) { + long ids[4] = { 0, 0, 0, 0 }; + parseLabeledLongs( line, ids, 4 ); + proc.uid = ids[0]; + proc.euid = ids[1]; + proc.suid = ids[2]; + proc.fsuid = ids[3]; + } else if ( String::startsWith( line, "TracerPid:" ) ) { + proc.tracerPid = parseLabeledLong( line ); + } else if ( String::startsWith( line, "VmRSS:" ) ) { + long rss = parseLabeledLong( line ); + if ( rss > 0 ) + proc.vmRSS = rss; + } else if ( String::startsWith( line, "RssFile:" ) ) { + proc.sharedMem += parseLabeledLong( line ); + proc.hasSharedInfo = true; + } else if ( String::startsWith( line, "RssShmem:" ) ) { + proc.sharedMem += parseLabeledLong( line ); + proc.hasSharedInfo = true; + } else if ( String::startsWith( line, "Name:" ) ) { + if ( proc.name.empty() ) + proc.name.assign( parseLabeledString( line ) ); + } + } ); +} + +void ProcessCollectorLinux::readProcessCmdline( ProcessInfo& proc, long pid ) { + char path[kProcPathCapacity]; + if ( 0 == formatProcPath( path, sizeof( path ), pid, "cmdline" ) ) + return; + + int fd = ::open( path, O_RDONLY | O_CLOEXEC ); + if ( fd < 0 ) + return; + + char buffer[kProcFileCapacity]; + proc.commandLine.clear(); + for ( ;; ) { + ssize_t bytes = ::read( fd, buffer, sizeof( buffer ) ); + if ( bytes <= 0 ) + break; + proc.commandLine.append( buffer, static_cast( bytes ) ); + } + ::close( fd ); + + if ( proc.commandLine.empty() ) + return; + + // argv entries are NUL separated. Keep the executable separately for the table, then replace + // the separators with spaces for a useful clipboard command line. + proc.command.clear(); + const size_t firstArgEnd = proc.commandLine.find( '\0' ); + proc.command.assign( proc.commandLine.data(), + firstArgEnd == std::string::npos ? proc.commandLine.size() : firstArgEnd ); + for ( char& character : proc.commandLine ) { + if ( character == '\0' ) + character = ' '; + } + while ( !proc.commandLine.empty() && proc.commandLine.back() == ' ' ) + proc.commandLine.pop_back(); +} + +void ProcessCollectorLinux::readProcessIO( ProcessInfo& proc, long pid ) { + char path[kProcPathCapacity]; + if ( 0 == formatProcPath( path, sizeof( path ), pid, "io" ) ) + return; + + char buffer[kProcFileCapacity]; + size_t length = 0; + if ( !readProcFile( path, buffer, sizeof( buffer ), length ) ) + return; + + String::readBySeparator( std::string_view( buffer, length ), [&proc]( std::string_view line ) { + const char* value = nullptr; + if ( String::startsWith( line, "read_bytes:" ) ) { + value = skipSeparator( line ); + if ( value ) + proc.ioReadBytes = strtoll( value, nullptr, 10 ); + } else if ( String::startsWith( line, "write_bytes:" ) ) { + value = skipSeparator( line ); + if ( value ) + proc.ioWriteBytes = strtoll( value, nullptr, 10 ); + } + } ); +} + +void ProcessCollectorLinux::readSystemMemory( SystemInfo& sysInfo ) { + char buffer[kProcFileCapacity]; + size_t length = 0; + if ( !readProcFile( "/proc/meminfo", buffer, sizeof( buffer ), length ) ) + return; + + String::readBySeparator( std::string_view( buffer, length ), + [&sysInfo]( std::string_view line ) { + if ( String::startsWith( line, "MemTotal:" ) ) { + sysInfo.totalMemory = parseLabeledLong( line ); + } else if ( String::startsWith( line, "MemFree:" ) ) { + sysInfo.freeMemory = parseLabeledLong( line ); + } else if ( String::startsWith( line, "MemAvailable:" ) ) { + sysInfo.availableMemory = parseLabeledLong( line ); + } else if ( String::startsWith( line, "SwapTotal:" ) ) { + sysInfo.totalSwap = parseLabeledLong( line ); + } else if ( String::startsWith( line, "SwapFree:" ) ) { + sysInfo.freeSwap = parseLabeledLong( line ); + } + } ); +} + +void ProcessCollectorLinux::readSystemUptime( SystemInfo& sysInfo ) { + char buffer[kProcFileCapacity]; + size_t length = 0; + if ( !readProcFile( "/proc/uptime", buffer, sizeof( buffer ), length ) ) + return; + + char* end = nullptr; + const double uptime = strtod( buffer, &end ); + if ( end != buffer && uptime >= 0 ) + sysInfo.uptimeSeconds = uptime; +} + +void ProcessCollectorLinux::resolveUser( ProcessInfo& proc ) { + // Insert first, then read: an insertion can rehash the cache, so no reference may be held + // across a lookup for a second uid. + userInfo( proc.uid ); + if ( proc.euid != proc.uid ) + userInfo( proc.euid ); + + const UserInfo& real = mUserCache.at( proc.uid ); + proc.username = real.name; + proc.canLogin = real.canLogin; + proc.euidCanLogin = proc.euid == proc.uid ? real.canLogin : mUserCache.at( proc.euid ).canLogin; +} + +const ProcessCollectorLinux::UserInfo& ProcessCollectorLinux::userInfo( long uid ) { + auto it = mUserCache.find( uid ); + if ( it != mUserCache.end() ) + return it->second; + + UserInfo info; + struct passwd* pw = getpwuid( static_cast( uid ) ); + if ( pw ) { + info.name = pw->pw_name ? pw->pw_name : std::to_string( uid ); + info.canLogin = isLoginShell( pw->pw_shell ); + } else { + info.name = std::to_string( uid ); + info.canLogin = false; + } + + return mUserCache.emplace( uid, std::move( info ) ).first->second; +} + +bool ProcessCollectorLinux::collect( std::vector& processes, SystemInfo& sysInfo ) { + // 0 is the "never seen" sentinel for the per-pid caches below. + ++mPass; + if ( mPass == 0 ) + ++mPass; + + // CPU usage needs two samples separated by a real interval. A sub-jiffy window quantises into + // noise (a few busy jiffies out of a few total reads as ~100%), and the very first sample has + // no reference point, so both keep the last known value instead of inventing one. + long long idle = 0, total = 0; + long long deltaTotal = 0; + long long deltaIdle = 0; + + if ( readCpuTimes( idle, total ) ) { + if ( mPrevTotalCpu > 0 && total > mPrevTotalCpu ) { + deltaTotal = total - mPrevTotalCpu; + deltaIdle = idle - mPrevIdleCpu; + } + mPrevTotalCpu = total; + mPrevIdleCpu = idle; + } + + const bool haveSample = deltaTotal >= mMinSampleJiffies; + if ( haveSample ) + mLastCpuUsage = static_cast( deltaTotal - deltaIdle ) / deltaTotal * 100.f; + sysInfo.cpuUsage = mLastCpuUsage; + sysInfo.cpuCount = mProcessorCount; + + // System memory + readSystemMemory( sysInfo ); + sysInfo.clockTicksPerSecond = mJiffiesPerSecond; + readSystemUptime( sysInfo ); + + // GPU figures are per-PID and driver-provided, so query them once per pass and apply below. + // The maps are members so the per-pass queries do not reallocate them. + mGpuUsage.clear(); + mGpuMemory.clear(); + if ( mGpuReader.isAvailable() ) + mGpuReader.query( mGpuUsage, mGpuMemory ); + + // Network packet capture runs continuously in its own thread; this refresh only joins the + // current procfs socket ownership with the endpoints seen by that capture thread. + mNetworkMonitor.refreshMapping(); + + DIR* procDir = opendir( "/proc" ); + if ( !procDir ) + return false; + + processes.clear(); + + struct dirent* entry; + while ( ( entry = readdir( procDir ) ) != nullptr ) { + // Check if directory entry is a PID + char* endptr = nullptr; + long pid = strtol( entry->d_name, &endptr, 10 ); + if ( *endptr != '\0' || pid <= 0 ) + continue; + + // /proc reports DT_DIR, but other filesystems may return DT_UNKNOWN; verify those. + if ( entry->d_type == DT_UNKNOWN ) { + char dirPath[kProcPathCapacity]; + int written = snprintf( dirPath, sizeof( dirPath ), "/proc/%ld", pid ); + if ( written <= 0 || !FileInfo( dirPath ).isDirectory() ) + continue; + } else if ( entry->d_type != DT_DIR ) { + continue; + } + + ProcessInfo proc; + proc.pid = pid; + + char path[kProcPathCapacity]; + char buffer[kProcFileCapacity]; + size_t length = 0; + + // /proc/[pid]/stat — name, state, ppid, utime, stime, nice, threads, vsize, rss + if ( 0 == formatProcPath( path, sizeof( path ), pid, "stat" ) || + !readProcFile( path, buffer, sizeof( buffer ), length ) ) + continue; + + if ( !readProcessStat( proc, buffer ) ) + continue; + + // /proc/[pid]/status — uid variants, VmRSS, the shared breakdown and TracerPid + if ( 0 != formatProcPath( path, sizeof( path ), pid, "status" ) && + readProcFile( path, buffer, sizeof( buffer ), length ) ) + readProcessStatus( proc, buffer, length ); + + // ksysguard's Memory column is the process's private memory: resident memory minus the + // pages it shares with other processes. Only derived when the kernel reported the shared + // breakdown; otherwise vmURSS stays -1 and the display falls back to RSS. + if ( proc.hasSharedInfo ) + proc.vmURSS = proc.vmRSS - proc.sharedMem; + + // 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 + // changed is a different process that reused the number, so it starts from scratch. + long long curTicks = proc.userTime + proc.sysTime; + TickEntry& tickEntry = mProcessTicks[pid]; + const bool sameProcess = tickEntry.startTime == proc.startTime; + const bool hadPrevious = sameProcess && tickEntry.pass == mPass - 1; + long long deltaTicks = hadPrevious ? curTicks - tickEntry.ticks : 0; + tickEntry.ticks = curTicks; + tickEntry.startTime = proc.startTime; + tickEntry.pass = mPass; + + if ( haveSample && deltaTicks > 0 ) { + // deltaTotal spans every core, so dividing by the core count turns it into elapsed + // wall-clock ticks: the result is percent of a single core, matching ksysguard. + double wallTicks = static_cast( deltaTotal ) / mProcessorCount; + proc.userUsage = static_cast( deltaTicks * 100.0 / wallTicks ); + } + + // Read command line + readProcessCmdline( proc, pid ); + + // Read actual storage I/O totals. Processes without permission to expose this file keep the + // default -1 values, so the corresponding optional columns remain empty. + readProcessIO( proc, pid ); + + // Resolve username and login capability from uid + resolveUser( proc ); + + auto usageIt = mGpuUsage.find( pid ); + if ( usageIt != mGpuUsage.end() ) + proc.gpuUsage = usageIt->second; + + auto memoryIt = mGpuMemory.find( pid ); + if ( memoryIt != mGpuMemory.end() ) + proc.gpuMemory = memoryIt->second; + + // Icons are resolved once per process, not once per pass: the executable behind a pid does + // not change, and the resolver walks the desktop index on a miss. A pid that was reused by + // a new process is a miss, so it does not keep the dead process's icon. + auto iconIt = mProcessIcons.find( pid ); + if ( iconIt != mProcessIcons.end() && iconIt->second.startTime != proc.startTime ) + iconIt = mProcessIcons.end(); + + if ( iconIt == mProcessIcons.end() ) { + char exeBuffer[PATH_MAX]; + IconEntry iconEntry; + iconEntry.path = mIconResolver.iconFor( + readExePath( pid, exeBuffer, sizeof( exeBuffer ) ), proc.name ); + iconEntry.startTime = proc.startTime; + iconEntry.pass = mPass; + iconIt = mProcessIcons.insert_or_assign( pid, std::move( iconEntry ) ).first; + } else { + iconIt->second.pass = mPass; + } + proc.iconPath = iconIt->second.path; + + processes.push_back( std::move( proc ) ); + } + + closedir( procDir ); + + // Sweeping is amortised: entries of exited processes only cost memory until the next sweep. + if ( ( mPass % kCacheSweepInterval ) == 0 ) + pruneCaches(); + + mNetworkMonitor.applyRates( processes ); + + return true; +} + +void ProcessCollectorLinux::pruneCaches() { + for ( auto it = mProcessTicks.begin(); it != mProcessTicks.end(); ) { + if ( it->second.pass != mPass ) + it = mProcessTicks.erase( it ); + else + ++it; + } + + for ( auto it = mProcessIcons.begin(); it != mProcessIcons.end(); ) { + if ( it->second.pass != mPass ) + it = mProcessIcons.erase( it ); + else + ++it; + } +} + +} // namespace eproc diff --git a/src/tools/eproc/platform/linux/process_collector_linux.hpp b/src/tools/eproc/platform/linux/process_collector_linux.hpp new file mode 100644 index 000000000..8125e3204 --- /dev/null +++ b/src/tools/eproc/platform/linux/process_collector_linux.hpp @@ -0,0 +1,87 @@ +#ifndef EPROC_PROCESS_COLLECTOR_LINUX_HPP +#define EPROC_PROCESS_COLLECTOR_LINUX_HPP + +#include "../../process_collector.hpp" +#include "gpu_reader_nvidia.hpp" +#include "process_icon_resolver.hpp" +#include "process_network_monitor.hpp" +#include + +namespace eproc { + +class ProcessCollectorLinux : public ProcessCollector { + public: + ProcessCollectorLinux(); + ~ProcessCollectorLinux() override; + + bool collect( std::vector& processes, SystemInfo& sysInfo ) override; + + private: + // Cached per-uid identity, since passwd lookups can hit NSS. + struct UserInfo { + std::string name; + bool canLogin{ false }; + }; + + // Per-pid values tagged with the pass they were last seen in, so exited processes can be + // pruned without rebuilding (and reallocating every node of) the map on each pass. The start + // time pins an entry to one process incarnation, because a recycled pid would otherwise + // inherit the figures of the process that died. + struct TickEntry { + long long ticks{ 0 }; + long long startTime{ 0 }; + Uint32 pass{ 0 }; + }; + + struct IconEntry { + std::string path; + long long startTime{ 0 }; + Uint32 pass{ 0 }; + }; + + // KiB per memory page (e.g. 4 for a 4096-byte page size) + long mPageSizeKb{ 4 }; + int mProcessorCount{ 1 }; + // Kernel clock ticks per second, used to size the minimum meaningful sampling window. + long mJiffiesPerSecond{ 100 }; + + // Previous CPU totals for delta calculation + long long mPrevTotalCpu{ 0 }; + long long mPrevIdleCpu{ 0 }; + // Machine-wide jiffies that must elapse before a CPU delta is trusted (~200ms across cores). + long long mMinSampleJiffies{ 0 }; + float mLastCpuUsage{ 0.f }; + + UnorderedMap mProcessTicks; + UnorderedMap mProcessIcons; + Uint32 mPass{ 0 }; + + // Reused between passes so the GPU queries do not allocate a fresh map every second. + UnorderedMap mGpuUsage; + UnorderedMap mGpuMemory; + + bool readCpuTimes( long long& idle, long long& total ); + bool readProcessStat( ProcessInfo& proc, const char* statLine ); + void readProcessStatus( ProcessInfo& proc, const char* content, size_t length ); + void readProcessCmdline( ProcessInfo& proc, long pid ); + void readProcessIO( ProcessInfo& proc, long pid ); + void readSystemMemory( SystemInfo& sysInfo ); + void readSystemUptime( SystemInfo& sysInfo ); + void resolveUser( ProcessInfo& proc ); + void pruneCaches(); + + /** Cached passwd lookup for a uid (name + login-shell capability). */ + const UserInfo& userInfo( long uid ); + + UnorderedMap mUserCache; + + // Both run on the collection worker. Icons are resolved through the XDG desktop index (cached + // internally), and GPU figures come from NVML when an NVIDIA driver is present. + ProcessIconResolver mIconResolver; + NvidiaGpuReader mGpuReader; + ProcessNetworkMonitor mNetworkMonitor; +}; + +} // namespace eproc + +#endif // EPROC_PROCESS_COLLECTOR_LINUX_HPP diff --git a/src/tools/eproc/platform/linux/process_icon_resolver.cpp b/src/tools/eproc/platform/linux/process_icon_resolver.cpp new file mode 100644 index 000000000..ac0aeb257 --- /dev/null +++ b/src/tools/eproc/platform/linux/process_icon_resolver.cpp @@ -0,0 +1,451 @@ +#include "process_icon_resolver.hpp" + +#include + +#include +#include +#include +#include + +#include +#include +#include + +using namespace EE; +using namespace EE::System; + +namespace eproc { + +// The icon themes probed for an icon name, in preference order. Every one of them is optional. +static const char* kIconThemes[] = { "hicolor", "breeze", "Adwaita" }; + +// The unthemed icon directory, searched after all the themes. +static const char* kIconPixmapDir = "/usr/share/pixmaps/"; + +// Sizes present in a theme, and the extensions an icon file may use. The two extra large sizes at +// the end are not part of the classic set but are the only ones a few applications ship. +static const char* kIconSizes[] = { "16x16", "22x22", "24x24", "32x32", "48x48", + "64x64", "128x128", "scalable", "256x256", "512x512" }; +static const char* kIconExtensions[] = { ".png", ".svg", ".xpm" }; + +// Sizes preferred for the PNG pass, in order; the scalable SVG comes right after them. +static const char* kPreferredPngSizes[] = { "24x24", "32x32", "48x48" }; + +// Suffixes of a wrapper or bundle binary that the corresponding desktop id does not carry. +static const char* kExecutableSuffixes[] = { ".bin", ".sh", ".py", ".pl", ".run" }; + +// Directory names that identify a location rather than an application, and are never an app id. +static const char* kGenericDirNames[] = { "bin", "sbin", "lib", "lib64", "libexec", "usr", + "opt", "local", "share", "program", "applications" }; + +static bool isGenericDirName( std::string_view name ) { + for ( const char* generic : kGenericDirNames ) { + if ( name == generic ) + return true; + } + return false; +} + +// Everything after the last separator. The kernel appends " (deleted)" to a /proc//exe link +// when the binary has been replaced or removed, which would never match an index key. The result is +// always a new string, so the argument is only ever read. +static std::string baseName( std::string_view path ) { + std::string name( path ); + size_t slash = name.find_last_of( '/' ); + if ( slash != std::string::npos ) + name.erase( 0, slash + 1 ); + if ( String::endsWith( name, " (deleted)" ) ) + name.resize( name.size() - strlen( " (deleted)" ) ); + return name; +} + +// First token of an XDG Exec value, with the field codes (%U, %F, %u, %f, %%, ...) removed: that is +// the executable name the desktop entry is keyed by. +static std::string execToken( const std::string& exec ) { + size_t start = exec.find_first_not_of( " \t" ); + if ( start == std::string::npos ) + return {}; + + std::string token; + if ( exec[start] == '"' ) { + size_t end = exec.find( '"', start + 1 ); + if ( end == std::string::npos ) + return {}; + token = exec.substr( start + 1, end - start - 1 ); + } else { + size_t end = exec.find_first_of( " \t", start ); + token = end == std::string::npos ? exec.substr( start ) : exec.substr( start, end - start ); + } + + std::string cleaned; + cleaned.reserve( token.size() ); + for ( size_t i = 0; i < token.size(); i++ ) { + if ( token[i] == '%' && i + 1 < token.size() ) { + if ( token[i + 1] == '%' ) + cleaned += '%'; + i++; + continue; + } + cleaned += token[i]; + } + + return baseName( cleaned ); +} + +// True when an icon name carries its own extension, in which case it is never extended further: an +// "Icon=foo.svg" entry must not be probed as "foo.svg.png". +static bool hasIconExtension( const std::string& name ) { + for ( const char* extension : kIconExtensions ) { + if ( String::endsWith( name, extension ) ) + return true; + } + return false; +} + +static bool allowsExtension( const std::string& name, const char* extension ) { + return !hasIconExtension( name ) || String::endsWith( name, extension ); +} + +static std::string themeIconPath( const char* theme, const char* size, const std::string& name ) { + std::string path = "/usr/share/icons/"; + path += theme; + path += '/'; + path += size; + path += "/apps/"; + path += name; + return path; +} + +// Maps an icon name to the icon file that backs it. Preference order: 32x32 PNG, then 48x48 PNG, +// then the scalable SVG, then any other icon of any size and known extension, and finally the +// unthemed /usr/share/pixmaps directory. +static std::string findIconFile( const std::string& iconName ) { + if ( iconName.empty() ) + return {}; + + // An absolute path (used by a few hand written entries) is taken as is. + if ( iconName[0] == '/' ) { + FileInfo icon( iconName ); + return icon.isRegularFile() ? iconName : std::string(); + } + + // "pixmaps/" refers to the unthemed pixmap directory. + std::string name = String::startsWith( iconName, "pixmaps/" ) + ? iconName.substr( strlen( "pixmaps/" ) ) + : iconName; + if ( name.empty() ) + return {}; + + if ( allowsExtension( name, ".png" ) ) { + for ( const char* size : kPreferredPngSizes ) { + for ( const char* theme : kIconThemes ) { + std::string path = themeIconPath( theme, size, name + ".png" ); + if ( FileInfo( path ).isRegularFile() ) + return path; + } + } + } + + if ( allowsExtension( name, ".svg" ) ) { + for ( const char* theme : kIconThemes ) { + std::string path = themeIconPath( theme, "scalable", name + ".svg" ); + if ( FileInfo( path ).isRegularFile() ) + return path; + } + } + + for ( const char* theme : kIconThemes ) { + for ( const char* size : kIconSizes ) { + for ( const char* extension : kIconExtensions ) { + if ( !allowsExtension( name, extension ) ) + continue; + std::string path = themeIconPath( theme, size, name + extension ); + if ( FileInfo( path ).isRegularFile() ) + return path; + } + } + } + + for ( const char* extension : kIconExtensions ) { + if ( !allowsExtension( name, extension ) ) + continue; + std::string path = std::string( kIconPixmapDir ) + name + extension; + if ( FileInfo( path ).isRegularFile() ) + return path; + } + + // Some entries point straight at an extension-less file in the pixmap directory. + std::string nakedPath = std::string( kIconPixmapDir ) + name; + if ( FileInfo( nakedPath ).isRegularFile() ) + return nakedPath; + + return {}; +} + +// Every directory that can hold desktop entries, in XDG precedence order: the user's own entries +// shadow the system-wide ones, which is why they are scanned (and therefore indexed) first. +static std::vector desktopDirectories() { + std::vector dirs; + + auto addDirectory = [&dirs]( const std::string& dir ) { + if ( dir.empty() ) + return; + for ( const std::string& existing : dirs ) { + if ( existing == dir ) + return; + } + dirs.push_back( dir ); + }; + + auto addDataDirectory = [&addDirectory]( const std::string& dataDir ) { + if ( dataDir.empty() ) + return; + addDirectory( dataDir.back() == '/' ? dataDir + "applications" + : dataDir + "/applications" ); + }; + + // XDG_DATA_HOME defaults to $HOME/.local/share. + const char* dataHome = std::getenv( "XDG_DATA_HOME" ); + if ( dataHome && *dataHome ) { + addDataDirectory( dataHome ); + } else { + const char* home = std::getenv( "HOME" ); + if ( home && *home ) + addDataDirectory( std::string( home ) + "/.local/share" ); + } + + // XDG_DATA_DIRS defaults to /usr/local/share:/usr/share. The list is ordered, earlier entries + // taking precedence; addDirectory() keeps that order and drops the duplicates. + const char* dataDirs = std::getenv( "XDG_DATA_DIRS" ); + const std::string dataDirList = + ( dataDirs && *dataDirs ) ? dataDirs : "/usr/local/share:/usr/share"; + for ( std::string dataDir : String::split( dataDirList, ':' ) ) { + // A trailing separator would turn into a doubled one in addDataDirectory(). + while ( !dataDir.empty() && dataDir.back() == '/' ) + dataDir.pop_back(); + addDataDirectory( dataDir ); + } + + // The standard locations are always tried, even with a trimmed down environment. + addDirectory( "/usr/local/share/applications" ); + addDirectory( "/usr/share/applications" ); + + return dirs; +} + +// Reads the [Desktop Entry] group of a .desktop file. Only the unlocalized keys are considered: +// "Key[lang]=..." only ever repeats an already seen "Key=" value. +static void parseDesktopEntry( const std::string& path, std::string& exec, std::string& wmClass, + std::string& icon, bool& hidden ) { + std::ifstream file( path ); + if ( !file.is_open() ) + return; + + std::string line; + bool inGroup = false; + while ( std::getline( file, line ) ) { + if ( !line.empty() && line.back() == '\r' ) + line.pop_back(); + + if ( !line.empty() && line[0] == '[' ) { + // Past the desktop entry group there is nothing to index. + if ( inGroup ) + break; + size_t end = line.find( ']' ); + inGroup = + end != std::string::npos && line.compare( 0, end + 1, "[Desktop Entry]" ) == 0; + continue; + } + + if ( !inGroup || line.empty() || line[0] == '#' ) + continue; + + size_t separator = line.find( '=' ); + if ( separator == std::string::npos ) + continue; + + // Key and value are trimmed as views of the line, so no string is copied per entry line. + const std::string_view lineView( line ); + const std::string_view key = String::trim( lineView.substr( 0, separator ), " \t\r\n" ); + if ( key.empty() || key.find( '[' ) != std::string_view::npos ) + continue; + + const std::string_view value = String::trim( lineView.substr( separator + 1 ), " \t\r\n" ); + if ( value.empty() ) + continue; + + if ( key == "Exec" ) { + if ( exec.empty() ) + exec = value; + } else if ( key == "StartupWMClass" ) { + if ( wmClass.empty() ) + wmClass = value; + } else if ( key == "Icon" ) { + if ( icon.empty() ) + icon = value; + } else if ( key == "Hidden" ) { + // A hidden entry belongs to a removed or shadowed application. + if ( value == "true" ) + hidden = true; + } + } +} + +// Desktop ids are the desktop file basename without the ".desktop" suffix. An executable path +// rarely spells one out, so derive the plausible ids from it: the executable name without a common +// wrapper suffix, and the application directory it lives in (/usr/lib/firefox/firefox). +static std::vector desktopIdCandidates( const std::string& exePath, + const std::string& exeName ) { + std::vector ids; + + auto addId = [&ids, &exeName]( std::string_view id ) { + if ( id.empty() || id == exeName ) + return; + for ( const std::string& existing : ids ) { + if ( existing == id ) + return; + } + ids.emplace_back( id ); + }; + + const std::string_view exeNameView( exeName ); + for ( const char* suffix : kExecutableSuffixes ) { + if ( String::endsWith( exeName, suffix ) ) { + addId( exeNameView.substr( 0, exeName.size() - strlen( suffix ) ) ); + break; + } + } + + // The directory holding the executable, which is where a few vendors keep the app id + // (/usr/lib/firefox/firefox, /usr/lib/libreoffice/program/soffice). + size_t lastSlash = exePath.find_last_of( '/' ); + if ( lastSlash != std::string::npos && lastSlash > 0 ) { + const std::string parent = baseName( std::string_view( exePath ).substr( 0, lastSlash ) ); + if ( !isGenericDirName( parent ) ) + addId( parent ); + } + + return ids; +} + +const std::string& ProcessIconResolver::lookupKey( const std::string& key ) const { + if ( key.empty() ) + return mEmpty; + + auto it = mExecIndex.find( key ); + if ( it != mExecIndex.end() ) + return it->second; + + it = mIdIndex.find( key ); + if ( it != mIdIndex.end() ) + return it->second; + + return mEmpty; +} + +const std::string& ProcessIconResolver::resolveIconName( const std::string& iconName ) { + auto it = mIconPathIndex.find( iconName ); + if ( it != mIconPathIndex.end() ) + return it->second; + + std::string path = findIconFile( iconName ); + return mIconPathIndex.emplace( iconName, std::move( path ) ).first->second; +} + +void ProcessIconResolver::scanDirectory( const std::string& dir ) { + DIR* handle = opendir( dir.c_str() ); + if ( !handle ) + return; + + struct dirent* entry; + while ( ( entry = readdir( handle ) ) != nullptr ) { + const std::string fileName = entry->d_name; + if ( !String::endsWith( fileName, ".desktop" ) ) + continue; + + // A directory named "something.desktop" is not a desktop entry, and neither is a file + // that cannot be read. + const std::string path = dir + "/" + fileName; + if ( !FileInfo( path ).isRegularFile() ) + continue; + + indexDesktopFile( path ); + } + + closedir( handle ); +} + +void ProcessIconResolver::indexDesktopFile( const std::string& path ) { + std::string exec, wmClass, icon; + bool hidden = false; + parseDesktopEntry( path, exec, wmClass, icon, hidden ); + if ( hidden || icon.empty() ) + return; + + // The desktop file id is the weakest key: it only matches an executable that happens to be + // named after its desktop file, so it is kept apart and never shadows a real key. + std::string id = baseName( path ); + if ( String::endsWith( id, ".desktop" ) ) + id.resize( id.size() - strlen( ".desktop" ) ); + if ( !id.empty() && mIdIndex.find( id ) == mIdIndex.end() ) + mIdIndex.emplace( id, icon ); + + // First entry wins, which is the user's own one because it is scanned first. + if ( !wmClass.empty() && mExecIndex.find( wmClass ) == mExecIndex.end() ) + mExecIndex.emplace( wmClass, icon ); + + std::string executable = execToken( exec ); + if ( !executable.empty() && mExecIndex.find( executable ) == mExecIndex.end() ) + mExecIndex.emplace( executable, icon ); +} + +void ProcessIconResolver::buildIndex() { + mIndexBuilt = true; + + for ( const std::string& dir : desktopDirectories() ) + scanDirectory( dir ); + + if ( mExecIndex.empty() && mIdIndex.empty() ) { + Log::warning( "eproc: no desktop entries found, process icons will be unavailable" ); + } +} + +const std::string& ProcessIconResolver::iconFor( const std::string& exePath, + const std::string& name ) { + std::lock_guard lock( mMutex ); + + if ( !mIndexBuilt ) + buildIndex(); + + std::string cacheKey = exePath; + cacheKey += '|'; + cacheKey += name; + + auto cached = mResultCache.find( cacheKey ); + if ( cached != mResultCache.end() ) + return cached->second; + + const std::string exeName = baseName( exePath ); + + std::string iconName = lookupKey( exeName ); + if ( iconName.empty() ) { + for ( const std::string& id : desktopIdCandidates( exePath, exeName ) ) { + iconName = lookupKey( id ); + if ( !iconName.empty() ) + break; + } + } + if ( iconName.empty() ) { + // A blank name has no key to look up. + const std::string_view trimmedName = String::trim( std::string_view( name ), " \t\r\n" ); + if ( !trimmedName.empty() ) + iconName = lookupKey( std::string( trimmedName ) ); + } + + if ( iconName.empty() ) + return mResultCache.emplace( std::move( cacheKey ), std::string() ).first->second; + + return mResultCache.emplace( std::move( cacheKey ), resolveIconName( iconName ) ).first->second; +} + +} // namespace eproc diff --git a/src/tools/eproc/platform/linux/process_icon_resolver.hpp b/src/tools/eproc/platform/linux/process_icon_resolver.hpp new file mode 100644 index 000000000..63dcf917e --- /dev/null +++ b/src/tools/eproc/platform/linux/process_icon_resolver.hpp @@ -0,0 +1,73 @@ +#ifndef EPROC_PROCESS_ICON_RESOLVER_HPP +#define EPROC_PROCESS_ICON_RESOLVER_HPP + +#include +#include +#include + +using namespace EE; + +namespace eproc { + +/** Resolves the icon of a process to an absolute icon file path following the XDG desktop entry + * route: an executable name is mapped to a .desktop entry, and that entry's icon name to a file + * inside the installed icon themes. X11 window icons are deliberately not used, so the result + * does not depend on a display connection. + * + * The desktop index is built once, lazily, on the first iconFor() call. Every result - including + * the "no icon" ones - is cached under the queried exe/name pair, so the steady state is a couple + * of hash lookups with no filesystem access at all. + * + * The resolver never throws and never fails hard: unreadable directories, missing environment + * variables and malformed desktop files are skipped, and an unavailable icon simply resolves to + * the empty string. All methods are safe to call from any thread. */ +class ProcessIconResolver { + public: + /** Resolves the icon file for a process. + * @param exePath absolute path of /proc//exe target (may be empty) + * @param name process name (comm), used as a fallback key + * @return absolute path to a PNG/SVG/XPM icon, or empty string when none found. + * The returned reference stays valid for the resolver's lifetime. */ + const std::string& iconFor( const std::string& exePath, const std::string& name ); + + private: + /** Reads every desktop entry once, keying the executable name, the window class and the desktop + * file id to the entry's icon name. Only ever called once, from iconFor(). */ + void buildIndex(); + + /** Scans @p dir for *.desktop files. Missing or unreadable directories are skipped silently. */ + void scanDirectory( const std::string& dir ); + + /** Indexes a single .desktop file. Silently ignores unreadable, hidden and icon-less entries. + */ + void indexDesktopFile( const std::string& path ); + + /** Returns the icon name registered for @p key, or an empty string when there is none. The + * executable/window-class index wins over the weaker desktop id index. */ + const std::string& lookupKey( const std::string& key ) const; + + /** Resolves an icon name to an absolute file path, caching the result (misses included). */ + const std::string& resolveIconName( const std::string& iconName ); + + bool mIndexBuilt{ false }; + + // Icon name per executable basename and per startup window class. + UnorderedMap mExecIndex; + // Icon name per desktop file id (the file name without ".desktop"), used as a weak fallback. + UnorderedMap mIdIndex; + + // Icon name -> icon file path. Misses are stored as empty strings so they are not retried. + UnorderedMap mIconPathIndex; + + // "|" -> icon file path, negative results included. + UnorderedMap mResultCache; + + // Returned when nothing matched; a member so the returned reference always stays valid. + std::string mEmpty; + + std::mutex mMutex; +}; + +} // namespace eproc + +#endif // EPROC_PROCESS_ICON_RESOLVER_HPP diff --git a/src/tools/eproc/platform/linux/process_network_monitor.cpp b/src/tools/eproc/platform/linux/process_network_monitor.cpp new file mode 100644 index 000000000..f05137dab --- /dev/null +++ b/src/tools/eproc/platform/linux/process_network_monitor.cpp @@ -0,0 +1,542 @@ +#include "process_network_monitor.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace EE::System; + +namespace eproc { + +namespace { + +constexpr Uint8 kIPv4 = 4; +constexpr Uint8 kIPv6 = 6; +constexpr size_t kProcPathCapacity = 64; +constexpr size_t kFdTargetCapacity = 128; + +#ifndef DLT_LINUX_SLL2 +constexpr int kLinuxSll2 = 276; +#else +constexpr int kLinuxSll2 = DLT_LINUX_SLL2; +#endif + +inline Uint16 readBigEndian16( const Uint8* data ) { + return static_cast( data[0] << 8 | data[1] ); +} + +inline Uint32 readBigEndian32( const Uint8* data ) { + return static_cast( data[0] ) << 24 | static_cast( data[1] ) << 16 | + static_cast( data[2] ) << 8 | static_cast( data[3] ); +} + +inline bool isNumericName( const char* name, long& value ) { + if ( !name || !*name ) + return false; + + char* end = nullptr; + value = strtol( name, &end, 10 ); + return value > 0 && end != name && *end == '\0'; +} + +inline int hexDigit( char value ) { + if ( value >= '0' && value <= '9' ) + return value - '0'; + if ( value >= 'a' && value <= 'f' ) + return value - 'a' + 10; + if ( value >= 'A' && value <= 'F' ) + return value - 'A' + 10; + return -1; +} + +inline bool parseHexByte( std::string_view value, size_t offset, Uint8& result ) { + if ( offset + 2 > value.size() ) + return false; + + const int high = hexDigit( value[offset] ); + const int low = hexDigit( value[offset + 1] ); + if ( high < 0 || low < 0 ) + return false; + + result = static_cast( high * 16 + low ); + return true; +} + +inline bool nextToken( const char*& cursor, const char* end, std::string_view& token ) { + while ( cursor < end && + ( *cursor == ' ' || *cursor == '\t' || *cursor == '\r' || *cursor == '\n' ) ) + ++cursor; + if ( cursor >= end ) + return false; + + const char* begin = cursor; + while ( cursor < end && *cursor != ' ' && *cursor != '\t' && *cursor != '\r' && + *cursor != '\n' ) + ++cursor; + + token = std::string_view( begin, static_cast( cursor - begin ) ); + return true; +} + +} // namespace + +std::size_t +ProcessNetworkMonitor::EndpointHash::operator()( const Endpoint& endpoint ) const noexcept { + std::size_t hash = endpoint.family * 131u + endpoint.port; + for ( Uint8 byte : endpoint.address ) + hash = hash * 16777619u ^ byte; + return hash; +} + +ProcessNetworkMonitor::ProcessNetworkMonitor() : mLastSnapshot( std::chrono::steady_clock::now() ) { + mCaptureThread = std::thread( &ProcessNetworkMonitor::captureLoop, this ); +} + +ProcessNetworkMonitor::~ProcessNetworkMonitor() { + mRunning.store( false ); + if ( mCaptureThread.joinable() ) + mCaptureThread.join(); +} + +bool ProcessNetworkMonitor::parseUnsigned( std::string_view token, Uint64& value, int base ) { + if ( token.empty() ) + return false; + + value = 0; + auto result = std::from_chars( token.data(), token.data() + token.size(), value, base ); + return result.ec == std::errc() && result.ptr == token.data() + token.size(); +} + +bool ProcessNetworkMonitor::parseProcEndpoint( std::string_view token, Endpoint& endpoint ) { + const size_t separator = token.rfind( ':' ); + if ( separator == std::string_view::npos ) + return false; + + const std::string_view address = token.substr( 0, separator ); + Uint64 port = 0; + if ( !parseUnsigned( token.substr( separator + 1 ), port, 16 ) || port > 0xffff ) + return false; + + Endpoint parsed; + parsed.port = static_cast( port ); + if ( address.size() == 8 ) { + parsed.family = kIPv4; + for ( size_t i = 0; i < 4; ++i ) { + if ( !parseHexByte( address, ( 3 - i ) * 2, parsed.address[i] ) ) + return false; + } + } else if ( address.size() == 32 ) { + parsed.family = kIPv6; + for ( size_t word = 0; word < 4; ++word ) { + for ( size_t byte = 0; byte < 4; ++byte ) { + if ( !parseHexByte( address, word * 8 + ( 3 - byte ) * 2, + parsed.address[word * 4 + byte] ) ) + return false; + } + } + } else { + return false; + } + + bool isMapped = parsed.family == kIPv6; + for ( size_t i = 0; isMapped && i < 10; ++i ) + isMapped = parsed.address[i] == 0; + isMapped = isMapped && parsed.address[10] == 0xff && parsed.address[11] == 0xff; + if ( isMapped ) { + Endpoint mapped; + mapped.family = kIPv4; + mapped.port = parsed.port; + std::copy_n( parsed.address.begin() + 12, 4, mapped.address.begin() ); + parsed = mapped; + } + + endpoint = parsed; + return true; +} + +bool ProcessNetworkMonitor::parseSocketLine( std::string_view line, Endpoint& endpoint, + Uint64& inode ) { + const char* cursor = line.data(); + const char* end = cursor + line.size(); + std::string_view token; + std::string_view localAddress; + std::string_view inodeToken; + + for ( int index = 0; index <= 9; ++index ) { + if ( !nextToken( cursor, end, token ) ) + return false; + if ( index == 1 ) + localAddress = token; + else if ( index == 9 ) + inodeToken = token; + } + + return parseProcEndpoint( localAddress, endpoint ) && parseUnsigned( inodeToken, inode, 10 ); +} + +void ProcessNetworkMonitor::collectSocketOwners( UnorderedMap& owners ) { + DIR* procDirectory = opendir( "/proc" ); + if ( !procDirectory ) + return; + + char fdPath[kProcPathCapacity]; + char target[kFdTargetCapacity]; + while ( dirent* processEntry = readdir( procDirectory ) ) { + long pid = 0; + if ( !isNumericName( processEntry->d_name, pid ) ) + continue; + + const int pathLength = snprintf( fdPath, sizeof( fdPath ), "/proc/%ld/fd", pid ); + if ( pathLength <= 0 || static_cast( pathLength ) >= sizeof( fdPath ) ) + continue; + + DIR* fdDirectory = opendir( fdPath ); + if ( !fdDirectory ) + continue; + + while ( dirent* fdEntry = readdir( fdDirectory ) ) { + if ( fdEntry->d_name[0] == '.' && + ( fdEntry->d_name[1] == '\0' || + ( fdEntry->d_name[1] == '.' && fdEntry->d_name[2] == '\0' ) ) ) + continue; + + ssize_t length = + readlinkat( dirfd( fdDirectory ), fdEntry->d_name, target, sizeof( target ) - 1 ); + if ( length <= 9 ) + continue; + target[length] = '\0'; + + std::string_view link( target, static_cast( length ) ); + if ( link.compare( 0, 8, "socket:[" ) != 0 || link.back() != ']' ) + continue; + + Uint64 inode = 0; + if ( parseUnsigned( link.substr( 8, link.size() - 9 ), inode, 10 ) ) + owners.insert_or_assign( inode, pid ); + } + + closedir( fdDirectory ); + } + + closedir( procDirectory ); +} + +void ProcessNetworkMonitor::collectSocketTable( const char* path, Uint8 family, + const UnorderedMap& owners, + EndpointMap& endpoints ) { + FILE* file = fopen( path, "r" ); + if ( !file ) + return; + + char line[512]; + while ( fgets( line, sizeof( line ), file ) ) { + Endpoint endpoint; + Uint64 inode = 0; + if ( !parseSocketLine( line, endpoint, inode ) || + ( endpoint.family != family && !( family == kIPv6 && endpoint.family == kIPv4 ) ) ) + continue; + + auto owner = owners.find( inode ); + if ( owner != owners.end() ) + endpoints.insert_or_assign( endpoint, owner->second ); + } + + fclose( file ); +} + +void ProcessNetworkMonitor::refreshMapping() { + UnorderedMap owners; + collectSocketOwners( owners ); + + EndpointMap endpoints; + collectSocketTable( "/proc/net/tcp", kIPv4, owners, endpoints ); + collectSocketTable( "/proc/net/tcp6", kIPv6, owners, endpoints ); + collectSocketTable( "/proc/net/udp", kIPv4, owners, endpoints ); + collectSocketTable( "/proc/net/udp6", kIPv6, owners, endpoints ); + + std::lock_guard lock( mMutex ); + mEndpoints = std::move( endpoints ); +} + +long ProcessNetworkMonitor::findPid( const Endpoint& endpoint ) const { + auto exact = mEndpoints.find( endpoint ); + if ( exact != mEndpoints.end() ) + return exact->second; + + Endpoint wildcard = endpoint; + wildcard.address.fill( 0 ); + auto anyAddress = mEndpoints.find( wildcard ); + return anyAddress != mEndpoints.end() ? anyAddress->second : 0; +} + +bool ProcessNetworkMonitor::parsePacket( int dataLink, const Uint8* data, size_t length, + Endpoint& source, Endpoint& destination ) { + size_t networkOffset = 0; + Uint16 etherType = 0; + + switch ( dataLink ) { + case DLT_EN10MB: + if ( length < 14 ) + return false; + networkOffset = 14; + etherType = readBigEndian16( data + 12 ); + while ( etherType == ETHERTYPE_VLAN || etherType == 0x88a8 || etherType == 0x9100 ) { + if ( length < networkOffset + 4 ) + return false; + etherType = readBigEndian16( data + networkOffset + 2 ); + networkOffset += 4; + } + break; + case DLT_LINUX_SLL: + if ( length < 16 ) + return false; + networkOffset = 16; + etherType = readBigEndian16( data + 14 ); + break; + case kLinuxSll2: + if ( length < 20 ) + return false; + networkOffset = 20; + etherType = readBigEndian16( data ); + break; + case DLT_RAW: + networkOffset = 0; + break; + case DLT_NULL: { + if ( length < 4 ) + return false; + Uint32 linkFamily = 0; + std::memcpy( &linkFamily, data, sizeof( linkFamily ) ); + if ( linkFamily != AF_INET && linkFamily != AF_INET6 ) + linkFamily = ntohl( linkFamily ); + if ( linkFamily == AF_INET ) + etherType = ETHERTYPE_IP; + else if ( linkFamily == AF_INET6 ) + etherType = ETHERTYPE_IPV6; + else + return false; + networkOffset = 4; + break; + } + case DLT_LOOP: { + if ( length < 4 ) + return false; + const Uint32 linkFamily = readBigEndian32( data ); + if ( linkFamily == AF_INET ) + etherType = ETHERTYPE_IP; + else if ( linkFamily == AF_INET6 ) + etherType = ETHERTYPE_IPV6; + else + return false; + networkOffset = 4; + break; + } + default: + return false; + } + + Uint8 transportProtocol = 0; + size_t transportOffset = 0; + if ( etherType == ETHERTYPE_IP ) { + if ( length < networkOffset + 20 ) + return false; + const Uint8 versionAndLength = data[networkOffset]; + if ( ( versionAndLength >> 4 ) != 4 ) + return false; + const size_t headerLength = static_cast( versionAndLength & 0x0f ) * 4; + if ( headerLength < 20 || length < networkOffset + headerLength ) + return false; + if ( ( readBigEndian16( data + networkOffset + 6 ) & 0x1fff ) != 0 ) + return false; + + source = Endpoint{}; + destination = Endpoint{}; + source.family = destination.family = kIPv4; + std::copy_n( data + networkOffset + 12, 4, source.address.begin() ); + std::copy_n( data + networkOffset + 16, 4, destination.address.begin() ); + transportProtocol = data[networkOffset + 9]; + transportOffset = networkOffset + headerLength; + } else if ( etherType == ETHERTYPE_IPV6 ) { + if ( length < networkOffset + 40 ) + return false; + if ( ( data[networkOffset] >> 4 ) != 6 ) + return false; + + source = Endpoint{}; + destination = Endpoint{}; + source.family = destination.family = kIPv6; + std::copy_n( data + networkOffset + 8, 16, source.address.begin() ); + std::copy_n( data + networkOffset + 24, 16, destination.address.begin() ); + + transportProtocol = data[networkOffset + 6]; + transportOffset = networkOffset + 40; + while ( transportProtocol != IPPROTO_TCP && transportProtocol != IPPROTO_UDP ) { + size_t extensionLength = 0; + if ( transportProtocol == IPPROTO_HOPOPTS || transportProtocol == IPPROTO_ROUTING || + transportProtocol == IPPROTO_DSTOPTS ) { + if ( length < transportOffset + 2 ) + return false; + extensionLength = static_cast( data[transportOffset + 1] + 1 ) * 8; + } else if ( transportProtocol == IPPROTO_FRAGMENT ) { + if ( length < transportOffset + 8 || + ( readBigEndian16( data + transportOffset + 2 ) & 0xfff8 ) != 0 ) + return false; + extensionLength = 8; + } else if ( transportProtocol == IPPROTO_AH ) { + if ( length < transportOffset + 2 ) + return false; + extensionLength = static_cast( data[transportOffset + 1] + 2 ) * 4; + } else { + return false; + } + + if ( length < transportOffset + extensionLength ) + return false; + transportProtocol = data[transportOffset]; + transportOffset += extensionLength; + } + } else { + return false; + } + + if ( ( transportProtocol != IPPROTO_TCP && transportProtocol != IPPROTO_UDP ) || + length < transportOffset + 4 ) + return false; + + source.port = readBigEndian16( data + transportOffset ); + destination.port = readBigEndian16( data + transportOffset + 2 ); + return source.port != 0 && destination.port != 0; +} + +void ProcessNetworkMonitor::processPacket( int dataLink, const Uint8* data, size_t length, + size_t wireLength ) { + Endpoint source; + Endpoint destination; + if ( !parsePacket( dataLink, data, length, source, destination ) ) + return; + + std::lock_guard lock( mMutex ); + const long uploadPid = findPid( source ); + const long downloadPid = findPid( destination ); + const long pid = uploadPid > 0 ? uploadPid : downloadPid; + if ( pid <= 0 ) + return; + + Traffic& traffic = mTraffic[pid]; + if ( uploadPid > 0 ) + traffic.upload += wireLength; + else + traffic.download += wireLength; +} + +long ProcessNetworkMonitor::rateFor( Uint64 bytes, std::chrono::steady_clock::duration elapsed ) { + const long double seconds = std::chrono::duration( elapsed ).count(); + if ( seconds <= 0.0L ) + return 0; + + const long double rate = static_cast( bytes ) / seconds; + if ( rate >= static_cast( std::numeric_limits::max() ) ) + return std::numeric_limits::max(); + return static_cast( rate ); +} + +void ProcessNetworkMonitor::applyRates( std::vector& processes ) { + const auto now = std::chrono::steady_clock::now(); + std::lock_guard lock( mMutex ); + + const bool available = mAvailable.load(); + const auto elapsed = now - mLastSnapshot; + for ( ProcessInfo& process : processes ) { + if ( !available ) { + process.netDownload = -1; + process.netUpload = -1; + continue; + } + + auto traffic = mTraffic.find( process.pid ); + if ( traffic == mTraffic.end() ) { + process.netDownload = 0; + process.netUpload = 0; + } else { + process.netDownload = rateFor( traffic->second.download, elapsed ); + process.netUpload = rateFor( traffic->second.upload, elapsed ); + } + } + + mTraffic.clear(); + mLastSnapshot = now; +} + +void ProcessNetworkMonitor::captureLoop() { + char errorBuffer[PCAP_ERRBUF_SIZE] = {}; + pcap_t* capture = pcap_create( nullptr, errorBuffer ); + if ( !capture ) { + Log::warning( "eproc: could not start per-process network capture: %s", errorBuffer ); + return; + } + + pcap_set_snaplen( capture, 256 ); + pcap_set_promisc( capture, 0 ); + pcap_set_timeout( capture, 250 ); + int result = pcap_activate( capture ); + if ( result < 0 ) { + Log::warning( "eproc: per-process network capture is unavailable: %s", + pcap_geterr( capture ) ); + pcap_close( capture ); + return; + } + + bpf_program filter; + const int compileResult = + pcap_compile( capture, &filter, "tcp or udp", 1, PCAP_NETMASK_UNKNOWN ); + if ( compileResult < 0 ) { + Log::warning( "eproc: could not install the per-process network capture filter: %s", + pcap_geterr( capture ) ); + pcap_close( capture ); + return; + } + if ( pcap_setfilter( capture, &filter ) < 0 ) { + Log::warning( "eproc: could not install the per-process network capture filter: %s", + pcap_geterr( capture ) ); + pcap_freecode( &filter ); + pcap_close( capture ); + return; + } + pcap_freecode( &filter ); + + mDataLink = pcap_datalink( capture ); + if ( mDataLink < 0 ) { + pcap_close( capture ); + return; + } + + mAvailable.store( true ); + while ( mRunning.load() ) { + pcap_pkthdr* header = nullptr; + const u_char* data = nullptr; + result = pcap_next_ex( capture, &header, &data ); + if ( result == 1 ) + processPacket( mDataLink, data, header->caplen, header->len ); + else if ( result == PCAP_ERROR_BREAK || result == PCAP_ERROR ) + break; + } + + mAvailable.store( false ); + pcap_close( capture ); +} + +} // namespace eproc diff --git a/src/tools/eproc/platform/linux/process_network_monitor.hpp b/src/tools/eproc/platform/linux/process_network_monitor.hpp new file mode 100644 index 000000000..f78aaa59b --- /dev/null +++ b/src/tools/eproc/platform/linux/process_network_monitor.hpp @@ -0,0 +1,87 @@ +#ifndef EPROC_PROCESS_NETWORK_MONITOR_HPP +#define EPROC_PROCESS_NETWORK_MONITOR_HPP + +#include "../../process_info.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace eproc { + +/** Collects per-process network traffic on Linux. + * + * Linux does not expose network byte counters per process. The implementation therefore captures + * TCP/UDP packet headers and joins their local endpoints with socket inodes from procfs. Packet + * capture runs continuously in its own thread; the process collector only refreshes the procfs + * ownership map and consumes the counters at snapshot time. + */ +class ProcessNetworkMonitor { + public: + ProcessNetworkMonitor(); + ~ProcessNetworkMonitor(); + + /** Refreshes the socket endpoint to PID map. This is intended for the collection worker. */ + void refreshMapping(); + + /** Copies the current byte rates into @p processes and starts a new measurement interval. */ + void applyRates( std::vector& processes ); + + private: + struct Endpoint { + Uint8 family{ 0 }; + Uint16 port{ 0 }; + std::array address{}; + + bool operator==( const Endpoint& other ) const { + return family == other.family && port == other.port && address == other.address; + } + }; + + struct EndpointHash { + std::size_t operator()( const Endpoint& endpoint ) const noexcept; + }; + + struct Traffic { + Uint64 download{ 0 }; + Uint64 upload{ 0 }; + }; + + using EndpointMap = UnorderedMap; + using TrafficMap = UnorderedMap; + + void captureLoop(); + void processPacket( int dataLink, const Uint8* data, size_t length, size_t wireLength ); + + static bool parsePacket( int dataLink, const Uint8* data, size_t length, Endpoint& source, + Endpoint& destination ); + static bool parseProcEndpoint( std::string_view token, Endpoint& endpoint ); + static bool parseSocketLine( std::string_view line, Endpoint& endpoint, Uint64& inode ); + static bool parseUnsigned( std::string_view token, Uint64& value, int base ); + + static void collectSocketOwners( UnorderedMap& owners ); + static void collectSocketTable( const char* path, Uint8 family, + const UnorderedMap& owners, + EndpointMap& endpoints ); + + long findPid( const Endpoint& endpoint ) const; + static long rateFor( Uint64 bytes, std::chrono::steady_clock::duration elapsed ); + + std::atomic mRunning{ true }; + std::atomic mAvailable{ false }; + std::thread mCaptureThread; + int mDataLink{ -1 }; + + mutable std::mutex mMutex; + EndpointMap mEndpoints; + TrafficMap mTraffic; + std::chrono::steady_clock::time_point mLastSnapshot; +}; + +} // namespace eproc + +#endif // EPROC_PROCESS_NETWORK_MONITOR_HPP diff --git a/src/tools/eproc/process_collector.cpp b/src/tools/eproc/process_collector.cpp new file mode 100644 index 000000000..4b47c792a --- /dev/null +++ b/src/tools/eproc/process_collector.cpp @@ -0,0 +1,37 @@ +#include "process_collector.hpp" +#include + +#if EE_PLATFORM == EE_PLATFORM_LINUX +#include "platform/linux/process_collector_linux.hpp" + +#include +#include +#endif + +using namespace EE::System; + +namespace eproc { + +std::unique_ptr ProcessCollector::create() { +#if EE_PLATFORM == EE_PLATFORM_LINUX + return std::make_unique(); +#else + Log::error( "eproc: no process collector available for this platform" ); + return nullptr; +#endif +} + +bool sendProcessSignal( long pid, int signal ) { +#if EE_PLATFORM == EE_PLATFORM_LINUX + return ::kill( static_cast( pid ), signal ) == 0; +#else + Log::error( "eproc: sendProcessSignal is not implemented for this platform" ); + return false; +#endif +} + +bool killProcess( long pid ) { + return sendProcessSignal( pid, 9 ); // SIGKILL +} + +} // namespace eproc diff --git a/src/tools/eproc/process_collector.hpp b/src/tools/eproc/process_collector.hpp new file mode 100644 index 000000000..f94375f31 --- /dev/null +++ b/src/tools/eproc/process_collector.hpp @@ -0,0 +1,51 @@ +#ifndef EPROC_PROCESS_COLLECTOR_HPP +#define EPROC_PROCESS_COLLECTOR_HPP + +#include "process_info.hpp" +#include +#include + +namespace eproc { + +struct SystemInfo { + long totalMemory{ 0 }; + long freeMemory{ 0 }; + long availableMemory{ 0 }; + long totalSwap{ 0 }; + long freeSwap{ 0 }; + int cpuCount{ 1 }; + float cpuUsage{ 0.f }; + long clockTicksPerSecond{ 100 }; + double uptimeSeconds{ 0.0 }; + + long getTotalMemoryKB() const { return totalMemory; } + long getUsedMemoryKB() const { return totalMemory - availableMemory; } + long getUsedSwapKB() const { return totalSwap - freeSwap; } +}; + +class ProcessCollector { + public: + virtual ~ProcessCollector() = default; + + /** Collects every process and the system-wide counters into the given buffers. + * Not thread-safe: one instance must never be used concurrently, because per-process CPU + * usage is derived from the delta against the previous sample held by the instance. + * @return true on success. */ + virtual bool collect( std::vector& processes, SystemInfo& sysInfo ) = 0; + + /** Returns the collector for the running OS, or nullptr when the platform is unsupported. */ + static std::unique_ptr create(); + + protected: + ProcessCollector() = default; +}; + +/** Sends a process signal (e.g. SIGTERM 15, SIGKILL 9) to @p pid. Returns true on success. */ +bool sendProcessSignal( long pid, int signal ); + +/** Sends SIGKILL to @p pid. Returns true on success. */ +bool killProcess( long pid ); + +} // namespace eproc + +#endif // EPROC_PROCESS_COLLECTOR_HPP diff --git a/src/tools/eproc/process_info.cpp b/src/tools/eproc/process_info.cpp new file mode 100644 index 000000000..7defc9cb3 --- /dev/null +++ b/src/tools/eproc/process_info.cpp @@ -0,0 +1,148 @@ +#include "process_info.hpp" + +#include + +namespace eproc { + +static std::string formatScaledIEC( double amount, const char* const* units, size_t unitCount ) { + for ( size_t i = 0; i + 1 < unitCount; ++i ) { + if ( amount < 1024.0 ) { + char buf[32]; + snprintf( buf, sizeof( buf ), "%.1f %s", amount, units[i] ); + return buf; + } + amount /= 1024.0; + } + + char buf[32]; + snprintf( buf, sizeof( buf ), "%.1f %s", amount, units[unitCount - 1] ); + return buf; +} + +std::string formatBytesPerSecond( long bytes ) { + if ( bytes <= 0 ) + return {}; + + static const char* const units[] = { "K/s", "M/s", "G/s", "T/s" }; + if ( bytes < 1024 ) + return std::to_string( bytes ) + " B/s"; + return formatScaledIEC( static_cast( bytes ) / 1024.0, units, 4 ); +} + +std::string formatBytes( long long bytes ) { + if ( bytes < 0 ) + return {}; + + if ( bytes < 1024 ) + return std::to_string( bytes ) + " B"; + + static const char* const units[] = { "KiB", "MiB", "GiB", "TiB", "PiB" }; + return formatScaledIEC( static_cast( bytes ) / 1024.0, units, 5 ); +} + +// Mirrors ksysguard6's ProcessModel::formatByteSize(): the unit is picked with a 0.9 hysteresis +// threshold so a value never reads as "1.0" of a unit it barely exceeds, kilobytes stay integral, +// and larger units keep one decimal. That is what produces "0 K", "853.8 M" and "4.2 G". +std::string formatKiB( long kib ) { + if ( kib < 0 ) + return {}; + + static const double KiB = 1024.0; + static const char* const units[] = { "M", "G", "T", "P" }; + + if ( static_cast( kib ) < KiB * 0.9 ) + return std::to_string( kib ) + " K"; + + double amount = kib; + for ( size_t i = 0; i + 1 < 4; ++i ) { + amount /= KiB; + if ( amount < KiB * 0.9 ) { + char buf[32]; + snprintf( buf, sizeof( buf ), "%.1f %s", amount, units[i] ); + return buf; + } + } + + char buf[32]; + snprintf( buf, sizeof( buf ), "%.1f %s", amount / KiB, units[3] ); + return buf; +} + +// IEC form used by the status bar: "3.2 MiB", "62.7 GiB". Kilobyte amounts stay integral, +// matching how the original renders small memory totals. +std::string formatKiBIEC( long kib ) { + if ( kib < 0 ) + return {}; + + if ( kib < 1024 ) + return std::to_string( kib ) + " KiB"; + + static const char* const units[] = { "MiB", "GiB", "TiB", "PiB" }; + return formatScaledIEC( static_cast( kib ) / 1024.0, units, 4 ); +} + +std::string ProcessInfo::formatMemory() const { + return formatKiB( getMemoryForSort() ); +} + +std::string ProcessInfo::formatSharedMem() const { + return formatKiB( sharedMem ); +} + +std::string ProcessInfo::formatCpu() const { + int total = userUsage + sysUsage; + if ( total <= 0 ) + return {}; + return std::to_string( total ) + "%"; +} + +std::string ProcessInfo::formatGpuUsage() const { + if ( gpuUsage < 0 ) + return {}; + return std::to_string( gpuUsage ) + "%"; +} + +std::string ProcessInfo::formatGpuMemory() const { + if ( gpuMemory < 0 ) + return {}; + return formatKiBIEC( gpuMemory ); +} + +std::string ProcessInfo::formatDownload() const { + return formatBytesPerSecond( netDownload ); +} + +std::string ProcessInfo::formatUpload() const { + return formatBytesPerSecond( netUpload ); +} + +std::string ProcessInfo::formatCpuTime( long ticksPerSecond ) const { + const long long totalTicks = static_cast( userTime ) + sysTime; + if ( totalTicks < 0 || ticksPerSecond <= 0 ) + return {}; + + const long long totalSeconds = totalTicks / ticksPerSecond; + char buffer[32]; + snprintf( buffer, sizeof( buffer ), "%lld:%02lld", totalSeconds / 60, totalSeconds % 60 ); + return buffer; +} + +std::string ProcessInfo::formatRelativeStartTime( double uptimeSeconds, + long ticksPerSecond ) const { + if ( startTime <= 0 || uptimeSeconds < 0 || ticksPerSecond <= 0 ) + return {}; + + const double age = uptimeSeconds - static_cast( startTime ) / ticksPerSecond; + if ( age < 0 ) + return {}; + + const long long totalSeconds = static_cast( age ); + const long long hours = totalSeconds / 3600; + const long long minutes = ( totalSeconds / 60 ) % 60; + const long long seconds = totalSeconds % 60; + char buffer[48]; + snprintf( buffer, sizeof( buffer ), "%lld:%02lld:%02lld", hours, minutes, seconds ); + return buffer; +} + +} // namespace eproc diff --git a/src/tools/eproc/process_info.hpp b/src/tools/eproc/process_info.hpp new file mode 100644 index 000000000..1de850f5e --- /dev/null +++ b/src/tools/eproc/process_info.hpp @@ -0,0 +1,125 @@ +#ifndef EPROC_PROCESS_INFO_HPP +#define EPROC_PROCESS_INFO_HPP + +#include +#include + +using namespace EE; + +namespace eproc { + +enum class ProcessStatus : Uint8 { + Running, + Sleeping, + DiskSleep, + Zombie, + Stopped, + Paging, + Ended, + Other +}; + +struct ProcessInfo { + long pid{ 0 }; + long parentPid{ 0 }; + std::string name; + std::string username; + + // Real, effective, saved and filesystem user ids (all four from the status "Uid:" line). + long uid{ 0 }; + long euid{ 0 }; + long suid{ 0 }; + long fsuid{ 0 }; + // True when the account's shell is a real login shell (used to tell system users apart). + bool canLogin{ false }; + // Same check for the effective uid, which the original's User Processes filter also consults. + bool euidCanLogin{ false }; + + ProcessStatus status{ ProcessStatus::Other }; + + // CPU + int userUsage{ 0 }; + int sysUsage{ 0 }; + long userTime{ 0 }; + long sysTime{ 0 }; + + // Memory (kilobytes) + long vmSize{ 0 }; + long vmRSS{ 0 }; + // Private memory: resident pages the process does not share with anyone else. This is what + // ksysguard6 shows in its Memory column (VmRSS - shared, from statm), and -1 means the kernel + // did not report the shared breakdown so the display falls back to vmRSS. + long vmURSS{ -1 }; + long vmPSS{ 0 }; + long sharedMem{ 0 }; + // True when the kernel reported the shared-memory breakdown, so vmURSS could be derived. + bool hasSharedInfo{ false }; + + // Network (bytes, -1 if not available) + long netDownload{ -1 }; + long netUpload{ -1 }; + + // GPU (percentage, -1 if not available) + int gpuUsage{ -1 }; + long gpuMemory{ -1 }; + + // Disk I/O totals (bytes, -1 if not available) + long long ioReadBytes{ -1 }; + long long ioWriteBytes{ -1 }; + + // Other + int niceLevel{ 0 }; + int numThreads{ 0 }; + // The complete command line, reconstructed from argv. The command column uses only the + // executable name, while this value is used by actions that need the original arguments. + std::string commandLine; + std::string command; + // Controlling terminal device number, 0 when the process has none. + long ttyNr{ 0 }; + // Friendly Linux tty name (for example, "pts/2"). Empty when there is no controlling tty. + std::string tty; + // PID of the process tracing (debugging) this one, 0 when it is not traced. + long tracerPid{ 0 }; + // Process start time in clock ticks since boot (stat field 22). Together with the pid it + // identifies one incarnation of a process, which matters because pids are recycled. + long long startTime{ 0 }; + // Resolved icon file for this process, empty when none could be found. + std::string iconPath; + + // Sorting helpers + /** Private memory when known, otherwise plain RSS. */ + long getMemoryForSort() const { return vmURSS >= 0 ? vmURSS : vmRSS; } + + int getCpuForSort() const { return userUsage + sysUsage; } + + // Formatted display strings + std::string formatMemory() const; + std::string formatSharedMem() const; + std::string formatCpu() const; + std::string formatGpuUsage() const; + std::string formatGpuMemory() const; + std::string formatDownload() const; + std::string formatUpload() const; + std::string formatCpuTime( long ticksPerSecond ) const; + std::string formatRelativeStartTime( double uptimeSeconds, long ticksPerSecond ) const; +}; + +/** Formats a KiB amount using the largest fitting binary unit (K/M/G/T/P), matching the short + * form used by the original process table (e.g. "0 K", "853.8 M", "4.2 G"). + * Returns an empty string for negative input. */ +std::string formatKiB( long kib ); + +/** Formats a KiB amount using IEC units (KiB/MiB/GiB/TiB/PiB), used by the status bar. + * Returns an empty string for negative input. */ +std::string formatKiBIEC( long kib ); + +/** Formats a byte-per-second amount using the largest fitting binary unit (B/K/M/G). + * Returns an empty string for zero or negative input. */ +std::string formatBytesPerSecond( long bytes ); + +/** Formats a byte total using binary units. Returns an empty string for negative input. */ +std::string formatBytes( long long bytes ); + +} // namespace eproc + +#endif // EPROC_PROCESS_INFO_HPP diff --git a/src/tools/eproc/process_model.cpp b/src/tools/eproc/process_model.cpp new file mode 100644 index 000000000..bfd292185 --- /dev/null +++ b/src/tools/eproc/process_model.cpp @@ -0,0 +1,527 @@ +#include "process_model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if EE_PLATFORM != EE_PLATFORM_WIN +#include +#endif + +namespace eproc { + +namespace { + +const char* processColumnClass( size_t column ) { + switch ( column ) { + case ProcessModel::ColIcon: + return "eproc-process-column-icon"; + case ProcessModel::ColName: + return "eproc-process-column-name"; + case ProcessModel::ColPid: + return "eproc-process-column-pid"; + case ProcessModel::ColUsername: + return "eproc-process-column-username"; + case ProcessModel::ColCpu: + return "eproc-process-column-cpu"; + case ProcessModel::ColMemory: + return "eproc-process-column-memory"; + case ProcessModel::ColSharedMem: + return "eproc-process-column-shared-memory"; + case ProcessModel::ColGpuUsage: + return "eproc-process-column-gpu-usage"; + case ProcessModel::ColGpuMemory: + return "eproc-process-column-gpu-memory"; + case ProcessModel::ColDownload: + return "eproc-process-column-download"; + case ProcessModel::ColUpload: + return "eproc-process-column-upload"; + case ProcessModel::ColCommand: + return "eproc-process-column-command"; + case ProcessModel::ColTotalMemory: + return "eproc-process-column-total-memory"; + case ProcessModel::ColVirtualSize: + return "eproc-process-column-virtual-size"; + case ProcessModel::ColCpuTime: + return "eproc-process-column-cpu-time"; + case ProcessModel::ColNiceness: + return "eproc-process-column-niceness"; + case ProcessModel::ColRelativeStartTime: + return "eproc-process-column-relative-start-time"; + case ProcessModel::ColTty: + return "eproc-process-column-tty"; + case ProcessModel::ColIoRead: + return "eproc-process-column-io-read"; + case ProcessModel::ColIoWrite: + return "eproc-process-column-io-write"; + default: + return "eproc-process-column-base"; + } +} + +} // namespace + +ProcessModel::ProcessModel( UISceneNode* ui ) : mUI( ui ) {} + +ProcessModel::~ProcessModel() {} + +size_t ProcessModel::rowCount( const ModelIndex& ) const { + return mFilteredProcesses.size(); +} + +size_t ProcessModel::columnCount( const ModelIndex& ) const { + return ColCount; +} + +std::string ProcessModel::columnName( const size_t& column ) const { + switch ( column ) { + case ColIcon: + return ""; + case ColName: + return mUI->i18n( "eproc_column_name", "Name" ).toUtf8(); + case ColPid: + return mUI->i18n( "eproc_column_pid", "PID" ).toUtf8(); + case ColUsername: + return mUI->i18n( "eproc_column_username", "Username" ).toUtf8(); + case ColCpu: + return mUI->i18n( "eproc_column_cpu", "CPU %" ).toUtf8(); + case ColMemory: + return mUI->i18n( "eproc_column_memory", "Memory" ).toUtf8(); + case ColSharedMem: + return mUI->i18n( "eproc_column_shared_memory", "Shared Mem" ).toUtf8(); + case ColGpuUsage: + return mUI->i18n( "eproc_column_gpu_usage", "GPU Usage" ).toUtf8(); + case ColGpuMemory: + return mUI->i18n( "eproc_column_gpu_memory", "GPU Memory" ).toUtf8(); + case ColDownload: + return mUI->i18n( "eproc_column_download", "Download" ).toUtf8(); + case ColUpload: + return mUI->i18n( "eproc_column_upload", "Upload" ).toUtf8(); + case ColCommand: + return mUI->i18n( "eproc_column_command", "Command" ).toUtf8(); + case ColTotalMemory: + return mUI->i18n( "eproc_column_total_memory", "Total Memory" ).toUtf8(); + case ColVirtualSize: + return mUI->i18n( "eproc_column_virtual_size", "Virtual Size" ).toUtf8(); + case ColCpuTime: + return mUI->i18n( "eproc_column_cpu_time", "CPU Time" ).toUtf8(); + case ColNiceness: + return mUI->i18n( "eproc_column_niceness", "Niceness" ).toUtf8(); + case ColRelativeStartTime: + return mUI->i18n( "eproc_column_relative_start_time", "Relative Start Time" ).toUtf8(); + case ColTty: + return mUI->i18n( "eproc_column_tty", "TTY" ).toUtf8(); + case ColIoRead: + return mUI->i18n( "eproc_column_io_read", "IO Read" ).toUtf8(); + case ColIoWrite: + return mUI->i18n( "eproc_column_io_write", "IO Write" ).toUtf8(); + default: + return {}; + } +} + +Variant ProcessModel::data( const ModelIndex& index, ModelRole role ) const { + const std::string EMPTY = ""; + + if ( role == ModelRole::Class ) { + const char* cls = processColumnClass( index.column() ); + return cls ? Variant( cls ) : Variant(); + } + + if ( role == ModelRole::Icon ) { + // The icon lives in its own column so that every icon lines up, instead of padding the + // name text. + const auto* proc = getProcessByRow( index.row() ); + if ( !proc || index.column() != ColIcon || proc->iconPath.empty() ) + return Variant(); + return Variant( iconFor( proc->iconPath ) ); + } + + if ( role == ModelRole::Sort ) { + const auto* proc = getProcessByRow( index.row() ); + if ( !proc ) + return Variant(); + switch ( index.column() ) { + case ColPid: + return Variant( static_cast( proc->pid ) ); + case ColCpu: + return Variant( static_cast( proc->getCpuForSort() ) ); + case ColMemory: + return Variant( static_cast( proc->getMemoryForSort() ) ); + case ColSharedMem: + return Variant( static_cast( proc->sharedMem ) ); + case ColGpuUsage: + return Variant( static_cast( proc->gpuUsage ) ); + case ColGpuMemory: + return Variant( static_cast( proc->gpuMemory ) ); + case ColDownload: + return Variant( static_cast( proc->netDownload ) ); + case ColUpload: + return Variant( static_cast( proc->netUpload ) ); + case ColTotalMemory: + return Variant( static_cast( proc->vmRSS ) ); + case ColVirtualSize: + return Variant( static_cast( proc->vmSize ) ); + case ColCpuTime: + return Variant( static_cast( proc->userTime ) + proc->sysTime ); + case ColNiceness: + return Variant( static_cast( proc->niceLevel ) ); + case ColRelativeStartTime: + return Variant( static_cast( proc->startTime ) ); + case ColTty: + return Variant( static_cast( proc->ttyNr ) ); + case ColIoRead: + return Variant( static_cast( proc->ioReadBytes ) ); + case ColIoWrite: + return Variant( static_cast( proc->ioWriteBytes ) ); + default: + break; + } + } + + if ( role != ModelRole::Display ) + return Variant(); + + const auto* proc = getProcessByRow( index.row() ); + if ( !proc ) + return Variant(); + + switch ( index.column() ) { + case ColName: + return Variant( proc->name ); + case ColPid: + return Variant( String::toString( static_cast( proc->pid ) ) ); + case ColUsername: + return Variant( proc->username ); + case ColCpu: { + std::string cpu = proc->formatCpu(); + return Variant( cpu ); + } + case ColMemory: + return Variant( proc->formatMemory() ); + case ColSharedMem: + return Variant( proc->formatSharedMem() ); + case ColGpuUsage: + return Variant( proc->formatGpuUsage() ); + case ColGpuMemory: + return Variant( proc->formatGpuMemory() ); + case ColDownload: + return Variant( proc->formatDownload() ); + case ColUpload: + return Variant( proc->formatUpload() ); + case ColCommand: + return Variant( proc->command ); + case ColTotalMemory: + return Variant( formatKiB( proc->vmRSS ) ); + case ColVirtualSize: + return Variant( formatKiB( proc->vmSize ) ); + case ColCpuTime: + return Variant( proc->formatCpuTime( mSystemInfo.clockTicksPerSecond ) ); + case ColNiceness: + return Variant( String::toString( static_cast( proc->niceLevel ) ) ); + case ColRelativeStartTime: + return Variant( proc->formatRelativeStartTime( mSystemInfo.uptimeSeconds, + mSystemInfo.clockTicksPerSecond ) ); + case ColTty: + return Variant( proc->tty ); + case ColIoRead: + return Variant( formatBytes( proc->ioReadBytes ) ); + case ColIoWrite: + return Variant( formatBytes( proc->ioWriteBytes ) ); + default: + return Variant( EMPTY ); + } +} + +ModelIndex ProcessModel::index( int row, int column, const ModelIndex& ) const { + return createIndex( row, column ); +} + +void ProcessModel::applySnapshot( std::vector&& processes, + const SystemInfo& sysInfo ) { + // 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. + UnorderedMap currentStartTimes; + currentStartTimes.reserve( processes.size() ); + for ( const auto& process : processes ) + currentStartTimes[process.pid] = process.startTime; + + std::vector endedProcesses; + endedProcesses.reserve( mProcesses.size() ); + for ( auto& previous : mProcesses ) { + // An ended process was already shown during the previous update. Do not keep it for a + // second update. + if ( previous.status == ProcessStatus::Ended ) + continue; + + auto current = currentStartTimes.find( previous.pid ); + const bool processStillExists = current != currentStartTimes.end() && + ( previous.startTime == 0 || current->second == 0 || + previous.startTime == current->second ); + if ( processStillExists ) + continue; + + previous.status = ProcessStatus::Ended; + endedProcesses.emplace_back( std::move( previous ) ); + } + + processes.reserve( processes.size() + endedProcesses.size() ); + for ( auto& process : endedProcesses ) + processes.emplace_back( std::move( process ) ); + + mProcesses = std::move( processes ); + mSystemInfo = sysInfo; + applyFilters(); + onModelUpdate(); +} + +void ProcessModel::setFilter( FilterMode mode ) { + mFilterMode = mode; + applyFilters(); + onModelUpdate(); +} + +void ProcessModel::setTextFilter( const std::string& text ) { + mTextRegex.reset(); + mTextLiteral.clear(); + + if ( !text.empty() ) { + // Compiled here rather than per row: the filter runs over every process on every pass. + // useCache is false because the pattern changes on each keystroke: the cache is + // least-recently-used and shared with the syntax definitions, so caching every typed prefix + // would evict the patterns the tokenizer is working from. + // AllowFallback keeps the default engine behaviour: a pattern PCRE2 rejects is retried + // with Oniguruma before it is treated as invalid. + auto regex = std::make_unique( + text, RegEx::Options::Utf | RegEx::Options::AllowFallback | RegEx::Options::Caseless, + false ); + if ( regex->isValid() ) { + mTextRegex = std::move( regex ); + } else { + // An unfinished pattern matches nothing, which would blank the table while the user is + // still typing it, so it is searched for literally instead. + mTextLiteral = text; + std::transform( mTextLiteral.begin(), mTextLiteral.end(), mTextLiteral.begin(), + []( unsigned char c ) { return std::tolower( c ); } ); + } + } + + applyFilters(); + onModelUpdate(); +} + +// Case-insensitive substring test that does not allocate. +static bool containsIgnoreCase( std::string_view haystack, std::string_view lowerNeedle ) { + if ( lowerNeedle.empty() ) + return true; + if ( haystack.size() < lowerNeedle.size() ) + return false; + + for ( size_t i = 0; i + lowerNeedle.size() <= haystack.size(); ++i ) { + size_t j = 0; + for ( ; j < lowerNeedle.size(); ++j ) { + if ( std::tolower( static_cast( haystack[i + j] ) ) != lowerNeedle[j] ) + break; + } + if ( j == lowerNeedle.size() ) + return true; + } + return false; +} + +bool ProcessModel::matchesText( const ProcessInfo& proc ) const { + if ( !mTextRegex && mTextLiteral.empty() ) + return true; + + // The original lets the user search by PID too. The digits are formatted into a stack buffer + // because this runs for every process on every pass while a filter is active. + char pidBuffer[24]; + int pidLength = snprintf( pidBuffer, sizeof( pidBuffer ), "%ld", proc.pid ); + size_t pidSize = pidLength > 0 ? static_cast( pidLength ) : 0; + + if ( mTextRegex ) { + // Each column is matched on its own, so an anchored pattern applies to every one of them. + return mTextRegex->matches( proc.name ) || mTextRegex->matches( proc.command ) || + mTextRegex->matches( proc.username ) || + mTextRegex->matches( pidBuffer, 0, nullptr, pidSize ); + } + + const std::string_view pid( pidBuffer, pidSize ); + return containsIgnoreCase( proc.name, mTextLiteral ) || + containsIgnoreCase( proc.command, mTextLiteral ) || + containsIgnoreCase( proc.username, mTextLiteral ) || + containsIgnoreCase( pid, mTextLiteral ); +} + +bool ProcessModel::accepts( const ProcessInfo& proc ) const { + switch ( mFilterMode ) { + case AllProcesses: + return true; + + case SystemProcesses: + // System accounts are those below uid 100, or accounts that cannot log in. + return proc.uid < 100 || !proc.canLogin; + + case UserProcesses: + // Keep a process when either its real or effective id belongs to a login-capable + // account, mirroring the original's paired check. + return ( proc.uid >= 100 && proc.canLogin ) || + ( proc.euid >= 100 && proc.euidCanLogin ); + + case OwnProcesses: { +#if EE_PLATFORM == EE_PLATFORM_WIN + const long own = -1; +#else + const long own = static_cast( getuid() ); +#endif + return proc.uid == own || proc.euid == own || proc.suid == own || proc.fsuid == own; + } + + case ProgramsOnly: { + // A "program" owns a terminal or a GUI window. login/getty *are* the tty rather than + // something started from it, so the original hides them as well. + if ( proc.ttyNr == 0 && mGuiPids.count( proc.pid ) == 0 ) + return false; + if ( proc.parentPid == 1 && + ( proc.name == "login" || + proc.name.compare( std::max( 0, (int)proc.name.size() - 5 ), 5, "getty" ) == + 0 ) ) + return false; + return true; + } + + default: + return true; + } +} + +void ProcessModel::applyFilters() { + mFilteredProcesses.clear(); + mFilteredProcesses.reserve( mProcesses.size() ); + + for ( auto& proc : mProcesses ) { + if ( !accepts( proc ) || !matchesText( proc ) ) + continue; + + mFilteredProcesses.push_back( &proc ); + } +} + +void ProcessModel::setGuiWindowPids( UnorderedSet&& pids ) { + mGuiPids = std::move( pids ); +} + +// nanosvg substitutes its own default (white) fill for the SVG constructs it cannot parse, so an +// unsupported icon rasterizes to a blank white square. Such a result is reported as "no icon" +// rather than drawn as a misleading box. +static bool isBlankIcon( const Image& image ) { + const Uint8* pixels = image.getPixelsPtr(); + const unsigned int channels = image.getChannels(); + const unsigned int width = image.getWidth(); + const unsigned int height = image.getHeight(); + + if ( !pixels || width == 0 || height == 0 || channels < 3 ) + return false; // no pixel access: trust the image rather than dropping the icon + + for ( unsigned int y = 0; y < height; ++y ) { + for ( unsigned int x = 0; x < width; ++x ) { + const Uint8* pixel = pixels + ( static_cast( y ) * width + x ) * channels; + if ( channels == 4 && pixel[3] < 8 ) + continue; // fully transparent + if ( pixel[0] < 240 || pixel[1] < 240 || pixel[2] < 240 ) + return false; // real content + } + } + + return true; +} + +// The original renders 16px icons in the name column. +static constexpr int kIconSizeDp = 16; + +DrawablePtr ProcessModel::iconFor( const std::string& path ) const { + auto it = mIconCache.find( path ); + if ( it != mIconCache.end() ) + return it->second; + + // Loading is done once per icon file: the table asks for this on every cell refresh, and + // re-loading the texture each time would be pathological. + const Uint32 iconPx = static_cast( PixelDensity::dpToPxI( kIconSizeDp ) ); + Image image; + + if ( String::endsWith( path, ".svg" ) || String::endsWith( path, ".svgz" ) ) { + // Scalable icons are rasterized before resizing, so the same high-quality image resampler + // is used for both SVG and bitmap icons. + std::ifstream file( path, std::ios::binary ); + std::string svg( ( std::istreambuf_iterator( file ) ), + std::istreambuf_iterator() ); + if ( !svg.empty() ) { + Image::FormatConfiguration format; + int width = 0, height = 0, channels = 0; + if ( Image::getInfoFromMemory( reinterpret_cast( svg.data() ), + svg.size(), &width, &height, &channels, format ) ) { + format.svgScale( iconPx / static_cast( eemax( width, height ) ) ); + image = Image( reinterpret_cast( svg.data() ), + static_cast( svg.size() ), 4, format ); + } + } + } else { + image = Image( path, 4 ); + } + + // Decode at the final pixel size and use Lanczos resampling for bitmap icons. This avoids + // relying on the GPU's texture filtering to reduce large process icons at draw time. + if ( image.getPixelsPtr() && image.getWidth() > 0 && image.getHeight() > 0 ) { + if ( image.getWidth() != iconPx || image.getHeight() != iconPx ) + image.resize( iconPx, iconPx, Image::RESAMPLER_LANCZOS4 ); + + if ( isBlankIcon( image ) ) + image = Image(); + } + + TexturePtr texture; + if ( image.getPixelsPtr() && image.getWidth() > 0 && image.getHeight() > 0 ) { + texture = TextureFactory::instance()->loadFromPixels( + image.getPixelsPtr(), image.getWidth(), image.getHeight(), image.getChannels(), false, + Texture::ClampMode::ClampToEdge, false, false, path ); + } + + // The table draws a ModelRole::Icon drawable at its own size, so the icon is built already + // scaled to the row height; at native resolution it would overflow the row. + DrawablePtr drawable; + if ( texture ) { + // GlyphDrawable feeds the source rect to quadsSetTexCoord as u/v, so the rect is + // normalized: (0,0,1,1) selects the whole texture. + Rect srcRect( 0, 0, 1, 1 ); + auto* glyph = GlyphDrawable::New( texture, srcRect, Sizef( iconPx, iconPx ), path ); + glyph->setDrawMode( GlyphDrawable::DrawMode::Image ); + glyph->setGlyphRenderMode( GlyphRenderMode::Color ); + glyph->setPixelDensity( PixelDensity::getPixelDensity() ); + drawable = DrawablePtr( glyph ); + } + + mIconCache.emplace( path, drawable ); + return drawable; +} + +const ProcessInfo* ProcessModel::getProcessByRow( int row ) const { + if ( row < 0 || static_cast( row ) >= mFilteredProcesses.size() ) + return nullptr; + return mFilteredProcesses[row]; +} + +int ProcessModel::rowForPid( long pid ) const { + for ( size_t i = 0; i < mFilteredProcesses.size(); ++i ) { + if ( mFilteredProcesses[i]->pid == pid ) + return static_cast( i ); + } + return -1; +} + +} // namespace eproc diff --git a/src/tools/eproc/process_model.hpp b/src/tools/eproc/process_model.hpp new file mode 100644 index 000000000..5e1cb65bc --- /dev/null +++ b/src/tools/eproc/process_model.hpp @@ -0,0 +1,144 @@ +#ifndef EPROC_PROCESS_MODEL_HPP +#define EPROC_PROCESS_MODEL_HPP + +#include "process_collector.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace EE::UI { +class UISceneNode; +} + +using namespace EE; +using namespace EE::Graphics; +using namespace EE::System; +using namespace EE::UI; +using namespace EE::UI::Models; + +namespace eproc { + +/** Presents a snapshot of the process list to the views. + * + * Collection happens off the UI thread (see App::collectAsync), so this model never reads + * /proc itself: it only owns the last snapshot handed to it by applySnapshot(). All methods are + * UI-thread only. */ +class ProcessModel : public Model { + public: + enum Columns : size_t { + ColIcon = 0, + ColName, + ColPid, + ColUsername, + ColCpu, + ColMemory, + ColSharedMem, + ColGpuUsage, + ColGpuMemory, + ColDownload, + ColUpload, + ColTotalMemory, + ColVirtualSize, + ColCpuTime, + ColNiceness, + ColRelativeStartTime, + ColTty, + ColIoRead, + ColIoWrite, + ColCommand, + ColCount + }; + + /** Mirrors ksysguard6's ProcessFilter::State, minus the two tree variants (this model is + * flat). The numeric order matches the original enum so the dropdown maps directly. */ + enum FilterMode { + AllProcesses = 0, + SystemProcesses, + UserProcesses, + OwnProcesses, + ProgramsOnly, + FilterModeCount + }; + + static std::shared_ptr create( UISceneNode* ui ) { + return std::shared_ptr( new ProcessModel( ui ) ); + } + + ~ProcessModel(); + + size_t rowCount( const ModelIndex& = ModelIndex() ) const override; + size_t columnCount( const ModelIndex& = ModelIndex() ) const override; + std::string columnName( const size_t& column ) const override; + Variant data( const ModelIndex& index, ModelRole role = ModelRole::Display ) const override; + ModelIndex index( int row, int column = 0, + 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 setFilter( FilterMode mode ); + + /** Quick search: a case-insensitive regular expression matched against the name, command, + * username and PID columns (the original also lets the user search by PID). */ + void setTextFilter( const std::string& text ); + + /** PIDs owning a top-level window, used by the Programs Only filter. Set this before + * applySnapshot so the filter sees current data. */ + void setGuiWindowPids( UnorderedSet&& pids ); + + FilterMode getFilter() const { return mFilterMode; } + const SystemInfo& getSystemInfo() const { return mSystemInfo; } + + /** Number of rows currently visible after filtering. */ + size_t visibleCount() const { return mFilteredProcesses.size(); } + + const ProcessInfo* getProcessByRow( int row ) const; + + /** Row of the currently visible list holding @p pid, or -1 when it is filtered out or gone. + * Used to re-select a process by identity across a snapshot, since rows reorder. */ + int rowForPid( long pid ) const; + + /** The icon column carries no data of its own, so sorting it is meaningless. */ + bool isColumnSortable( const size_t& columnIndex ) const override { + return columnIndex != ColIcon; + } + + /** Enables per-column CSS classes for process table cells. */ + bool classModelRoleEnabled() override { return true; } + + private: + explicit ProcessModel( UISceneNode* ui ); + + void applyFilters(); + + /** Applies the active filter mode to a single process, mirroring the original's predicates. */ + bool accepts( const ProcessInfo& proc ) const; + + bool matchesText( const ProcessInfo& proc ) const; + + /** Loads (once) and caches the drawable for an icon file. */ + DrawablePtr iconFor( const std::string& path ) const; + + std::vector mProcesses; + std::vector mFilteredProcesses; + UISceneNode* mUI{ nullptr }; + SystemInfo mSystemInfo; + FilterMode mFilterMode{ AllProcesses }; + // Compiled once per filter change, never per row. Null means "no text filter". + std::unique_ptr mTextRegex; + // Set only when the typed text does not compile as a pattern (a group still open, a stray + // quantifier): it is then searched literally, lowercased, so a half-typed pattern does not + // blank the table. Empty when it is not in use. + std::string mTextLiteral; + UnorderedSet mGuiPids; + mutable UnorderedMap mIconCache; +}; + +} // namespace eproc + +#endif // EPROC_PROCESS_MODEL_HPP