diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml new file mode 100644 index 000000000..b1843200c --- /dev/null +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -0,0 +1,103 @@ +name: ARStack Studio Windows Lifecycle + +on: + push: + paths: + - 'apps/arstack_studio/**' + - '.github/workflows/arstack-studio-windows-lifecycle.yml' + pull_request: + paths: + - 'apps/arstack_studio/**' + - '.github/workflows/arstack-studio-windows-lifecycle.yml' + +permissions: + contents: read + +concurrency: + group: arstack-studio-windows-lifecycle-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + close-exit-reopen: + name: Close -> process exit -> immediate reopen + runs-on: windows-2022 + timeout-minutes: 12 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.10.3' + arch: win64_msvc2022_64 + modules: 'qtserialport' + cache: true + + - name: Configure Studio + shell: powershell + run: | + cmake -S apps/arstack_studio -B build-arstack-studio -G "Visual Studio 17 2022" -A x64 ` + -DARIEC61850_WARNINGS_AS_ERRORS=ON + if ($LASTEXITCODE -ne 0) { throw "Studio configure failed: $LASTEXITCODE" } + + - name: Build actual Studio executable + shell: powershell + run: | + cmake --build build-arstack-studio --config Release --target arstack_studio --parallel + if ($LASTEXITCODE -ne 0) { throw "Studio build failed: $LASTEXITCODE" } + + - name: Two-cycle graceful lifecycle regression + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + $env:QT_QPA_PLATFORM = 'offscreen' + $env:QT_QUICK_BACKEND = 'software' + + $candidate = Get-ChildItem -Path (Join-Path $PWD 'build-arstack-studio') -Recurse -File | + Where-Object { $_.Name -ieq 'arstack_studio.exe' -or $_.Name -ieq 'ARStackStudio.exe' } | + Select-Object -First 1 + if ($null -eq $candidate) { + throw 'Built ARStack Studio executable was not found.' + } + $exe = $candidate.FullName + Write-Host "Lifecycle executable: $exe" + + function Invoke-LifecycleCycle([int]$Cycle) { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $exe + $startInfo.Arguments = '--check-app-lifecycle' + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + if (-not $process.Start()) { + $process.Dispose() + throw "Cycle ${Cycle}: failed to start Studio process." + } + + $pidValue = $process.Id + if (-not $process.WaitForExit(12000)) { + Stop-Process -Id $pidValue -Force -ErrorAction SilentlyContinue + $process.Dispose() + throw "Cycle ${Cycle}: Studio process did not exit within 12 seconds after primary-window close." + } + + $exitCode = $process.ExitCode + $process.Dispose() + + if ($exitCode -ne 0) { + throw "Cycle ${Cycle}: Studio lifecycle coordinator exited with code $exitCode." + } + if (Get-Process -Id $pidValue -ErrorAction SilentlyContinue) { + throw "Cycle ${Cycle}: Studio PID $pidValue remained alive after native WaitForExit." + } + + Write-Host "Cycle $Cycle PASS: close -> clean worker retirement -> PID exit." + } + + Invoke-LifecycleCycle 1 + Start-Sleep -Milliseconds 250 + Invoke-LifecycleCycle 2 + Write-Host 'Windows lifecycle gate: PASS · close -> process exit -> lock release -> immediate reopen -> clean exit' diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index ae72cf0e9..b1076f687 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -5,8 +5,11 @@ #include "DeviceIoWorker.hpp" #include +#include #include #include +#include +#include #include #include @@ -73,19 +76,77 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { } DeviceController::~DeviceController() { - if (ioWorker_ == nullptr || !ioThread_.isRunning()) return; + static_cast(shutdown()); +} + +bool DeviceController::shutdown() { + if (shutdownComplete_) return true; + if (shuttingDown_) return !ioThread_.isRunning(); + shuttingDown_ = true; + pendingAutoDetect_ = false; + pendingConnectPort_.clear(); + + if (ioWorker_ == nullptr || !ioThread_.isRunning()) { + ioWorker_ = nullptr; + ioWorkerReady_ = false; + ioWorkerAffinityValid_ = false; + shutdownComplete_ = true; + shuttingDown_ = false; + return true; + } const bool requestStop = running_; if (QThread::currentThread() == &ioThread_) { ioWorker_->shutdown(requestStop); - } else { - QMetaObject::invokeMethod( - ioWorker_, - [worker = ioWorker_, requestStop] { worker->shutdown(requestStop); }, - Qt::BlockingQueuedConnection); + ioThread_.quit(); + shuttingDown_ = false; + return false; } + + auto acknowledged = QSharedPointer::create(); + DeviceIoWorker* const worker = ioWorker_; + const bool queued = QMetaObject::invokeMethod( + worker, + [worker, requestStop, acknowledged] { + worker->shutdown(requestStop); + acknowledged->release(); + }, + Qt::QueuedConnection); + bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs()); + if (!graceful) { + qCritical().noquote() + << "Device I/O shutdown acknowledgement timed out; entering bounded retirement fallback."; + } + ioThread_.quit(); - ioThread_.wait(); + bool joined = ioThread_.wait(shutdownJoinTimeoutMs()); + if (!joined) { + graceful = false; + qCritical().noquote() + << "Device I/O thread missed primary join deadline; retrying bounded retirement."; + ioThread_.requestInterruption(); + ioThread_.quit(); + joined = ioThread_.wait(shutdownRetryTimeoutMs()); + } + if (!joined) { + // Emergency process-exit containment only. Normal CI must never reach this. + graceful = false; + qCritical().noquote() + << "Device I/O thread still alive; emergency terminate fallback engaged."; + ioThread_.terminate(); + joined = ioThread_.wait(shutdownForceTimeoutMs()); + } + + ioWorker_ = nullptr; + ioWorkerReady_ = false; + ioWorkerAffinityValid_ = false; + connected_ = false; + running_ = false; + profileDeploying_ = false; + shutdownComplete_ = joined; + shuttingDown_ = false; + if (!joined) qCritical().noquote() << "Device I/O thread could not be retired before exit."; + return graceful && joined; } void DeviceController::connectWorkerSignals() { diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 661f64df0..7854f4507 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -100,6 +100,14 @@ class DeviceController : public QObject { explicit DeviceController(QObject* parent = nullptr); ~DeviceController() override; + // Application-lifetime boundary required by AGENTS.md section 11. + // Returns false if an emergency retirement fallback was required. + bool shutdown(); + [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 1200; } + [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; } + [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; } + [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; } + [[nodiscard]] QStringList ports() const; [[nodiscard]] QString recommendedPort() const; [[nodiscard]] QString discoveryStatus() const; @@ -330,6 +338,8 @@ class DeviceController : public QObject { bool ptpRunning_{false}; bool ioWorkerReady_{false}; bool ioWorkerAffinityValid_{false}; + bool shuttingDown_{false}; + bool shutdownComplete_{false}; bool pendingAutoDetect_{false}; double signalFrequencyHz_{50.0}; QString ptpStatus_{QStringLiteral("Waiting for device")}; diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index 3814c25c2..f01d800fe 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -15,6 +16,8 @@ #include #include #include +#include +#include #include @@ -45,7 +48,7 @@ FirmwareManager::FirmwareManager(QObject* parent) : QObject(parent) { } FirmwareManager::~FirmwareManager() { - shutdown(); + static_cast(shutdown()); } void FirmwareManager::connectWorkerSignals() { @@ -318,23 +321,64 @@ void FirmwareManager::cancel() { Qt::QueuedConnection); } -void FirmwareManager::shutdown() { - if (shuttingDown_) return; +bool FirmwareManager::shutdown() { + if (shuttingDown_) return !workerThread_.isRunning(); shuttingDown_ = true; operation_ = Operation::none; activeOperationGeneration_ = 0; busy_ = false; cancelRequested_ = true; - if (worker_ != nullptr && workerThread_.isRunning()) { - if (QThread::currentThread() == &workerThread_) { - worker_->shutdown(); - } else { - QMetaObject::invokeMethod(worker_, &FirmwareWorker::shutdown, Qt::BlockingQueuedConnection); - } + if (worker_ == nullptr || !workerThread_.isRunning()) { + worker_ = nullptr; + workerReady_ = false; + workerAffinityValid_ = false; + return true; + } + if (QThread::currentThread() == &workerThread_) { + worker_->shutdown(); workerThread_.quit(); - workerThread_.wait(); + return false; } + + auto acknowledged = QSharedPointer::create(); + FirmwareWorker* const worker = worker_; + const bool queued = QMetaObject::invokeMethod( + worker, + [worker, acknowledged] { + worker->shutdown(); + acknowledged->release(); + }, + Qt::QueuedConnection); + bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs()); + if (!graceful) { + qCritical().noquote() + << "Firmware worker shutdown acknowledgement timed out; entering bounded retirement fallback."; + } + + workerThread_.quit(); + bool joined = workerThread_.wait(shutdownJoinTimeoutMs()); + if (!joined) { + graceful = false; + qCritical().noquote() + << "Firmware worker missed primary join deadline; retrying bounded retirement."; + workerThread_.requestInterruption(); + workerThread_.quit(); + joined = workerThread_.wait(shutdownRetryTimeoutMs()); + } + if (!joined) { + graceful = false; + qCritical().noquote() + << "Firmware worker still alive; emergency terminate fallback engaged."; + workerThread_.terminate(); + joined = workerThread_.wait(shutdownForceTimeoutMs()); + } + + worker_ = nullptr; + workerReady_ = false; + workerAffinityValid_ = false; + if (!joined) qCritical().noquote() << "Firmware worker could not be retired before exit."; + return graceful && joined; } void FirmwareManager::clearLog() { diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp index 6261ffe55..259490eb2 100644 --- a/apps/arstack_studio/src/FirmwareManager.hpp +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -71,7 +71,11 @@ class FirmwareManager : public QObject { bool probeTarget(const QString& portName); bool installFirmware(const QString& portName); void cancel(); - void shutdown(); + bool shutdown(); + [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 2200; } + [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; } + [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; } + [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; } signals: void stateChanged(); diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index e35551617..efaeb559c 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -98,6 +98,31 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent }); } +SmartSessionController::~SmartSessionController() { + shutdown(); +} + +void SmartSessionController::shutdown() { + if (shuttingDown_) return; + shuttingDown_ = true; + started_ = false; + discoveryTimer_.stop(); + prepareTimer_.stop(); + reconnectTimer_.stop(); + profileSyncTimer_.stop(); + firmwareHandoffWatchdog_.shutdown(); + deviceRecoveryMonitor_.shutdown(); + updateRequested_ = false; + pendingReleaseGeneration_ = 0; + profileSyncStage_ = ProfileSyncStage::idle; + if (device_ != nullptr) QObject::disconnect(device_, nullptr, this, nullptr); + if (profiles_ != nullptr) QObject::disconnect(profiles_, nullptr, this, nullptr); + if (firmware_ != nullptr) QObject::disconnect(firmware_, nullptr, this, nullptr); + device_ = nullptr; + profiles_ = nullptr; + firmware_ = nullptr; +} + QObject* SmartSessionController::device() const noexcept { return device_; } QObject* SmartSessionController::profiles() const noexcept { return profiles_; } QObject* SmartSessionController::firmware() const noexcept { return firmware_; } @@ -237,7 +262,7 @@ void SmartSessionController::setFirmware(QObject* object) { } void SmartSessionController::start() { - if (started_) return; + if (started_ || shuttingDown_) return; started_ = true; if (profiles_ != nullptr) static_cast(ensureDefaultProfile()); discoveryTimer_.start(); @@ -925,6 +950,7 @@ bool SmartSessionController::deviceControlAvailable() const noexcept { } void SmartSessionController::reconcile() { + if (shuttingDown_) return; if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) { setPresentation( QStringLiteral("INITIALIZING"), diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index e6b6268a1..e07b23068 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -48,6 +48,9 @@ class SmartSessionController : public QObject { Q_ENUM(PortOwner) explicit SmartSessionController(QObject* parent = nullptr); + ~SmartSessionController() override; + + void shutdown(); [[nodiscard]] QObject* device() const noexcept; [[nodiscard]] QObject* profiles() const noexcept; @@ -188,6 +191,7 @@ class SmartSessionController : public QObject { QObject::connect(owner_, &SmartSessionController::stateChanged, owner_, [this] { synchronize(); }); QObject::connect(&timer_, &QTimer::timeout, owner_, [this] { expire(); }); } + void shutdown() { timer_.stop(); owner_ = nullptr; } private: [[nodiscard]] bool waitingForAck() const noexcept { @@ -229,6 +233,12 @@ class SmartSessionController : public QObject { explicit DeviceRecoveryMonitor(SmartSessionController* owner) : owner_(owner) { QObject::connect(owner_, &SmartSessionController::dependenciesChanged, owner_, [this] { bindDevice(); }); } + void shutdown() { + QObject::disconnect(verifiedConnection_); + QObject::disconnect(identificationConnection_); + device_ = nullptr; + owner_ = nullptr; + } private: void bindDevice() { @@ -350,6 +360,7 @@ class SmartSessionController : public QObject { bool manualRecoveryArmed_{false}; bool setupError_{false}; bool recoveryPending_{false}; + bool shuttingDown_{false}; int updateReconnectAttempts_{0}; int profileSyncAttempts_{0}; PortOwner portOwner_{PortOwner::none}; diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 19d115b42..0d1335928 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -3,7 +3,6 @@ #include "DeviceController.hpp" -#include #include #include @@ -79,17 +78,6 @@ class StudioDeviceController : public DeviceController { } }); - if (auto* app = QCoreApplication::instance(); app != nullptr) { - connect(app, &QCoreApplication::aboutToQuit, this, [this] { - controlHealthTimer_.stop(); - healthProbeOutstanding_ = false; - setSessionHeartbeatEnabled(false); - clearPendingLiveCommands(); - if (connected() && running()) { - static_cast(DeviceController::stop()); - } - }); - } } [[nodiscard]] bool currentFirmwareIdentitySeen() const { diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 31c8e981d..1c43fd507 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -445,12 +445,6 @@ int main(int argc, char* argv[]) { } FirmwareManager firmwareService; - QObject::connect( - &app, - &QCoreApplication::aboutToQuit, - &firmwareService, - &FirmwareManager::shutdown, - Qt::DirectConnection); qmlRegisterType("ARStack.Studio", 1, 0, "SclProfileModel"); qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); @@ -476,6 +470,37 @@ int main(int argc, char* argv[]) { mainWindow->installEventFilter(&primaryCloseFilter); } + QPointer lifecycleDevice; + QPointer lifecycleSession; + if (mainWindow != nullptr) { + lifecycleDevice = mainWindow->findChild(); + lifecycleSession = mainWindow->findChild(); + } + if (mainWindow != nullptr && (lifecycleDevice.isNull() || lifecycleSession.isNull())) { + qCritical().noquote() + << "Application lifecycle owner discovery failed; refusing an unowned worker/session lifetime."; + return 14; + } + + bool lifecycleCoordinatorInvoked = false; + bool lifecycleShutdownClean = false; + QObject::connect( + &app, + &QCoreApplication::aboutToQuit, + &app, + [&] { + lifecycleCoordinatorInvoked = true; + if (!lifecycleSession.isNull()) lifecycleSession->shutdown(); + const bool deviceClean = lifecycleDevice.isNull() || lifecycleDevice->shutdown(); + const bool firmwareClean = firmwareService.shutdown(); + lifecycleShutdownClean = deviceClean && firmwareClean; + if (!lifecycleShutdownClean) { + qCritical().noquote() + << "Application shutdown required an emergency worker-retirement fallback."; + } + }, + Qt::DirectConnection); + if (lifecycleCheck) { if (mainWindow == nullptr) { qCritical().noquote() << "Application lifecycle regression: main window was not created."; @@ -507,8 +532,14 @@ int main(int argc, char* argv[]) { } const int result = app.exec(); + if (!lifecycleCoordinatorInvoked || !lifecycleShutdownClean) { + qCritical().noquote() + << "Application lifecycle regression: shutdown coordinator did not retire all workers cleanly."; + return result == 0 ? 15 : result; + } if (lifecycleCheck && result == 0) { - qInfo().noquote() << "Application lifecycle regression: PASS · primary window close reached the lifetime filter"; + qInfo().noquote() + << "Application lifecycle regression: PASS · close -> supervisor stop -> worker join -> process exit"; } return result; } \ No newline at end of file