From fa97005b4ca84925dbace2db1e03900fef8dd203 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:15:56 +0800 Subject: [PATCH 1/3] diagnose(shell): report why a live selection was discarded refs #156 TextSelection gains an opt-in discard observer with named branches (epoch, anchor, identity, text, endpoint); the shell installs a JSONL writer only when TERMFORGE_SELECTION_DIAG is set. Selection contracts add a group proving the observer receives the epoch branch; behaviour is unchanged without the variable. --- experiments/core/selection_test.cpp | 27 +++++++++++++++++++++-- experiments/core/text_selection.hpp | 25 +++++++++++++++------ experiments/shell/README.md | 4 ++++ experiments/shell/shell.cpp | 34 +++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 9 deletions(-) diff --git a/experiments/core/selection_test.cpp b/experiments/core/selection_test.cpp index 09025742..36e676d2 100644 --- a/experiments/core/selection_test.cpp +++ b/experiments/core/selection_test.cpp @@ -4,6 +4,8 @@ #include "text_selection.hpp" #include #include +#include +#include using namespace termforge; static void check(bool value, const char* why) { if (!value) throw std::runtime_error(why); } @@ -184,6 +186,26 @@ static void empty_stale_and_capacity_boundaries() { check(!selection.set(bounded, {at(bounded, 0, 0), at(bounded, bounded.size() - 1, 0)}) && selection.empty(), "selection snapshot budget rejects excessive retained rows without stale range"); } + +static std::vector observed_discards; +static void observe_discard(const char* reason) { observed_discards.push_back(reason ? reason : ""); } +static void discard_reasons_are_reported() { + Parser p(2, 6, 2); + p.feed("ONETWO\r\nTHREE"); + TextDocument document(p.screen); + TextSelection selection; + check(selection.set(document, {at(document, 0, 0), at(document, 1, 3)}), "diagnostic selection fixture"); + TextSelection::set_discard_observer(&observe_discard); + Parser bumped(2, 6, 2); + bumped.feed("ONETWO\r\nTHREE"); + bumped.screen.invalidate_buffer_readers(); + observed_discards.clear(); + check(!selection.reconcile(TextDocument(bumped.screen)), "epoch bump invalidates the selection"); + check(observed_discards.size() == 1 && observed_discards.front() == "epoch-mismatch", + "discard observer reports the epoch branch by name"); + TextSelection::set_discard_observer(nullptr); +} + int main() { try { std::ostringstream json; @@ -197,6 +219,7 @@ int main() { half_open_endpoint_boundary(); word_line_policy(); empty_stale_and_capacity_boundaries(); - std::cout << "selection contracts passed (7 independent groups)\n"; + discard_reasons_are_reported(); + std::cout << "selection contracts passed (8 independent groups)\n"; } catch (const std::exception& error) { std::cerr << error.what() << '\n'; return 1; } -} +} \ No newline at end of file diff --git a/experiments/core/text_selection.hpp b/experiments/core/text_selection.hpp index 824d02ec..3a06410f 100644 --- a/experiments/core/text_selection.hpp +++ b/experiments/core/text_selection.hpp @@ -10,6 +10,14 @@ struct SelectionCopy { std::string text; }; class TextSelection { +public: + // 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); + static void set_discard_observer(DiscardObserver observer) { discard_observer_ = observer; } +private: + static inline DiscardObserver discard_observer_ = nullptr; std::optional range_; std::vector saved_; bool invalidated_ = false; @@ -32,7 +40,10 @@ class TextSelection { return c >= 0x80 || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' ? 0 : 2; } - bool invalidate() { clear(); invalidated_ = true; return false; } + bool invalidate(const char* reason) { + if (discard_observer_) discard_observer_(reason); + clear(); invalidated_ = true; return false; + } public: static constexpr size_t maximum_snapshot_bytes = 8 * 1024 * 1024; void clear() { range_.reset(); saved_.clear(); invalidated_ = false; } @@ -66,28 +77,28 @@ class TextSelection { bool reconcile(const TextDocument& document) { if (!range_) return !invalidated_; if (range_->begin.epoch != document.epoch() || range_->end.epoch != document.epoch()) - return invalidate(); + return invalidate("epoch-mismatch"); const auto begin = document.index_of(range_->begin.line_id); - if (!begin || saved_.size() > document.size() - *begin) return invalidate(); + if (!begin || saved_.size() > document.size() - *begin) return invalidate("anchor-missing-or-shrunk"); for (size_t i = 0; i < saved_.size(); ++i) { const auto meta = document.meta(*begin + i); auto& saved = saved_[i]; if (meta.id != saved.projection.id && (i + 1 != saved_.size() || range_->end.column != 0 || - !saved.projection.text.empty() || !saved.projection.spans.empty())) return invalidate(); + !saved.projection.text.empty() || !saved.projection.spans.empty())) return invalidate("row-identity-replaced"); if (meta.id != saved.projection.id || meta.version != saved.projection.version || meta.joins_next != saved.joins_next) { auto current = document.line(*begin + i); // 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(); + if (!same_text(saved, current, compare_boundary)) return invalidate("row-text-changed"); saved = std::move(current); } } const auto first = document.position(*begin, range_->begin.column); const auto last = document.position(*begin + saved_.size() - 1, range_->end.column); - if (!first || !last) return invalidate(); + if (!first || !last) return invalidate("endpoint-unavailable"); range_ = TextRange{*first, *last}; return true; } bool select_word(const TextDocument& document, size_t index, size_t column) { @@ -170,4 +181,4 @@ class TextSelection { return {text.empty() ? SelectionCopyStatus::empty : SelectionCopyStatus::ok, std::move(text)}; } }; -} // namespace termforge +} // namespace termforge \ No newline at end of file diff --git a/experiments/shell/README.md b/experiments/shell/README.md index 79968db9..2fe09cbd 100644 --- a/experiments/shell/README.md +++ b/experiments/shell/README.md @@ -59,6 +59,10 @@ R1 的 [#12–#17 限定合同](../../docs/milestones/R1-local-text.md)和 [#18 自动测试仅读写各自结果目录下的 `workspace.layout`,不会读取或修改上述用户配置。恢复场景为 data-only,不生成位图;若等待超时,`persistence-failure.json` 保留有界 PTY 输出和文本屏幕以区分输入与显示问题,内容仅限合成会话。 +## 选区丢弃诊断 + +几何/DPI/缩放变化后若选区被 `TextSelection::reconcile` 丢弃,可用环境变量 `TERMFORGE_SELECTION_DIAG=` 打开一次性诊断:每次丢弃写入一行 `{"schema":"termforge-selection-discard-v1","reason":"..."}`,`reason` 取 `epoch-mismatch`、`anchor-missing-or-shrunk`、`row-identity-replaced`、`row-text-changed`、`endpoint-unavailable` 之一。未设置该变量时不写任何文件、行为不变;`selection_core` 里新增的分组只验证观察器能收到分支名。 + ## 所有权与调度 - ConPTY 输出线程将最多 64 KiB 的读取块放入 256 KiB 队列;满时等待窗口消费,正常运行不丢弃字节。 diff --git a/experiments/shell/shell.cpp b/experiments/shell/shell.cpp index 6b10af73..f4b84c80 100644 --- a/experiments/shell/shell.cpp +++ b/experiments/shell/shell.cpp @@ -19,9 +19,42 @@ #include "operation_observation.hpp" #include "shell_launch.hpp" #include +#include +#include +#include +#include #include #include +namespace { +// Opt-in diagnostic for the selection-discard path, used when a hosted geometry +// change drops a live selection. Gated by TERMFORGE_SELECTION_DIAG=; +// without it nothing is written and no behaviour changes. +std::string selection_diag_path() { +#ifdef _MSC_VER + char* buffer = nullptr; size_t size = 0; + if (_dupenv_s(&buffer, &size, "TERMFORGE_SELECTION_DIAG") != 0 || !buffer) return {}; + std::string value(buffer); std::free(buffer); return value; +#else + const char* value = std::getenv("TERMFORGE_SELECTION_DIAG"); + return value ? std::string(value) : std::string{}; +#endif +} +void selection_discard_diagnostic(const char* reason) { + 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 << "\"}\n"; +} +void install_selection_diagnostic_once() { + static std::once_flag once; + std::call_once(once, [] { + if (!selection_diag_path().empty()) termforge::TextSelection::set_discard_observer(&selection_discard_diagnostic); + }); +} +} // namespace + struct Result { int resize_verified = 0, rows = 24, columns = 80; size_t consumed = 0, output_high_water = 0, input_high_water = 0, input_rejected = 0, max_consumed_per_tick = 0; @@ -352,6 +385,7 @@ class ShellWindow { }); } void publish(std::optional scroll_operation=std::nullopt,bool scroll_changed=false) { + install_selection_diagnostic_once(); selection_.reconcile(termforge::TextDocument(parser_.screen)); auto view = viewport_.snapshot(parser_.screen); if(runtime_enabled_) runtime_published_viewport_=view.viewport; From 558e0eeeeee644ff7e4742802fd4c69ee44ec862 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:25:16 +0800 Subject: [PATCH 2/3] diagnose(shell): point the selection diagnostic at the preserved artifacts refs #156 The python runner sets TERMFORGE_SELECTION_DIAG inside each scenario output directory so a hosted discard reason lands in build/test-artifacts and survives as failure evidence. --- experiments/shell/README.md | 2 +- experiments/shell/run.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/experiments/shell/README.md b/experiments/shell/README.md index 2fe09cbd..5caaf33d 100644 --- a/experiments/shell/README.md +++ b/experiments/shell/README.md @@ -61,7 +61,7 @@ R1 的 [#12–#17 限定合同](../../docs/milestones/R1-local-text.md)和 [#18 ## 选区丢弃诊断 -几何/DPI/缩放变化后若选区被 `TextSelection::reconcile` 丢弃,可用环境变量 `TERMFORGE_SELECTION_DIAG=` 打开一次性诊断:每次丢弃写入一行 `{"schema":"termforge-selection-discard-v1","reason":"..."}`,`reason` 取 `epoch-mismatch`、`anchor-missing-or-shrunk`、`row-identity-replaced`、`row-text-changed`、`endpoint-unavailable` 之一。未设置该变量时不写任何文件、行为不变;`selection_core` 里新增的分组只验证观察器能收到分支名。 +几何/DPI/缩放变化后若选区被 `TextSelection::reconcile` 丢弃,可用环境变量 `TERMFORGE_SELECTION_DIAG=` 打开一次性诊断:每次丢弃写入一行 `{"schema":"termforge-selection-discard-v1","reason":"..."}`,`reason` 取 `epoch-mismatch`、`anchor-missing-or-shrunk`、`row-identity-replaced`、`row-text-changed`、`endpoint-unavailable` 之一。未设置该变量时不写任何文件、行为不变;`experiments/shell/run.py` 的 `execute()` 会自动把该变量指向本次场景产物目录下的 `selection-discard.jsonl`,因此失败时该文件随 `build/test-artifacts/` 一起保留。`selection_core` 里新增的分组只验证观察器能收到分支名。 ## 所有权与调度 diff --git a/experiments/shell/run.py b/experiments/shell/run.py index 271d8ac7..5d3d1335 100644 --- a/experiments/shell/run.py +++ b/experiments/shell/run.py @@ -428,10 +428,14 @@ def execute(exe, mode, out, timeout=40, settings_source=None, *, resources=False job = OwnedJob() desktop = OwnedTestDesktop() record["desktop_name"] = desktop.name + # Opt-in selection-discard diagnostic: the shell only writes this file when + # the variable is set, and the path stays inside the preserved artifacts. + environment = dict(os.environ) + environment.setdefault("TERMFORGE_SELECTION_DIAG", str((out / "selection-discard.jsonl").resolve())) process = subprocess.Popen([str(exe.with_name("shell_test_launcher.exe").resolve()), desktop.full_name, mode, str((out / "frame.bmp").resolve())], cwd=out, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - creationflags=subprocess.CREATE_NO_WINDOW) + env=environment, creationflags=subprocess.CREATE_NO_WINDOW) job.assign(process.pid) assigned = True if resources: From 261ea8d596e830ab6d470998f6130dcabd29a33d Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:26:34 +0800 Subject: [PATCH 3/3] diagnose(core): keep the selection header newline refs #156 Trailing newline restored after the diagnostic edit; no behaviour change. --- experiments/core/text_selection.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experiments/core/text_selection.hpp b/experiments/core/text_selection.hpp index 3a06410f..3f136c54 100644 --- a/experiments/core/text_selection.hpp +++ b/experiments/core/text_selection.hpp @@ -181,4 +181,4 @@ class TextSelection { return {text.empty() ? SelectionCopyStatus::empty : SelectionCopyStatus::ok, std::move(text)}; } }; -} // namespace termforge \ No newline at end of file +} // namespace termforge