Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions .github/workflows/arstack-studio-windows-lifecycle.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
name: ARStack Studio Windows Lifecycle

on:
push:
paths:
- 'apps/arstack_studio/**'
- '.github/workflows/arstack-studio-windows-lifecycle.yml'
pull_request:
paths:
- 'apps/arstack_studio/**'
- '.github/workflows/arstack-studio-windows-lifecycle.yml'

permissions:
contents: read

concurrency:
group: arstack-studio-windows-lifecycle-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
close-exit-reopen:
name: Close -> process exit -> immediate reopen
runs-on: windows-2022
timeout-minutes: 12
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install Qt
uses: jurplel/install-qt-action@v4
with:
version: '6.10.3'
arch: win64_msvc2022_64
modules: 'qtserialport'
cache: true

- name: Configure Studio
shell: powershell
run: |
cmake -S apps/arstack_studio -B build-arstack-studio -G "Visual Studio 17 2022" -A x64 `
-DARIEC61850_WARNINGS_AS_ERRORS=ON
if ($LASTEXITCODE -ne 0) { throw "Studio configure failed: $LASTEXITCODE" }

- name: Build actual Studio executable
shell: powershell
run: |
cmake --build build-arstack-studio --config Release --target arstack_studio --parallel
if ($LASTEXITCODE -ne 0) { throw "Studio build failed: $LASTEXITCODE" }

- name: Two-cycle graceful lifecycle regression
shell: powershell
run: |
$ErrorActionPreference = 'Stop'
$env:QT_QPA_PLATFORM = 'offscreen'
$env:QT_QUICK_BACKEND = 'software'

$candidate = Get-ChildItem -Path (Join-Path $PWD 'build-arstack-studio') -Recurse -File |
Where-Object { $_.Name -ieq 'arstack_studio.exe' -or $_.Name -ieq 'ARStackStudio.exe' } |
Select-Object -First 1
if ($null -eq $candidate) {
throw 'Built ARStack Studio executable was not found.'
}
$exe = $candidate.FullName
Write-Host "Lifecycle executable: $exe"

function Invoke-LifecycleCycle([int]$Cycle) {
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $exe
$startInfo.Arguments = '--check-app-lifecycle'
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true

$process = New-Object System.Diagnostics.Process
$process.StartInfo = $startInfo
if (-not $process.Start()) {
$process.Dispose()
throw "Cycle ${Cycle}: failed to start Studio process."
}

$pidValue = $process.Id
if (-not $process.WaitForExit(12000)) {
Stop-Process -Id $pidValue -Force -ErrorAction SilentlyContinue
$process.Dispose()
throw "Cycle ${Cycle}: Studio process did not exit within 12 seconds after primary-window close."
}

$exitCode = $process.ExitCode
$process.Dispose()

if ($exitCode -ne 0) {
throw "Cycle ${Cycle}: Studio lifecycle coordinator exited with code $exitCode."
}
if (Get-Process -Id $pidValue -ErrorAction SilentlyContinue) {
throw "Cycle ${Cycle}: Studio PID $pidValue remained alive after native WaitForExit."
}

Write-Host "Cycle $Cycle PASS: close -> clean worker retirement -> PID exit."
}

Invoke-LifecycleCycle 1
Start-Sleep -Milliseconds 250
Invoke-LifecycleCycle 2
Write-Host 'Windows lifecycle gate: PASS · close -> process exit -> lock release -> immediate reopen -> clean exit'
75 changes: 68 additions & 7 deletions apps/arstack_studio/src/DeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
#include "DeviceIoWorker.hpp"

#include <QDateTime>
#include <QDebug>
#include <QMetaObject>
#include <QRegularExpression>
#include <QSemaphore>
#include <QSharedPointer>

#include <cmath>
#include <limits>
Expand Down Expand Up @@ -73,19 +76,77 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) {
}

DeviceController::~DeviceController() {
if (ioWorker_ == nullptr || !ioThread_.isRunning()) return;
static_cast<void>(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<QSemaphore>::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() {
Expand Down
10 changes: 10 additions & 0 deletions apps/arstack_studio/src/DeviceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")};
Expand Down
64 changes: 54 additions & 10 deletions apps/arstack_studio/src/FirmwareManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
Expand All @@ -15,6 +16,8 @@
#include <QMetaObject>
#include <QProcessEnvironment>
#include <QRegularExpression>
#include <QSemaphore>
#include <QSharedPointer>

#include <algorithm>

Expand Down Expand Up @@ -45,7 +48,7 @@ FirmwareManager::FirmwareManager(QObject* parent) : QObject(parent) {
}

FirmwareManager::~FirmwareManager() {
shutdown();
static_cast<void>(shutdown());
}

void FirmwareManager::connectWorkerSignals() {
Expand Down Expand Up @@ -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<QSemaphore>::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() {
Expand Down
6 changes: 5 additions & 1 deletion apps/arstack_studio/src/FirmwareManager.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
28 changes: 27 additions & 1 deletion apps/arstack_studio/src/SmartSessionController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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_; }
Expand Down Expand Up @@ -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<void>(ensureDefaultProfile());
discoveryTimer_.start();
Expand Down Expand Up @@ -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"),
Expand Down
Loading
Loading