diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index b3c659652..0e660ca98 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -44,6 +44,9 @@ class DeterministicSessionHarness final { {"dual-revision catalog selects only qualified silicon", revisionCatalogFailClosed()}, {"firmware timeout retains port until flasher exits", firmwarePortReleaseWaitsForWorker()}, {"firmware update waits for fresh SV and PTP STOP readback", firmwareStopProofPreventsEarlyFlash()}, + {"retry after unverified STOP cannot probe ROM without renewed consent", unverifiedFirmwareRetryNeverProbesRom()}, + {"slow firmware STOP suspends ordinary health teardown, resumes afterward", maintenanceStopDoesNotTriggerHealthDisconnect()}, + {"firmware STOP proof readbacks survive M4 exclusive retirement", maintenanceProofReadbackSurvivesM4Retirement()}, {"real timed-out firmware subprocess retires before COM release", firmwareWorkerTimeoutAwaitsProcessExit()}, {"current identity -> READY without recovery", currentIdentityNeverRecovers()}, {"legacy identity -> firmware update", legacyIdentityOffersUpdate()}, @@ -216,6 +219,88 @@ class DeterministicSessionHarness final { } + static bool maintenanceProofReadbackSurvivesM4Retirement() { + Fixture f; + if (!f.profileReady) return false; + seedVerified(f, identity(), QStringLiteral("COM7"), false); + auto& d = f.device; + d.ioWorkerReady_ = true; + d.firmwareQuiescenceActive_ = true; + d.firmwareQuiescenceSession_ = d.sessionGeneration_; + d.firmwareQuiescenceBoot_ = d.identity_.bootId; + d.firmwareSvStopAck_ = true; + d.ptpStartStage_ = DeviceController::PtpStartStage::awaiting_stop_readback; + d.ptpRunning_ = true; + d.m4ScenarioRetiring_ = true; + d.deferredShow_ = true; + d.deferredPtpShow_ = true; + d.deferredProfileShow_ = true; + d.flushDeferredReadbacks(); + const bool waitForSerialRetirement = + d.deferredShow_ && d.deferredPtpShow_ && + !d.firmwareQuiescenceVerified(); + d.m4ScenarioRetiring_ = false; + d.flushDeferredReadbacks(); + const bool proofsRetried = + !d.deferredShow_ && !d.deferredPtpShow_ && + !d.deferredProfileShow_ && + d.ptpStartStage_ == DeviceController::PtpStartStage::awaiting_stop_readback && + !d.firmwareQuiescenceVerified(); + d.endFirmwareQuiescence(); + return waitForSerialRetirement && proofsRetried; + } + + static bool unverifiedFirmwareRetryNeverProbesRom() { + Fixture f; + if (!f.profileReady) return false; + auto& s = f.session; + s.updatePort_ = QStringLiteral("COM7"); + s.updateExpectedDeviceId_ = QStringLiteral("A1B2C3D4E5F6"); + s.setupError_ = true; + const bool accepted = s.retryFirmwareUpdate(); + return !accepted && !s.updateRequested_ && + s.portOwner_ != SmartSessionController::PortOwner::firmwareTool && + !f.firmware.busy_ && !f.firmware.targetVerified_ && + s.lastFirmwareUpdateResult_.contains(QStringLiteral("identity was lost"), + Qt::CaseInsensitive); + } + + static bool maintenanceStopDoesNotTriggerHealthDisconnect() { + Fixture f; + if (!f.profileReady) return false; + seedVerified(f, identity(), QStringLiteral("COM7"), false); + auto& d = f.device; + d.controlHealthTimer_.stop(); + d.controlResponsive_ = true; + d.healthProbeOutstanding_ = true; + d.missedHealthReplies_ = 1; + d.firmwareQuiescenceActive_ = true; + d.firmwareQuiescenceSession_ = d.sessionGeneration_; + d.firmwareQuiescenceBoot_ = d.identity_.bootId; + bool failed = false; + QObject::connect(&d, &StudioDeviceController::controlHealthFailed, &d, + [&failed] { failed = true; }); + // The same two health ticks that previously tore down COM must not + // compete with supervised STOP. No fake STOP proof is synthesized. + d.serviceControlHealth(); + d.serviceControlHealth(); + const bool protectedDuringStop = + !failed && d.connected_ && d.deviceVerified_ && + d.controlResponsive_ && d.missedHealthReplies_ == 0 && + !d.healthProbeOutstanding_ && !d.firmwareQuiescenceVerified(); + const bool boundedProof = + DeviceController::firmwarePtpStopProofDeadlineMs() == 40000 && + SmartSessionController::firmwareVerifiedStopDeadlineMs() == 45000 && + SmartSessionController::firmwareTotalDeadlineMs() == 300000; + d.endFirmwareQuiescence(); + // Ordinary health monitoring resumes on the original controller. + // An unresponsive board is still rejected outside maintenance. + d.healthProbeOutstanding_ = true; + d.serviceControlHealth(); + return protectedDuringStop && boundedProof && !failed && + d.missedHealthReplies_ == 1 && !d.controlResponsive_; + } + static bool firmwareStopProofPreventsEarlyFlash() { Fixture f; if (!f.profileReady) return false; @@ -231,11 +316,16 @@ class DeterministicSessionHarness final { d.ptpRunning_ = true; d.ptpStartStage_ = DeviceController::PtpStartStage::stop_unconfirmed; d.scenarioStage_ = DeviceController::ScenarioStage::awaiting_commit; + d.deferredShow_ = true; + d.deferredPtpShow_ = true; + d.deferredProfileShow_ = true; const bool started = s.beginFirmwareUpdate(); const bool safeStage = started && s.updateRequested_ && s.updateStage_ == SmartSessionController::UpdateStage::stopping && s.portOwner_ == SmartSessionController::PortOwner::deviceSession && d.scenarioStage_ == DeviceController::ScenarioStage::idle && + !d.deferredShow_ && !d.deferredPtpShow_ && + !d.deferredProfileShow_ && !d.firmwareQuiescenceVerified() && !f.firmware.busy_; if (!safeStage) { s.updateRequested_ = false; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 0bf13a391..6482e86f8 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1812,6 +1812,13 @@ bool DeviceController::beginFirmwareQuiescence() { firmwareQuiescenceActive_ = true; firmwareQuiescenceSession_ = sessionGeneration_; firmwareQuiescenceBoot_ = identity_.bootId; + // Old PROFILE/SHOW readbacks belong to the pre-update session. Priority + // STOP retires queued ordinary work in DeviceIoWorker; clear its facade + // coalescing flags too, or a stale PTP SHOW can prevent maintenance proof + // while triggering a spurious firmware-safety error toast. + deferredShow_ = false; + deferredProfileShow_ = false; + deferredPtpShow_ = false; // Explicit operator-authorized STOP for BOTH services, independent of // cached telemetry. STOP preempts pending SV START in the serial worker. @@ -1912,8 +1919,14 @@ bool DeviceController::beginPtpStopVerification() { return false; } // Absolute STOP readback deadline; STOP itself uses the worker safety lane. - ptpStartTimer_.start(profileDeploying_ || scenarioStage_ != ScenarioStage::idle - ? 12000 : 5000); + // A verified maintenance STOP may follow a slow legacy PROFILE SHOW + // response and an already-backlogged UART console. The field trace + // showed the STOP ACK arriving ~12 s after dispatch; 5/12 s cut it off. + // Still fail closed on a bounded timer; supervisor owns the 45 s + // whole-stage limit and never flashes without fresh STOP proof. + ptpStartTimer_.start(firmwareQuiescenceActive_ + ? firmwarePtpStopProofDeadlineMs() + : (profileDeploying_ || scenarioStage_ != ScenarioStage::idle ? 12000 : 5000)); return true; } @@ -2374,6 +2387,25 @@ void DeviceController::flushDeferredReadbacks() { scenarioStage_ != ScenarioStage::idle || !connected_ || !ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) return; + if (firmwareQuiescenceActive_) { + // The queued STOP-proof SHOW/PTP SHOW were created *after* their + // respective current-boot ACKs: entry already discarded all old + // deferred reads. During exclusive M4 retirement, preserve these + // two proof reads until that serial lease is actually retired. + // Never replay unrelated PROFILE SHOW into firmware maintenance. + deferredProfileShow_ = false; + if (std::exchange(deferredPtpShow_, false) && + ptpStartStage_ == PtpStartStage::awaiting_stop_readback && + !sendQuietCommand(QStringLiteral("PTP SHOW"))) { + markPtpStopUnconfirmed(QStringLiteral( + "Firmware PTP STOP proof readback could not be queued.")); + } + if (std::exchange(deferredShow_, false) && firmwareSvStopAck_ && + !sendQuietCommand(QStringLiteral("SHOW"))) { + setError(QStringLiteral("Firmware SV STOP proof readback could not be queued.")); + } + return; + } // At most one of each telemetry/readback type. No competing serial writer. if (std::exchange(deferredPtpShow_, false)) { if (!sendQuietCommand(QStringLiteral("PTP SHOW")) && diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 0ad3b8008..7249e05bf 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -317,7 +317,13 @@ class DeviceController : public QObject { // serial owner: the existing STOP/PTP STOP/readback paths remain authoritative. bool beginFirmwareQuiescence(); void endFirmwareQuiescence() noexcept; + [[nodiscard]] bool firmwareQuiescenceActive() const noexcept { + return firmwareQuiescenceActive_; + } [[nodiscard]] bool firmwareQuiescenceVerified() const noexcept; + // P1.3: bounded maintenance proof for slow legacy console responses. + // Normal operator PTP STOP still retains its independent 5 s deadline. + [[nodiscard]] static constexpr int firmwarePtpStopProofDeadlineMs() noexcept { return 40000; } bool configurePtp(const QVariantMap& profile); bool setPtpRole(const QString& requestedRole) { diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index ab5d70c48..c30a7ad65 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -612,10 +612,13 @@ bool SmartSessionController::beginFirmwareInstall(const bool supportedBoardAndIs !firmware_->bundleReady() || firmware_->busy() || blankBoardPort_.isEmpty()) { return false; } - return beginFirmwareOperation(blankBoardPort_); + // ROM-only recovery is authorized exclusively from this confirmed entry. + // An ordinary Retry after lost application identity must never inherit it. + return beginFirmwareOperation(blankBoardPort_, true); } -bool SmartSessionController::beginFirmwareOperation(const QString& portName) { +bool SmartSessionController::beginFirmwareOperation( + const QString& portName, const bool authorizedBlankBoardRecovery) { const QString port = portName.trimmed(); if (firmwareProcessRetirementPending_ && firmware_ != nullptr && !firmware_->busy()) { firmwareProcessRetirementPending_ = false; @@ -623,6 +626,22 @@ bool SmartSessionController::beginFirmwareOperation(const QString& portName) { } if (device_ == nullptr || firmware_ == nullptr || port.isEmpty() || firmware_->busy() || firmwareProcessRetirementPending_) return false; + // A lost identity after an interrupted STOP is NOT an authorized blank + // board. Retry must first re-identify the same physical device and + // repeat the supervised STOP, or obtain explicit isolated-ROM recovery + // authorization through beginFirmwareInstall(). Never treat COM15 alone + // as a reason to reset a potentially autonomous publisher. + if (!device_->deviceVerified() && + (!authorizedBlankBoardRecovery || !blankBoardDetected_ || + blankBoardPort_.trimmed().compare(port, Qt::CaseInsensitive) != 0)) { + latchFirmwareFailure(QStringLiteral( + "Board identity was lost during firmware setup. Reconnect and identify " + "the board before retrying; unverified ROM recovery requires explicit " + "supported-board and isolated-bench confirmation. No firmware was written.")); + emit firmwareUpdateFinished(false); + reconcile(); + return false; + } updatePort_ = port; updateExpectedDeviceId_ = device_->deviceVerified() ? device_->deviceId().trimmed() : QString{}; diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 12a351f17..8b89f9e84 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -143,6 +143,11 @@ class SmartSessionController : public QObject { const QString& observed) noexcept; [[nodiscard]] static constexpr int firmwareStopAckTimeoutMs() noexcept { return 3500; } + // Legacy board console can take >12 s to drain an older PROFILE SHOW + // before delivering STOP ACK, PTP STOP and fresh SHOW results. This is + // a bounded, operator-authorized maintenance proof stage, never a + // waiver of ACK/readback. Global firmware transaction cap stays 300 s. + [[nodiscard]] static constexpr int firmwareVerifiedStopDeadlineMs() noexcept { return 45000; } [[nodiscard]] static constexpr int firmwareReleaseAckTimeoutMs() noexcept { return 2000; } [[nodiscard]] static constexpr int firmwareTotalDeadlineMs() noexcept { return 300000; } @@ -267,7 +272,8 @@ class SmartSessionController : public QObject { } [[nodiscard]] int timeoutForStage(const UpdateStage stage) const noexcept { return stage == UpdateStage::stopping - ? (owner_ != nullptr && owner_->stoppingPtpForFirmware_ ? 12000 : firmwareStopAckTimeoutMs()) + ? (owner_ != nullptr && owner_->stoppingPtpForFirmware_ + ? firmwareVerifiedStopDeadlineMs() : firmwareStopAckTimeoutMs()) : firmwareReleaseAckTimeoutMs(); } void synchronize() { @@ -384,7 +390,7 @@ class SmartSessionController : public QObject { void refreshRecoveryOfferFromIdentity(); void clearBlankBoardContext(); void latchFirmwareFailure(QString message); - bool beginFirmwareOperation(const QString& portName); + bool beginFirmwareOperation(const QString& portName, bool authorizedBlankBoardRecovery = false); bool ensureDefaultProfile(); void setPresentation(QString state, QString status, bool startReady, bool firmwareUpdateRequired); void refreshFirmwareIdentity(); diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 29c809d91..049ad7fd2 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -243,6 +243,24 @@ class StudioDeviceController : public DeviceController { return; } + // Firmware STOP is an explicitly authorized exclusive safety phase. + // Field evidence: ordinary SHOW health missed twice and disconnected + // COM four seconds after STOP, but the firmware finally acknowledged + // STOP about twelve seconds later. While maintenance STOP owns the + // serial proof lane, ordinary SHOW probes MUST NOT retire the port. + // Clear a pre-existing outstanding probe as it belongs to the old + // transaction; keep the 700 ms heartbeat and physical USB/OS error + // monitoring active in DeviceIoWorker. Normal health resumes after + // endFirmwareQuiescence(), without carrying stale misses. + if (firmwareQuiescenceActive()) { + healthProbeOutstanding_ = false; + if (missedHealthReplies_ != 0) { + missedHealthReplies_ = 0; + emit controlHealthChanged(); + } + return; + } + // Canonical BIN and CFG scenario transactions both own the serial // console. Suppress only probe accounting during known exclusivity, // never the worker's independent safety heartbeat/STOP. Resume diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index d8b9022c6..34aac1476 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -707,14 +707,26 @@ def main() -> None: # The GUI's normal transient conditions are never shown as operator faults. # Validate both intended (positive) paths and removed (negative) paths. + # Normal STOP remains bounded; the authorized firmware-maintenance STOP + # has its own bounded, longer proof window. Importantly, normal SHOW-health + # polling must not retire the COM while STOP ACK/readback is still pending. + # Preserve the original exclusivity checks and assert the replacement + # behavioral contract instead of matching the obsolete 5 s timer syntax. + maintenance_controller = (STUDIO / "src/StudioDeviceController.hpp").read_text(encoding="utf-8") require("Coalesced %1 until exclusive serial authority is released." in device_cpp and "flushDeferredReadbacks()" in device_cpp and "std::exchange(deferredPtpShow_, false)" in device_cpp and "if (readback) return deferReadback();" in device_cpp and "Canonical binary profile transaction owns" not in device_cpp and "Could not request PTP STOP readback." not in device_cpp - and "ptpStartTimer_.start(profileDeploying_" in device_cpp, - "busy device polling must defer quietly; verified STOP has an absolute deadline") + and "ptpStartTimer_.start(firmwareQuiescenceActive_" in device_cpp + and "firmwarePtpStopProofDeadlineMs()" in device_cpp + and "firmwareVerifiedStopDeadlineMs()" in session_h + and "if (firmwareQuiescenceActive()) {" in maintenance_controller + and "healthProbeOutstanding_ = false;" in maintenance_controller + and "deferredProfileShow_ = false;" in device_cpp, + "busy device polling must defer quietly; maintenance STOP requires" + " bounded fresh ACK/readback and no premature COM retirement") require('toolTipText: "Time sync & SV lock details"' in timing_ui and 'Accessible.name: "Time sync & SV lock details"' in timing_ui and 'objectName: "studioTimingDetails"' in timing_ui