From c7bb6c7b7602fab6f3cc88137b14db575622675a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 11:41:47 +0700 Subject: [PATCH 1/2] fix(studio): fail-closed bare SV START and reserve bounded emergency STOP --- apps/arstack_studio/qml/WorkflowBar.qml | 10 +++- .../src/DeterministicSessionHarness.cpp | 36 +++++++++++++ apps/arstack_studio/src/DeviceController.cpp | 7 ++- apps/arstack_studio/src/DeviceIoWorker.cpp | 50 ++++++++++++++++--- apps/arstack_studio/src/DeviceIoWorker.hpp | 4 +- .../src/SmartSessionController.cpp | 13 +++-- 6 files changed, 104 insertions(+), 16 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 2cccd2a2c..7c8d4308e 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -301,8 +301,14 @@ SurfacePanel { } function requestStop() { - if (ribbon.injectionRunning && !smartSession.requestStop()) - controller.showMessage("Studio could not stop the current device session.", true) + if (!ribbon.injectionRunning && !ribbon.startQueued) return + if (!smartSession.requestStop()) { + controller.showMessage("Studio could not queue safety STOP; device state is unverified.", true) + return + } + ribbon.startQueued = false + startWatchdog.stop() + controller.pendingApply = true } function openEngineeringDialog() { diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 30fc39c63..bf6e576d6 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -49,6 +49,8 @@ class DeterministicSessionHarness final { {"PTP rejected config never starts", ptpGuidedRejectedConfigStops()}, {"PTP STOP cancels pending start after verified readback", ptpGuidedStopCancelsStart()}, {"ABC live SET encoder remains consistent and bounded", abcSetpointBatchContract()}, + {"bare SV START cannot bypass full 4I+4V scenario", bareSvStartIsRejected()}, + {"SV STOP cancels queued START despite full queue", svSafetyStopBypassesFullQueue()}, {"PTP STOP full queue uses bounded priority slot", ptpSafetyStopBypassesFullQueue()}, {"PTP STOP requires firmware ACK and stopped readback", ptpStopConfirmationCannotBeGuessed()}, {"PTP STOP timeout blocks restart pending manual recovery", ptpUnverifiedStopBlocksRestart()}, @@ -683,6 +685,40 @@ class DeterministicSessionHarness final { dcNegative == QStringLiteral("SET IA -1000 0 0"); } + static bool bareSvStartIsRejected() { + Fixture f; + if (!f.profileReady) return false; + seedVerified(f, identity(), QStringLiteral("COM15"), false); + return !f.device.start() && + f.device.lastError().contains(QStringLiteral("Bare START refused")) && + !f.session.requestStart() && + !f.device.running(); + } + + static bool svSafetyStopBypassesFullQueue() { + DeviceIoWorker worker; + for (int i = 0; i < DeviceIoWorker::commandQueueCapacity(); ++i) { + const QString command = i == 5 ? QStringLiteral("START") : + (i == 10 ? QStringLiteral("SCENARIO START 7") : + QStringLiteral("PROFILE SHOW")); + worker.commandQueue_.enqueue(DeviceIoWorker::PendingCommand{ + command, command.toUtf8() + '\n', false, i == 5 || i == 10}); + } + worker.exclusiveCommandsRemaining_ = 2; + worker.queuePrioritySvStop(); // no COM: check bounded queue mutation + if (!worker.svStopPending_ || + worker.commandQueue_.size() != DeviceIoWorker::commandQueueCapacity() - 2 || + worker.exclusiveCommandsRemaining_ != 0) return false; + for (const auto& command : worker.commandQueue_) { + if (command.text == QStringLiteral("START") || + command.text.startsWith(QStringLiteral("SCENARIO START "))) return false; + } + worker.queuePrioritySvStop(); + worker.queuePriorityPtpStop(); + return worker.svStopPending_ && worker.ptpStopPending_ && + worker.commandQueue_.size() == DeviceIoWorker::commandQueueCapacity() - 2; + } + static bool ptpSafetyStopBypassesFullQueue() { DeviceIoWorker worker; for (int i = 0; i < DeviceIoWorker::commandQueueCapacity(); ++i) { diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index b0a216e40..8066c0fd9 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -632,7 +632,12 @@ void DeviceController::handlePortClosed(const QString& portName) { } bool DeviceController::sendShow() { return sendCommand(QStringLiteral("SHOW")); } -bool DeviceController::start() { return sendCommand(QStringLiteral("START")); } +bool DeviceController::start() { + // A bare START has no source-of-truth snapshot. The only operator + // entrance is startWithScenario(), which serializes all eight channels. + setError(QStringLiteral("Bare START refused: provide the current 4I+4V scenario.")); + return false; +} bool DeviceController::startWithScenario(const QVariantMap& scenario) { if (!deviceVerified_ || running_) return false; diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 2d08f8c18..03f216579 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -126,6 +126,7 @@ void DeviceIoWorker::shutdown(const bool requestSvStop, const bool requestPtpSto // during grandmaster shutdown. This runs once during teardown only. commandQueue_.clear(); ptpStopPending_ = false; + svStopPending_ = false; if ((requestSvStop || requestPtpStop) && serial_ != nullptr && serial_->isOpen()) { const auto sendBoundedStop = [this](const QByteArray& command) { const qint64 queued = serial_->write(command); @@ -245,6 +246,7 @@ bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automa pendingRx_.clear(); commandQueue_.clear(); ptpStopPending_ = false; + svStopPending_ = false; exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; @@ -293,6 +295,7 @@ void DeviceIoWorker::closePortInternal(const bool emitRelease) { pendingRx_.clear(); commandQueue_.clear(); ptpStopPending_ = false; + svStopPending_ = false; exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; @@ -377,11 +380,17 @@ bool DeviceIoWorker::enqueueCommandsInternal( } if (commands.isEmpty()) return true; - if (!exclusive && commands.size() == 1 && - commands.constFirst().trimmed() == QStringLiteral("PTP STOP")) { - // One reserved safety slot cannot be rejected by normal queue saturation. - queuePriorityPtpStop(); - return true; + if (!exclusive && commands.size() == 1) { + const QString command = commands.constFirst().trimmed(); + if (command == QStringLiteral("PTP STOP")) { + queuePriorityPtpStop(); + return true; + } + if (command == QStringLiteral("STOP")) { + // A safety STOP must never be rejected by a full normal queue. + queuePrioritySvStop(); + return true; + } } if (exclusiveCommandsRemaining_ > 0) { @@ -445,12 +454,39 @@ void DeviceIoWorker::queuePriorityPtpStop() { pumpWriteQueue(); } +void DeviceIoWorker::queuePrioritySvStop() { + // Drop every not-yet-written SV START, including a future transactional + // START. Preserve other commands and the independent PTP STOP lease. + QQueue retained; + while (!commandQueue_.isEmpty()) { + PendingCommand command = commandQueue_.dequeue(); + if (command.text == QStringLiteral("START") || + command.text.startsWith(QStringLiteral("SCENARIO START "))) { + if (command.exclusive && exclusiveCommandsRemaining_ > 0) { + --exclusiveCommandsRemaining_; + } + continue; + } + retained.enqueue(std::move(command)); + } + commandQueue_ = std::move(retained); + if (!(writeActive_ && activeCommand_.text == QStringLiteral("STOP"))) { + svStopPending_ = true; // coalesce repeated operator cancellations + } + pumpWriteQueue(); +} + void DeviceIoWorker::pumpWriteQueue() { if (serial_ == nullptr || !serial_->isOpen() || writeActive_ || - (!ptpStopPending_ && commandQueue_.isEmpty())) return; + (!svStopPending_ && !ptpStopPending_ && commandQueue_.isEmpty())) return; - if (ptpStopPending_) { + if (svStopPending_) { + svStopPending_ = false; + activeCommand_ = PendingCommand{ + QStringLiteral("STOP"), QByteArrayLiteral("STOP\n"), false, false}; + } else if (ptpStopPending_) { ptpStopPending_ = false; + svStopPending_ = false; activeCommand_ = PendingCommand{ QStringLiteral("PTP STOP"), QByteArrayLiteral("PTP STOP\n"), false, false}; } else { diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index f817de0c7..6ca4003d8 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -165,6 +165,7 @@ public slots: bool exclusive, bool reportRejection); void queuePriorityPtpStop(); + void queuePrioritySvStop(); void pumpWriteQueue(); void completeActiveWrite(); void processReadyRead(); @@ -193,6 +194,7 @@ public slots: bool genericProbeActive_{false}; bool identityConfirmed_{false}; bool writeActive_{false}; - bool ptpStopPending_{false}; // reserved bounded emergency STOP slot + bool ptpStopPending_{false}; // reserved bounded emergency PTP STOP slot + bool svStopPending_{false}; // independent reserved emergency SV STOP slot bool heartbeatEnabled_{false}; }; \ No newline at end of file diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index d33833a70..8566083a9 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -306,8 +306,9 @@ void SmartSessionController::start() { } bool SmartSessionController::requestStart() { - if (!startReady_ || !deviceControlAvailable() || device_ == nullptr || device_->running()) return false; - return device_->start(); + // Retain the QML method for compatibility, but never let an indirect + // caller bypass startScenarioSnapshot()/requestStartWithScenario(). + return false; } bool SmartSessionController::requestStartWithScenario(const QVariantMap& scenario) { @@ -316,9 +317,11 @@ bool SmartSessionController::requestStartWithScenario(const QVariantMap& scenari } bool SmartSessionController::requestStop() { - if (device_ == nullptr) return false; - if (!device_->running()) return true; - if (portOwner_ != PortOwner::deviceSession || updateRequested_) return false; + if (device_ == nullptr || portOwner_ != PortOwner::deviceSession || updateRequested_) + return false; + // STOP is also a cancellation request while START is queued but the + // asynchronous firmware RUNNING acknowledgement has not arrived. + if (!device_->connected() || !device_->deviceVerified()) return false; return device_->stop(); } From eb7448b23ef37721ecc02a2907554c969fa24dcb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 11:43:37 +0700 Subject: [PATCH 2/2] test(studio): permit STOP during pending START and preserve SV priority --- apps/arstack_studio/qml/ModernRibbon.qml | 2 +- apps/arstack_studio/src/DeterministicSessionHarness.cpp | 2 +- apps/arstack_studio/src/DeviceIoWorker.cpp | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 80e8f8d39..c8222c3b5 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -108,7 +108,7 @@ Rectangle { text: "Stop" tone: bar.workflow.injectionRunning ? "danger" : "neutral" iconSource: Qt.resolvedUrl("../assets/material/stop.svg") - enabled: (bar.workflow.injectionRunning || bar.controller.offlinePreviewRunning) + enabled: (bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning) && !bar.session.updatingFirmware toolTipText: "" onClicked: { diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index bf6e576d6..1c8fda205 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -689,7 +689,7 @@ class DeterministicSessionHarness final { Fixture f; if (!f.profileReady) return false; seedVerified(f, identity(), QStringLiteral("COM15"), false); - return !f.device.start() && + return !f.device.DeviceController::start() && f.device.lastError().contains(QStringLiteral("Bare START refused")) && !f.session.requestStart() && !f.device.running(); diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 03f216579..35a8fc83b 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -486,7 +486,6 @@ void DeviceIoWorker::pumpWriteQueue() { QStringLiteral("STOP"), QByteArrayLiteral("STOP\n"), false, false}; } else if (ptpStopPending_) { ptpStopPending_ = false; - svStopPending_ = false; activeCommand_ = PendingCommand{ QStringLiteral("PTP STOP"), QByteArrayLiteral("PTP STOP\n"), false, false}; } else {