diff --git a/ApplicationExeCode/CMakeLists.txt b/ApplicationExeCode/CMakeLists.txt index 5915907992..57054c1227 100644 --- a/ApplicationExeCode/CMakeLists.txt +++ b/ApplicationExeCode/CMakeLists.txt @@ -239,6 +239,14 @@ if(UNIX AND NOT APPLE) target_link_libraries(ResInsight PRIVATE xcb) endif() +if(MSVC) + # Delay-load opengl32.dll so that the statically linked OpenGL calls can be + # redirected to the software rasterizer opengl32sw.dll in headless mode, see + # riaDelayLoadHook in RiaMainTools.cpp + target_link_libraries(ResInsight PRIVATE delayimp) + target_link_options(ResInsight PRIVATE /DELAYLOAD:opengl32.dll) +endif() + # ############################################################################## # Unity builds # ############################################################################## @@ -421,6 +429,14 @@ if(RESINSIGHT_PRIVATE_INSTALL) RUNTIME_DEPENDENCIES PRE_EXCLUDE_REGEXES "api-ms-" "ext-ms-" POST_EXCLUDE_REGEXES ".*system32/.*\\.dll" ) + + # The software OpenGL rasterizer is loaded dynamically (used by --headless), + # so it is not picked up by the RUNTIME_DEPENDENCIES scan above + install( + FILES $/opengl32sw.dll + DESTINATION ${RESINSIGHT_INSTALL_FOLDER} + OPTIONAL + ) endif() if(${CMAKE_SYSTEM_NAME} MATCHES "Linux") diff --git a/ApplicationExeCode/RiaMain.cpp b/ApplicationExeCode/RiaMain.cpp index 9a24d78b4b..bc69269639 100644 --- a/ApplicationExeCode/RiaMain.cpp +++ b/ApplicationExeCode/RiaMain.cpp @@ -86,6 +86,7 @@ void riaFilteredMessageHandler( QtMsgType type, const QMessageLogContext& contex RiaApplication* createApplication( int& argc, char* argv[] ) { + bool isHeadless = false; for ( int i = 1; i < argc; ++i ) { if ( !qstrcmp( argv[i], "--console" ) || !qstrcmp( argv[i], "--unittest" ) ) @@ -96,7 +97,18 @@ RiaApplication* createApplication( int& argc, char* argv[] ) return new RiaConsoleApplication( argc, argv ); #endif } + + if ( !qstrcmp( argv[i], "--headless" ) ) + { + isHeadless = true; + } } + + if ( isHeadless ) + { + RiaMainTools::enableHeadlessRendering(); + } + #ifdef ENABLE_GRPC return new RiaGrpcGuiApplication( argc, argv ); #else diff --git a/ApplicationExeCode/RiaMainTools.cpp b/ApplicationExeCode/RiaMainTools.cpp index eeca777a1f..e2403e609f 100644 --- a/ApplicationExeCode/RiaMainTools.cpp +++ b/ApplicationExeCode/RiaMainTools.cpp @@ -19,6 +19,7 @@ #include "RiaMainTools.h" #include "RiaBaseDefs.h" #include "RiaFileLogger.h" +#include "RiaGuiApplication.h" #include "RiaLogging.h" #include "RiaOpenTelemetryManager.h" #include "RiaPreferencesOpenTelemetry.h" @@ -41,6 +42,7 @@ #include #include #include +#include #include #include #include @@ -73,6 +75,74 @@ #endif #endif +#ifdef WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include + +#include +#endif + +#ifdef WIN32 +//-------------------------------------------------------------------------------------------------- +/// In headless mode Qt creates its OpenGL context using the software rasterizer opengl32sw.dll +/// (Qt::AA_UseSoftwareOpenGL), while the statically linked OpenGL calls in the visualization +/// framework would go to the system opengl32.dll where no context is current, causing an endless +/// loop in glGetError(). opengl32.dll is therefore delay-loaded (see CMakeLists.txt) and redirected +/// here to the same software rasterizer. +//-------------------------------------------------------------------------------------------------- +FARPROC WINAPI riaDelayLoadHook( unsigned dliNotify, PDelayLoadInfo pdli ) +{ + if ( dliNotify == dliNotePreLoadLibrary && RiaGuiApplication::isHeadless() && + _stricmp( pdli->szDll, "opengl32.dll" ) == 0 ) + { + if ( HMODULE softwareOpenGl = LoadLibraryW( L"opengl32sw.dll" ) ) + { + return reinterpret_cast( softwareOpenGl ); + } + } + + return nullptr; +} + +extern "C" const PfnDliHook __pfnDliNotifyHook2 = riaDelayLoadHook; + +//-------------------------------------------------------------------------------------------------- +/// ResInsight is built as a GUI application on Windows, so stdout is only available when redirected to a file or +/// pipe. Attach to the console of the launching process (e.g. PowerShell or cmd) so headless log output is visible. +//-------------------------------------------------------------------------------------------------- +void riaAttachToParentConsole() +{ + // Only files and pipes are real redirections. A GUI process launched from a console (e.g. Windows Terminal) can + // inherit the console handles of the parent without being attached to the console, and writing to those blocks + auto isRedirected = []( DWORD stdHandle ) + { + HANDLE handle = GetStdHandle( stdHandle ); + if ( handle == nullptr || handle == INVALID_HANDLE_VALUE ) return false; + + const DWORD fileType = GetFileType( handle ); + return fileType == FILE_TYPE_DISK || fileType == FILE_TYPE_PIPE; + }; + + const bool stdOutRedirected = isRedirected( STD_OUTPUT_HANDLE ); + const bool stdErrRedirected = isRedirected( STD_ERROR_HANDLE ); + if ( stdOutRedirected && stdErrRedirected ) return; + + if ( !AttachConsole( ATTACH_PARENT_PROCESS ) ) return; + + FILE* stream = nullptr; + if ( !stdOutRedirected ) freopen_s( &stream, "CONOUT$", "w", stdout ); + if ( !stdErrRedirected ) freopen_s( &stream, "CONOUT$", "w", stderr ); + + std::cout.clear(); + std::cerr.clear(); +} +#endif + namespace internal { #if RIA_HAS_STD_STACKTRACE @@ -516,4 +586,24 @@ void deleteStaleSettingsLockFiles() } } +//-------------------------------------------------------------------------------------------------- +/// Configure software OpenGL rendering for headless mode, allowing 3D views to be rendered and +/// grabbed without a GPU and without showing any windows. Must be called before the QApplication +/// object is created. +//-------------------------------------------------------------------------------------------------- +void enableHeadlessRendering() +{ + // Use software OpenGL rendering so 3D views can be rendered without a GPU. On Windows this + // makes Qt load opengl32sw.dll (Mesa llvmpipe). Must be set before the QApplication exists. + QApplication::setAttribute( Qt::AA_UseSoftwareOpenGL ); +#ifndef WIN32 + // On Linux the attribute has no effect, force Mesa software rendering instead + qputenv( "LIBGL_ALWAYS_SOFTWARE", "1" ); +#else + riaAttachToParentConsole(); +#endif + + RiaGuiApplication::enableHeadlessMode(); +} + } // namespace RiaMainTools diff --git a/ApplicationExeCode/RiaMainTools.h b/ApplicationExeCode/RiaMainTools.h index 9c95d1f985..76d01d422a 100644 --- a/ApplicationExeCode/RiaMainTools.h +++ b/ApplicationExeCode/RiaMainTools.h @@ -26,6 +26,7 @@ namespace RiaMainTools void initializeSingletons(); void releaseSingletonAndFactoryObjects(); void deleteStaleSettingsLockFiles(); +void enableHeadlessRendering(); // True once crash logging has started. The crash handler pumps the Qt event loop while flushing // telemetry, so timer-driven application logic must check this flag and stay idle to avoid diff --git a/ApplicationLibCode/Application/RiaApplication.cpp b/ApplicationLibCode/Application/RiaApplication.cpp index e902643f3d..1bdb035f43 100644 --- a/ApplicationLibCode/Application/RiaApplication.cpp +++ b/ApplicationLibCode/Application/RiaApplication.cpp @@ -30,6 +30,7 @@ #include "RiaBaseDefs.h" #include "RiaFilePathTools.h" #include "RiaFontCache.h" +#include "RiaGuiApplication.h" #include "RiaImportEclipseCaseTools.h" #include "RiaLogging.h" #include "RiaNetworkTools.h" @@ -1555,7 +1556,8 @@ void RiaApplication::applyPreferences() project()->rootPlotTemplateItem()->updateConnectedEditors(); } - caf::ProgressInfoStatic::setEnabled( RiaPreferencesSystem::current()->showProgressBar() ); + // No windows are shown in headless mode, so progress dialogs must stay hidden independent of preferences + caf::ProgressInfoStatic::setEnabled( !RiaGuiApplication::isHeadless() && RiaPreferencesSystem::current()->showProgressBar() ); if ( m_preferencesFileName.isEmpty() ) { diff --git a/ApplicationLibCode/Application/RiaGuiApplication.cpp b/ApplicationLibCode/Application/RiaGuiApplication.cpp index 08030618be..daa349b4a8 100644 --- a/ApplicationLibCode/Application/RiaGuiApplication.cpp +++ b/ApplicationLibCode/Application/RiaGuiApplication.cpp @@ -158,6 +158,8 @@ /// //================================================================================================== +bool RiaGuiApplication::sm_headlessMode = false; + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -166,6 +168,22 @@ bool RiaGuiApplication::isRunning() return dynamic_cast( RiaApplication::instance() ) != nullptr; } +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +void RiaGuiApplication::enableHeadlessMode() +{ + sm_headlessMode = true; +} + +//-------------------------------------------------------------------------------------------------- +/// +//-------------------------------------------------------------------------------------------------- +bool RiaGuiApplication::isHeadless() +{ + return sm_headlessMode; +} + //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- @@ -284,6 +302,9 @@ QString RiaGuiApplication::promptForProjectSaveAsFileName() const //-------------------------------------------------------------------------------------------------- bool RiaGuiApplication::askUserToSaveModifiedProject() { + // A modal dialog would hang a headless batch run + if ( isHeadless() ) return true; + if ( RiaPreferencesSystem::current()->showProjectChangedDialog() && caf::PdmUiModelChangeDetector::instance()->isModelChanged() ) { QMessageBox msgBox( activeMainWindow() ); @@ -549,6 +570,9 @@ void RiaGuiApplication::initialize() { RiaApplication::initialize(); + // Progress dialogs would pop up windows and can block a headless batch run + if ( isHeadless() ) caf::ProgressInfoStatic::setEnabled( false ); + applyGuiPreferences( nullptr ); // Create main windows @@ -576,6 +600,23 @@ void RiaGuiApplication::initialize() RiaLogging::appendLoggerInstance( std::move( logger ) ); } + if ( isHeadless() ) + { + // The message panels are never shown in headless mode, so also write to stdout to make batch runs diagnosable + auto stdLogger = std::make_unique(); + + if ( m_logLevelFromCommandLine.has_value() ) + { + stdLogger->setLevel( m_logLevelFromCommandLine.value() ); + } + else + { + stdLogger->setLevel( int( RiaLogging::logLevelBasedOnPreferences() ) ); + } + + RiaLogging::appendLoggerInstance( std::move( stdLogger ) ); + } + m_socketServer = new RiaSocketServer( this ); } @@ -671,10 +712,28 @@ RiaApplication::ApplicationStatus RiaGuiApplication::handleArguments( cvf::Progr // Use a logger writing to stdout instead of message panel // This is useful when executing regression tests on a build server, and this is the reason for creating the // logger when parsing the command line options - auto stdLogger = std::make_unique(); - stdLogger->setLevel( int( RILogLevel::RI_LL_DEBUG ) ); + // In headless mode a stdout logger already exists, reuse it to avoid duplicated log lines + RiaStdOutLogger* existingStdLogger = nullptr; + for ( auto logger : RiaLogging::loggerInstances() ) + { + if ( auto candidate = dynamic_cast( logger ) ) + { + existingStdLogger = candidate; + break; + } + } - RiaLogging::appendLoggerInstance( std::move( stdLogger ) ); + if ( existingStdLogger ) + { + existingStdLogger->setLevel( int( RILogLevel::RI_LL_DEBUG ) ); + } + else + { + auto stdLogger = std::make_unique(); + stdLogger->setLevel( int( RILogLevel::RI_LL_DEBUG ) ); + + RiaLogging::appendLoggerInstance( std::move( stdLogger ) ); + } RiaRegressionTestRunner::instance()->executeRegressionTests( regressionTestPath, QStringList() ); return ApplicationStatus::EXIT_COMPLETED; @@ -926,29 +985,53 @@ RiaApplication::ApplicationStatus RiaGuiApplication::handleArguments( cvf::Progr snapshotFolder = snapshotFolderFromCommandLine; } + QStringList snapshotErrors; + if ( snapshotPlots ) { auto mainPlotWnd = mainPlotWindow(); if ( mainPlotWnd ) { - mainPlotWnd->show(); - mainPlotWnd->raise(); + if ( !isHeadless() ) + { + mainPlotWnd->show(); + mainPlotWnd->raise(); + } processEvents(); - RicSnapshotAllPlotsToFileFeature::exportSnapshotOfPlotsIntoFolder( snapshotFolder, snapshotWidth, snapshotHeight ); + auto result = + RicSnapshotAllPlotsToFileFeature::exportSnapshotOfPlotsIntoFolder( snapshotFolder, snapshotWidth, snapshotHeight ); + if ( !result ) snapshotErrors.push_back( result.error() ); + } + else + { + // The plot window is only created when the project shows plots, so there is nothing to export + RiaLogging::warning( "No plot window available, no plot snapshots exported" ); } } if ( snapshotViews ) { auto mainWnd = RiuMainWindow::instance(); - mainWnd->show(); - mainWnd->raise(); + if ( mainWnd ) + { + if ( !isHeadless() ) + { + mainWnd->show(); + mainWnd->raise(); + } - processEvents(); + processEvents(); - RicSnapshotAllViewsToFileFeature::exportSnapshotOfViewsIntoFolder( snapshotFolder, snapshotWidth, snapshotHeight ); + auto result = + RicSnapshotAllViewsToFileFeature::exportSnapshotOfViewsIntoFolder( snapshotFolder, snapshotWidth, snapshotHeight ); + if ( !result ) snapshotErrors.push_back( result.error() ); + } + else + { + snapshotErrors.push_back( "No main window available for view snapshot export" ); + } } auto mainPlotWnd = mainPlotWindow(); @@ -959,6 +1042,14 @@ RiaApplication::ApplicationStatus RiaGuiApplication::handleArguments( cvf::Progr if ( RiuMainWindow::instance() ) RiuMainWindow::instance()->loadWinGeoAndDockToolBarLayout(); + if ( !snapshotErrors.empty() ) + { + // Report a non-zero exit code, as a batch run with missing images is otherwise indistinguishable from success + RiaLogging::error( snapshotErrors.join( ". " ).toStdString() ); + + return ApplicationStatus::EXIT_WITH_ERROR; + } + return ApplicationStatus::EXIT_COMPLETED; } @@ -1063,7 +1154,7 @@ RiuMainWindow* RiaGuiApplication::getOrCreateAndShowMainWindow() { createMainWindow(); } - else + else if ( !isHeadless() ) { m_mainWindow->show(); @@ -1141,7 +1232,7 @@ void RiaGuiApplication::createMainWindow() m_mainWindow->setDefaultWindowSize(); m_mainWindow->setDefaultToolbarVisibility(); m_mainWindow->loadWinGeoAndDockToolBarLayout(); - m_mainWindow->showWindow(); + if ( !isHeadless() ) m_mainWindow->showWindow(); // if there is an existing logger, reconnect to it @@ -1199,18 +1290,21 @@ RiuPlotMainWindow* RiaGuiApplication::getOrCreateAndShowMainPlotWindow() } } - if ( m_mainPlotWindow->isMinimized() ) + if ( !isHeadless() ) { - m_mainPlotWindow->showNormal(); - m_mainPlotWindow->update(); - } - else - { - m_mainPlotWindow->show(); - } + if ( m_mainPlotWindow->isMinimized() ) + { + m_mainPlotWindow->showNormal(); + m_mainPlotWindow->update(); + } + else + { + m_mainPlotWindow->show(); + } - m_mainPlotWindow->raise(); - m_mainPlotWindow->activateWindow(); + m_mainPlotWindow->raise(); + m_mainPlotWindow->activateWindow(); + } return m_mainPlotWindow.get(); } @@ -1339,6 +1433,19 @@ void RiaGuiApplication::clearAllSelections() //-------------------------------------------------------------------------------------------------- void RiaGuiApplication::showFormattedTextInMessageBoxOrConsole( const QString& text ) { + // A modal dialog would hang a headless batch run + if ( isHeadless() ) + { + const std::string stdText = text.toStdString(); + std::cout << stdText; + + // Make sure the text ends with a newline and reaches the console even if the application aborts later + if ( stdText.empty() || stdText.back() != '\n' ) std::cout << '\n'; + std::cout.flush(); + + return; + } + // Create a message dialog with cut/paste friendly text QDialog dlg( widgetToUseAsParent() ); dlg.setModal( true ); @@ -1444,9 +1551,9 @@ void RiaGuiApplication::onProjectOpened() if ( !m_mainPlotWindow ) { createMainPlotWindow(); - m_mainPlotWindow->show(); + if ( !isHeadless() ) m_mainPlotWindow->show(); } - else + else if ( !isHeadless() ) { m_mainPlotWindow->show(); m_mainPlotWindow->raise(); @@ -1478,7 +1585,7 @@ void RiaGuiApplication::onProjectOpened() // Make sure to process events before this function to avoid strange Qt crash RiuPlotMainWindowTools::refreshToolbars(); - if ( m_project->showPlotWindow() && m_project->showPlotWindowOnTop() ) + if ( m_project->showPlotWindow() && m_project->showPlotWindowOnTop() && !isHeadless() ) { m_mainPlotWindow->raise(); m_mainPlotWindow->activateWindow(); @@ -1873,7 +1980,8 @@ void RiaGuiApplication::runMultiCaseSnapshots( const QString& templateProj bool loadOk = loadProject( templateProjectFileName, ProjectLoadAction::PLA_NONE, &modifier ); if ( loadOk ) { - RicSnapshotAllViewsToFileFeature::exportSnapshotOfViewsIntoFolder( snapshotFolderName ); + auto result = RicSnapshotAllViewsToFileFeature::exportSnapshotOfViewsIntoFolder( snapshotFolderName ); + if ( !result ) RiaLogging::error( result.error().toStdString() ); } } m_mainWindow->dockManager()->restoreState( curState ); diff --git a/ApplicationLibCode/Application/RiaGuiApplication.h b/ApplicationLibCode/Application/RiaGuiApplication.h index 2a23cc64b4..571280adb6 100644 --- a/ApplicationLibCode/Application/RiaGuiApplication.h +++ b/ApplicationLibCode/Application/RiaGuiApplication.h @@ -82,6 +82,9 @@ public: static bool isRunning(); static RiaGuiApplication* instance(); + static void enableHeadlessMode(); + static bool isHeadless(); + RiaGuiApplication( int& argc, char** argv ); ~RiaGuiApplication() override; @@ -172,4 +175,6 @@ private: QPointer m_mainPlotWindow; std::unique_ptr m_recentFileActionProvider; + + static bool sm_headlessMode; }; diff --git a/ApplicationLibCode/Application/Tools/RiaArgumentParser.cpp b/ApplicationLibCode/Application/Tools/RiaArgumentParser.cpp index 753e5a90ad..da3184d6cb 100644 --- a/ApplicationLibCode/Application/Tools/RiaArgumentParser.cpp +++ b/ApplicationLibCode/Application/Tools/RiaArgumentParser.cpp @@ -71,6 +71,11 @@ bool RiaArgumentParser::parseArguments( cvf::ProgramOptions* progOpt ) cvf::ProgramOptions::MULTI_VALUE ); progOpt->registerOption( "size", " ", "Set size of the main application window.", cvf::ProgramOptions::MULTI_VALUE ); progOpt->registerOption( "console", "", "Launch as a console application without graphics" ); + progOpt->registerOption( "headless", + "", + "Run without showing any windows, using software OpenGL rendering. " + "Combine with --savesnapshots to export images on machines without a GPU. " + "On Linux without a display server, run under xvfb-run." ); progOpt->registerOption( "server", "[]", "Launch as a GRPC server. Default port is 50051", cvf::ProgramOptions::SINGLE_VALUE ); progOpt->registerOption( "portnumberfile", "[]", "Write the port number to this file.", cvf::ProgramOptions::SINGLE_VALUE ); diff --git a/ApplicationLibCode/Application/Tools/RiaRegressionTestRunner.cpp b/ApplicationLibCode/Application/Tools/RiaRegressionTestRunner.cpp index 018bed678d..73bfc111cf 100644 --- a/ApplicationLibCode/Application/Tools/RiaRegressionTestRunner.cpp +++ b/ApplicationLibCode/Application/Tools/RiaRegressionTestRunner.cpp @@ -250,9 +250,10 @@ void RiaRegressionTestRunner::runRegressionTest() if ( regressionTestConfig.exportSnapshots3dViews ) { QSize defaultSize = RiaRegressionTestRunner::regressionDefaultImageSize(); - RicSnapshotAllViewsToFileFeature::exportSnapshotOfViewsIntoFolder( fullPathGeneratedFolder, - defaultSize.width(), - defaultSize.height() ); + auto result = RicSnapshotAllViewsToFileFeature::exportSnapshotOfViewsIntoFolder( fullPathGeneratedFolder, + defaultSize.width(), + defaultSize.height() ); + if ( !result ) RiaLogging::error( result.error().toStdString() ); QApplication::processEvents(); } @@ -260,10 +261,11 @@ void RiaRegressionTestRunner::runRegressionTest() if ( regressionTestConfig.exportSnapshotsPlots ) { QSize defaultSize = RiaRegressionTestRunner::regressionDefaultImageSize(); - RicSnapshotAllPlotsToFileFeature::exportSnapshotOfPlotsIntoFolder( fullPathGeneratedFolder, - defaultSize.width(), - defaultSize.height(), - true /*activate widget*/ ); + auto result = RicSnapshotAllPlotsToFileFeature::exportSnapshotOfPlotsIntoFolder( fullPathGeneratedFolder, + defaultSize.width(), + defaultSize.height(), + true /*activate widget*/ ); + if ( !result ) RiaLogging::error( result.error().toStdString() ); } uint64_t usedMemoryBeforeClose = caf::MemoryInspector::getApplicationPhysicalMemoryUsageMiB(); diff --git a/ApplicationLibCode/UserInterface/RiuMainWindow.cpp b/ApplicationLibCode/UserInterface/RiuMainWindow.cpp index f14526fbdc..0e6f0dbe12 100644 --- a/ApplicationLibCode/UserInterface/RiuMainWindow.cpp +++ b/ApplicationLibCode/UserInterface/RiuMainWindow.cpp @@ -1788,7 +1788,11 @@ void RiuMainWindow::slotSnapshotAllViewsToFile() // Save images in snapshot catalog relative to project directory QString absolutePathToSnapshotDir = app->createAbsolutePathFromProjectRelativePath( "snapshots" ); - RicSnapshotAllViewsToFileFeature::exportSnapshotOfViewsIntoFolder( absolutePathToSnapshotDir ); + + if ( auto result = RicSnapshotAllViewsToFileFeature::exportSnapshotOfViewsIntoFolder( absolutePathToSnapshotDir ); !result ) + { + RiaLogging::error( result.error().toStdString() ); + } } //--------------------------------------------------------------------------------------------------