Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
26 changes: 26 additions & 0 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class DeterministicSessionHarness final {
{"PTP STOP during exclusive CFG/BIN defers proof, never invents OFF", ptpStopReadbackDeferredDuringExclusive()},
{"UART paced but STOP remains reserved queue preemption", serialPacingDoesNotRemoveSafetySlot()},
{"binary progress -> per-step deadline rearmed", binaryProfileProgressRearmsDeadline()},
{"USB port loss requires two verified misses; replug emits one arrival", usbPhysicalPortLossAndArrivalGate()},
{"READY unplug/replug COM renumber -> READY stopped", readyReplugRecoversWithoutStart()},
{"RUNNING unplug/replug -> never auto-restarts", runningReplugNeverAutoStarts()},
{"wrong device during recovery -> SETUP ERROR", wrongDeviceRecoveryFailsClosed()},
Expand Down Expand Up @@ -1672,6 +1673,31 @@ class DeterministicSessionHarness final {
return armed;
}

static bool usbPhysicalPortLossAndArrivalGate() {
using Worker = DeviceIoWorker;
using Session = SmartSessionController;
const QStringList before{QStringLiteral("COM7"), QStringLiteral("COM15")};
const QStringList unplugged{QStringLiteral("COM7")};
const QStringList replugged{QStringLiteral("COM7"), QStringLiteral("COM16")};
const QStringList unchanged{QStringLiteral("COM7"), QStringLiteral("COM15")};
return Worker::missingPortConfirmationPolls() == 2 &&
Worker::presencePollIntervalMs() == 750 &&
!Worker::verifiedPortPhysicallyGone(true, true, 0) &&
!Worker::verifiedPortPhysicallyGone(true, true, 1) &&
Worker::verifiedPortPhysicallyGone(true, true, 2) &&
!Worker::verifiedPortPhysicallyGone(false, true, 2) &&
!Worker::verifiedPortPhysicallyGone(true, false, 2) &&
!Session::hasNewlyEnumeratedPort(before, unplugged) &&
!Session::hasNewlyEnumeratedPort(before, unchanged) &&
Session::hasNewlyEnumeratedPort(unplugged, replugged) &&
Session::hasNewlyEnumeratedPort(
QStringList{QStringLiteral("COM15")},
QStringList{QStringLiteral("com15"), QStringLiteral("COM16")}) &&
!Session::hasNewlyEnumeratedPort(
QStringList{QStringLiteral("COM15")},
QStringList{QStringLiteral("com15")});
}

static bool readyReplugRecoversWithoutStart() {
Fixture fixture;
if (!fixture.profileReady) return false;
Expand Down
16 changes: 16 additions & 0 deletions apps/arstack_studio/src/DeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,12 @@ void DeviceController::connectWorkerSignals() {
} else setError(message);
});

connect(ioWorker_, &DeviceIoWorker::unexpectedPortLoss, this,
[this](const quint64 generation, const QString& port) {
if (!workerEventIsCurrent(sessionGeneration_, generation)) return;
emit unexpectedTransportLoss(generation, port);
});

connect(ioWorker_, &DeviceIoWorker::transportError, this,
[this](const quint64 generation, const QString& message, const bool fatal) {
if (!workerEventIsCurrent(sessionGeneration_, generation)) return;
Expand Down Expand Up @@ -646,6 +652,16 @@ void DeviceController::disconnectPort() {
Qt::QueuedConnection);
}

void DeviceController::recoverUnresponsivePort() {
if (!ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0 ||
!deviceVerified_ || !connected_) return;
const quint64 generation = sessionGeneration_;
QMetaObject::invokeMethod(
ioWorker_,
[worker = ioWorker_, generation] { worker->retireUnresponsivePort(generation); },
Qt::QueuedConnection);
}

void DeviceController::handlePortSnapshot(
const QStringList& ports,
const QString& recommendedPort,
Expand Down
5 changes: 5 additions & 0 deletions apps/arstack_studio/src/DeviceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ class DeviceController : public QObject {
bool autoDetectAndConnect();
bool connectPort(const QString& portName);
void disconnectPort();
// Only after independent bounded control-health failure.
void recoverUnresponsivePort();
bool sendShow();
virtual bool start();
// Single validated, bounded FIFO transaction. START is always last.
Expand Down Expand Up @@ -330,6 +332,9 @@ class DeviceController : public QObject {
void scenarioApplicationFinished();
void liveScenarioApplied();
void portReleased(quint64 generation, const QString& portName);
// Worker has closed a verified COM unexpectedly; the supervisor can
// re-identify without mistaking this for a manual disconnect.
void unexpectedTransportLoss(quint64 generation, const QString& portName);

protected:
bool sendQuietCommand(const QString& command);
Expand Down
46 changes: 44 additions & 2 deletions apps/arstack_studio/src/DeviceIoWorker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,30 @@ void DeviceIoWorker::refreshPortsInternal(const bool forceSignal) {
ports_ = std::move(discovered);
recommendedPort_ = recommended;
highConfidenceCount_ = highConfidence.size();
if (forceSignal || changed) {
if (forceSignal || changed)
emit portsObserved(activeGeneration_, ports_, recommendedPort_, highConfidenceCount_);

// Windows USB bridges can keep QSerialPort::isOpen() true after removal.
// Reuse the existing 750-ms enumeration; do not poll the board or PTP.
// Confirm two consecutive missing OS snapshots before retiring COM.
if (serial_ == nullptr || !serial_->isOpen() || !identityConfirmed_) {
consecutiveMissingPortPolls_ = 0;
return;
}
const QString ownedPort = serial_->portName().trimmed();
if (!ownedPort.isEmpty() && ports_.contains(ownedPort, Qt::CaseInsensitive)) {
consecutiveMissingPortPolls_ = 0;
return;
}
if (consecutiveMissingPortPolls_ < missingPortConfirmationPolls())
++consecutiveMissingPortPolls_;
if (verifiedPortPhysicallyGone(
serial_->isOpen(), identityConfirmed_, consecutiveMissingPortPolls_)) {
// Only release the stale serial transport. NEVER issue SV/PTP STOP,
// firmware reset, or START based on USB enumeration.
handleSerialFailure(QStringLiteral(
"Verified USB serial port %1 vanished; releasing stale COM handle for rediscovery.")
.arg(ownedPort));
}
}

Expand Down Expand Up @@ -248,6 +270,7 @@ bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automa
automaticConnection_ = automatic;
identityConfirmed_ = false;
identifyAttempts_ = 0;
consecutiveMissingPortPolls_ = 0;
pendingRx_.clear();
if (writeGapTimer_ != nullptr) writeGapTimer_->stop();
commandQueue_.clear();
Expand Down Expand Up @@ -291,6 +314,17 @@ void DeviceIoWorker::disconnectPort(const quint64 generation) {
closePortInternal(true);
}

void DeviceIoWorker::retireUnresponsivePort(const quint64 generation) {
if (!initialized_ || shuttingDown_ || generation == 0 ||
generation != activeGeneration_ || serial_ == nullptr ||
!serial_->isOpen() || !identityConfirmed_) return;
// A bounded failure of bidirectional SHOW/telemetry means the old
// handle may be a zombie even if Windows has already re-enumerated
// the same COM number. Do not mistake this for operator DISCONNECT.
handleSerialFailure(QStringLiteral(
"Verified serial control path stopped responding; retiring stale COM for recovery."));
}

void DeviceIoWorker::closePortInternal(const bool emitRelease) {
if (serial_ == nullptr) return;
if (verificationTimer_ != nullptr) verificationTimer_->stop();
Expand All @@ -300,6 +334,7 @@ void DeviceIoWorker::closePortInternal(const bool emitRelease) {
heartbeatPending_ = false;
identityConfirmed_ = false;
identifyAttempts_ = 0;
consecutiveMissingPortPolls_ = 0;
pendingRx_.clear();
commandQueue_.clear();
ptpStopPending_ = false;
Expand Down Expand Up @@ -353,6 +388,7 @@ void DeviceIoWorker::finishIdentificationTimeout() {
void DeviceIoWorker::confirmIdentity(const quint64 generation) {
if (serial_ == nullptr || !serial_->isOpen() || generation != activeGeneration_) return;
identityConfirmed_ = true;
consecutiveMissingPortPolls_ = 0;
automaticConnection_ = false;
genericProbeActive_ = false;
probeQueue_.clear();
Expand Down Expand Up @@ -572,11 +608,17 @@ void DeviceIoWorker::handleSerialFailure(const QString& message) {
const QString detail = message.trimmed().isEmpty()
? QStringLiteral("Serial transport failed.")
: message.trimmed();
emit transportError(activeGeneration_, detail, true);
const bool hadVerifiedIdentity = identityConfirmed_;
const quint64 lostGeneration = activeGeneration_;
const QString lostPort = serial_ != nullptr ? serial_->portName() : lastPort_;
emit transportError(lostGeneration, detail, true);
probeQueue_.clear();
genericProbeActive_ = false;
automaticConnection_ = false;
// Close and release the handle BEFORE asking the GUI for new discovery.
closePortInternal(true);
if (hadVerifiedIdentity && lostGeneration != 0U)
emit unexpectedPortLoss(lostGeneration, lostPort);
}

void DeviceIoWorker::setHeartbeatEnabled(const bool enabled, const quint64 generation) {
Expand Down
17 changes: 17 additions & 0 deletions apps/arstack_studio/src/DeviceIoWorker.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ class DeviceIoWorker final : public QObject {

[[nodiscard]] static constexpr int commandQueueCapacity() noexcept { return 64; }
[[nodiscard]] static constexpr int presencePollIntervalMs() noexcept { return 750; }
// USB removal may not emit QSerialPort::ResourceError on every Windows
// driver. Use the EXISTING low-rate port inventory (no extra poll) and
// require two consecutive missing observations to ignore enumeration
// flicker. This releases only the stale GUI COM handle, never SV/PTP.
[[nodiscard]] static constexpr int missingPortConfirmationPolls() noexcept { return 2; }
[[nodiscard]] static constexpr bool verifiedPortPhysicallyGone(
const bool serialOpen, const bool identityConfirmed,
const int consecutiveMissingSnapshots) noexcept {
return serialOpen && identityConfirmed &&
consecutiveMissingSnapshots >= missingPortConfirmationPolls();
}
[[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 5; }
[[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 1100; }

Expand Down Expand Up @@ -121,6 +132,8 @@ public slots:
void autoDetectAndConnect(quint64 generation);
void connectPort(const QString& portName, quint64 generation);
void disconnectPort(quint64 generation);
// Bounded no-reply recovery, different from manual DISCONNECT.
void retireUnresponsivePort(quint64 generation);
void confirmIdentity(quint64 generation);
void enqueueCommands(
const QStringList& commands,
Expand All @@ -144,6 +157,9 @@ public slots:
void commandTransmitted(quint64 generation, const QString& command, bool quiet);
void commandRejected(quint64 generation, const QString& message);
void transportError(quint64 generation, const QString& message, bool fatal);
// Emitted only after the worker has CLOSED a failed verified COM port.
// Deliberate user disconnect must never emit an automatic recovery event.
void unexpectedPortLoss(quint64 generation, const QString& portName);

private:
friend class DeterministicSessionHarness;
Expand Down Expand Up @@ -197,6 +213,7 @@ public slots:
quint64 activeGeneration_{0};
int highConfidenceCount_{0};
int identifyAttempts_{0};
int consecutiveMissingPortPolls_{0};
int exclusiveCommandsRemaining_{0};
bool initialized_{false};
bool shuttingDown_{false};
Expand Down
44 changes: 44 additions & 0 deletions apps/arstack_studio/src/SmartSessionController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ void SmartSessionController::setDevice(QObject* object) {
if (device_ == next) return;
if (device_ != nullptr) disconnect(device_, nullptr, this, nullptr);
device_ = next;
lastEnumeratedPorts_ = device_ != nullptr ? device_->ports() : QStringList{};
deviceFirmwareVersion_.clear();
profileSyncBootId_.clear();
resetProfileSync(true);
Expand Down Expand Up @@ -740,9 +741,52 @@ void SmartSessionController::reconnectDeviceSignals() {
refreshRecoveryOfferFromIdentity();
reconcile();
});
// A verified COM was retired by the worker AFTER QSerialPort::close().
// Unlike portsChanged, this also covers rapid USB unplug/replug where
// Windows reuses COM15 between two 750-ms scans and no list edge exists.
// Queue the re-identification on the supervisor thread; never restart
// SV/PTP, bypass semantic identity, or seize COM from the flasher.
connect(device_, &DeviceController::unexpectedTransportLoss, this,
[this](const quint64 generation, const QString&) {
if (!generationIsCurrent(sessionGeneration_, generation)) return;
QTimer::singleShot(0, this, [this, generation] {
if (!generationIsCurrent(sessionGeneration_, generation) ||
!started_ || shuttingDown_ || device_ == nullptr ||
updateRequested_ || setupError_ || blankBoardDetected_ ||
manualRecoveryArmed_ || portOwner_ == PortOwner::firmwareTool ||
(firmware_ != nullptr && firmware_->busy()) ||
device_->connected() || device_->discovering() ||
device_->ports().isEmpty()) return;
// This is physical transport loss, not the operator selecting
// Disconnect. A fresh session generation retires late ACKs.
static_cast<void>(startDeviceDiscovery());
reconcile();
});
});
connect(device_, &DeviceController::portsChanged, this, [this] {
if (device_ == nullptr) return;
const QStringList nowVisible = device_->ports();
const bool arrival = hasNewlyEnumeratedPort(lastEnumeratedPorts_, nowVisible);
lastEnumeratedPorts_ = nowVisible;
refreshRecoveryOfferFromIdentity();
reconcile();
if (!arrival) return;
// Wake once on USB arrival instead of waiting for periodic discovery.
// No serial I/O on the UI thread and no USB polling beyond the
// existing 750-ms DeviceIoWorker inventory. A removed port never
// triggers a probe; manual disconnect alone never triggers a probe.
// All identity/port ownership validation remains in the supervisor.
QTimer::singleShot(0, this, [this] {
if (!started_ || shuttingDown_ || device_ == nullptr ||
updateRequested_ || setupError_ || blankBoardDetected_ ||
manualRecoveryArmed_ || portOwner_ == PortOwner::firmwareTool ||
(firmware_ != nullptr && firmware_->busy()) ||
device_->connected() || device_->discovering() ||
device_->ports().isEmpty())
return;
static_cast<void>(startDeviceDiscovery());
reconcile();
});
});
connect(device_, &DeviceController::runningChanged, this, [this] {
if (updateRequested_ && updateStage_ == UpdateStage::stopping &&
Expand Down
12 changes: 12 additions & 0 deletions apps/arstack_studio/src/SmartSessionController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,17 @@ class SmartSessionController : public QObject {
const bool serialConnected) noexcept {
return owner == PortOwner::firmwareTool && !serialConnected;
}
// No OS watcher / extra scan: react to the existing worker's 750-ms
// physical port inventory and queue semantic IDENTIFY only on arrival.
// A pure predicate makes COM renumbering testable without USB hardware.
[[nodiscard]] static bool hasNewlyEnumeratedPort(
const QStringList& previous, const QStringList& current) noexcept {
for (const QString& name : current) {
if (!previous.contains(name, Qt::CaseInsensitive)) return true;
}
return false;
}

[[nodiscard]] static bool recoveryIdentityMatches(
const QString& expectedDeviceId,
const QString& observedDeviceId) noexcept {
Expand Down Expand Up @@ -413,6 +424,7 @@ class SmartSessionController : public QObject {
bool setupError_{false};
bool recoveryPending_{false};
bool shuttingDown_{false};
QStringList lastEnumeratedPorts_;
int unidentifiedDiscoveryTicks_{0};
int updateReconnectAttempts_{0};
int profileSyncAttempts_{0};
Expand Down
20 changes: 7 additions & 13 deletions apps/arstack_studio/src/StudioDeviceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,9 @@ class StudioDeviceController : public DeviceController {
// so it naturally suppresses unnecessary health retries while RUNNING.
confirmControlHealth();
});
connect(this, &DeviceController::portsChanged, this, [this] {
// QSerialPort errors are still authoritative, but the worker's
// 750 ms enumeration snapshot closes the gap where Windows removes
// a COM device before Qt surfaces ResourceError/DeviceNotFoundError.
const QString activePort = portName().trimmed();
if (connected() && !activePort.isEmpty() && !ports().contains(activePort)) {
emit deviceMessage(QStringLiteral(
"%1 disappeared from USB/COM enumeration. Releasing the stale session for automatic recovery.")
.arg(activePort));
disconnectPort();
}
});
// M3.G: The serial worker owns confirmed USB disappearance and its
// two-snapshot hysteresis. Never disconnect here on a single UI
// portsChanged event: duplicate owners caused spurious teardown.

}

Expand Down Expand Up @@ -268,7 +259,10 @@ class StudioDeviceController : public DeviceController {
emit deviceMessage(QStringLiteral(
"Device control health timed out after bounded probes. Reconnecting without restarting SMV automatically."));
emit controlHealthFailed();
disconnectPort();
// A no-reply serial handle is a transport fault, NOT an
// operator DISCONNECT. Retire it through the worker's sole
// ownership path; notify the supervisor for prompt reconnect.
recoverUnresponsivePort();
return;
}
}
Expand Down
Loading
Loading