From 3571c44131944c8dd88fb4c711c8c85b64dd1aca Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:42:28 +0700 Subject: [PATCH 01/10] chore: stage Studio graceful-shutdown hardening --- .../stage-studio-graceful-shutdown.yml | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 .github/workflows/stage-studio-graceful-shutdown.yml diff --git a/.github/workflows/stage-studio-graceful-shutdown.yml b/.github/workflows/stage-studio-graceful-shutdown.yml new file mode 100644 index 000000000..3602838c3 --- /dev/null +++ b/.github/workflows/stage-studio-graceful-shutdown.yml @@ -0,0 +1,136 @@ +name: Stage Studio graceful shutdown hardening + +on: + push: + branches: + - fix/studio-graceful-shutdown-p0 + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/studio-graceful-shutdown-p0 + fetch-depth: 0 + + - name: Apply bounded lifecycle ownership patch + shell: python + run: | + from pathlib import Path + + def replace_once(path, old, new): + p = Path(path) + text = p.read_text(encoding='utf-8') + if text.count(old) != 1: + raise SystemExit(f'{path}: expected exactly one replacement anchor, got {text.count(old)}') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + + # DeviceController: explicit, bounded, idempotent worker retirement. + replace_once('apps/arstack_studio/src/DeviceController.hpp', + ' explicit DeviceController(QObject* parent = nullptr);\n ~DeviceController() override;\n', + ' explicit DeviceController(QObject* parent = nullptr);\n ~DeviceController() override;\n\n' + ' // Application-lifetime boundary. Stops new transport work, asks the\n' + ' // worker to retire its timers/serial handle, and joins the worker with\n' + ' // bounded deadlines. Returns false if any emergency fallback was needed.\n' + ' bool shutdown();\n' + ' [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 1200; }\n' + ' [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; }\n' + ' [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; }\n' + ' [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; }\n') + replace_once('apps/arstack_studio/src/DeviceController.hpp', + ' bool ioWorkerReady_{false};\n bool ioWorkerAffinityValid_{false};\n bool pendingAutoDetect_{false};\n', + ' bool ioWorkerReady_{false};\n bool ioWorkerAffinityValid_{false};\n bool shuttingDown_{false};\n bool shutdownComplete_{false};\n bool pendingAutoDetect_{false};\n') + + replace_once('apps/arstack_studio/src/DeviceController.cpp', + '#include \n#include \n#include \n', + '#include \n#include \n#include \n#include \n#include \n#include \n') + replace_once('apps/arstack_studio/src/DeviceController.cpp', + '''DeviceController::~DeviceController() {\n if (ioWorker_ == nullptr || !ioThread_.isRunning()) return;\n\n const bool requestStop = running_;\n if (QThread::currentThread() == &ioThread_) {\n ioWorker_->shutdown(requestStop);\n } else {\n QMetaObject::invokeMethod(\n ioWorker_,\n [worker = ioWorker_, requestStop] { worker->shutdown(requestStop); },\n Qt::BlockingQueuedConnection);\n }\n ioThread_.quit();\n ioThread_.wait();\n}\n''', + '''DeviceController::~DeviceController() {\n static_cast(shutdown());\n}\n\nbool DeviceController::shutdown() {\n if (shutdownComplete_) return true;\n if (shuttingDown_) return !ioThread_.isRunning();\n shuttingDown_ = true;\n pendingAutoDetect_ = false;\n pendingConnectPort_.clear();\n\n if (ioWorker_ == nullptr || !ioThread_.isRunning()) {\n ioWorker_ = nullptr;\n ioWorkerReady_ = false;\n ioWorkerAffinityValid_ = false;\n shutdownComplete_ = true;\n return true;\n }\n\n const bool requestStop = running_;\n if (QThread::currentThread() == &ioThread_) {\n // This path is not expected for the GUI-owned controller. Retire the\n // worker but never attempt to join the current thread from itself.\n ioWorker_->shutdown(requestStop);\n ioThread_.quit();\n return false;\n }\n\n auto acknowledged = QSharedPointer::create();\n DeviceIoWorker* const worker = ioWorker_;\n const bool queued = QMetaObject::invokeMethod(\n worker,\n [worker, requestStop, acknowledged] {\n worker->shutdown(requestStop);\n acknowledged->release();\n },\n Qt::QueuedConnection);\n\n bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs());\n if (!graceful) {\n qCritical().noquote()\n << "Device I/O shutdown acknowledgement timed out; forcing bounded thread retirement.";\n }\n\n ioThread_.quit();\n bool joined = ioThread_.wait(shutdownJoinTimeoutMs());\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Device I/O thread did not join on the primary deadline; retrying bounded retirement.";\n ioThread_.requestInterruption();\n ioThread_.quit();\n joined = ioThread_.wait(shutdownRetryTimeoutMs());\n }\n if (!joined) {\n // Emergency process-exit containment only. The normal contract is for\n // DeviceIoWorker::shutdown() to have already closed timers and QSerialPort.\n // CI treats reaching this fallback as a lifecycle failure.\n graceful = false;\n qCritical().noquote()\n << "Device I/O thread remained alive after bounded retirement; terminating emergency fallback.";\n ioThread_.terminate();\n joined = ioThread_.wait(shutdownForceTimeoutMs());\n }\n\n ioWorker_ = nullptr;\n ioWorkerReady_ = false;\n ioWorkerAffinityValid_ = false;\n connected_ = false;\n running_ = false;\n profileDeploying_ = false;\n shuttingDown_ = false;\n shutdownComplete_ = joined;\n if (!joined) {\n qCritical().noquote() << "Device I/O thread could not be retired before application exit.";\n }\n return graceful && joined;\n}\n''') + + # Studio controller no longer owns a competing aboutToQuit path. + replace_once('apps/arstack_studio/src/StudioDeviceController.hpp', + '#include \n#include \n', + '#include \n') + old_about = '''\n if (auto* app = QCoreApplication::instance(); app != nullptr) {\n connect(app, &QCoreApplication::aboutToQuit, this, [this] {\n controlHealthTimer_.stop();\n healthProbeOutstanding_ = false;\n setSessionHeartbeatEnabled(false);\n clearPendingLiveCommands();\n if (connected() && running()) {\n static_cast(DeviceController::stop());\n }\n });\n }\n''' + replace_once('apps/arstack_studio/src/StudioDeviceController.hpp', old_about, '\n') + + # SmartSessionController: explicit cancellation of all timers/callback owners. + replace_once('apps/arstack_studio/src/SmartSessionController.hpp', + ' explicit SmartSessionController(QObject* parent = nullptr);\n', + ' explicit SmartSessionController(QObject* parent = nullptr);\n ~SmartSessionController() override;\n\n void shutdown();\n') + replace_once('apps/arstack_studio/src/SmartSessionController.hpp', + ' SmartSessionController* owner_{nullptr};\n QTimer timer_;\n', + ' void shutdown() { timer_.stop(); owner_ = nullptr; }\n SmartSessionController* owner_{nullptr};\n QTimer timer_;\n') + replace_once('apps/arstack_studio/src/SmartSessionController.hpp', + ' SmartSessionController* owner_{nullptr};\n DeviceController* device_{nullptr};\n', + ' void shutdown() {\n QObject::disconnect(verifiedConnection_);\n QObject::disconnect(identificationConnection_);\n device_ = nullptr;\n owner_ = nullptr;\n }\n SmartSessionController* owner_{nullptr};\n DeviceController* device_{nullptr};\n') + replace_once('apps/arstack_studio/src/SmartSessionController.hpp', + ' bool recoveryPending_{false};\n int updateReconnectAttempts_{0};\n', + ' bool recoveryPending_{false};\n bool shuttingDown_{false};\n int updateReconnectAttempts_{0};\n') + + constructor_end = ''' reconnectTimer_.setInterval(3000);\n reconnectTimer_.setSingleShot(true);\n connect(&reconnectTimer_, &QTimer::timeout, this, [this] {\n if (device_ == nullptr || !updateRequested_ || updateStage_ != UpdateStage::reconnecting) return;\n if (device_->deviceVerified()) {\n reconcile();\n return;\n }\n if (device_->discovering() || device_->connected()) {\n reconnectTimer_.start();\n return;\n }\n if (updateReconnectAttempts_ >= kMaxUpdateReconnectAttempts) {\n latchFirmwareFailure(QStringLiteral(\n "Firmware was written, but Studio could not reconnect and verify the ESP32-P4. Reconnect USB and retry firmware setup."));\n emit firmwareUpdateFinished(false);\n reconcile();\n return;\n }\n ++updateReconnectAttempts_;\n static_cast(startDeviceDiscovery());\n reconnectTimer_.start();\n reconcile();\n });\n}\n''' + constructor_new = constructor_end + '''\nSmartSessionController::~SmartSessionController() {\n shutdown();\n}\n\nvoid SmartSessionController::shutdown() {\n if (shuttingDown_) return;\n shuttingDown_ = true;\n started_ = false;\n discoveryTimer_.stop();\n prepareTimer_.stop();\n reconnectTimer_.stop();\n profileSyncTimer_.stop();\n firmwareHandoffWatchdog_.shutdown();\n deviceRecoveryMonitor_.shutdown();\n updateRequested_ = false;\n pendingReleaseGeneration_ = 0;\n profileSyncStage_ = ProfileSyncStage::idle;\n if (device_ != nullptr) QObject::disconnect(device_, nullptr, this, nullptr);\n if (profiles_ != nullptr) QObject::disconnect(profiles_, nullptr, this, nullptr);\n if (firmware_ != nullptr) QObject::disconnect(firmware_, nullptr, this, nullptr);\n device_ = nullptr;\n profiles_ = nullptr;\n firmware_ = nullptr;\n}\n''' + replace_once('apps/arstack_studio/src/SmartSessionController.cpp', constructor_end, constructor_new) + replace_once('apps/arstack_studio/src/SmartSessionController.cpp', + 'void SmartSessionController::start() {\n if (started_) return;\n', + 'void SmartSessionController::start() {\n if (started_ || shuttingDown_) return;\n') + replace_once('apps/arstack_studio/src/SmartSessionController.cpp', + 'void SmartSessionController::reconcile() {\n if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) {\n', + 'void SmartSessionController::reconcile() {\n if (shuttingDown_) return;\n if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) {\n') + + # FirmwareManager: same bounded worker retirement semantics as serial I/O. + replace_once('apps/arstack_studio/src/FirmwareManager.hpp', + ' void cancel();\n void shutdown();\n', + ' void cancel();\n bool shutdown();\n [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 2200; }\n [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; }\n [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; }\n [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; }\n') + replace_once('apps/arstack_studio/src/FirmwareManager.cpp', + '#include \n', + '#include \n#include \n#include \n') + replace_once('apps/arstack_studio/src/FirmwareManager.cpp', + 'FirmwareManager::~FirmwareManager() {\n shutdown();\n}\n', + 'FirmwareManager::~FirmwareManager() {\n static_cast(shutdown());\n}\n') + replace_once('apps/arstack_studio/src/FirmwareManager.cpp', + '''void FirmwareManager::shutdown() {\n if (shuttingDown_) return;\n shuttingDown_ = true;\n operation_ = Operation::none;\n activeOperationGeneration_ = 0;\n busy_ = false;\n cancelRequested_ = true;\n\n if (worker_ != nullptr && workerThread_.isRunning()) {\n if (QThread::currentThread() == &workerThread_) {\n worker_->shutdown();\n } else {\n QMetaObject::invokeMethod(worker_, &FirmwareWorker::shutdown, Qt::BlockingQueuedConnection);\n }\n workerThread_.quit();\n workerThread_.wait();\n }\n}\n''', + '''bool FirmwareManager::shutdown() {\n if (shuttingDown_) return !workerThread_.isRunning();\n shuttingDown_ = true;\n operation_ = Operation::none;\n activeOperationGeneration_ = 0;\n busy_ = false;\n cancelRequested_ = true;\n\n if (worker_ == nullptr || !workerThread_.isRunning()) {\n worker_ = nullptr;\n workerReady_ = false;\n workerAffinityValid_ = false;\n return true;\n }\n if (QThread::currentThread() == &workerThread_) {\n worker_->shutdown();\n workerThread_.quit();\n return false;\n }\n\n auto acknowledged = QSharedPointer::create();\n FirmwareWorker* const worker = worker_;\n const bool queued = QMetaObject::invokeMethod(\n worker,\n [worker, acknowledged] {\n worker->shutdown();\n acknowledged->release();\n },\n Qt::QueuedConnection);\n bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs());\n if (!graceful) {\n qCritical().noquote()\n << "Firmware worker shutdown acknowledgement timed out; forcing bounded retirement.";\n }\n\n workerThread_.quit();\n bool joined = workerThread_.wait(shutdownJoinTimeoutMs());\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Firmware worker did not join on the primary deadline; retrying bounded retirement.";\n workerThread_.requestInterruption();\n workerThread_.quit();\n joined = workerThread_.wait(shutdownRetryTimeoutMs());\n }\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Firmware worker remained alive after bounded retirement; terminating emergency fallback.";\n workerThread_.terminate();\n joined = workerThread_.wait(shutdownForceTimeoutMs());\n }\n\n worker_ = nullptr;\n workerReady_ = false;\n workerAffinityValid_ = false;\n if (!joined) qCritical().noquote() << "Firmware worker could not be retired before application exit.";\n return graceful && joined;\n}\n''') + + # main(): one application-boundary coordinator, while the Qt event loop still exists. + replace_once('apps/arstack_studio/src/main.cpp', + ''' FirmwareManager firmwareService;\n QObject::connect(\n &app,\n &QCoreApplication::aboutToQuit,\n &firmwareService,\n &FirmwareManager::shutdown,\n Qt::DirectConnection);\n\n''', + ''' FirmwareManager firmwareService;\n\n''') + anchor = ''' PrimaryWindowCloseFilter primaryCloseFilter{&app};\n if (mainWindow != nullptr) {\n mainWindow->installEventFilter(&primaryCloseFilter);\n }\n\n''' + replacement = anchor + ''' QPointer lifecycleDevice;\n QPointer lifecycleSession;\n if (mainWindow != nullptr) {\n lifecycleDevice = mainWindow->findChild();\n lifecycleSession = mainWindow->findChild();\n }\n if (mainWindow != nullptr && (lifecycleDevice.isNull() || lifecycleSession.isNull())) {\n qCritical().noquote()\n << "Application lifecycle owner discovery failed; refusing an unowned worker/session lifetime.";\n return 14;\n }\n\n bool lifecycleCoordinatorInvoked = false;\n bool lifecycleShutdownClean = false;\n QObject::connect(\n &app,\n &QCoreApplication::aboutToQuit,\n &app,\n [&] {\n lifecycleCoordinatorInvoked = true;\n if (!lifecycleSession.isNull()) lifecycleSession->shutdown();\n const bool deviceClean = lifecycleDevice.isNull() || lifecycleDevice->shutdown();\n const bool firmwareClean = firmwareService.shutdown();\n lifecycleShutdownClean = deviceClean && firmwareClean;\n if (!lifecycleShutdownClean) {\n qCritical().noquote()\n << "Application shutdown required an emergency worker-retirement fallback.";\n }\n },\n Qt::DirectConnection);\n\n''' + replace_once('apps/arstack_studio/src/main.cpp', anchor, replacement) + replace_once('apps/arstack_studio/src/main.cpp', + ''' const int result = app.exec();\n if (lifecycleCheck && result == 0) {\n qInfo().noquote() << "Application lifecycle regression: PASS · primary window close reached the lifetime filter";\n }\n return result;\n}\n''', + ''' const int result = app.exec();\n if (!lifecycleCoordinatorInvoked || !lifecycleShutdownClean) {\n qCritical().noquote()\n << "Application lifecycle regression: shutdown coordinator did not retire all workers cleanly.";\n return result == 0 ? 15 : result;\n }\n if (lifecycleCheck && result == 0) {\n qInfo().noquote()\n << "Application lifecycle regression: PASS · close -> supervisor stop -> device/firmware worker join -> lock release";\n }\n return result;\n}\n''') + + - name: Validate focused diff + shell: bash + run: | + set -euo pipefail + git diff --check + changed="$(git diff --name-only)" + echo "$changed" + if echo "$changed" | grep -q '^apps/ied_simulator/'; then + echo 'IED Simulator must not be touched by Studio lifecycle fix.' + exit 1 + fi + if echo "$changed" | grep -q '^embedded/'; then + echo 'Embedded firmware must not be touched by desktop lifecycle fix.' + exit 1 + fi + + - name: Commit source hardening + shell: bash + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add apps/arstack_studio/src + git commit -m 'fix(studio): retire workers before GUI event loop exits' + git push origin HEAD:fix/studio-graceful-shutdown-p0 From ee4d2e8adbcfb03c942440a5d462e231bdc21ca5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:46:00 +0700 Subject: [PATCH 02/10] chore: replace brittle shutdown staging helper --- .../stage-studio-graceful-shutdown.yml | 136 ------------------ 1 file changed, 136 deletions(-) delete mode 100644 .github/workflows/stage-studio-graceful-shutdown.yml diff --git a/.github/workflows/stage-studio-graceful-shutdown.yml b/.github/workflows/stage-studio-graceful-shutdown.yml deleted file mode 100644 index 3602838c3..000000000 --- a/.github/workflows/stage-studio-graceful-shutdown.yml +++ /dev/null @@ -1,136 +0,0 @@ -name: Stage Studio graceful shutdown hardening - -on: - push: - branches: - - fix/studio-graceful-shutdown-p0 - -permissions: - contents: write - -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/studio-graceful-shutdown-p0 - fetch-depth: 0 - - - name: Apply bounded lifecycle ownership patch - shell: python - run: | - from pathlib import Path - - def replace_once(path, old, new): - p = Path(path) - text = p.read_text(encoding='utf-8') - if text.count(old) != 1: - raise SystemExit(f'{path}: expected exactly one replacement anchor, got {text.count(old)}') - p.write_text(text.replace(old, new, 1), encoding='utf-8') - - # DeviceController: explicit, bounded, idempotent worker retirement. - replace_once('apps/arstack_studio/src/DeviceController.hpp', - ' explicit DeviceController(QObject* parent = nullptr);\n ~DeviceController() override;\n', - ' explicit DeviceController(QObject* parent = nullptr);\n ~DeviceController() override;\n\n' - ' // Application-lifetime boundary. Stops new transport work, asks the\n' - ' // worker to retire its timers/serial handle, and joins the worker with\n' - ' // bounded deadlines. Returns false if any emergency fallback was needed.\n' - ' bool shutdown();\n' - ' [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 1200; }\n' - ' [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; }\n' - ' [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; }\n' - ' [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; }\n') - replace_once('apps/arstack_studio/src/DeviceController.hpp', - ' bool ioWorkerReady_{false};\n bool ioWorkerAffinityValid_{false};\n bool pendingAutoDetect_{false};\n', - ' bool ioWorkerReady_{false};\n bool ioWorkerAffinityValid_{false};\n bool shuttingDown_{false};\n bool shutdownComplete_{false};\n bool pendingAutoDetect_{false};\n') - - replace_once('apps/arstack_studio/src/DeviceController.cpp', - '#include \n#include \n#include \n', - '#include \n#include \n#include \n#include \n#include \n#include \n') - replace_once('apps/arstack_studio/src/DeviceController.cpp', - '''DeviceController::~DeviceController() {\n if (ioWorker_ == nullptr || !ioThread_.isRunning()) return;\n\n const bool requestStop = running_;\n if (QThread::currentThread() == &ioThread_) {\n ioWorker_->shutdown(requestStop);\n } else {\n QMetaObject::invokeMethod(\n ioWorker_,\n [worker = ioWorker_, requestStop] { worker->shutdown(requestStop); },\n Qt::BlockingQueuedConnection);\n }\n ioThread_.quit();\n ioThread_.wait();\n}\n''', - '''DeviceController::~DeviceController() {\n static_cast(shutdown());\n}\n\nbool DeviceController::shutdown() {\n if (shutdownComplete_) return true;\n if (shuttingDown_) return !ioThread_.isRunning();\n shuttingDown_ = true;\n pendingAutoDetect_ = false;\n pendingConnectPort_.clear();\n\n if (ioWorker_ == nullptr || !ioThread_.isRunning()) {\n ioWorker_ = nullptr;\n ioWorkerReady_ = false;\n ioWorkerAffinityValid_ = false;\n shutdownComplete_ = true;\n return true;\n }\n\n const bool requestStop = running_;\n if (QThread::currentThread() == &ioThread_) {\n // This path is not expected for the GUI-owned controller. Retire the\n // worker but never attempt to join the current thread from itself.\n ioWorker_->shutdown(requestStop);\n ioThread_.quit();\n return false;\n }\n\n auto acknowledged = QSharedPointer::create();\n DeviceIoWorker* const worker = ioWorker_;\n const bool queued = QMetaObject::invokeMethod(\n worker,\n [worker, requestStop, acknowledged] {\n worker->shutdown(requestStop);\n acknowledged->release();\n },\n Qt::QueuedConnection);\n\n bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs());\n if (!graceful) {\n qCritical().noquote()\n << "Device I/O shutdown acknowledgement timed out; forcing bounded thread retirement.";\n }\n\n ioThread_.quit();\n bool joined = ioThread_.wait(shutdownJoinTimeoutMs());\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Device I/O thread did not join on the primary deadline; retrying bounded retirement.";\n ioThread_.requestInterruption();\n ioThread_.quit();\n joined = ioThread_.wait(shutdownRetryTimeoutMs());\n }\n if (!joined) {\n // Emergency process-exit containment only. The normal contract is for\n // DeviceIoWorker::shutdown() to have already closed timers and QSerialPort.\n // CI treats reaching this fallback as a lifecycle failure.\n graceful = false;\n qCritical().noquote()\n << "Device I/O thread remained alive after bounded retirement; terminating emergency fallback.";\n ioThread_.terminate();\n joined = ioThread_.wait(shutdownForceTimeoutMs());\n }\n\n ioWorker_ = nullptr;\n ioWorkerReady_ = false;\n ioWorkerAffinityValid_ = false;\n connected_ = false;\n running_ = false;\n profileDeploying_ = false;\n shuttingDown_ = false;\n shutdownComplete_ = joined;\n if (!joined) {\n qCritical().noquote() << "Device I/O thread could not be retired before application exit.";\n }\n return graceful && joined;\n}\n''') - - # Studio controller no longer owns a competing aboutToQuit path. - replace_once('apps/arstack_studio/src/StudioDeviceController.hpp', - '#include \n#include \n', - '#include \n') - old_about = '''\n if (auto* app = QCoreApplication::instance(); app != nullptr) {\n connect(app, &QCoreApplication::aboutToQuit, this, [this] {\n controlHealthTimer_.stop();\n healthProbeOutstanding_ = false;\n setSessionHeartbeatEnabled(false);\n clearPendingLiveCommands();\n if (connected() && running()) {\n static_cast(DeviceController::stop());\n }\n });\n }\n''' - replace_once('apps/arstack_studio/src/StudioDeviceController.hpp', old_about, '\n') - - # SmartSessionController: explicit cancellation of all timers/callback owners. - replace_once('apps/arstack_studio/src/SmartSessionController.hpp', - ' explicit SmartSessionController(QObject* parent = nullptr);\n', - ' explicit SmartSessionController(QObject* parent = nullptr);\n ~SmartSessionController() override;\n\n void shutdown();\n') - replace_once('apps/arstack_studio/src/SmartSessionController.hpp', - ' SmartSessionController* owner_{nullptr};\n QTimer timer_;\n', - ' void shutdown() { timer_.stop(); owner_ = nullptr; }\n SmartSessionController* owner_{nullptr};\n QTimer timer_;\n') - replace_once('apps/arstack_studio/src/SmartSessionController.hpp', - ' SmartSessionController* owner_{nullptr};\n DeviceController* device_{nullptr};\n', - ' void shutdown() {\n QObject::disconnect(verifiedConnection_);\n QObject::disconnect(identificationConnection_);\n device_ = nullptr;\n owner_ = nullptr;\n }\n SmartSessionController* owner_{nullptr};\n DeviceController* device_{nullptr};\n') - replace_once('apps/arstack_studio/src/SmartSessionController.hpp', - ' bool recoveryPending_{false};\n int updateReconnectAttempts_{0};\n', - ' bool recoveryPending_{false};\n bool shuttingDown_{false};\n int updateReconnectAttempts_{0};\n') - - constructor_end = ''' reconnectTimer_.setInterval(3000);\n reconnectTimer_.setSingleShot(true);\n connect(&reconnectTimer_, &QTimer::timeout, this, [this] {\n if (device_ == nullptr || !updateRequested_ || updateStage_ != UpdateStage::reconnecting) return;\n if (device_->deviceVerified()) {\n reconcile();\n return;\n }\n if (device_->discovering() || device_->connected()) {\n reconnectTimer_.start();\n return;\n }\n if (updateReconnectAttempts_ >= kMaxUpdateReconnectAttempts) {\n latchFirmwareFailure(QStringLiteral(\n "Firmware was written, but Studio could not reconnect and verify the ESP32-P4. Reconnect USB and retry firmware setup."));\n emit firmwareUpdateFinished(false);\n reconcile();\n return;\n }\n ++updateReconnectAttempts_;\n static_cast(startDeviceDiscovery());\n reconnectTimer_.start();\n reconcile();\n });\n}\n''' - constructor_new = constructor_end + '''\nSmartSessionController::~SmartSessionController() {\n shutdown();\n}\n\nvoid SmartSessionController::shutdown() {\n if (shuttingDown_) return;\n shuttingDown_ = true;\n started_ = false;\n discoveryTimer_.stop();\n prepareTimer_.stop();\n reconnectTimer_.stop();\n profileSyncTimer_.stop();\n firmwareHandoffWatchdog_.shutdown();\n deviceRecoveryMonitor_.shutdown();\n updateRequested_ = false;\n pendingReleaseGeneration_ = 0;\n profileSyncStage_ = ProfileSyncStage::idle;\n if (device_ != nullptr) QObject::disconnect(device_, nullptr, this, nullptr);\n if (profiles_ != nullptr) QObject::disconnect(profiles_, nullptr, this, nullptr);\n if (firmware_ != nullptr) QObject::disconnect(firmware_, nullptr, this, nullptr);\n device_ = nullptr;\n profiles_ = nullptr;\n firmware_ = nullptr;\n}\n''' - replace_once('apps/arstack_studio/src/SmartSessionController.cpp', constructor_end, constructor_new) - replace_once('apps/arstack_studio/src/SmartSessionController.cpp', - 'void SmartSessionController::start() {\n if (started_) return;\n', - 'void SmartSessionController::start() {\n if (started_ || shuttingDown_) return;\n') - replace_once('apps/arstack_studio/src/SmartSessionController.cpp', - 'void SmartSessionController::reconcile() {\n if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) {\n', - 'void SmartSessionController::reconcile() {\n if (shuttingDown_) return;\n if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) {\n') - - # FirmwareManager: same bounded worker retirement semantics as serial I/O. - replace_once('apps/arstack_studio/src/FirmwareManager.hpp', - ' void cancel();\n void shutdown();\n', - ' void cancel();\n bool shutdown();\n [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 2200; }\n [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; }\n [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; }\n [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; }\n') - replace_once('apps/arstack_studio/src/FirmwareManager.cpp', - '#include \n', - '#include \n#include \n#include \n') - replace_once('apps/arstack_studio/src/FirmwareManager.cpp', - 'FirmwareManager::~FirmwareManager() {\n shutdown();\n}\n', - 'FirmwareManager::~FirmwareManager() {\n static_cast(shutdown());\n}\n') - replace_once('apps/arstack_studio/src/FirmwareManager.cpp', - '''void FirmwareManager::shutdown() {\n if (shuttingDown_) return;\n shuttingDown_ = true;\n operation_ = Operation::none;\n activeOperationGeneration_ = 0;\n busy_ = false;\n cancelRequested_ = true;\n\n if (worker_ != nullptr && workerThread_.isRunning()) {\n if (QThread::currentThread() == &workerThread_) {\n worker_->shutdown();\n } else {\n QMetaObject::invokeMethod(worker_, &FirmwareWorker::shutdown, Qt::BlockingQueuedConnection);\n }\n workerThread_.quit();\n workerThread_.wait();\n }\n}\n''', - '''bool FirmwareManager::shutdown() {\n if (shuttingDown_) return !workerThread_.isRunning();\n shuttingDown_ = true;\n operation_ = Operation::none;\n activeOperationGeneration_ = 0;\n busy_ = false;\n cancelRequested_ = true;\n\n if (worker_ == nullptr || !workerThread_.isRunning()) {\n worker_ = nullptr;\n workerReady_ = false;\n workerAffinityValid_ = false;\n return true;\n }\n if (QThread::currentThread() == &workerThread_) {\n worker_->shutdown();\n workerThread_.quit();\n return false;\n }\n\n auto acknowledged = QSharedPointer::create();\n FirmwareWorker* const worker = worker_;\n const bool queued = QMetaObject::invokeMethod(\n worker,\n [worker, acknowledged] {\n worker->shutdown();\n acknowledged->release();\n },\n Qt::QueuedConnection);\n bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs());\n if (!graceful) {\n qCritical().noquote()\n << "Firmware worker shutdown acknowledgement timed out; forcing bounded retirement.";\n }\n\n workerThread_.quit();\n bool joined = workerThread_.wait(shutdownJoinTimeoutMs());\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Firmware worker did not join on the primary deadline; retrying bounded retirement.";\n workerThread_.requestInterruption();\n workerThread_.quit();\n joined = workerThread_.wait(shutdownRetryTimeoutMs());\n }\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Firmware worker remained alive after bounded retirement; terminating emergency fallback.";\n workerThread_.terminate();\n joined = workerThread_.wait(shutdownForceTimeoutMs());\n }\n\n worker_ = nullptr;\n workerReady_ = false;\n workerAffinityValid_ = false;\n if (!joined) qCritical().noquote() << "Firmware worker could not be retired before application exit.";\n return graceful && joined;\n}\n''') - - # main(): one application-boundary coordinator, while the Qt event loop still exists. - replace_once('apps/arstack_studio/src/main.cpp', - ''' FirmwareManager firmwareService;\n QObject::connect(\n &app,\n &QCoreApplication::aboutToQuit,\n &firmwareService,\n &FirmwareManager::shutdown,\n Qt::DirectConnection);\n\n''', - ''' FirmwareManager firmwareService;\n\n''') - anchor = ''' PrimaryWindowCloseFilter primaryCloseFilter{&app};\n if (mainWindow != nullptr) {\n mainWindow->installEventFilter(&primaryCloseFilter);\n }\n\n''' - replacement = anchor + ''' QPointer lifecycleDevice;\n QPointer lifecycleSession;\n if (mainWindow != nullptr) {\n lifecycleDevice = mainWindow->findChild();\n lifecycleSession = mainWindow->findChild();\n }\n if (mainWindow != nullptr && (lifecycleDevice.isNull() || lifecycleSession.isNull())) {\n qCritical().noquote()\n << "Application lifecycle owner discovery failed; refusing an unowned worker/session lifetime.";\n return 14;\n }\n\n bool lifecycleCoordinatorInvoked = false;\n bool lifecycleShutdownClean = false;\n QObject::connect(\n &app,\n &QCoreApplication::aboutToQuit,\n &app,\n [&] {\n lifecycleCoordinatorInvoked = true;\n if (!lifecycleSession.isNull()) lifecycleSession->shutdown();\n const bool deviceClean = lifecycleDevice.isNull() || lifecycleDevice->shutdown();\n const bool firmwareClean = firmwareService.shutdown();\n lifecycleShutdownClean = deviceClean && firmwareClean;\n if (!lifecycleShutdownClean) {\n qCritical().noquote()\n << "Application shutdown required an emergency worker-retirement fallback.";\n }\n },\n Qt::DirectConnection);\n\n''' - replace_once('apps/arstack_studio/src/main.cpp', anchor, replacement) - replace_once('apps/arstack_studio/src/main.cpp', - ''' const int result = app.exec();\n if (lifecycleCheck && result == 0) {\n qInfo().noquote() << "Application lifecycle regression: PASS · primary window close reached the lifetime filter";\n }\n return result;\n}\n''', - ''' const int result = app.exec();\n if (!lifecycleCoordinatorInvoked || !lifecycleShutdownClean) {\n qCritical().noquote()\n << "Application lifecycle regression: shutdown coordinator did not retire all workers cleanly.";\n return result == 0 ? 15 : result;\n }\n if (lifecycleCheck && result == 0) {\n qInfo().noquote()\n << "Application lifecycle regression: PASS · close -> supervisor stop -> device/firmware worker join -> lock release";\n }\n return result;\n}\n''') - - - name: Validate focused diff - shell: bash - run: | - set -euo pipefail - git diff --check - changed="$(git diff --name-only)" - echo "$changed" - if echo "$changed" | grep -q '^apps/ied_simulator/'; then - echo 'IED Simulator must not be touched by Studio lifecycle fix.' - exit 1 - fi - if echo "$changed" | grep -q '^embedded/'; then - echo 'Embedded firmware must not be touched by desktop lifecycle fix.' - exit 1 - fi - - - name: Commit source hardening - shell: bash - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add apps/arstack_studio/src - git commit -m 'fix(studio): retire workers before GUI event loop exits' - git push origin HEAD:fix/studio-graceful-shutdown-p0 From 6e884674b0b433bd4c84c70743e32e3cd2495925 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:47:08 +0700 Subject: [PATCH 03/10] chore: stage bounded Studio lifecycle ownership --- .../stage-studio-graceful-shutdown-v2.yml | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 .github/workflows/stage-studio-graceful-shutdown-v2.yml diff --git a/.github/workflows/stage-studio-graceful-shutdown-v2.yml b/.github/workflows/stage-studio-graceful-shutdown-v2.yml new file mode 100644 index 000000000..d79d13a11 --- /dev/null +++ b/.github/workflows/stage-studio-graceful-shutdown-v2.yml @@ -0,0 +1,171 @@ +name: Stage Studio graceful shutdown v2 + +on: + push: + branches: + - fix/studio-graceful-shutdown-p0 + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/studio-graceful-shutdown-p0 + fetch-depth: 0 + + - name: Apply lifecycle ownership patch + shell: python + run: | + from pathlib import Path + import re + + def replace_once(path, old, new): + p = Path(path) + text = p.read_text(encoding='utf-8') + count = text.count(old) + if count != 1: + raise SystemExit(f'{path}: replacement anchor count={count}') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + + def regex_once(path, pattern, replacement): + p = Path(path) + text = p.read_text(encoding='utf-8') + new, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) + if count != 1: + raise SystemExit(f'{path}: regex anchor count={count}: {pattern}') + p.write_text(new, encoding='utf-8') + + # DeviceController owns serial-worker retirement explicitly and boundedly. + replace_once( + 'apps/arstack_studio/src/DeviceController.hpp', + ' explicit DeviceController(QObject* parent = nullptr);\n ~DeviceController() override;\n', + ' explicit DeviceController(QObject* parent = nullptr);\n ~DeviceController() override;\n\n' + ' // Application-lifetime boundary required by AGENTS.md section 11.\n' + ' // Returns false if an emergency thread-retirement fallback was required.\n' + ' bool shutdown();\n' + ' [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 1200; }\n' + ' [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; }\n' + ' [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; }\n' + ' [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; }\n') + replace_once( + 'apps/arstack_studio/src/DeviceController.hpp', + ' bool ioWorkerReady_{false};\n bool ioWorkerAffinityValid_{false};\n bool pendingAutoDetect_{false};\n', + ' bool ioWorkerReady_{false};\n bool ioWorkerAffinityValid_{false};\n bool shuttingDown_{false};\n bool shutdownComplete_{false};\n bool pendingAutoDetect_{false};\n') + replace_once( + 'apps/arstack_studio/src/DeviceController.cpp', + '#include \n#include \n#include \n', + '#include \n#include \n#include \n#include \n#include \n#include \n') + old_device_dtor = '''DeviceController::~DeviceController() {\n if (ioWorker_ == nullptr || !ioThread_.isRunning()) return;\n\n const bool requestStop = running_;\n if (QThread::currentThread() == &ioThread_) {\n ioWorker_->shutdown(requestStop);\n } else {\n QMetaObject::invokeMethod(\n ioWorker_,\n [worker = ioWorker_, requestStop] { worker->shutdown(requestStop); },\n Qt::BlockingQueuedConnection);\n }\n ioThread_.quit();\n ioThread_.wait();\n}\n''' + new_device_dtor = '''DeviceController::~DeviceController() {\n static_cast(shutdown());\n}\n\nbool DeviceController::shutdown() {\n if (shutdownComplete_) return true;\n if (shuttingDown_) return !ioThread_.isRunning();\n shuttingDown_ = true;\n pendingAutoDetect_ = false;\n pendingConnectPort_.clear();\n\n if (ioWorker_ == nullptr || !ioThread_.isRunning()) {\n ioWorker_ = nullptr;\n ioWorkerReady_ = false;\n ioWorkerAffinityValid_ = false;\n shutdownComplete_ = true;\n shuttingDown_ = false;\n return true;\n }\n\n const bool requestStop = running_;\n if (QThread::currentThread() == &ioThread_) {\n ioWorker_->shutdown(requestStop);\n ioThread_.quit();\n shuttingDown_ = false;\n return false;\n }\n\n auto acknowledged = QSharedPointer::create();\n DeviceIoWorker* const worker = ioWorker_;\n const bool queued = QMetaObject::invokeMethod(\n worker,\n [worker, requestStop, acknowledged] {\n worker->shutdown(requestStop);\n acknowledged->release();\n },\n Qt::QueuedConnection);\n\n bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs());\n if (!graceful) {\n qCritical().noquote()\n << "Device I/O shutdown acknowledgement timed out; entering bounded retirement fallback.";\n }\n\n ioThread_.quit();\n bool joined = ioThread_.wait(shutdownJoinTimeoutMs());\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Device I/O thread missed primary join deadline; retrying bounded retirement.";\n ioThread_.requestInterruption();\n ioThread_.quit();\n joined = ioThread_.wait(shutdownRetryTimeoutMs());\n }\n if (!joined) {\n // Emergency process-exit containment only; normal CI must never reach this.\n graceful = false;\n qCritical().noquote()\n << "Device I/O thread still alive; emergency terminate fallback engaged.";\n ioThread_.terminate();\n joined = ioThread_.wait(shutdownForceTimeoutMs());\n }\n\n ioWorker_ = nullptr;\n ioWorkerReady_ = false;\n ioWorkerAffinityValid_ = false;\n connected_ = false;\n running_ = false;\n profileDeploying_ = false;\n shutdownComplete_ = joined;\n shuttingDown_ = false;\n if (!joined) qCritical().noquote() << "Device I/O thread could not be retired before exit.";\n return graceful && joined;\n}\n''' + replace_once('apps/arstack_studio/src/DeviceController.cpp', old_device_dtor, new_device_dtor) + + # StudioDeviceController no longer competes for application shutdown authority. + replace_once( + 'apps/arstack_studio/src/StudioDeviceController.hpp', + '#include \n#include \n', + '#include \n') + old_about = '''\n if (auto* app = QCoreApplication::instance(); app != nullptr) {\n connect(app, &QCoreApplication::aboutToQuit, this, [this] {\n controlHealthTimer_.stop();\n healthProbeOutstanding_ = false;\n setSessionHeartbeatEnabled(false);\n clearPendingLiveCommands();\n if (connected() && running()) {\n static_cast(DeviceController::stop());\n }\n });\n }\n''' + replace_once('apps/arstack_studio/src/StudioDeviceController.hpp', old_about, '\n') + + # Supervisor explicitly stops all timers/callback owners before device teardown. + replace_once( + 'apps/arstack_studio/src/SmartSessionController.hpp', + ' explicit SmartSessionController(QObject* parent = nullptr);\n', + ' explicit SmartSessionController(QObject* parent = nullptr);\n ~SmartSessionController() override;\n\n void shutdown();\n') + replace_once( + 'apps/arstack_studio/src/SmartSessionController.hpp', + ''' explicit FirmwareHandoffWatchdog(SmartSessionController* owner) : owner_(owner) {\n timer_.setSingleShot(true);\n QObject::connect(owner_, &SmartSessionController::stateChanged, owner_, [this] { synchronize(); });\n QObject::connect(&timer_, &QTimer::timeout, owner_, [this] { expire(); });\n }\n\n private:\n''', + ''' explicit FirmwareHandoffWatchdog(SmartSessionController* owner) : owner_(owner) {\n timer_.setSingleShot(true);\n QObject::connect(owner_, &SmartSessionController::stateChanged, owner_, [this] { synchronize(); });\n QObject::connect(&timer_, &QTimer::timeout, owner_, [this] { expire(); });\n }\n void shutdown() { timer_.stop(); owner_ = nullptr; }\n\n private:\n''') + replace_once( + 'apps/arstack_studio/src/SmartSessionController.hpp', + ''' explicit DeviceRecoveryMonitor(SmartSessionController* owner) : owner_(owner) {\n QObject::connect(owner_, &SmartSessionController::dependenciesChanged, owner_, [this] { bindDevice(); });\n }\n\n private:\n''', + ''' explicit DeviceRecoveryMonitor(SmartSessionController* owner) : owner_(owner) {\n QObject::connect(owner_, &SmartSessionController::dependenciesChanged, owner_, [this] { bindDevice(); });\n }\n void shutdown() {\n QObject::disconnect(verifiedConnection_);\n QObject::disconnect(identificationConnection_);\n device_ = nullptr;\n owner_ = nullptr;\n }\n\n private:\n''') + replace_once( + 'apps/arstack_studio/src/SmartSessionController.hpp', + ' bool setupError_{false};\n bool recoveryPending_{false};\n int updateReconnectAttempts_{0};\n', + ' bool setupError_{false};\n bool recoveryPending_{false};\n bool shuttingDown_{false};\n int updateReconnectAttempts_{0};\n') + + constructor_tail = ''' reconnectTimer_.setInterval(3000);\n reconnectTimer_.setSingleShot(true);\n connect(&reconnectTimer_, &QTimer::timeout, this, [this] {\n if (device_ == nullptr || !updateRequested_ || updateStage_ != UpdateStage::reconnecting) return;\n if (device_->deviceVerified()) {\n reconcile();\n return;\n }\n if (device_->discovering() || device_->connected()) {\n reconnectTimer_.start();\n return;\n }\n if (updateReconnectAttempts_ >= kMaxUpdateReconnectAttempts) {\n latchFirmwareFailure(QStringLiteral(\n "Firmware was written, but Studio could not reconnect and verify the ESP32-P4. Reconnect USB and retry firmware setup."));\n emit firmwareUpdateFinished(false);\n reconcile();\n return;\n }\n ++updateReconnectAttempts_;\n static_cast(startDeviceDiscovery());\n reconnectTimer_.start();\n reconcile();\n });\n}\n''' + replace_once( + 'apps/arstack_studio/src/SmartSessionController.cpp', + constructor_tail, + constructor_tail + '''\nSmartSessionController::~SmartSessionController() {\n shutdown();\n}\n\nvoid SmartSessionController::shutdown() {\n if (shuttingDown_) return;\n shuttingDown_ = true;\n started_ = false;\n discoveryTimer_.stop();\n prepareTimer_.stop();\n reconnectTimer_.stop();\n profileSyncTimer_.stop();\n firmwareHandoffWatchdog_.shutdown();\n deviceRecoveryMonitor_.shutdown();\n updateRequested_ = false;\n pendingReleaseGeneration_ = 0;\n profileSyncStage_ = ProfileSyncStage::idle;\n if (device_ != nullptr) QObject::disconnect(device_, nullptr, this, nullptr);\n if (profiles_ != nullptr) QObject::disconnect(profiles_, nullptr, this, nullptr);\n if (firmware_ != nullptr) QObject::disconnect(firmware_, nullptr, this, nullptr);\n device_ = nullptr;\n profiles_ = nullptr;\n firmware_ = nullptr;\n}\n''') + replace_once( + 'apps/arstack_studio/src/SmartSessionController.cpp', + 'void SmartSessionController::start() {\n if (started_) return;\n', + 'void SmartSessionController::start() {\n if (started_ || shuttingDown_) return;\n') + replace_once( + 'apps/arstack_studio/src/SmartSessionController.cpp', + 'void SmartSessionController::reconcile() {\n if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) {\n', + 'void SmartSessionController::reconcile() {\n if (shuttingDown_) return;\n if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) {\n') + + # Firmware process worker retirement receives the same bounded ownership contract. + replace_once( + 'apps/arstack_studio/src/FirmwareManager.hpp', + ' void cancel();\n void shutdown();\n', + ' void cancel();\n bool shutdown();\n' + ' [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 2200; }\n' + ' [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; }\n' + ' [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; }\n' + ' [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; }\n') + replace_once( + 'apps/arstack_studio/src/FirmwareManager.cpp', + '#include \n#include \n', + '#include \n#include \n#include \n') + replace_once( + 'apps/arstack_studio/src/FirmwareManager.cpp', + '#include \n', + '#include \n#include \n#include \n') + replace_once( + 'apps/arstack_studio/src/FirmwareManager.cpp', + 'FirmwareManager::~FirmwareManager() {\n shutdown();\n}\n', + 'FirmwareManager::~FirmwareManager() {\n static_cast(shutdown());\n}\n') + old_fw_shutdown = '''void FirmwareManager::shutdown() {\n if (shuttingDown_) return;\n shuttingDown_ = true;\n operation_ = Operation::none;\n activeOperationGeneration_ = 0;\n busy_ = false;\n cancelRequested_ = true;\n\n if (worker_ != nullptr && workerThread_.isRunning()) {\n if (QThread::currentThread() == &workerThread_) {\n worker_->shutdown();\n } else {\n QMetaObject::invokeMethod(worker_, &FirmwareWorker::shutdown, Qt::BlockingQueuedConnection);\n }\n workerThread_.quit();\n workerThread_.wait();\n }\n}\n''' + new_fw_shutdown = '''bool FirmwareManager::shutdown() {\n if (shuttingDown_) return !workerThread_.isRunning();\n shuttingDown_ = true;\n operation_ = Operation::none;\n activeOperationGeneration_ = 0;\n busy_ = false;\n cancelRequested_ = true;\n\n if (worker_ == nullptr || !workerThread_.isRunning()) {\n worker_ = nullptr;\n workerReady_ = false;\n workerAffinityValid_ = false;\n return true;\n }\n if (QThread::currentThread() == &workerThread_) {\n worker_->shutdown();\n workerThread_.quit();\n return false;\n }\n\n auto acknowledged = QSharedPointer::create();\n FirmwareWorker* const worker = worker_;\n const bool queued = QMetaObject::invokeMethod(\n worker,\n [worker, acknowledged] {\n worker->shutdown();\n acknowledged->release();\n },\n Qt::QueuedConnection);\n bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs());\n if (!graceful) {\n qCritical().noquote()\n << "Firmware worker shutdown acknowledgement timed out; entering bounded retirement fallback.";\n }\n\n workerThread_.quit();\n bool joined = workerThread_.wait(shutdownJoinTimeoutMs());\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Firmware worker missed primary join deadline; retrying bounded retirement.";\n workerThread_.requestInterruption();\n workerThread_.quit();\n joined = workerThread_.wait(shutdownRetryTimeoutMs());\n }\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Firmware worker still alive; emergency terminate fallback engaged.";\n workerThread_.terminate();\n joined = workerThread_.wait(shutdownForceTimeoutMs());\n }\n\n worker_ = nullptr;\n workerReady_ = false;\n workerAffinityValid_ = false;\n if (!joined) qCritical().noquote() << "Firmware worker could not be retired before exit.";\n return graceful && joined;\n}\n''' + replace_once('apps/arstack_studio/src/FirmwareManager.cpp', old_fw_shutdown, new_fw_shutdown) + + # One application boundary owns shutdown ordering while both worker loops still exist. + regex_once( + 'apps/arstack_studio/src/main.cpp', + r' FirmwareManager firmwareService;\n QObject::connect\(\n &app,\n &QCoreApplication::aboutToQuit,\n &firmwareService,\n &FirmwareManager::shutdown,\n Qt::DirectConnection\);\n', + ' FirmwareManager firmwareService;\n') + anchor = ''' PrimaryWindowCloseFilter primaryCloseFilter{&app};\n if (mainWindow != nullptr) {\n mainWindow->installEventFilter(&primaryCloseFilter);\n }\n\n''' + coordinator = anchor + ''' QPointer lifecycleDevice;\n QPointer lifecycleSession;\n if (mainWindow != nullptr) {\n lifecycleDevice = mainWindow->findChild();\n lifecycleSession = mainWindow->findChild();\n }\n if (mainWindow != nullptr && (lifecycleDevice.isNull() || lifecycleSession.isNull())) {\n qCritical().noquote()\n << "Application lifecycle owner discovery failed; refusing an unowned worker/session lifetime.";\n return 14;\n }\n\n bool lifecycleCoordinatorInvoked = false;\n bool lifecycleShutdownClean = false;\n QObject::connect(\n &app,\n &QCoreApplication::aboutToQuit,\n &app,\n [&] {\n lifecycleCoordinatorInvoked = true;\n if (!lifecycleSession.isNull()) lifecycleSession->shutdown();\n const bool deviceClean = lifecycleDevice.isNull() || lifecycleDevice->shutdown();\n const bool firmwareClean = firmwareService.shutdown();\n lifecycleShutdownClean = deviceClean && firmwareClean;\n if (!lifecycleShutdownClean) {\n qCritical().noquote()\n << "Application shutdown required an emergency worker-retirement fallback.";\n }\n },\n Qt::DirectConnection);\n\n''' + replace_once('apps/arstack_studio/src/main.cpp', anchor, coordinator) + replace_once( + 'apps/arstack_studio/src/main.cpp', + ''' const int result = app.exec();\n if (lifecycleCheck && result == 0) {\n qInfo().noquote() << "Application lifecycle regression: PASS · primary window close reached the lifetime filter";\n }\n return result;\n}\n''', + ''' const int result = app.exec();\n if (!lifecycleCoordinatorInvoked || !lifecycleShutdownClean) {\n qCritical().noquote()\n << "Application lifecycle regression: shutdown coordinator did not retire all workers cleanly.";\n return result == 0 ? 15 : result;\n }\n if (lifecycleCheck && result == 0) {\n qInfo().noquote()\n << "Application lifecycle regression: PASS · close -> supervisor stop -> worker join -> process exit";\n }\n return result;\n}\n''') + + - name: Validate focused diff + shell: bash + run: | + set -euo pipefail + git diff --check + git diff --stat + changed="$(git diff --name-only)" + echo "$changed" + test -n "$changed" + if echo "$changed" | grep -q '^apps/ied_simulator/'; then + echo 'IED Simulator must not be touched.' + exit 1 + fi + if echo "$changed" | grep -q '^embedded/'; then + echo 'Embedded firmware must not be touched.' + exit 1 + fi + + - name: Commit source hardening + shell: bash + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add apps/arstack_studio/src + git commit -m 'fix(studio): retire workers before GUI event loop exits' + git push origin HEAD:fix/studio-graceful-shutdown-p0 From 7f0f736389e8c3f58997c96dfb35391df0b2e4b6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:48:34 +0700 Subject: [PATCH 04/10] chore: harden shutdown staging matcher --- .../workflows/fix-stage-graceful-shutdown.yml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/fix-stage-graceful-shutdown.yml diff --git a/.github/workflows/fix-stage-graceful-shutdown.yml b/.github/workflows/fix-stage-graceful-shutdown.yml new file mode 100644 index 000000000..4ab91ef12 --- /dev/null +++ b/.github/workflows/fix-stage-graceful-shutdown.yml @@ -0,0 +1,39 @@ +name: Fix graceful shutdown staging matcher + +on: + push: + branches: + - fix/studio-graceful-shutdown-p0 + +permissions: + contents: write + +jobs: + fix: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/studio-graceful-shutdown-p0 + fetch-depth: 0 + - name: Replace brittle main-window anchor with structural regex + shell: python + run: | + from pathlib import Path + p = Path('.github/workflows/stage-studio-graceful-shutdown-v2.yml') + text = p.read_text(encoding='utf-8') + old = " replace_once('apps/arstack_studio/src/main.cpp', anchor, coordinator)\n" + new = """ regex_once(\n 'apps/arstack_studio/src/main.cpp',\n r' PrimaryWindowCloseFilter primaryCloseFilter\\{&app\\};\\n if \\(mainWindow != nullptr\\) \\{\\n mainWindow->installEventFilter\\(&primaryCloseFilter\\);\\n \\}\\n(?:\\n)?',\n coordinator)\n""" + if text.count(old) != 1: + raise SystemExit(f'expected one main-window anchor call, got {text.count(old)}') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + - name: Commit matcher hardening + shell: bash + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add .github/workflows/stage-studio-graceful-shutdown-v2.yml + git commit -m 'chore: use structural matcher for shutdown coordinator staging' + git push origin HEAD:fix/studio-graceful-shutdown-p0 From ab3ad943defec3d207254ca7fc9f4a348eea3e74 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:50:13 +0700 Subject: [PATCH 05/10] chore: stage final Studio lifecycle hardening --- .../stage-studio-graceful-shutdown-v3.yml | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 .github/workflows/stage-studio-graceful-shutdown-v3.yml diff --git a/.github/workflows/stage-studio-graceful-shutdown-v3.yml b/.github/workflows/stage-studio-graceful-shutdown-v3.yml new file mode 100644 index 000000000..3554e483d --- /dev/null +++ b/.github/workflows/stage-studio-graceful-shutdown-v3.yml @@ -0,0 +1,143 @@ +name: Stage Studio graceful shutdown v3 + +on: + push: + branches: + - fix/studio-graceful-shutdown-p0 + +permissions: + contents: write + +jobs: + patch: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/studio-graceful-shutdown-p0 + fetch-depth: 0 + + - name: Apply bounded lifecycle ownership patch + shell: python + run: | + from pathlib import Path + import re + + def replace_once(path, old, new): + p = Path(path) + text = p.read_text(encoding='utf-8') + count = text.count(old) + if count != 1: + raise SystemExit(f'{path}: replacement anchor count={count}') + p.write_text(text.replace(old, new, 1), encoding='utf-8') + + def regex_once(path, pattern, replacement): + p = Path(path) + text = p.read_text(encoding='utf-8') + new, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) + if count != 1: + raise SystemExit(f'{path}: regex anchor count={count}') + p.write_text(new, encoding='utf-8') + + # DeviceController: explicit bounded transport-worker retirement. + replace_once('apps/arstack_studio/src/DeviceController.hpp', + ' explicit DeviceController(QObject* parent = nullptr);\n ~DeviceController() override;\n', + ' explicit DeviceController(QObject* parent = nullptr);\n ~DeviceController() override;\n\n' + ' // Application-lifetime boundary required by AGENTS.md section 11.\n' + ' // Returns false if an emergency retirement fallback was required.\n' + ' bool shutdown();\n' + ' [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 1200; }\n' + ' [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; }\n' + ' [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; }\n' + ' [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; }\n') + replace_once('apps/arstack_studio/src/DeviceController.hpp', + ' bool ioWorkerReady_{false};\n bool ioWorkerAffinityValid_{false};\n bool pendingAutoDetect_{false};\n', + ' bool ioWorkerReady_{false};\n bool ioWorkerAffinityValid_{false};\n bool shuttingDown_{false};\n bool shutdownComplete_{false};\n bool pendingAutoDetect_{false};\n') + replace_once('apps/arstack_studio/src/DeviceController.cpp', + '#include \n#include \n#include \n', + '#include \n#include \n#include \n#include \n#include \n#include \n') + replace_once('apps/arstack_studio/src/DeviceController.cpp', + '''DeviceController::~DeviceController() {\n if (ioWorker_ == nullptr || !ioThread_.isRunning()) return;\n\n const bool requestStop = running_;\n if (QThread::currentThread() == &ioThread_) {\n ioWorker_->shutdown(requestStop);\n } else {\n QMetaObject::invokeMethod(\n ioWorker_,\n [worker = ioWorker_, requestStop] { worker->shutdown(requestStop); },\n Qt::BlockingQueuedConnection);\n }\n ioThread_.quit();\n ioThread_.wait();\n}\n''', + '''DeviceController::~DeviceController() {\n static_cast(shutdown());\n}\n\nbool DeviceController::shutdown() {\n if (shutdownComplete_) return true;\n if (shuttingDown_) return !ioThread_.isRunning();\n shuttingDown_ = true;\n pendingAutoDetect_ = false;\n pendingConnectPort_.clear();\n\n if (ioWorker_ == nullptr || !ioThread_.isRunning()) {\n ioWorker_ = nullptr;\n ioWorkerReady_ = false;\n ioWorkerAffinityValid_ = false;\n shutdownComplete_ = true;\n shuttingDown_ = false;\n return true;\n }\n\n const bool requestStop = running_;\n if (QThread::currentThread() == &ioThread_) {\n ioWorker_->shutdown(requestStop);\n ioThread_.quit();\n shuttingDown_ = false;\n return false;\n }\n\n auto acknowledged = QSharedPointer::create();\n DeviceIoWorker* const worker = ioWorker_;\n const bool queued = QMetaObject::invokeMethod(\n worker,\n [worker, requestStop, acknowledged] {\n worker->shutdown(requestStop);\n acknowledged->release();\n },\n Qt::QueuedConnection);\n bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs());\n if (!graceful) {\n qCritical().noquote()\n << "Device I/O shutdown acknowledgement timed out; entering bounded retirement fallback.";\n }\n\n ioThread_.quit();\n bool joined = ioThread_.wait(shutdownJoinTimeoutMs());\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Device I/O thread missed primary join deadline; retrying bounded retirement.";\n ioThread_.requestInterruption();\n ioThread_.quit();\n joined = ioThread_.wait(shutdownRetryTimeoutMs());\n }\n if (!joined) {\n // Emergency process-exit containment only. Normal CI must never reach this.\n graceful = false;\n qCritical().noquote()\n << "Device I/O thread still alive; emergency terminate fallback engaged.";\n ioThread_.terminate();\n joined = ioThread_.wait(shutdownForceTimeoutMs());\n }\n\n ioWorker_ = nullptr;\n ioWorkerReady_ = false;\n ioWorkerAffinityValid_ = false;\n connected_ = false;\n running_ = false;\n profileDeploying_ = false;\n shutdownComplete_ = joined;\n shuttingDown_ = false;\n if (!joined) qCritical().noquote() << "Device I/O thread could not be retired before exit.";\n return graceful && joined;\n}\n''') + + # Remove competing app-level shutdown ownership from the device facade. + replace_once('apps/arstack_studio/src/StudioDeviceController.hpp', + '#include \n#include \n', '#include \n') + replace_once('apps/arstack_studio/src/StudioDeviceController.hpp', + '''\n if (auto* app = QCoreApplication::instance(); app != nullptr) {\n connect(app, &QCoreApplication::aboutToQuit, this, [this] {\n controlHealthTimer_.stop();\n healthProbeOutstanding_ = false;\n setSessionHeartbeatEnabled(false);\n clearPendingLiveCommands();\n if (connected() && running()) {\n static_cast(DeviceController::stop());\n }\n });\n }\n''', '\n') + + # SmartSessionController retires timers and callback owners first. + replace_once('apps/arstack_studio/src/SmartSessionController.hpp', + ' explicit SmartSessionController(QObject* parent = nullptr);\n', + ' explicit SmartSessionController(QObject* parent = nullptr);\n ~SmartSessionController() override;\n\n void shutdown();\n') + replace_once('apps/arstack_studio/src/SmartSessionController.hpp', + ''' explicit FirmwareHandoffWatchdog(SmartSessionController* owner) : owner_(owner) {\n timer_.setSingleShot(true);\n QObject::connect(owner_, &SmartSessionController::stateChanged, owner_, [this] { synchronize(); });\n QObject::connect(&timer_, &QTimer::timeout, owner_, [this] { expire(); });\n }\n\n private:\n''', + ''' explicit FirmwareHandoffWatchdog(SmartSessionController* owner) : owner_(owner) {\n timer_.setSingleShot(true);\n QObject::connect(owner_, &SmartSessionController::stateChanged, owner_, [this] { synchronize(); });\n QObject::connect(&timer_, &QTimer::timeout, owner_, [this] { expire(); });\n }\n void shutdown() { timer_.stop(); owner_ = nullptr; }\n\n private:\n''') + replace_once('apps/arstack_studio/src/SmartSessionController.hpp', + ''' explicit DeviceRecoveryMonitor(SmartSessionController* owner) : owner_(owner) {\n QObject::connect(owner_, &SmartSessionController::dependenciesChanged, owner_, [this] { bindDevice(); });\n }\n\n private:\n''', + ''' explicit DeviceRecoveryMonitor(SmartSessionController* owner) : owner_(owner) {\n QObject::connect(owner_, &SmartSessionController::dependenciesChanged, owner_, [this] { bindDevice(); });\n }\n void shutdown() {\n QObject::disconnect(verifiedConnection_);\n QObject::disconnect(identificationConnection_);\n device_ = nullptr;\n owner_ = nullptr;\n }\n\n private:\n''') + replace_once('apps/arstack_studio/src/SmartSessionController.hpp', + ' bool setupError_{false};\n bool recoveryPending_{false};\n int updateReconnectAttempts_{0};\n', + ' bool setupError_{false};\n bool recoveryPending_{false};\n bool shuttingDown_{false};\n int updateReconnectAttempts_{0};\n') + constructor_tail = ''' reconnectTimer_.setInterval(3000);\n reconnectTimer_.setSingleShot(true);\n connect(&reconnectTimer_, &QTimer::timeout, this, [this] {\n if (device_ == nullptr || !updateRequested_ || updateStage_ != UpdateStage::reconnecting) return;\n if (device_->deviceVerified()) {\n reconcile();\n return;\n }\n if (device_->discovering() || device_->connected()) {\n reconnectTimer_.start();\n return;\n }\n if (updateReconnectAttempts_ >= kMaxUpdateReconnectAttempts) {\n latchFirmwareFailure(QStringLiteral(\n "Firmware was written, but Studio could not reconnect and verify the ESP32-P4. Reconnect USB and retry firmware setup."));\n emit firmwareUpdateFinished(false);\n reconcile();\n return;\n }\n ++updateReconnectAttempts_;\n static_cast(startDeviceDiscovery());\n reconnectTimer_.start();\n reconcile();\n });\n}\n''' + replace_once('apps/arstack_studio/src/SmartSessionController.cpp', constructor_tail, + constructor_tail + '''\nSmartSessionController::~SmartSessionController() {\n shutdown();\n}\n\nvoid SmartSessionController::shutdown() {\n if (shuttingDown_) return;\n shuttingDown_ = true;\n started_ = false;\n discoveryTimer_.stop();\n prepareTimer_.stop();\n reconnectTimer_.stop();\n profileSyncTimer_.stop();\n firmwareHandoffWatchdog_.shutdown();\n deviceRecoveryMonitor_.shutdown();\n updateRequested_ = false;\n pendingReleaseGeneration_ = 0;\n profileSyncStage_ = ProfileSyncStage::idle;\n if (device_ != nullptr) QObject::disconnect(device_, nullptr, this, nullptr);\n if (profiles_ != nullptr) QObject::disconnect(profiles_, nullptr, this, nullptr);\n if (firmware_ != nullptr) QObject::disconnect(firmware_, nullptr, this, nullptr);\n device_ = nullptr;\n profiles_ = nullptr;\n firmware_ = nullptr;\n}\n''') + replace_once('apps/arstack_studio/src/SmartSessionController.cpp', + 'void SmartSessionController::start() {\n if (started_) return;\n', + 'void SmartSessionController::start() {\n if (started_ || shuttingDown_) return;\n') + replace_once('apps/arstack_studio/src/SmartSessionController.cpp', + 'void SmartSessionController::reconcile() {\n if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) {\n', + 'void SmartSessionController::reconcile() {\n if (shuttingDown_) return;\n if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) {\n') + + # Firmware worker follows the same bounded ownership contract. + replace_once('apps/arstack_studio/src/FirmwareManager.hpp', + ' void cancel();\n void shutdown();\n', + ' void cancel();\n bool shutdown();\n' + ' [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 2200; }\n' + ' [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; }\n' + ' [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; }\n' + ' [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; }\n') + replace_once('apps/arstack_studio/src/FirmwareManager.cpp', + '#include \n#include \n', '#include \n#include \n#include \n') + replace_once('apps/arstack_studio/src/FirmwareManager.cpp', + '#include \n', '#include \n#include \n#include \n') + replace_once('apps/arstack_studio/src/FirmwareManager.cpp', + 'FirmwareManager::~FirmwareManager() {\n shutdown();\n}\n', + 'FirmwareManager::~FirmwareManager() {\n static_cast(shutdown());\n}\n') + replace_once('apps/arstack_studio/src/FirmwareManager.cpp', + '''void FirmwareManager::shutdown() {\n if (shuttingDown_) return;\n shuttingDown_ = true;\n operation_ = Operation::none;\n activeOperationGeneration_ = 0;\n busy_ = false;\n cancelRequested_ = true;\n\n if (worker_ != nullptr && workerThread_.isRunning()) {\n if (QThread::currentThread() == &workerThread_) {\n worker_->shutdown();\n } else {\n QMetaObject::invokeMethod(worker_, &FirmwareWorker::shutdown, Qt::BlockingQueuedConnection);\n }\n workerThread_.quit();\n workerThread_.wait();\n }\n}\n''', + '''bool FirmwareManager::shutdown() {\n if (shuttingDown_) return !workerThread_.isRunning();\n shuttingDown_ = true;\n operation_ = Operation::none;\n activeOperationGeneration_ = 0;\n busy_ = false;\n cancelRequested_ = true;\n\n if (worker_ == nullptr || !workerThread_.isRunning()) {\n worker_ = nullptr;\n workerReady_ = false;\n workerAffinityValid_ = false;\n return true;\n }\n if (QThread::currentThread() == &workerThread_) {\n worker_->shutdown();\n workerThread_.quit();\n return false;\n }\n\n auto acknowledged = QSharedPointer::create();\n FirmwareWorker* const worker = worker_;\n const bool queued = QMetaObject::invokeMethod(\n worker,\n [worker, acknowledged] {\n worker->shutdown();\n acknowledged->release();\n },\n Qt::QueuedConnection);\n bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs());\n if (!graceful) {\n qCritical().noquote()\n << "Firmware worker shutdown acknowledgement timed out; entering bounded retirement fallback.";\n }\n\n workerThread_.quit();\n bool joined = workerThread_.wait(shutdownJoinTimeoutMs());\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Firmware worker missed primary join deadline; retrying bounded retirement.";\n workerThread_.requestInterruption();\n workerThread_.quit();\n joined = workerThread_.wait(shutdownRetryTimeoutMs());\n }\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Firmware worker still alive; emergency terminate fallback engaged.";\n workerThread_.terminate();\n joined = workerThread_.wait(shutdownForceTimeoutMs());\n }\n\n worker_ = nullptr;\n workerReady_ = false;\n workerAffinityValid_ = false;\n if (!joined) qCritical().noquote() << "Firmware worker could not be retired before exit.";\n return graceful && joined;\n}\n''') + + # Single application boundary controls shutdown ordering. + regex_once('apps/arstack_studio/src/main.cpp', + r' FirmwareManager firmwareService;\n QObject::connect\(\n &app,\n &QCoreApplication::aboutToQuit,\n &firmwareService,\n &FirmwareManager::shutdown,\n Qt::DirectConnection\);\n', + ' FirmwareManager firmwareService;\n') + window_pattern = r' PrimaryWindowCloseFilter primaryCloseFilter\{&app\};\n if \(mainWindow != nullptr\) \{\n mainWindow->installEventFilter\(&primaryCloseFilter\);\n \}\n(?:\n)?' + coordinator = ''' PrimaryWindowCloseFilter primaryCloseFilter{&app};\n if (mainWindow != nullptr) {\n mainWindow->installEventFilter(&primaryCloseFilter);\n }\n\n QPointer lifecycleDevice;\n QPointer lifecycleSession;\n if (mainWindow != nullptr) {\n lifecycleDevice = mainWindow->findChild();\n lifecycleSession = mainWindow->findChild();\n }\n if (mainWindow != nullptr && (lifecycleDevice.isNull() || lifecycleSession.isNull())) {\n qCritical().noquote()\n << "Application lifecycle owner discovery failed; refusing an unowned worker/session lifetime.";\n return 14;\n }\n\n bool lifecycleCoordinatorInvoked = false;\n bool lifecycleShutdownClean = false;\n QObject::connect(\n &app,\n &QCoreApplication::aboutToQuit,\n &app,\n [&] {\n lifecycleCoordinatorInvoked = true;\n if (!lifecycleSession.isNull()) lifecycleSession->shutdown();\n const bool deviceClean = lifecycleDevice.isNull() || lifecycleDevice->shutdown();\n const bool firmwareClean = firmwareService.shutdown();\n lifecycleShutdownClean = deviceClean && firmwareClean;\n if (!lifecycleShutdownClean) {\n qCritical().noquote()\n << "Application shutdown required an emergency worker-retirement fallback.";\n }\n },\n Qt::DirectConnection);\n\n''' + regex_once('apps/arstack_studio/src/main.cpp', window_pattern, coordinator) + regex_once('apps/arstack_studio/src/main.cpp', + r' const int result = app\.exec\(\);\n if \(lifecycleCheck && result == 0\) \{\n qInfo\(\)\.noquote\(\) << "Application lifecycle regression: PASS · primary window close reached the lifetime filter";\n \}\n return result;\n\}', + ''' const int result = app.exec();\n if (!lifecycleCoordinatorInvoked || !lifecycleShutdownClean) {\n qCritical().noquote()\n << "Application lifecycle regression: shutdown coordinator did not retire all workers cleanly.";\n return result == 0 ? 15 : result;\n }\n if (lifecycleCheck && result == 0) {\n qInfo().noquote()\n << "Application lifecycle regression: PASS · close -> supervisor stop -> worker join -> process exit";\n }\n return result;\n}''') + + - name: Validate focused diff + shell: bash + run: | + set -euo pipefail + git diff --check + git diff --stat + changed="$(git diff --name-only)" + echo "$changed" + test -n "$changed" + if echo "$changed" | grep -q '^apps/ied_simulator/'; then exit 21; fi + if echo "$changed" | grep -q '^embedded/'; then exit 22; fi + + - name: Commit source lifecycle hardening + shell: bash + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add apps/arstack_studio/src + git commit -m 'fix(studio): retire workers before GUI event loop exits' + git push origin HEAD:fix/studio-graceful-shutdown-p0 From 75e5f2dfd1fcf382bb2698a55ce9fcff57413390 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:50:24 +0000 Subject: [PATCH 06/10] fix(studio): retire workers before GUI event loop exits --- apps/arstack_studio/src/DeviceController.cpp | 75 +++++++++++++++++-- apps/arstack_studio/src/DeviceController.hpp | 10 +++ apps/arstack_studio/src/FirmwareManager.cpp | 64 +++++++++++++--- apps/arstack_studio/src/FirmwareManager.hpp | 6 +- .../src/SmartSessionController.cpp | 28 ++++++- .../src/SmartSessionController.hpp | 11 +++ .../src/StudioDeviceController.hpp | 12 --- apps/arstack_studio/src/main.cpp | 45 +++++++++-- 8 files changed, 213 insertions(+), 38 deletions(-) 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 From 9c40c7a418db47bcb37404ee5a1ba59176653ef3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:51:09 +0700 Subject: [PATCH 07/10] chore: remove temporary lifecycle staging workflows --- .../workflows/fix-stage-graceful-shutdown.yml | 39 ---- .../stage-studio-graceful-shutdown-v2.yml | 171 ------------------ .../stage-studio-graceful-shutdown-v3.yml | 143 --------------- 3 files changed, 353 deletions(-) delete mode 100644 .github/workflows/fix-stage-graceful-shutdown.yml delete mode 100644 .github/workflows/stage-studio-graceful-shutdown-v2.yml delete mode 100644 .github/workflows/stage-studio-graceful-shutdown-v3.yml diff --git a/.github/workflows/fix-stage-graceful-shutdown.yml b/.github/workflows/fix-stage-graceful-shutdown.yml deleted file mode 100644 index 4ab91ef12..000000000 --- a/.github/workflows/fix-stage-graceful-shutdown.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Fix graceful shutdown staging matcher - -on: - push: - branches: - - fix/studio-graceful-shutdown-p0 - -permissions: - contents: write - -jobs: - fix: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/studio-graceful-shutdown-p0 - fetch-depth: 0 - - name: Replace brittle main-window anchor with structural regex - shell: python - run: | - from pathlib import Path - p = Path('.github/workflows/stage-studio-graceful-shutdown-v2.yml') - text = p.read_text(encoding='utf-8') - old = " replace_once('apps/arstack_studio/src/main.cpp', anchor, coordinator)\n" - new = """ regex_once(\n 'apps/arstack_studio/src/main.cpp',\n r' PrimaryWindowCloseFilter primaryCloseFilter\\{&app\\};\\n if \\(mainWindow != nullptr\\) \\{\\n mainWindow->installEventFilter\\(&primaryCloseFilter\\);\\n \\}\\n(?:\\n)?',\n coordinator)\n""" - if text.count(old) != 1: - raise SystemExit(f'expected one main-window anchor call, got {text.count(old)}') - p.write_text(text.replace(old, new, 1), encoding='utf-8') - - name: Commit matcher hardening - shell: bash - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add .github/workflows/stage-studio-graceful-shutdown-v2.yml - git commit -m 'chore: use structural matcher for shutdown coordinator staging' - git push origin HEAD:fix/studio-graceful-shutdown-p0 diff --git a/.github/workflows/stage-studio-graceful-shutdown-v2.yml b/.github/workflows/stage-studio-graceful-shutdown-v2.yml deleted file mode 100644 index d79d13a11..000000000 --- a/.github/workflows/stage-studio-graceful-shutdown-v2.yml +++ /dev/null @@ -1,171 +0,0 @@ -name: Stage Studio graceful shutdown v2 - -on: - push: - branches: - - fix/studio-graceful-shutdown-p0 - -permissions: - contents: write - -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/studio-graceful-shutdown-p0 - fetch-depth: 0 - - - name: Apply lifecycle ownership patch - shell: python - run: | - from pathlib import Path - import re - - def replace_once(path, old, new): - p = Path(path) - text = p.read_text(encoding='utf-8') - count = text.count(old) - if count != 1: - raise SystemExit(f'{path}: replacement anchor count={count}') - p.write_text(text.replace(old, new, 1), encoding='utf-8') - - def regex_once(path, pattern, replacement): - p = Path(path) - text = p.read_text(encoding='utf-8') - new, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) - if count != 1: - raise SystemExit(f'{path}: regex anchor count={count}: {pattern}') - p.write_text(new, encoding='utf-8') - - # DeviceController owns serial-worker retirement explicitly and boundedly. - replace_once( - 'apps/arstack_studio/src/DeviceController.hpp', - ' explicit DeviceController(QObject* parent = nullptr);\n ~DeviceController() override;\n', - ' explicit DeviceController(QObject* parent = nullptr);\n ~DeviceController() override;\n\n' - ' // Application-lifetime boundary required by AGENTS.md section 11.\n' - ' // Returns false if an emergency thread-retirement fallback was required.\n' - ' bool shutdown();\n' - ' [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 1200; }\n' - ' [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; }\n' - ' [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; }\n' - ' [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; }\n') - replace_once( - 'apps/arstack_studio/src/DeviceController.hpp', - ' bool ioWorkerReady_{false};\n bool ioWorkerAffinityValid_{false};\n bool pendingAutoDetect_{false};\n', - ' bool ioWorkerReady_{false};\n bool ioWorkerAffinityValid_{false};\n bool shuttingDown_{false};\n bool shutdownComplete_{false};\n bool pendingAutoDetect_{false};\n') - replace_once( - 'apps/arstack_studio/src/DeviceController.cpp', - '#include \n#include \n#include \n', - '#include \n#include \n#include \n#include \n#include \n#include \n') - old_device_dtor = '''DeviceController::~DeviceController() {\n if (ioWorker_ == nullptr || !ioThread_.isRunning()) return;\n\n const bool requestStop = running_;\n if (QThread::currentThread() == &ioThread_) {\n ioWorker_->shutdown(requestStop);\n } else {\n QMetaObject::invokeMethod(\n ioWorker_,\n [worker = ioWorker_, requestStop] { worker->shutdown(requestStop); },\n Qt::BlockingQueuedConnection);\n }\n ioThread_.quit();\n ioThread_.wait();\n}\n''' - new_device_dtor = '''DeviceController::~DeviceController() {\n static_cast(shutdown());\n}\n\nbool DeviceController::shutdown() {\n if (shutdownComplete_) return true;\n if (shuttingDown_) return !ioThread_.isRunning();\n shuttingDown_ = true;\n pendingAutoDetect_ = false;\n pendingConnectPort_.clear();\n\n if (ioWorker_ == nullptr || !ioThread_.isRunning()) {\n ioWorker_ = nullptr;\n ioWorkerReady_ = false;\n ioWorkerAffinityValid_ = false;\n shutdownComplete_ = true;\n shuttingDown_ = false;\n return true;\n }\n\n const bool requestStop = running_;\n if (QThread::currentThread() == &ioThread_) {\n ioWorker_->shutdown(requestStop);\n ioThread_.quit();\n shuttingDown_ = false;\n return false;\n }\n\n auto acknowledged = QSharedPointer::create();\n DeviceIoWorker* const worker = ioWorker_;\n const bool queued = QMetaObject::invokeMethod(\n worker,\n [worker, requestStop, acknowledged] {\n worker->shutdown(requestStop);\n acknowledged->release();\n },\n Qt::QueuedConnection);\n\n bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs());\n if (!graceful) {\n qCritical().noquote()\n << "Device I/O shutdown acknowledgement timed out; entering bounded retirement fallback.";\n }\n\n ioThread_.quit();\n bool joined = ioThread_.wait(shutdownJoinTimeoutMs());\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Device I/O thread missed primary join deadline; retrying bounded retirement.";\n ioThread_.requestInterruption();\n ioThread_.quit();\n joined = ioThread_.wait(shutdownRetryTimeoutMs());\n }\n if (!joined) {\n // Emergency process-exit containment only; normal CI must never reach this.\n graceful = false;\n qCritical().noquote()\n << "Device I/O thread still alive; emergency terminate fallback engaged.";\n ioThread_.terminate();\n joined = ioThread_.wait(shutdownForceTimeoutMs());\n }\n\n ioWorker_ = nullptr;\n ioWorkerReady_ = false;\n ioWorkerAffinityValid_ = false;\n connected_ = false;\n running_ = false;\n profileDeploying_ = false;\n shutdownComplete_ = joined;\n shuttingDown_ = false;\n if (!joined) qCritical().noquote() << "Device I/O thread could not be retired before exit.";\n return graceful && joined;\n}\n''' - replace_once('apps/arstack_studio/src/DeviceController.cpp', old_device_dtor, new_device_dtor) - - # StudioDeviceController no longer competes for application shutdown authority. - replace_once( - 'apps/arstack_studio/src/StudioDeviceController.hpp', - '#include \n#include \n', - '#include \n') - old_about = '''\n if (auto* app = QCoreApplication::instance(); app != nullptr) {\n connect(app, &QCoreApplication::aboutToQuit, this, [this] {\n controlHealthTimer_.stop();\n healthProbeOutstanding_ = false;\n setSessionHeartbeatEnabled(false);\n clearPendingLiveCommands();\n if (connected() && running()) {\n static_cast(DeviceController::stop());\n }\n });\n }\n''' - replace_once('apps/arstack_studio/src/StudioDeviceController.hpp', old_about, '\n') - - # Supervisor explicitly stops all timers/callback owners before device teardown. - replace_once( - 'apps/arstack_studio/src/SmartSessionController.hpp', - ' explicit SmartSessionController(QObject* parent = nullptr);\n', - ' explicit SmartSessionController(QObject* parent = nullptr);\n ~SmartSessionController() override;\n\n void shutdown();\n') - replace_once( - 'apps/arstack_studio/src/SmartSessionController.hpp', - ''' explicit FirmwareHandoffWatchdog(SmartSessionController* owner) : owner_(owner) {\n timer_.setSingleShot(true);\n QObject::connect(owner_, &SmartSessionController::stateChanged, owner_, [this] { synchronize(); });\n QObject::connect(&timer_, &QTimer::timeout, owner_, [this] { expire(); });\n }\n\n private:\n''', - ''' explicit FirmwareHandoffWatchdog(SmartSessionController* owner) : owner_(owner) {\n timer_.setSingleShot(true);\n QObject::connect(owner_, &SmartSessionController::stateChanged, owner_, [this] { synchronize(); });\n QObject::connect(&timer_, &QTimer::timeout, owner_, [this] { expire(); });\n }\n void shutdown() { timer_.stop(); owner_ = nullptr; }\n\n private:\n''') - replace_once( - 'apps/arstack_studio/src/SmartSessionController.hpp', - ''' explicit DeviceRecoveryMonitor(SmartSessionController* owner) : owner_(owner) {\n QObject::connect(owner_, &SmartSessionController::dependenciesChanged, owner_, [this] { bindDevice(); });\n }\n\n private:\n''', - ''' explicit DeviceRecoveryMonitor(SmartSessionController* owner) : owner_(owner) {\n QObject::connect(owner_, &SmartSessionController::dependenciesChanged, owner_, [this] { bindDevice(); });\n }\n void shutdown() {\n QObject::disconnect(verifiedConnection_);\n QObject::disconnect(identificationConnection_);\n device_ = nullptr;\n owner_ = nullptr;\n }\n\n private:\n''') - replace_once( - 'apps/arstack_studio/src/SmartSessionController.hpp', - ' bool setupError_{false};\n bool recoveryPending_{false};\n int updateReconnectAttempts_{0};\n', - ' bool setupError_{false};\n bool recoveryPending_{false};\n bool shuttingDown_{false};\n int updateReconnectAttempts_{0};\n') - - constructor_tail = ''' reconnectTimer_.setInterval(3000);\n reconnectTimer_.setSingleShot(true);\n connect(&reconnectTimer_, &QTimer::timeout, this, [this] {\n if (device_ == nullptr || !updateRequested_ || updateStage_ != UpdateStage::reconnecting) return;\n if (device_->deviceVerified()) {\n reconcile();\n return;\n }\n if (device_->discovering() || device_->connected()) {\n reconnectTimer_.start();\n return;\n }\n if (updateReconnectAttempts_ >= kMaxUpdateReconnectAttempts) {\n latchFirmwareFailure(QStringLiteral(\n "Firmware was written, but Studio could not reconnect and verify the ESP32-P4. Reconnect USB and retry firmware setup."));\n emit firmwareUpdateFinished(false);\n reconcile();\n return;\n }\n ++updateReconnectAttempts_;\n static_cast(startDeviceDiscovery());\n reconnectTimer_.start();\n reconcile();\n });\n}\n''' - replace_once( - 'apps/arstack_studio/src/SmartSessionController.cpp', - constructor_tail, - constructor_tail + '''\nSmartSessionController::~SmartSessionController() {\n shutdown();\n}\n\nvoid SmartSessionController::shutdown() {\n if (shuttingDown_) return;\n shuttingDown_ = true;\n started_ = false;\n discoveryTimer_.stop();\n prepareTimer_.stop();\n reconnectTimer_.stop();\n profileSyncTimer_.stop();\n firmwareHandoffWatchdog_.shutdown();\n deviceRecoveryMonitor_.shutdown();\n updateRequested_ = false;\n pendingReleaseGeneration_ = 0;\n profileSyncStage_ = ProfileSyncStage::idle;\n if (device_ != nullptr) QObject::disconnect(device_, nullptr, this, nullptr);\n if (profiles_ != nullptr) QObject::disconnect(profiles_, nullptr, this, nullptr);\n if (firmware_ != nullptr) QObject::disconnect(firmware_, nullptr, this, nullptr);\n device_ = nullptr;\n profiles_ = nullptr;\n firmware_ = nullptr;\n}\n''') - replace_once( - 'apps/arstack_studio/src/SmartSessionController.cpp', - 'void SmartSessionController::start() {\n if (started_) return;\n', - 'void SmartSessionController::start() {\n if (started_ || shuttingDown_) return;\n') - replace_once( - 'apps/arstack_studio/src/SmartSessionController.cpp', - 'void SmartSessionController::reconcile() {\n if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) {\n', - 'void SmartSessionController::reconcile() {\n if (shuttingDown_) return;\n if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) {\n') - - # Firmware process worker retirement receives the same bounded ownership contract. - replace_once( - 'apps/arstack_studio/src/FirmwareManager.hpp', - ' void cancel();\n void shutdown();\n', - ' void cancel();\n bool shutdown();\n' - ' [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 2200; }\n' - ' [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; }\n' - ' [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; }\n' - ' [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; }\n') - replace_once( - 'apps/arstack_studio/src/FirmwareManager.cpp', - '#include \n#include \n', - '#include \n#include \n#include \n') - replace_once( - 'apps/arstack_studio/src/FirmwareManager.cpp', - '#include \n', - '#include \n#include \n#include \n') - replace_once( - 'apps/arstack_studio/src/FirmwareManager.cpp', - 'FirmwareManager::~FirmwareManager() {\n shutdown();\n}\n', - 'FirmwareManager::~FirmwareManager() {\n static_cast(shutdown());\n}\n') - old_fw_shutdown = '''void FirmwareManager::shutdown() {\n if (shuttingDown_) return;\n shuttingDown_ = true;\n operation_ = Operation::none;\n activeOperationGeneration_ = 0;\n busy_ = false;\n cancelRequested_ = true;\n\n if (worker_ != nullptr && workerThread_.isRunning()) {\n if (QThread::currentThread() == &workerThread_) {\n worker_->shutdown();\n } else {\n QMetaObject::invokeMethod(worker_, &FirmwareWorker::shutdown, Qt::BlockingQueuedConnection);\n }\n workerThread_.quit();\n workerThread_.wait();\n }\n}\n''' - new_fw_shutdown = '''bool FirmwareManager::shutdown() {\n if (shuttingDown_) return !workerThread_.isRunning();\n shuttingDown_ = true;\n operation_ = Operation::none;\n activeOperationGeneration_ = 0;\n busy_ = false;\n cancelRequested_ = true;\n\n if (worker_ == nullptr || !workerThread_.isRunning()) {\n worker_ = nullptr;\n workerReady_ = false;\n workerAffinityValid_ = false;\n return true;\n }\n if (QThread::currentThread() == &workerThread_) {\n worker_->shutdown();\n workerThread_.quit();\n return false;\n }\n\n auto acknowledged = QSharedPointer::create();\n FirmwareWorker* const worker = worker_;\n const bool queued = QMetaObject::invokeMethod(\n worker,\n [worker, acknowledged] {\n worker->shutdown();\n acknowledged->release();\n },\n Qt::QueuedConnection);\n bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs());\n if (!graceful) {\n qCritical().noquote()\n << "Firmware worker shutdown acknowledgement timed out; entering bounded retirement fallback.";\n }\n\n workerThread_.quit();\n bool joined = workerThread_.wait(shutdownJoinTimeoutMs());\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Firmware worker missed primary join deadline; retrying bounded retirement.";\n workerThread_.requestInterruption();\n workerThread_.quit();\n joined = workerThread_.wait(shutdownRetryTimeoutMs());\n }\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Firmware worker still alive; emergency terminate fallback engaged.";\n workerThread_.terminate();\n joined = workerThread_.wait(shutdownForceTimeoutMs());\n }\n\n worker_ = nullptr;\n workerReady_ = false;\n workerAffinityValid_ = false;\n if (!joined) qCritical().noquote() << "Firmware worker could not be retired before exit.";\n return graceful && joined;\n}\n''' - replace_once('apps/arstack_studio/src/FirmwareManager.cpp', old_fw_shutdown, new_fw_shutdown) - - # One application boundary owns shutdown ordering while both worker loops still exist. - regex_once( - 'apps/arstack_studio/src/main.cpp', - r' FirmwareManager firmwareService;\n QObject::connect\(\n &app,\n &QCoreApplication::aboutToQuit,\n &firmwareService,\n &FirmwareManager::shutdown,\n Qt::DirectConnection\);\n', - ' FirmwareManager firmwareService;\n') - anchor = ''' PrimaryWindowCloseFilter primaryCloseFilter{&app};\n if (mainWindow != nullptr) {\n mainWindow->installEventFilter(&primaryCloseFilter);\n }\n\n''' - coordinator = anchor + ''' QPointer lifecycleDevice;\n QPointer lifecycleSession;\n if (mainWindow != nullptr) {\n lifecycleDevice = mainWindow->findChild();\n lifecycleSession = mainWindow->findChild();\n }\n if (mainWindow != nullptr && (lifecycleDevice.isNull() || lifecycleSession.isNull())) {\n qCritical().noquote()\n << "Application lifecycle owner discovery failed; refusing an unowned worker/session lifetime.";\n return 14;\n }\n\n bool lifecycleCoordinatorInvoked = false;\n bool lifecycleShutdownClean = false;\n QObject::connect(\n &app,\n &QCoreApplication::aboutToQuit,\n &app,\n [&] {\n lifecycleCoordinatorInvoked = true;\n if (!lifecycleSession.isNull()) lifecycleSession->shutdown();\n const bool deviceClean = lifecycleDevice.isNull() || lifecycleDevice->shutdown();\n const bool firmwareClean = firmwareService.shutdown();\n lifecycleShutdownClean = deviceClean && firmwareClean;\n if (!lifecycleShutdownClean) {\n qCritical().noquote()\n << "Application shutdown required an emergency worker-retirement fallback.";\n }\n },\n Qt::DirectConnection);\n\n''' - replace_once('apps/arstack_studio/src/main.cpp', anchor, coordinator) - replace_once( - 'apps/arstack_studio/src/main.cpp', - ''' const int result = app.exec();\n if (lifecycleCheck && result == 0) {\n qInfo().noquote() << "Application lifecycle regression: PASS · primary window close reached the lifetime filter";\n }\n return result;\n}\n''', - ''' const int result = app.exec();\n if (!lifecycleCoordinatorInvoked || !lifecycleShutdownClean) {\n qCritical().noquote()\n << "Application lifecycle regression: shutdown coordinator did not retire all workers cleanly.";\n return result == 0 ? 15 : result;\n }\n if (lifecycleCheck && result == 0) {\n qInfo().noquote()\n << "Application lifecycle regression: PASS · close -> supervisor stop -> worker join -> process exit";\n }\n return result;\n}\n''') - - - name: Validate focused diff - shell: bash - run: | - set -euo pipefail - git diff --check - git diff --stat - changed="$(git diff --name-only)" - echo "$changed" - test -n "$changed" - if echo "$changed" | grep -q '^apps/ied_simulator/'; then - echo 'IED Simulator must not be touched.' - exit 1 - fi - if echo "$changed" | grep -q '^embedded/'; then - echo 'Embedded firmware must not be touched.' - exit 1 - fi - - - name: Commit source hardening - shell: bash - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add apps/arstack_studio/src - git commit -m 'fix(studio): retire workers before GUI event loop exits' - git push origin HEAD:fix/studio-graceful-shutdown-p0 diff --git a/.github/workflows/stage-studio-graceful-shutdown-v3.yml b/.github/workflows/stage-studio-graceful-shutdown-v3.yml deleted file mode 100644 index 3554e483d..000000000 --- a/.github/workflows/stage-studio-graceful-shutdown-v3.yml +++ /dev/null @@ -1,143 +0,0 @@ -name: Stage Studio graceful shutdown v3 - -on: - push: - branches: - - fix/studio-graceful-shutdown-p0 - -permissions: - contents: write - -jobs: - patch: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/studio-graceful-shutdown-p0 - fetch-depth: 0 - - - name: Apply bounded lifecycle ownership patch - shell: python - run: | - from pathlib import Path - import re - - def replace_once(path, old, new): - p = Path(path) - text = p.read_text(encoding='utf-8') - count = text.count(old) - if count != 1: - raise SystemExit(f'{path}: replacement anchor count={count}') - p.write_text(text.replace(old, new, 1), encoding='utf-8') - - def regex_once(path, pattern, replacement): - p = Path(path) - text = p.read_text(encoding='utf-8') - new, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) - if count != 1: - raise SystemExit(f'{path}: regex anchor count={count}') - p.write_text(new, encoding='utf-8') - - # DeviceController: explicit bounded transport-worker retirement. - replace_once('apps/arstack_studio/src/DeviceController.hpp', - ' explicit DeviceController(QObject* parent = nullptr);\n ~DeviceController() override;\n', - ' explicit DeviceController(QObject* parent = nullptr);\n ~DeviceController() override;\n\n' - ' // Application-lifetime boundary required by AGENTS.md section 11.\n' - ' // Returns false if an emergency retirement fallback was required.\n' - ' bool shutdown();\n' - ' [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 1200; }\n' - ' [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; }\n' - ' [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; }\n' - ' [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; }\n') - replace_once('apps/arstack_studio/src/DeviceController.hpp', - ' bool ioWorkerReady_{false};\n bool ioWorkerAffinityValid_{false};\n bool pendingAutoDetect_{false};\n', - ' bool ioWorkerReady_{false};\n bool ioWorkerAffinityValid_{false};\n bool shuttingDown_{false};\n bool shutdownComplete_{false};\n bool pendingAutoDetect_{false};\n') - replace_once('apps/arstack_studio/src/DeviceController.cpp', - '#include \n#include \n#include \n', - '#include \n#include \n#include \n#include \n#include \n#include \n') - replace_once('apps/arstack_studio/src/DeviceController.cpp', - '''DeviceController::~DeviceController() {\n if (ioWorker_ == nullptr || !ioThread_.isRunning()) return;\n\n const bool requestStop = running_;\n if (QThread::currentThread() == &ioThread_) {\n ioWorker_->shutdown(requestStop);\n } else {\n QMetaObject::invokeMethod(\n ioWorker_,\n [worker = ioWorker_, requestStop] { worker->shutdown(requestStop); },\n Qt::BlockingQueuedConnection);\n }\n ioThread_.quit();\n ioThread_.wait();\n}\n''', - '''DeviceController::~DeviceController() {\n static_cast(shutdown());\n}\n\nbool DeviceController::shutdown() {\n if (shutdownComplete_) return true;\n if (shuttingDown_) return !ioThread_.isRunning();\n shuttingDown_ = true;\n pendingAutoDetect_ = false;\n pendingConnectPort_.clear();\n\n if (ioWorker_ == nullptr || !ioThread_.isRunning()) {\n ioWorker_ = nullptr;\n ioWorkerReady_ = false;\n ioWorkerAffinityValid_ = false;\n shutdownComplete_ = true;\n shuttingDown_ = false;\n return true;\n }\n\n const bool requestStop = running_;\n if (QThread::currentThread() == &ioThread_) {\n ioWorker_->shutdown(requestStop);\n ioThread_.quit();\n shuttingDown_ = false;\n return false;\n }\n\n auto acknowledged = QSharedPointer::create();\n DeviceIoWorker* const worker = ioWorker_;\n const bool queued = QMetaObject::invokeMethod(\n worker,\n [worker, requestStop, acknowledged] {\n worker->shutdown(requestStop);\n acknowledged->release();\n },\n Qt::QueuedConnection);\n bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs());\n if (!graceful) {\n qCritical().noquote()\n << "Device I/O shutdown acknowledgement timed out; entering bounded retirement fallback.";\n }\n\n ioThread_.quit();\n bool joined = ioThread_.wait(shutdownJoinTimeoutMs());\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Device I/O thread missed primary join deadline; retrying bounded retirement.";\n ioThread_.requestInterruption();\n ioThread_.quit();\n joined = ioThread_.wait(shutdownRetryTimeoutMs());\n }\n if (!joined) {\n // Emergency process-exit containment only. Normal CI must never reach this.\n graceful = false;\n qCritical().noquote()\n << "Device I/O thread still alive; emergency terminate fallback engaged.";\n ioThread_.terminate();\n joined = ioThread_.wait(shutdownForceTimeoutMs());\n }\n\n ioWorker_ = nullptr;\n ioWorkerReady_ = false;\n ioWorkerAffinityValid_ = false;\n connected_ = false;\n running_ = false;\n profileDeploying_ = false;\n shutdownComplete_ = joined;\n shuttingDown_ = false;\n if (!joined) qCritical().noquote() << "Device I/O thread could not be retired before exit.";\n return graceful && joined;\n}\n''') - - # Remove competing app-level shutdown ownership from the device facade. - replace_once('apps/arstack_studio/src/StudioDeviceController.hpp', - '#include \n#include \n', '#include \n') - replace_once('apps/arstack_studio/src/StudioDeviceController.hpp', - '''\n if (auto* app = QCoreApplication::instance(); app != nullptr) {\n connect(app, &QCoreApplication::aboutToQuit, this, [this] {\n controlHealthTimer_.stop();\n healthProbeOutstanding_ = false;\n setSessionHeartbeatEnabled(false);\n clearPendingLiveCommands();\n if (connected() && running()) {\n static_cast(DeviceController::stop());\n }\n });\n }\n''', '\n') - - # SmartSessionController retires timers and callback owners first. - replace_once('apps/arstack_studio/src/SmartSessionController.hpp', - ' explicit SmartSessionController(QObject* parent = nullptr);\n', - ' explicit SmartSessionController(QObject* parent = nullptr);\n ~SmartSessionController() override;\n\n void shutdown();\n') - replace_once('apps/arstack_studio/src/SmartSessionController.hpp', - ''' explicit FirmwareHandoffWatchdog(SmartSessionController* owner) : owner_(owner) {\n timer_.setSingleShot(true);\n QObject::connect(owner_, &SmartSessionController::stateChanged, owner_, [this] { synchronize(); });\n QObject::connect(&timer_, &QTimer::timeout, owner_, [this] { expire(); });\n }\n\n private:\n''', - ''' explicit FirmwareHandoffWatchdog(SmartSessionController* owner) : owner_(owner) {\n timer_.setSingleShot(true);\n QObject::connect(owner_, &SmartSessionController::stateChanged, owner_, [this] { synchronize(); });\n QObject::connect(&timer_, &QTimer::timeout, owner_, [this] { expire(); });\n }\n void shutdown() { timer_.stop(); owner_ = nullptr; }\n\n private:\n''') - replace_once('apps/arstack_studio/src/SmartSessionController.hpp', - ''' explicit DeviceRecoveryMonitor(SmartSessionController* owner) : owner_(owner) {\n QObject::connect(owner_, &SmartSessionController::dependenciesChanged, owner_, [this] { bindDevice(); });\n }\n\n private:\n''', - ''' explicit DeviceRecoveryMonitor(SmartSessionController* owner) : owner_(owner) {\n QObject::connect(owner_, &SmartSessionController::dependenciesChanged, owner_, [this] { bindDevice(); });\n }\n void shutdown() {\n QObject::disconnect(verifiedConnection_);\n QObject::disconnect(identificationConnection_);\n device_ = nullptr;\n owner_ = nullptr;\n }\n\n private:\n''') - replace_once('apps/arstack_studio/src/SmartSessionController.hpp', - ' bool setupError_{false};\n bool recoveryPending_{false};\n int updateReconnectAttempts_{0};\n', - ' bool setupError_{false};\n bool recoveryPending_{false};\n bool shuttingDown_{false};\n int updateReconnectAttempts_{0};\n') - constructor_tail = ''' reconnectTimer_.setInterval(3000);\n reconnectTimer_.setSingleShot(true);\n connect(&reconnectTimer_, &QTimer::timeout, this, [this] {\n if (device_ == nullptr || !updateRequested_ || updateStage_ != UpdateStage::reconnecting) return;\n if (device_->deviceVerified()) {\n reconcile();\n return;\n }\n if (device_->discovering() || device_->connected()) {\n reconnectTimer_.start();\n return;\n }\n if (updateReconnectAttempts_ >= kMaxUpdateReconnectAttempts) {\n latchFirmwareFailure(QStringLiteral(\n "Firmware was written, but Studio could not reconnect and verify the ESP32-P4. Reconnect USB and retry firmware setup."));\n emit firmwareUpdateFinished(false);\n reconcile();\n return;\n }\n ++updateReconnectAttempts_;\n static_cast(startDeviceDiscovery());\n reconnectTimer_.start();\n reconcile();\n });\n}\n''' - replace_once('apps/arstack_studio/src/SmartSessionController.cpp', constructor_tail, - constructor_tail + '''\nSmartSessionController::~SmartSessionController() {\n shutdown();\n}\n\nvoid SmartSessionController::shutdown() {\n if (shuttingDown_) return;\n shuttingDown_ = true;\n started_ = false;\n discoveryTimer_.stop();\n prepareTimer_.stop();\n reconnectTimer_.stop();\n profileSyncTimer_.stop();\n firmwareHandoffWatchdog_.shutdown();\n deviceRecoveryMonitor_.shutdown();\n updateRequested_ = false;\n pendingReleaseGeneration_ = 0;\n profileSyncStage_ = ProfileSyncStage::idle;\n if (device_ != nullptr) QObject::disconnect(device_, nullptr, this, nullptr);\n if (profiles_ != nullptr) QObject::disconnect(profiles_, nullptr, this, nullptr);\n if (firmware_ != nullptr) QObject::disconnect(firmware_, nullptr, this, nullptr);\n device_ = nullptr;\n profiles_ = nullptr;\n firmware_ = nullptr;\n}\n''') - replace_once('apps/arstack_studio/src/SmartSessionController.cpp', - 'void SmartSessionController::start() {\n if (started_) return;\n', - 'void SmartSessionController::start() {\n if (started_ || shuttingDown_) return;\n') - replace_once('apps/arstack_studio/src/SmartSessionController.cpp', - 'void SmartSessionController::reconcile() {\n if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) {\n', - 'void SmartSessionController::reconcile() {\n if (shuttingDown_) return;\n if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) {\n') - - # Firmware worker follows the same bounded ownership contract. - replace_once('apps/arstack_studio/src/FirmwareManager.hpp', - ' void cancel();\n void shutdown();\n', - ' void cancel();\n bool shutdown();\n' - ' [[nodiscard]] static constexpr int shutdownAckTimeoutMs() noexcept { return 2200; }\n' - ' [[nodiscard]] static constexpr int shutdownJoinTimeoutMs() noexcept { return 1200; }\n' - ' [[nodiscard]] static constexpr int shutdownRetryTimeoutMs() noexcept { return 800; }\n' - ' [[nodiscard]] static constexpr int shutdownForceTimeoutMs() noexcept { return 500; }\n') - replace_once('apps/arstack_studio/src/FirmwareManager.cpp', - '#include \n#include \n', '#include \n#include \n#include \n') - replace_once('apps/arstack_studio/src/FirmwareManager.cpp', - '#include \n', '#include \n#include \n#include \n') - replace_once('apps/arstack_studio/src/FirmwareManager.cpp', - 'FirmwareManager::~FirmwareManager() {\n shutdown();\n}\n', - 'FirmwareManager::~FirmwareManager() {\n static_cast(shutdown());\n}\n') - replace_once('apps/arstack_studio/src/FirmwareManager.cpp', - '''void FirmwareManager::shutdown() {\n if (shuttingDown_) return;\n shuttingDown_ = true;\n operation_ = Operation::none;\n activeOperationGeneration_ = 0;\n busy_ = false;\n cancelRequested_ = true;\n\n if (worker_ != nullptr && workerThread_.isRunning()) {\n if (QThread::currentThread() == &workerThread_) {\n worker_->shutdown();\n } else {\n QMetaObject::invokeMethod(worker_, &FirmwareWorker::shutdown, Qt::BlockingQueuedConnection);\n }\n workerThread_.quit();\n workerThread_.wait();\n }\n}\n''', - '''bool FirmwareManager::shutdown() {\n if (shuttingDown_) return !workerThread_.isRunning();\n shuttingDown_ = true;\n operation_ = Operation::none;\n activeOperationGeneration_ = 0;\n busy_ = false;\n cancelRequested_ = true;\n\n if (worker_ == nullptr || !workerThread_.isRunning()) {\n worker_ = nullptr;\n workerReady_ = false;\n workerAffinityValid_ = false;\n return true;\n }\n if (QThread::currentThread() == &workerThread_) {\n worker_->shutdown();\n workerThread_.quit();\n return false;\n }\n\n auto acknowledged = QSharedPointer::create();\n FirmwareWorker* const worker = worker_;\n const bool queued = QMetaObject::invokeMethod(\n worker,\n [worker, acknowledged] {\n worker->shutdown();\n acknowledged->release();\n },\n Qt::QueuedConnection);\n bool graceful = queued && acknowledged->tryAcquire(1, shutdownAckTimeoutMs());\n if (!graceful) {\n qCritical().noquote()\n << "Firmware worker shutdown acknowledgement timed out; entering bounded retirement fallback.";\n }\n\n workerThread_.quit();\n bool joined = workerThread_.wait(shutdownJoinTimeoutMs());\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Firmware worker missed primary join deadline; retrying bounded retirement.";\n workerThread_.requestInterruption();\n workerThread_.quit();\n joined = workerThread_.wait(shutdownRetryTimeoutMs());\n }\n if (!joined) {\n graceful = false;\n qCritical().noquote()\n << "Firmware worker still alive; emergency terminate fallback engaged.";\n workerThread_.terminate();\n joined = workerThread_.wait(shutdownForceTimeoutMs());\n }\n\n worker_ = nullptr;\n workerReady_ = false;\n workerAffinityValid_ = false;\n if (!joined) qCritical().noquote() << "Firmware worker could not be retired before exit.";\n return graceful && joined;\n}\n''') - - # Single application boundary controls shutdown ordering. - regex_once('apps/arstack_studio/src/main.cpp', - r' FirmwareManager firmwareService;\n QObject::connect\(\n &app,\n &QCoreApplication::aboutToQuit,\n &firmwareService,\n &FirmwareManager::shutdown,\n Qt::DirectConnection\);\n', - ' FirmwareManager firmwareService;\n') - window_pattern = r' PrimaryWindowCloseFilter primaryCloseFilter\{&app\};\n if \(mainWindow != nullptr\) \{\n mainWindow->installEventFilter\(&primaryCloseFilter\);\n \}\n(?:\n)?' - coordinator = ''' PrimaryWindowCloseFilter primaryCloseFilter{&app};\n if (mainWindow != nullptr) {\n mainWindow->installEventFilter(&primaryCloseFilter);\n }\n\n QPointer lifecycleDevice;\n QPointer lifecycleSession;\n if (mainWindow != nullptr) {\n lifecycleDevice = mainWindow->findChild();\n lifecycleSession = mainWindow->findChild();\n }\n if (mainWindow != nullptr && (lifecycleDevice.isNull() || lifecycleSession.isNull())) {\n qCritical().noquote()\n << "Application lifecycle owner discovery failed; refusing an unowned worker/session lifetime.";\n return 14;\n }\n\n bool lifecycleCoordinatorInvoked = false;\n bool lifecycleShutdownClean = false;\n QObject::connect(\n &app,\n &QCoreApplication::aboutToQuit,\n &app,\n [&] {\n lifecycleCoordinatorInvoked = true;\n if (!lifecycleSession.isNull()) lifecycleSession->shutdown();\n const bool deviceClean = lifecycleDevice.isNull() || lifecycleDevice->shutdown();\n const bool firmwareClean = firmwareService.shutdown();\n lifecycleShutdownClean = deviceClean && firmwareClean;\n if (!lifecycleShutdownClean) {\n qCritical().noquote()\n << "Application shutdown required an emergency worker-retirement fallback.";\n }\n },\n Qt::DirectConnection);\n\n''' - regex_once('apps/arstack_studio/src/main.cpp', window_pattern, coordinator) - regex_once('apps/arstack_studio/src/main.cpp', - r' const int result = app\.exec\(\);\n if \(lifecycleCheck && result == 0\) \{\n qInfo\(\)\.noquote\(\) << "Application lifecycle regression: PASS · primary window close reached the lifetime filter";\n \}\n return result;\n\}', - ''' const int result = app.exec();\n if (!lifecycleCoordinatorInvoked || !lifecycleShutdownClean) {\n qCritical().noquote()\n << "Application lifecycle regression: shutdown coordinator did not retire all workers cleanly.";\n return result == 0 ? 15 : result;\n }\n if (lifecycleCheck && result == 0) {\n qInfo().noquote()\n << "Application lifecycle regression: PASS · close -> supervisor stop -> worker join -> process exit";\n }\n return result;\n}''') - - - name: Validate focused diff - shell: bash - run: | - set -euo pipefail - git diff --check - git diff --stat - changed="$(git diff --name-only)" - echo "$changed" - test -n "$changed" - if echo "$changed" | grep -q '^apps/ied_simulator/'; then exit 21; fi - if echo "$changed" | grep -q '^embedded/'; then exit 22; fi - - - name: Commit source lifecycle hardening - shell: bash - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add apps/arstack_studio/src - git commit -m 'fix(studio): retire workers before GUI event loop exits' - git push origin HEAD:fix/studio-graceful-shutdown-p0 From bac361ca2212c025612e4798e342a07dd4b3a5d4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 14:52:32 +0700 Subject: [PATCH 08/10] ci(studio): prove Windows close exit and immediate reopen --- .../arstack-studio-windows-lifecycle.yml | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 .github/workflows/arstack-studio-windows-lifecycle.yml diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml new file mode 100644 index 000000000..d02b10c60 --- /dev/null +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -0,0 +1,122 @@ +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) { + $stdout = Join-Path $env:RUNNER_TEMP "arstack-lifecycle-$Cycle.out.log" + $stderr = Join-Path $env:RUNNER_TEMP "arstack-lifecycle-$Cycle.err.log" + Remove-Item $stdout,$stderr -Force -ErrorAction SilentlyContinue + + $process = Start-Process -FilePath $exe ` + -ArgumentList '--check-app-lifecycle' ` + -PassThru ` + -RedirectStandardOutput $stdout ` + -RedirectStandardError $stderr + + if (-not $process.WaitForExit(12000)) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + throw "Cycle $Cycle: Studio process did not exit within 12 seconds after primary-window close." + } + + $process.WaitForExit() + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + + if ($process.ExitCode -ne 0) { + throw "Cycle $Cycle: Studio exited with code $($process.ExitCode)." + } + if (Get-Process -Id $process.Id -ErrorAction SilentlyContinue) { + throw "Cycle $Cycle: Studio PID $($process.Id) remained alive after WaitForExit." + } + + $combined = ((Get-Content $stdout -Raw -ErrorAction SilentlyContinue) + "`n" + + (Get-Content $stderr -Raw -ErrorAction SilentlyContinue)) + if ($combined -notmatch 'Application lifecycle regression: PASS') { + throw "Cycle $Cycle: lifecycle coordinator did not report clean worker retirement." + } + if ($combined -match 'emergency terminate fallback|emergency worker-retirement fallback|could not be retired') { + throw "Cycle $Cycle: emergency shutdown fallback was reached." + } + Write-Host "Cycle $Cycle PASS: close -> 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' + + - name: Retain lifecycle logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-windows-lifecycle-logs + path: | + ${{ runner.temp }}/arstack-lifecycle-1.out.log + ${{ runner.temp }}/arstack-lifecycle-1.err.log + ${{ runner.temp }}/arstack-lifecycle-2.out.log + ${{ runner.temp }}/arstack-lifecycle-2.err.log + if-no-files-found: ignore + retention-days: 7 From d667ab9450a46d541b879e3c0c08b08fe12d824a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 15:03:56 +0700 Subject: [PATCH 09/10] ci(studio): fix PowerShell 5.1 lifecycle gate interpolation --- .github/workflows/arstack-studio-windows-lifecycle.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index d02b10c60..2315ec200 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -78,7 +78,7 @@ jobs: Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue Get-Content $stdout -ErrorAction SilentlyContinue Get-Content $stderr -ErrorAction SilentlyContinue - throw "Cycle $Cycle: Studio process did not exit within 12 seconds after primary-window close." + throw "Cycle ${Cycle}: Studio process did not exit within 12 seconds after primary-window close." } $process.WaitForExit() @@ -86,19 +86,19 @@ jobs: Get-Content $stderr -ErrorAction SilentlyContinue if ($process.ExitCode -ne 0) { - throw "Cycle $Cycle: Studio exited with code $($process.ExitCode)." + throw "Cycle ${Cycle}: Studio exited with code $($process.ExitCode)." } if (Get-Process -Id $process.Id -ErrorAction SilentlyContinue) { - throw "Cycle $Cycle: Studio PID $($process.Id) remained alive after WaitForExit." + throw "Cycle ${Cycle}: Studio PID $($process.Id) remained alive after WaitForExit." } $combined = ((Get-Content $stdout -Raw -ErrorAction SilentlyContinue) + "`n" + (Get-Content $stderr -Raw -ErrorAction SilentlyContinue)) if ($combined -notmatch 'Application lifecycle regression: PASS') { - throw "Cycle $Cycle: lifecycle coordinator did not report clean worker retirement." + throw "Cycle ${Cycle}: lifecycle coordinator did not report clean worker retirement." } if ($combined -match 'emergency terminate fallback|emergency worker-retirement fallback|could not be retired') { - throw "Cycle $Cycle: emergency shutdown fallback was reached." + throw "Cycle ${Cycle}: emergency shutdown fallback was reached." } Write-Host "Cycle $Cycle PASS: close -> worker retirement -> PID exit." } From dbb564e396ed6e6b064b9b79ad21de110d1f342d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 15 Sep 2026 15:13:09 +0700 Subject: [PATCH 10/10] ci(studio): use native process exit contract for Windows GUI lifecycle --- .../arstack-studio-windows-lifecycle.yml | 63 +++++++------------ 1 file changed, 22 insertions(+), 41 deletions(-) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 2315ec200..b1843200c 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -64,59 +64,40 @@ jobs: Write-Host "Lifecycle executable: $exe" function Invoke-LifecycleCycle([int]$Cycle) { - $stdout = Join-Path $env:RUNNER_TEMP "arstack-lifecycle-$Cycle.out.log" - $stderr = Join-Path $env:RUNNER_TEMP "arstack-lifecycle-$Cycle.err.log" - Remove-Item $stdout,$stderr -Force -ErrorAction SilentlyContinue - - $process = Start-Process -FilePath $exe ` - -ArgumentList '--check-app-lifecycle' ` - -PassThru ` - -RedirectStandardOutput $stdout ` - -RedirectStandardError $stderr + $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 $process.Id -Force -ErrorAction SilentlyContinue - Get-Content $stdout -ErrorAction SilentlyContinue - Get-Content $stderr -ErrorAction SilentlyContinue + Stop-Process -Id $pidValue -Force -ErrorAction SilentlyContinue + $process.Dispose() throw "Cycle ${Cycle}: Studio process did not exit within 12 seconds after primary-window close." } - $process.WaitForExit() - Get-Content $stdout -ErrorAction SilentlyContinue - Get-Content $stderr -ErrorAction SilentlyContinue + $exitCode = $process.ExitCode + $process.Dispose() - if ($process.ExitCode -ne 0) { - throw "Cycle ${Cycle}: Studio exited with code $($process.ExitCode)." + if ($exitCode -ne 0) { + throw "Cycle ${Cycle}: Studio lifecycle coordinator exited with code $exitCode." } - if (Get-Process -Id $process.Id -ErrorAction SilentlyContinue) { - throw "Cycle ${Cycle}: Studio PID $($process.Id) remained alive after WaitForExit." + if (Get-Process -Id $pidValue -ErrorAction SilentlyContinue) { + throw "Cycle ${Cycle}: Studio PID $pidValue remained alive after native WaitForExit." } - $combined = ((Get-Content $stdout -Raw -ErrorAction SilentlyContinue) + "`n" + - (Get-Content $stderr -Raw -ErrorAction SilentlyContinue)) - if ($combined -notmatch 'Application lifecycle regression: PASS') { - throw "Cycle ${Cycle}: lifecycle coordinator did not report clean worker retirement." - } - if ($combined -match 'emergency terminate fallback|emergency worker-retirement fallback|could not be retired') { - throw "Cycle ${Cycle}: emergency shutdown fallback was reached." - } - Write-Host "Cycle $Cycle PASS: close -> worker retirement -> PID exit." + 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' - - - name: Retain lifecycle logs - if: always() - uses: actions/upload-artifact@v4 - with: - name: arstack-studio-windows-lifecycle-logs - path: | - ${{ runner.temp }}/arstack-lifecycle-1.out.log - ${{ runner.temp }}/arstack-lifecycle-1.err.log - ${{ runner.temp }}/arstack-lifecycle-2.out.log - ${{ runner.temp }}/arstack-lifecycle-2.err.log - if-no-files-found: ignore - retention-days: 7