Skip to content
Merged
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
90 changes: 90 additions & 0 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()},
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
36 changes: 34 additions & 2 deletions apps/arstack_studio/src/DeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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")) &&
Expand Down
6 changes: 6 additions & 0 deletions apps/arstack_studio/src/DeviceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
23 changes: 21 additions & 2 deletions apps/arstack_studio/src/SmartSessionController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -612,17 +612,36 @@ 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;
if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none);
}
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{};
Expand Down
10 changes: 8 additions & 2 deletions apps/arstack_studio/src/SmartSessionController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
Expand Down
18 changes: 18 additions & 0 deletions apps/arstack_studio/src/StudioDeviceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading