diff --git a/.github/workflows/conpty-compat.yml b/.github/workflows/conpty-compat.yml index 062e91c7..8ecd54b0 100644 --- a/.github/workflows/conpty-compat.yml +++ b/.github/workflows/conpty-compat.yml @@ -213,3 +213,33 @@ jobs: retention-days: 14 if-no-files-found: warn path: node-contract-timing.json + + windows-2025-actions-repeat: + # Bounded reproduction for #156 / #99 / #73: repeats the clipboard and + # geometry scenarios on the hosted image so the selection-discard + # diagnostics can be ordered against the scenario phase timeline. + runs-on: windows-2025 + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.12' + - name: Configure + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release + - name: Build + run: cmake --build build --config Release --parallel 2 + - name: Prepare hosted Windows shell prerequisites + run: python -m tools.prepare_hosted_shells --out local/hosted-shells + - name: Repeat the actions scenarios + env: + TERMFORGE_REQUIRE_SYSTEM_CLIPBOARD: '1' + run: ctest --test-dir build -C Release -R "text_actions|selection_context" --repeat until-fail:20 --output-on-failure + - name: Preserve repeat evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: actions-repeat-evidence + retention-days: 14 + if-no-files-found: warn + path: build/test-artifacts/ diff --git a/experiments/core/selection_test.cpp b/experiments/core/selection_test.cpp index 51f09fd0..c1bd7eb4 100644 --- a/experiments/core/selection_test.cpp +++ b/experiments/core/selection_test.cpp @@ -188,7 +188,7 @@ static void empty_stale_and_capacity_boundaries() { } static std::vector observed_discards; -static void observe_discard(const char* reason, size_t) { observed_discards.push_back(reason ? reason : ""); } +static void observe_discard(const TextSelection::DiscardReport& report) { observed_discards.push_back(report.reason ? report.reason : ""); } static void discard_reasons_are_reported() { Parser p(2, 6, 2); p.feed("ONETWO\r\nTHREE"); diff --git a/experiments/core/text_selection.hpp b/experiments/core/text_selection.hpp index 21ae9b1d..36c72885 100644 --- a/experiments/core/text_selection.hpp +++ b/experiments/core/text_selection.hpp @@ -14,7 +14,15 @@ class TextSelection { // Diagnostics only: never changes behaviour. A consumer may install a hook to // learn why a reconciled selection was discarded; the hook runs synchronously // on the caller's thread and must not reenter selection or the document. - using DiscardObserver = void (*)(const char* reason, size_t row); + // Diagnostics only: the report names the branch and, where it helps, the + // row plus the saved/current projection shape that differed. + struct DiscardReport { + const char* reason = ""; + size_t row = 0; + size_t saved_bytes = 0, current_bytes = 0; + size_t saved_columns = 0, current_columns = 0; + }; + using DiscardObserver = void (*)(const DiscardReport&); static void set_discard_observer(DiscardObserver observer) { discard_observer_ = observer; } private: static inline DiscardObserver discard_observer_ = nullptr; @@ -40,10 +48,13 @@ class TextSelection { return c >= 0x80 || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' ? 0 : 2; } - bool invalidate(const char* reason, size_t row = 0) { - if (discard_observer_) discard_observer_(reason, row); + bool invalidate(const DiscardReport& report) { + if (discard_observer_) discard_observer_(report); clear(); invalidated_ = true; return false; } + bool invalidate(const char* reason, size_t row = 0) { + DiscardReport report; report.reason = reason; report.row = row; return invalidate(report); + } public: static constexpr size_t maximum_snapshot_bytes = 8 * 1024 * 1024; void clear() { range_.reset(); saved_.clear(); invalidated_ = false; } @@ -92,7 +103,12 @@ class TextSelection { // The last row at column zero is an excluded half-open // endpoint. Its own following break is outside the copy. const bool compare_boundary = i + 1 != saved_.size() || range_->end.column != 0; - if (!same_text(saved, current, compare_boundary)) return invalidate("row-text-changed", i); + if (!same_text(saved, current, compare_boundary)) { + DiscardReport report; report.reason = "row-text-changed"; report.row = i; + report.saved_bytes = saved.projection.text.size(); report.current_bytes = current.projection.text.size(); + report.saved_columns = saved.columns; report.current_columns = current.columns; + return invalidate(report); + } saved = std::move(current); } } diff --git a/experiments/shell/shell.cpp b/experiments/shell/shell.cpp index b93151c1..3b51701e 100644 --- a/experiments/shell/shell.cpp +++ b/experiments/shell/shell.cpp @@ -40,13 +40,15 @@ std::string selection_diag_path() { return value ? std::string(value) : std::string{}; #endif } -void selection_discard_diagnostic(const char* reason, size_t row) { +void selection_discard_diagnostic(const termforge::TextSelection::DiscardReport& report) { static const std::string path = selection_diag_path(); if (path.empty()) return; std::ofstream stream(path, std::ios::app); if (!stream) return; - stream << "{\"schema\":\"termforge-selection-discard-v1\",\"reason\":\"" << reason - << "\",\"row\":" << row << ",\"t_ms\":" << GetTickCount64() << "}\n"; + stream << "{\"schema\":\"termforge-selection-discard-v1\",\"reason\":\"" << report.reason + << "\",\"row\":" << report.row << ",\"saved_bytes\":" << report.saved_bytes + << ",\"current_bytes\":" << report.current_bytes << ",\"saved_columns\":" << report.saved_columns + << ",\"current_columns\":" << report.current_columns << ",\"t_ms\":" << GetTickCount64() << "}\n"; } void install_selection_diagnostic_once() { static std::once_flag once;