Dedicated python env for ri-cloud-api (#14831)

- Add ri-cloud-api python env preferences
-  Prevent loop of restart/reconnect when manually clicking "start"/"Authenticate"
This commit is contained in:
Jørgen Herje authored and GitHub committed 2026-10-02 12:44:33 +02:00
1 parent 3046c7e09d
commit f015af98ee
11 files changed
+320 -26

No files matched your search

@@ -61,6 +61,13 @@ constexpr int readinessPollIntervalMs = 250;
// Number of ports scanned, starting at the wanted port, when looking for a free port to bind to.
constexpr int portRangeLength = 100;
// Shared hint appended to errors caused by a service that never responded, pointing at the most likely
// fix without claiming to have diagnosed the exact cause.
const std::string pythonEnvironmentHint =
"This could be caused by the configured Python environment (Preferences -> RI Cloud API, or "
"Preferences -> Scripting) missing the packages required by ri-cloud-api. Check the log above for "
"details, and fix the environment if needed.";
} // namespace
//--------------------------------------------------------------------------------------------------
@@ -74,7 +81,10 @@ RiaCloudApiService::RiaCloudApiService( const QString& serverAddress, int wanted
, m_wantedPort( wantedPort )
, m_port( -1 )
, m_consecutiveFailures( 0 )
, m_consecutiveLaunchFailures( 0 )
, m_isResponding( false )
, m_hasRespondedSinceStart( false )
, m_hasGivenUp( false )
{
// Never route loopback requests through a system/corporate proxy, which can add several seconds
// per request.
@@ -143,14 +153,31 @@ QString RiaCloudApiService::serverUrl() const
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiaCloudApiService::start()
void RiaCloudApiService::start( StartTrigger trigger )
{
// An Automatic call (e.g. Sumo's tokenReady, which can fire well after the give-up, such as on a
// background token refresh) must not silently re-arm a retry loop that already gave up. Only an
// explicit Manual start/restart may do that.
if ( trigger == StartTrigger::Automatic && m_hasGivenUp ) return;
if ( isRunning() ) return;
m_consecutiveLaunchFailures = 0;
m_hasRespondedSinceStart = false;
m_hasGivenUp = false;
launchProcess();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
void RiaCloudApiService::launchProcess()
{
// Clean up any stale QProcess instance (e.g. after an unexpected exit) before re-starting.
if ( m_process ) stop();
const QString pythonExecutable = RiaApplication::instance()->pythonPath();
const QString pythonExecutable = RiaApplication::instance()->cloudApiPythonPath();
if ( pythonExecutable.isEmpty() )
{
RiaLogging::error( "Cloud API service: no Python executable configured, cannot start service." );
@@ -209,7 +236,8 @@ void RiaCloudApiService::start()
// A non-zero exit means the service never came up, or died. Anything else is an
// ordinary shutdown.
if ( exitCode != 0 || exitStatus != QProcess::NormalExit )
const bool failed = exitCode != 0 || exitStatus != QProcess::NormalExit;
if ( failed )
{
RiaLogging::error( message );
}
@@ -220,6 +248,15 @@ void RiaCloudApiService::start()
m_isResponding = false;
emit statusChanged();
// React immediately rather than waiting for a timer. Deferred via singleShot, since
// restarting the process here, during its own finished() signal, would not be safe.
if ( failed )
{
m_startupTimer.stop();
m_healthTimer.stop();
QTimer::singleShot( 0, this, &RiaCloudApiService::autoRestart );
}
} );
m_consecutiveFailures = 0;
@@ -248,7 +285,7 @@ bool RiaCloudApiService::waitUntilResponding( int timeoutMs )
{
if ( m_isResponding ) return true;
if ( !isRunning() ) start();
if ( !isRunning() ) start( StartTrigger::Automatic );
// Nested event loops below let the periodic health check fire and restart() the service while this
// call is still waiting for it to boot. Stop both timers for the duration of the wait to avoid that.
@@ -277,7 +314,9 @@ bool RiaCloudApiService::waitUntilResponding( int timeoutMs )
if ( answered )
{
m_consecutiveFailures = 0;
m_consecutiveFailures = 0;
m_consecutiveLaunchFailures = 0;
m_hasRespondedSinceStart = true;
if ( !m_isResponding )
{
@@ -305,7 +344,14 @@ bool RiaCloudApiService::waitUntilResponding( int timeoutMs )
delayLoop.exec();
}
RiaLogging::error( std::format( "Cloud API service: not responding after {} ms, giving up.", elapsed.elapsed() ) );
if ( m_hasGivenUp )
{
RiaLogging::error( std::format( "Cloud API service: not available, giving up. {}", pythonEnvironmentHint ) );
}
else
{
RiaLogging::error( std::format( "Cloud API service: not responding after {} ms, giving up.", elapsed.elapsed() ) );
}
// Resume background health-checking if the process is still running, so it can still recover on
// its own without another blocking wait.
@@ -351,6 +397,35 @@ void RiaCloudApiService::restart()
start();
}
//--------------------------------------------------------------------------------------------------
/// Called when the process exits/crashes, or when a health check times out. If the service has never
/// answered a health check since the current start (see m_hasRespondedSinceStart), this is treated as a
/// configuration problem rather than a transient crash: retrying is unlikely to help, so this gives up
/// immediately instead of restarting, and sets m_hasGivenUp so a later Automatic start() (e.g. Sumo's
/// tokenReady) does not quietly re-arm the loop. If the service HAD been responding, this is instead
/// treated as an ordinary crash of an otherwise working server, and is retried no matter how many times it
/// takes to come back.
//--------------------------------------------------------------------------------------------------
void RiaCloudApiService::autoRestart()
{
m_consecutiveLaunchFailures++;
if ( !m_hasRespondedSinceStart )
{
RiaLogging::error( std::format( "Cloud API service: giving up after {} failed launch attempt{}. {}",
m_consecutiveLaunchFailures,
m_consecutiveLaunchFailures == 1 ? "" : "s",
pythonEnvironmentHint ) );
m_hasGivenUp = true;
stop();
return;
}
RiaLogging::warning( "Cloud API service: not responding, restarting." );
stop();
launchProcess();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
@@ -370,7 +445,7 @@ void RiaCloudApiService::onHealthCheck()
{
if ( !isRunning() || m_port < 0 )
{
restart();
autoRestart();
return;
}
@@ -390,8 +465,10 @@ void RiaCloudApiService::onHealthCheck()
const int statusCode = reply->attribute( QNetworkRequest::HttpStatusCodeAttribute ).toInt();
if ( reply->error() == QNetworkReply::NoError && statusCode == 200 )
{
m_consecutiveFailures = 0;
m_isResponding = true;
m_consecutiveFailures = 0;
m_consecutiveLaunchFailures = 0;
m_hasRespondedSinceStart = true;
m_isResponding = true;
}
else
{
@@ -404,7 +481,7 @@ void RiaCloudApiService::onHealthCheck()
if ( !m_isResponding && m_consecutiveFailures >= maxConsecutiveFailures )
{
restart();
autoRestart();
}
} );
}
@@ -48,7 +48,18 @@ public:
RiaCloudApiService( const QString& serverAddress, int wantedPort, QObject* parent = nullptr );
~RiaCloudApiService() override;
void start();
// Manual: an explicit user action (Start/Restart button). Automatic: triggered by application logic
// (e.g. Sumo authentication completing, or waitUntilResponding()).
//
// Does not affect whether autoRestart() gives up (see autoRestart()), only what happens after it has:
// a later Automatic call is then a no-op (see m_hasGivenUp), while a Manual one tries again.
enum class StartTrigger
{
Manual,
Automatic
};
void start( StartTrigger trigger = StartTrigger::Manual );
void stop();
void restart();
@@ -84,6 +95,15 @@ private slots:
void onReadyReadStandardOutput();
private:
// Called after a crash or an unresponsive health check. Gives up immediately if the service has never
// responded since this start (see m_hasRespondedSinceStart) -- likely a config problem, not worth
// retrying. Otherwise retries indefinitely, treating it as a crash of a previously working server.
void autoRestart();
// Shared process-launch implementation used by start() and by autoRestart()'s retry. Unlike start(),
// leaves m_consecutiveLaunchFailures and m_hasRespondedSinceStart untouched.
void launchProcess();
static int findAvailablePortNumber( int firstPort );
static QString serviceWorkingDirectory();
static QProcessEnvironment buildProcessEnvironment( const QString& workingDirectory );
@@ -99,5 +119,14 @@ private:
int m_port;
int m_consecutiveFailures;
int m_consecutiveLaunchFailures;
bool m_isResponding;
// True once the current run has answered a health check; autoRestart() then retries indefinitely
// instead of giving up on the first failure. Reset to false in start().
bool m_hasRespondedSinceStart;
// True once autoRestart() has given up on a run that never responded. Blocks a later Automatic
// start() (e.g. Sumo's tokenReady) from re-arming the loop; a Manual start/restart clears it.
bool m_hasGivenUp;
};