From 70da7627f76e5fba4fc3c3e968d8cf3ffd32c77c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:21:46 +0700 Subject: [PATCH 01/16] fix(ptp): hold one bounded physical runtime recovery intent through verified STOP --- apps/arstack_studio/src/DeviceController.hpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index f81ed4947..c2c6723f8 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -380,7 +380,7 @@ class DeviceController : public QObject { [[nodiscard]] bool needsPtpStopBeforeShutdown() const noexcept { return ptpRunning_ || ptpStarting(); } void advancePtpStartup(const QString& line); void failPtpStartup(const QString& reason, bool rollback); - bool beginPtpStopVerification(); + bool beginPtpStopVerification(bool resumeStart = false); void markPtpStopUnconfirmed(const QString& reason); void resetPtpStartup() noexcept; [[nodiscard]] static QString ptpConfigCommand(const QVariantMap& profile); @@ -438,6 +438,8 @@ class DeviceController : public QObject { QString pendingPtpConfig_; bool ptpRuntimeRoleSeen_{false}; bool ptpRuntimeRunningSeen_{false}; + bool ptpRestartAfterStop_{false}; + bool ptpRoleRecoveryAttempted_{false}; bool ioWorkerReady_{false}; bool ioWorkerAffinityValid_{false}; bool shuttingDown_{false}; From 9876f7af037294252f24d90dbb952579f3fe5d1e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:21:50 +0700 Subject: [PATCH 02/16] fix(ptp): recover stale board RUNNING only after STOP ACK/readback, exactly once --- apps/arstack_studio/src/DeviceController.cpp | 51 ++++++++++++++++++-- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index e20ab7e3e..afc7bd786 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1324,17 +1324,25 @@ void DeviceController::resetPtpStartup() noexcept { pendingPtpConfig_.clear(); ptpRuntimeRoleSeen_ = false; ptpRuntimeRunningSeen_ = false; + ptpRestartAfterStop_ = false; + ptpRoleRecoveryAttempted_ = false; } -bool DeviceController::beginPtpStopVerification() { +bool DeviceController::beginPtpStopVerification(const bool resumeStart) { ptpStartTimer_.stop(); ptpStopPollTimer_.stop(); ptpStartStage_ = PtpStartStage::awaiting_stop_ack; - pendingPtpRole_.clear(); - pendingPtpConfig_.clear(); + ptpRestartAfterStop_ = resumeStart && !pendingPtpRole_.isEmpty() && + !pendingPtpConfig_.isEmpty(); + if (!ptpRestartAfterStop_) { + pendingPtpRole_.clear(); + pendingPtpConfig_.clear(); + } ptpRuntimeRoleSeen_ = false; ptpRuntimeRunningSeen_ = false; - ptpStatus_ = QStringLiteral("Stopping PTP; waiting for board confirmation..."); + ptpStatus_ = ptpRestartAfterStop_ + ? QStringLiteral("PTP was already active on board; verifying STOP before retry...") + : QStringLiteral("Stopping PTP; waiting for board confirmation..."); emit ptpStateChanged(); // Emergency STOP has one reserved slot in the existing serial owner and // eliminates unsent START commands, even when the normal queue is full. @@ -1402,6 +1410,8 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole, return false; } ptpLastStartFailure_.clear(); + ptpRestartAfterStop_ = false; + ptpRoleRecoveryAttempted_ = false; pendingPtpRole_ = role; pendingPtpConfig_ = command; ptpStartStage_ = PtpStartStage::awaiting_role_ack; @@ -1436,8 +1446,24 @@ void DeviceController::advancePtpStartup(const QString& line) { const auto readback = kPtpStatusExpression.match(line); if (!readback.hasMatch()) return; if (readback.captured(1).compare(QStringLiteral("STOPPED"), Qt::CaseInsensitive) == 0) { - resetPtpStartup(); ptpRunning_ = false; + if (ptpRestartAfterStop_) { + // A single, evidence-triggered recovery of the same requested + // configuration. The board's verified STOPPED readback is a + // mandatory barrier before sending any role mutation. + ptpRestartAfterStop_ = false; + ptpStopPollTimer_.stop(); + ptpStartStage_ = PtpStartStage::awaiting_role_ack; + ptpStatus_ = QStringLiteral("PTP stopped; applying selected role..."); + emit ptpStateChanged(); + if (!sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(pendingPtpRole_))) { + failPtpStartup(QStringLiteral("Could not queue PTP role after verified STOP."), false); + return; + } + ptpStartTimer_.start(5000); + return; + } + resetPtpStartup(); ptpStatus_ = QStringLiteral("PTP stopped — confirmed by board"); emit ptpStateChanged(); } else if (!ptpStopPollTimer_.isActive()) { @@ -1454,6 +1480,21 @@ void DeviceController::advancePtpStartup(const QString& line) { line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTP unavailable"), Qt::CaseInsensitive); if (rejected) { + // If firmware explicitly says an earlier PTP runtime still owns the + // board, reconcile the physical state ONCE via verified STOP. Never + // retry on generic config faults, never retry without STOPPED, and + // never allow a second automatic recovery loop. + const bool boardStillRunning = + line.contains(QStringLiteral( + "PTPROLE rejected: stop PTP before changing operating role"), + Qt::CaseInsensitive); + if (ptpStartStage_ == PtpStartStage::awaiting_role_ack && + boardStillRunning && !ptpRoleRecoveryAttempted_) { + ptpRoleRecoveryAttempted_ = true; + if (!beginPtpStopVerification(true)) + failPtpStartup(QStringLiteral("Could not safely stop prior PTP runtime."), false); + return; + } const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start || ptpStartStage_ == PtpStartStage::awaiting_runtime; failPtpStartup(QStringLiteral("Board rejected command: %1").arg(line), rollback); From 89e86005b6e37351b1b990cdde1ea1e6187dab80 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:22:58 +0700 Subject: [PATCH 03/16] fix(studio): clipboard adapter verifies actual OS readback --- apps/arstack_studio/src/StudioClipboard.hpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/StudioClipboard.hpp b/apps/arstack_studio/src/StudioClipboard.hpp index 35c105ccb..a4645edf6 100644 --- a/apps/arstack_studio/src/StudioClipboard.hpp +++ b/apps/arstack_studio/src/StudioClipboard.hpp @@ -9,5 +9,7 @@ class StudioClipboard final : public QObject { Q_OBJECT public: using QObject::QObject; - Q_INVOKABLE void copyText(const QString& text) const; + // Return success only if the OS clipboard can read back the exact value. + // Never claim a successful diagnostic copy merely because setText returned. + Q_INVOKABLE bool copyText(const QString& text) const; }; From a948eb4dc293bc0146f76e661729c2f826c690d9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:23:04 +0700 Subject: [PATCH 04/16] fix(studio): do not report success when Windows clipboard write fails --- apps/arstack_studio/src/StudioClipboard.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/src/StudioClipboard.cpp b/apps/arstack_studio/src/StudioClipboard.cpp index ee2e3c2fd..05bc2e40b 100644 --- a/apps/arstack_studio/src/StudioClipboard.cpp +++ b/apps/arstack_studio/src/StudioClipboard.cpp @@ -4,7 +4,10 @@ #include #include -void StudioClipboard::copyText(const QString& text) const { - if (auto* clipboard = QGuiApplication::clipboard(); clipboard != nullptr) - clipboard->setText(text); +bool StudioClipboard::copyText(const QString& text) const { + if (text.isEmpty()) return false; + auto* clipboard = QGuiApplication::clipboard(); + if (clipboard == nullptr) return false; + clipboard->setText(text, QClipboard::Clipboard); + return clipboard->text(QClipboard::Clipboard) == text; } From d0e3057b8782a72480e407c47ad349c313561dba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:23:08 +0700 Subject: [PATCH 05/16] ux(studio): in-app selectable diagnostic fallback and truthful copy status --- apps/arstack_studio/qml/PtpExpertPanel.qml | 41 ++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 37b259886..d1a26c2d6 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -12,6 +12,7 @@ SurfacePanel { property string monoFont: "Inter" property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] property bool advancedVisible: false + property string diagnosticFallbackText: "" readonly property bool startPending: panel.device && panel.device.ptpStarting function roleIndex(role) { @@ -98,6 +99,30 @@ SurfacePanel { onTriggered: if (panel.session) panel.session.requestPtpRefresh() } + Dialog { + id: diagnosticFallback + parent: Overlay.overlay + anchors.centerIn: Overlay.overlay + modal: true + title: "PTP diagnostics · select and copy" + standardButtons: Dialog.Close + width: Math.min(760, Overlay.overlay.width - 32) + height: Math.min(480, Overlay.overlay.height - 32) + contentItem: ScrollView { + clip: true + TextArea { + id: diagnosticFallbackEditor + readOnly: true + selectByMouse: true + wrapMode: TextEdit.NoWrap + font.family: panel.monoFont + font.pixelSize: 11 + text: panel.diagnosticFallbackText + } + } + onOpened: diagnosticFallbackEditor.selectAll() + } + ScrollView { anchors.fill: parent contentWidth: availableWidth @@ -333,8 +358,20 @@ SurfacePanel { text: "Copy diagnostics" enabled: panel.session && panel.device.deviceVerified onClicked: { - StudioClipboard.copyText(panel.session.diagnosticsText()) - if (panel.controller) panel.controller.showMessage("PTP and firmware diagnostics copied.", false) + const evidence = panel.session.diagnosticsText() + if (StudioClipboard.copyText(evidence)) { + if (panel.controller) + panel.controller.showMessage("PTP diagnostics copied to clipboard.", false) + } else { + // Windows may temporarily deny clipboard ownership. + // Keep the complete text selectable in Studio instead + // of claiming success or asking for external scripts. + panel.diagnosticFallbackText = evidence + diagnosticFallback.open() + if (panel.controller) + panel.controller.showMessage( + "Clipboard unavailable. Diagnostic text is open: select and copy it.", true) + } } } Item { Layout.fillWidth: true } From 404bcd7cc5558fc9ca3bc4045d368fb834fcfd45 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:23:12 +0700 Subject: [PATCH 06/16] fix(studio): do not silently claim firmware diagnostics copied --- apps/arstack_studio/qml/FirmwarePanel.qml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index 552ba4f11..10cf39224 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -298,7 +298,14 @@ SurfacePanel { text: "Copy diagnostics" toolTipText: "Logs may include device identifiers; review before sharing." enabled: panel.session !== null - onClicked: StudioClipboard.copyText(panel.session.diagnosticsText()) + onClicked: { + const evidence = panel.session.diagnosticsText() + if (StudioClipboard.copyText(evidence)) { + panel.copyFeedback = "Firmware diagnostics copied." + } else { + panel.copyFeedback = "Clipboard unavailable. Open technical details to review diagnostics." + } + } } Item { Layout.fillWidth: true } } From f200fb5a6189465118400543b7ce8daae6dcac82 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:23:55 +0700 Subject: [PATCH 07/16] fix(studio): explicitly import registered StudioClipboard singleton in Time Sync QML --- apps/arstack_studio/qml/PtpExpertPanel.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index d1a26c2d6..0e325a96e 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import ARStack.Studio 1.0 SurfacePanel { id: panel From 701e09b26afb7db86105909123e14c06dd14fc84 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:23:59 +0700 Subject: [PATCH 08/16] ux(studio): show truthful firmware clipboard result adjacent to Copy button --- apps/arstack_studio/qml/FirmwarePanel.qml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index 10cf39224..1fb9741fc 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -12,6 +12,7 @@ SurfacePanel { property string uiFont: "Inter" property string monoFont: "Inter" property bool technicalDetailsVisible: false + property string copyFeedback: "" readonly property string firmwareDisplayStatus: panel.session && panel.session.firmwareUpdateResult.length > 0 && !panel.session.updatingFirmware @@ -307,6 +308,13 @@ SurfacePanel { } } } + Label { + visible: panel.copyFeedback.length > 0 + text: panel.copyFeedback + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + } Item { Layout.fillWidth: true } } From 9e56bb481a541cc3d0a9009fc89153e86d072cc4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:25:08 +0700 Subject: [PATCH 09/16] test(ptp): cover physical stale role recovery, bounded retries, STOP timeout and user cancellation --- .../src/DeterministicSessionHarness.cpp | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 5611f9dbe..e80fb0dd3 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -52,6 +52,10 @@ class DeterministicSessionHarness final { {"PTP source rejection survives verified safety STOP", ptpSourceReasonSurvivesSafetyStop()}, {"PTP rejected config never starts", ptpGuidedRejectedConfigStops()}, {"PTP STOP cancels pending start after verified readback", ptpGuidedStopCancelsStart()}, + {"PTP stale board role rejection triggers one verified recovery", ptpStaleRuntimeOneRecovery()}, + {"PTP stale runtime recovery cannot loop on second rejection", ptpStaleRuntimeNoRetryLoop()}, + {"PTP runtime recovery requires STOPPED evidence", ptpStaleRuntimeStopUnverified()}, + {"PTP user STOP cancels automatic recovery intent", ptpStaleRuntimeStopCancelsRecovery()}, {"legacy bare SV START refuses stale eight-channel state", legacyBareStartFailsClosed()}, {"SV commit ACK/readback START chain is generation bound", svTransactionAckChain()}, {"SV stale ACK and mismatched firmware readback fail closed", svTransactionNegativeAcks()}, @@ -820,6 +824,91 @@ class DeterministicSessionHarness final { "PTP status=STOPPED Announce=0 Sync=0 FollowUp=0 PdelayFrames=0 TXfail=0"); } + static bool ptpStaleRuntimeOneRecovery() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + // Board was still transmitting, but desktop had cached OFF. + d.processLine(QStringLiteral( + "E (246315) ar_smv_profile: PTPROLE rejected: stop PTP before changing operating role")); + if (!d.ptpStarting() || !d.ptpRoleRecoveryAttempted_ || + !d.ptpRestartAfterStop_ || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) + return false; + // Unrelated role ACK and pre-STOP telemetry must never advance. + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral( + "PTP status=RUNNING Announce=61 Sync=61 FollowUp=61 PdelayFrames=0 TXfail=1")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) + return false; + d.processLine(QStringLiteral("PTP stop requested")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback) + return false; + d.processLine(ptpStoppedStatus()); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_ack || + d.ptpRestartAfterStop_ || !d.ptpRoleRecoveryAttempted_) + return false; + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + d.processLine(QStringLiteral("PTP configuration accepted")); + d.processLine(QStringLiteral("PTP start accepted")); + d.processLine(ptpRoleEvidence(QStringLiteral("SOURCE"))); + d.processLine(QStringLiteral( + "PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0")); + return !d.ptpStarting() && d.ptpRunning_ && + d.ptpRole_ == QStringLiteral("SOURCE") && + !d.ptpRoleRecoveryAttempted_; + } + + static bool ptpStaleRuntimeNoRetryLoop() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + const QString stale = QStringLiteral( + "PTPROLE rejected: stop PTP before changing operating role"); + d.processLine(stale); + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_ack) + return false; + d.processLine(stale); + return !d.ptpStarting() && !d.ptpRestartAfterStop_ && + d.ptpLastStartFailure_.contains(stale); + } + + static bool ptpStaleRuntimeStopUnverified() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral( + "PTPROLE rejected: stop PTP before changing operating role")); + d.processLine(QStringLiteral("PTP stop requested")); + // Timeout is deterministic; no fake STOPPED readback accepted. + if (!QMetaObject::invokeMethod( + &d.ptpStartTimer_, "timeout", Qt::DirectConnection)) return false; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::stop_unconfirmed || + !d.ptpStarting() || !d.ptpRestartAfterStop_ || + f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile())) + return false; + return d.ptpStatus_.contains(QStringLiteral("unverified")); + } + + static bool ptpStaleRuntimeStopCancelsRecovery() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral( + "PTPROLE rejected: stop PTP before changing operating role")); + if (!d.ptpRestartAfterStop_) return false; + if (!f.session.requestStopPtp() || d.ptpRestartAfterStop_) return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); + d.processLine(QStringLiteral("PTPROLE accepted")); + return !d.ptpStarting() && + d.ptpStartStage_ == DeviceController::PtpStartStage::idle && + d.ptpStatus_.contains(QStringLiteral("confirmed by board")); + } + static bool ptpGuidedStopCancelsStart() { Fixture f; if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; From a3caf344542ec3a808d4b39556871df83c7318cf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:25:30 +0700 Subject: [PATCH 10/16] test(studio): verify native Qt clipboard roundtrip and refuse false success --- apps/arstack_studio/src/ClipboardHarness.cpp | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 apps/arstack_studio/src/ClipboardHarness.cpp diff --git a/apps/arstack_studio/src/ClipboardHarness.cpp b/apps/arstack_studio/src/ClipboardHarness.cpp new file mode 100644 index 000000000..b5107e620 --- /dev/null +++ b/apps/arstack_studio/src/ClipboardHarness.cpp @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Native GUI clipboard contract: exercise the SAME production adapter as Studio. +#include "StudioClipboard.hpp" + +#include +#include +#include + +int main(int argc, char** argv) { + QGuiApplication app(argc, argv); + StudioClipboard clipboard; + auto* osClipboard = QGuiApplication::clipboard(); + if (osClipboard == nullptr) return 2; + + if (clipboard.copyText(QString{})) return 3; // no fabricated success + constexpr auto kHeader = "ARStack Studio field diagnostics"; + const QString evidence = QString::fromUtf8( + "ARStack Studio field diagnostics\n" + "Installed firmware: df73fea0b68e7f47\n" + "PTP START error: PTPROLE rejected: stop PTP before changing operating role\n" + "SVSYNC event: ETHERNET_LINK_LOSS\n"); + if (!evidence.startsWith(QString::fromLatin1(kHeader))) return 4; + if (!clipboard.copyText(evidence)) return 5; + if (osClipboard->text(QClipboard::Clipboard) != evidence) return 6; + + const QString second = QString::fromUtf8( + "PTP diagnostic: SOURCE running\n" + "MAC phase ±2 us\n" + "SVSYNC ACQUIRED\n"); + if (!clipboard.copyText(second)) return 7; + if (osClipboard->text(QClipboard::Clipboard) != second) return 8; + if (osClipboard->text(QClipboard::Clipboard) == evidence) return 9; + return 0; +} From 9ea3f12a930305594f9197c07459c78688146109 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:25:44 +0700 Subject: [PATCH 11/16] test(studio): add native clipboard roundtrip harness using production adapter --- apps/arstack_studio/CMakeLists.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 4fa8163e1..993df310e 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -55,6 +55,16 @@ qt_add_executable(arstack_session_harness ) # Offline production wire decoder regression (no Npcap or hardware required). +# Isolated platform GUI clipboard verification (same source linked in Studio). +# No QML stubs, no fake service: tests actual QGuiApplication OS clipboard. +qt_add_executable(arstack_clipboard_harness + src/ClipboardHarness.cpp + src/StudioClipboard.cpp + src/StudioClipboard.hpp +) +target_link_libraries(arstack_clipboard_harness PRIVATE Qt6::Core Qt6::Gui) +target_compile_features(arstack_clipboard_harness PRIVATE cxx_std_20) + qt_add_executable(arstack_capture_harness src/SvCaptureHarness.cpp src/SvCaptureController.cpp From 4887471dad2c9f2b67ceafac8c7c7eb462a9d07c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:26:48 +0700 Subject: [PATCH 12/16] ci(studio): exercise actual GUI clipboard roundtrip on Windows and Linux --- .github/workflows/arstack-studio-qt.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index afd4ccb0a..97cbf821d 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -81,7 +81,7 @@ jobs: shell: powershell run: | cmake --build build-arstack-studio --config Release ` - --target arstack_session_harness arstack_windows_ownership_harness --parallel + --target arstack_session_harness arstack_clipboard_harness arstack_windows_ownership_harness --parallel - name: S8A deterministic session harness on Windows timeout-minutes: 1 @@ -98,6 +98,16 @@ jobs: throw "S8A Windows deterministic harness failed with exit code $exitCode." } + - name: Native Windows clipboard roundtrip regression + timeout-minutes: 1 + shell: powershell + run: | + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_clipboard_harness.exe' + & $exe + if ($LASTEXITCODE -ne 0) { + throw "Native Windows OS clipboard readback failed: exit $LASTEXITCODE" + } + - name: S8B cross-process ownership and serial-loss harness timeout-minutes: 1 shell: powershell @@ -216,7 +226,7 @@ jobs: run: | set -o pipefail cmake --build build-arstack-studio \ - --target arstack_studio arstack_session_harness arstack_capture_harness --parallel 2 \ + --target arstack_studio arstack_session_harness arstack_capture_harness arstack_clipboard_harness --parallel 2 \ 2>&1 | tee /tmp/arstack-studio-build.log - name: 4I+4V reference template regression @@ -240,6 +250,14 @@ jobs: timeout 20s ./build-arstack-studio/arstack_session_harness \ 2>&1 | tee /tmp/arstack-studio-session-harness.log + - name: Native GUI clipboard content/readback regression + shell: bash + run: | + set -o pipefail + timeout 15s env QT_QPA_PLATFORM=offscreen \ + ./build-arstack-studio/arstack_clipboard_harness \ + 2>&1 | tee /tmp/arstack-studio-clipboard-harness.log + - name: Native SV PCAP capture and CSV decoder regression shell: bash run: | From 0ca8106d9de44ad13dd54fedabf9eff5b64b91b9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:26:52 +0700 Subject: [PATCH 13/16] ci(studio): require native clipboard roundtrip before packaging matched firmware installer --- .github/workflows/arstack-studio-native-preview.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index fb92d1db8..63345e5a7 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -54,10 +54,12 @@ jobs: run: | cmake -S apps/arstack_studio -B build-studio-preview -G "Visual Studio 17 2022" -A x64 -DARIEC61850_WARNINGS_AS_ERRORS=ON -DARSTACK_STUDIO_ENGINEERING_PREVIEW=ON if ($LASTEXITCODE -ne 0) { throw 'Preview configuration failed' } - cmake --build build-studio-preview --config Release --target arstack_studio arstack_session_harness --parallel 2 + cmake --build build-studio-preview --config Release --target arstack_studio arstack_session_harness arstack_clipboard_harness --parallel 2 if ($LASTEXITCODE -ne 0) { throw 'Native Studio preview build failed' } & "$PWD\build-studio-preview\Release\arstack_session_harness.exe" if ($LASTEXITCODE -ne 0) { throw 'Native session regression failed' } + & "$PWD\build-studio-preview\Release\arstack_clipboard_harness.exe" + if ($LASTEXITCODE -ne 0) { throw 'Native clipboard readback regression failed' } # Differentiate product code regressions from missing deployed DLLs. $nativeGui = Join-Path $PWD 'build-studio-preview\Release\arstack_studio.exe' $probe = Start-Process -FilePath $nativeGui -ArgumentList '--check-reference-template' -Wait -PassThru From 8676f03f299d7869422a1c7ba379bc91f83db76f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:27:23 +0700 Subject: [PATCH 14/16] fix(ptp): second stale-runtime rejection is terminal STOP-UNVERIFIED, not false OFF --- apps/arstack_studio/src/DeviceController.cpp | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index afc7bd786..2549234a9 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1489,10 +1489,20 @@ void DeviceController::advancePtpStartup(const QString& line) { "PTPROLE rejected: stop PTP before changing operating role"), Qt::CaseInsensitive); if (ptpStartStage_ == PtpStartStage::awaiting_role_ack && - boardStillRunning && !ptpRoleRecoveryAttempted_) { - ptpRoleRecoveryAttempted_ = true; - if (!beginPtpStopVerification(true)) - failPtpStartup(QStringLiteral("Could not safely stop prior PTP runtime."), false); + boardStillRunning) { + const QString cause = QStringLiteral("Board PTP remained ACTIVE after one verified " + "STOP; manual STOP is required before START."); + ptpLastStartFailure_ = cause; + if (!ptpRoleRecoveryAttempted_) { + ptpRoleRecoveryAttempted_ = true; + if (!beginPtpStopVerification(true)) + markPtpStopUnconfirmed( + QStringLiteral("Could not queue STOP for stale PTP runtime.")); + } else { + // A second ACTIVE response means authority is inconsistent. + // Do not fabricate OFF or launch another retry loop. + markPtpStopUnconfirmed(cause); + } return; } const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start || From 97a787630d636429080e50c5215462545034aaec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:27:38 +0700 Subject: [PATCH 15/16] test(ptp): second stale RUNNING rejection locks into manual STOP recovery only --- apps/arstack_studio/src/DeterministicSessionHarness.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index e80fb0dd3..0ee5cb050 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -872,8 +872,11 @@ class DeterministicSessionHarness final { if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_ack) return false; d.processLine(stale); - return !d.ptpStarting() && !d.ptpRestartAfterStop_ && - d.ptpLastStartFailure_.contains(stale); + return d.ptpStarting() && !d.ptpRestartAfterStop_ && + d.ptpStartStage_ == DeviceController::PtpStartStage::stop_unconfirmed && + d.ptpLastStartFailure_.contains(QStringLiteral("manual STOP")) && + !f.session.requestStartPtpConfigured( + QStringLiteral("SOURCE"), ptpValidProfile()); } static bool ptpStaleRuntimeStopUnverified() { From 799b66481f77abdcbdb50a6f1cab5fe165ade006 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 04:27:56 +0700 Subject: [PATCH 16/16] test(studio): enforce shared PTP recovery and real clipboard success/fallback contracts --- .../tests/check_operator_workspace.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index bb6822df8..b6887ee15 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -477,6 +477,21 @@ def main() -> None: 'ptpRuntimeRunningSeen_' in device_control_cpp and 'if (!ptpRuntimeRoleSeen_ || !ptpRuntimeRunningSeen_' in device_control_cpp, "PTP errors must originate on board and two-order runtime proofs must be joined") + ptp_source = source("PtpExpertPanel.qml") + ptp_device = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + clipboard_cpp = (STUDIO / "src/StudioClipboard.cpp").read_text(encoding="utf-8") + qt_workflow = (STUDIO.parent.parent / ".github/workflows/arstack-studio-qt.yml").read_text(encoding="utf-8") + require("ptpRestartAfterStop_" in ptp_device and + "beginPtpStopVerification(true)" in ptp_device and + "PTPROLE rejected: stop PTP before changing operating role" in ptp_device and + "manual STOP is required" in ptp_device, + "PTP stale board runtime must follow bounded STOP + readback + role recovery") + require("import ARStack.Studio 1.0" in ptp_source and + "StudioClipboard.copyText(evidence)" in ptp_source and + "diagnosticFallback.open()" in ptp_source and + "clipboard->text(QClipboard::Clipboard) == text" in clipboard_cpp and + "arstack_clipboard_harness" in qt_workflow, + "Time Sync must use registered clipboard and verify a native OS readback") print("Studio operator workspace contract: PASS")