From 05fa8a18206fd03f0695f310c387fc1425aa9dc4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:42:20 +0700 Subject: [PATCH 01/14] feat(studio): bounded verified USB presence-loss gate independent of SV/PTP state --- apps/arstack_studio/src/DeviceIoWorker.hpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index 00cbdd7cf..744a64105 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -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; } @@ -197,6 +208,7 @@ public slots: quint64 activeGeneration_{0}; int highConfidenceCount_{0}; int identifyAttempts_{0}; + int consecutiveMissingPortPolls_{0}; int exclusiveCommandsRemaining_{0}; bool initialized_{false}; bool shuttingDown_{false}; From d4a04dd8846c9524615954fd228bab4c982119d9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:42:47 +0700 Subject: [PATCH 02/14] fix(studio): automatically retire stale COM on confirmed USB disappearance; leave PTP/SV untouched --- apps/arstack_studio/src/DeviceIoWorker.cpp | 27 +++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index b55817c26..c6bcc6415 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -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)); } } @@ -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(); @@ -300,6 +323,7 @@ void DeviceIoWorker::closePortInternal(const bool emitRelease) { heartbeatPending_ = false; identityConfirmed_ = false; identifyAttempts_ = 0; + consecutiveMissingPortPolls_ = 0; pendingRx_.clear(); commandQueue_.clear(); ptpStopPending_ = false; @@ -353,6 +377,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(); From 20f6ea6e7f8beb57d89944516a67813c511a602b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:43:08 +0700 Subject: [PATCH 03/14] feat(studio): pure USB-arrival gate and cached presence inventory for event-driven re-identification --- apps/arstack_studio/src/SmartSessionController.hpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index b6cefe0ae..bf183b572 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -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 { @@ -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}; From 6d007c22e79d2f74f486a83a0be877cb289c8147 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:43:22 +0700 Subject: [PATCH 04/14] fix(studio): re-identify on physical USB reappearance without restart, firmware action or extra polling --- .../src/SmartSessionController.cpp | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index bc9b4175a..f02f9a8c3 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -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); @@ -741,8 +742,29 @@ void SmartSessionController::reconnectDeviceSignals() { 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(startDeviceDiscovery()); + reconcile(); + }); }); connect(device_, &DeviceController::runningChanged, this, [this] { if (updateRequested_ && updateStage_ == UpdateStage::stopping && From e51721ad3d0185f2fcb3244b23da23daec34514d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:43:54 +0700 Subject: [PATCH 05/14] test(studio): USB disappearance hysteresis, COM renumber arrival and no duplicate reconnect regressions --- .../src/DeterministicSessionHarness.cpp | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 9a43acc63..3bf3d68d0 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -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()}, @@ -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; From a9cc5321e8248706540310f636bdc33606a97586 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:44:11 +0700 Subject: [PATCH 06/14] test(studio): enforce USB-only hotplug recovery without PTP/SV side effects --- .../tests/check_operator_workspace.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index a7a7259ab..df18318ce 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -783,6 +783,32 @@ def main() -> None: and "AR_PTP_POLICY_AUTO_PREFER_EXTERNAL" not in default_mode, "PTP SOURCE must default to INTERNAL_GM_ONLY, AUTO only operator opt-in") + # M3.F: USB hotplug must retire a stale Windows COM handle using ONLY + # existing 750-ms OS enumeration and hysteresis, without disturbing the + # Ethernet-side PTP/SV clock or requiring app restart. + io_worker = (STUDIO / "src/DeviceIoWorker.cpp").read_text(encoding="utf-8") + io_header = (STUDIO / "src/DeviceIoWorker.hpp").read_text(encoding="utf-8") + session_cpp = (STUDIO / "src/SmartSessionController.cpp").read_text(encoding="utf-8") + session_header = (STUDIO / "src/SmartSessionController.hpp").read_text(encoding="utf-8") + scan = io_worker.split("void DeviceIoWorker::refreshPortsInternal(", 1)[1].split( + "void DeviceIoWorker::autoDetectAndConnect(", 1)[0] + require("verifiedPortPhysicallyGone(" in scan + and "handleSerialFailure(" in scan + and "consecutiveMissingPortPolls_" in scan + and "presencePollIntervalMs() noexcept { return 750; }" in io_header + and "missingPortConfirmationPolls() noexcept { return 2; }" in io_header + and "PTP STOP" not in scan + and "SV STOP" not in scan.replace("// NEVER issue SV/PTP STOP,", "") + and "setHeartbeatEnabled(" not in scan, + "USB disappearance must retire only stale COM with bounded presence hysteresis") + require("hasNewlyEnumeratedPort(lastEnumeratedPorts_, nowVisible)" in session_cpp + and "lastEnumeratedPorts_ = nowVisible" in session_cpp + and "QTimer::singleShot(0, this, [this]" in session_cpp + and "device_->connected() || device_->discovering()" in session_cpp + and "portOwner_ == PortOwner::firmwareTool" in session_cpp + and "hasNewlyEnumeratedPort(" in session_header, + "USB reappearance must trigger safe semantic autodiscovery with owner gates") + print("Studio operator workspace contract: PASS") From f29e042c90dc1ceec3f5db0e68a1a7bd77dce5f3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 08:45:37 +0700 Subject: [PATCH 07/14] test(studio): verify actual PTP/SV command calls, not harmless explanatory comments --- apps/arstack_studio/tests/check_operator_workspace.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index df18318ce..a2f1da8c0 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -797,8 +797,9 @@ def main() -> None: and "consecutiveMissingPortPolls_" in scan and "presencePollIntervalMs() noexcept { return 750; }" in io_header and "missingPortConfirmationPolls() noexcept { return 2; }" in io_header - and "PTP STOP" not in scan - and "SV STOP" not in scan.replace("// NEVER issue SV/PTP STOP,", "") + and "queuePriorityPtpStop(" not in scan + and "queuePrioritySvStop(" not in scan + and "enqueueCommandsInternal(" not in scan and "setHeartbeatEnabled(" not in scan, "USB disappearance must retire only stale COM with bounded presence hysteresis") require("hasNewlyEnumeratedPort(lastEnumeratedPorts_, nowVisible)" in session_cpp From ac62f046d39dbf5f9eb66fee42bcb2db3f31c82d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 09:21:51 +0700 Subject: [PATCH 08/14] fix(studio): single serial loss authority; hysteresis owned by worker, health loss not manual disconnect --- .../src/StudioDeviceController.hpp | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index a10be566c..29c809d91 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -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. } @@ -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; } } From 11fefec5ee09ce38a6c470826d031927c6478b7a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 09:22:32 +0700 Subject: [PATCH 09/14] feat(studio): serial worker reports unexpected COM loss after release for event-driven recovery --- apps/arstack_studio/src/DeviceIoWorker.hpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index 744a64105..a4e56c4f3 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -132,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, @@ -155,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; From d51782be13da91d742f9e3af84774dff50966173 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 09:22:46 +0700 Subject: [PATCH 10/14] fix(studio): trigger reconnect only after verified zombie COM is closed, including stalled same-COM hotplug --- apps/arstack_studio/src/DeviceIoWorker.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index c6bcc6415..c22de17b4 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -314,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(); @@ -597,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) { From 1184fc1b789d726c690b0a1f4c29aba2834f0682 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 09:23:28 +0700 Subject: [PATCH 11/14] feat(studio): forward worker unexpected COM loss to discovery supervisor; keep manual disconnect distinct --- apps/arstack_studio/src/DeviceController.hpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index a2c34d3a5..d94212567 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -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. @@ -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); From 1195af1adaeaa2aa295e4342308cee59fb66717c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 09:23:41 +0700 Subject: [PATCH 12/14] fix(studio): forward post-close USB loss and retire stalled verified COM asynchronously --- apps/arstack_studio/src/DeviceController.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index c87f87fe1..0b0fa1254 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -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; @@ -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, From ebfcb7385c23d32a5d9021fb9c6b87307bf03b99 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 09:24:06 +0700 Subject: [PATCH 13/14] fix(studio): immediately rediscover after closed failed COM, including same-name quick USB replug --- .../src/SmartSessionController.cpp | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index f02f9a8c3..295829388 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -741,6 +741,28 @@ 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(startDeviceDiscovery()); + reconcile(); + }); + }); connect(device_, &DeviceController::portsChanged, this, [this] { if (device_ == nullptr) return; const QStringList nowVisible = device_->ports(); From a8ca25293311575d170d1f9ef1cd3aa388a8c002 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 09:25:40 +0700 Subject: [PATCH 14/14] test(studio): enforce single USB loss owner and event-driven post-close reconnect for same COM --- .../tests/check_operator_workspace.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index a2f1da8c0..2e9d2b66b 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -810,6 +810,26 @@ def main() -> None: and "hasNewlyEnumeratedPort(" in session_header, "USB reappearance must trigger safe semantic autodiscovery with owner gates") + # M3.G physical FAT found two independent disconnect owners. The + # worker must be the only one that declares serial transport loss; + # recovery must wake the supervisor AFTER closing the stale handle. + studio_controller = (STUDIO / "src/StudioDeviceController.hpp").read_text(encoding="utf-8") + device_cpp = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + device_header = (STUDIO / "src/DeviceController.hpp").read_text(encoding="utf-8") + require("DeviceController::portsChanged" not in studio_controller + and "recoverUnresponsivePort();" in studio_controller + and "DeviceIoWorker::retireUnresponsivePort(" in io_worker + and "emit unexpectedPortLoss(lostGeneration, lostPort)" in io_worker + and "closePortInternal(true);\n if (hadVerifiedIdentity" in io_worker + and "void unexpectedPortLoss(" in io_header + and "DeviceIoWorker::unexpectedPortLoss" in device_cpp + and "DeviceController::recoverUnresponsivePort()" in device_cpp + and "void unexpectedTransportLoss(" in device_header + and "DeviceController::unexpectedTransportLoss" in session_cpp + and "generationIsCurrent(sessionGeneration_, generation)" in session_cpp + and "startDeviceDiscovery()" in session_cpp, + "hotplug must use one worker loss authority and queued generation-safe supervisor reconnect") + print("Studio operator workspace contract: PASS")