Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 25 additions & 2 deletions experiments/core/selection_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
#include "text_selection.hpp"
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
using namespace termforge;
static void check(bool value, const char* why) { if (!value) throw std::runtime_error(why); }

Expand Down Expand Up @@ -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<std::string> 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;
Expand All @@ -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; }
}
}
23 changes: 17 additions & 6 deletions experiments/core/text_selection.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextRange> range_;
std::vector<DocumentLine> saved_;
bool invalidated_ = false;
Expand All @@ -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; }
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions experiments/shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<jsonl 路径>` 打开一次性诊断:每次丢弃写入一行 `{"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` 里新增的分组只验证观察器能收到分支名。

## 所有权与调度

- ConPTY 输出线程将最多 64 KiB 的读取块放入 256 KiB 队列;满时等待窗口消费,正常运行不丢弃字节。
Expand Down
6 changes: 5 additions & 1 deletion experiments/shell/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions experiments/shell/shell.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,42 @@
#include "operation_observation.hpp"
#include "shell_launch.hpp"
#include <atomic>
#include <cstdlib>
#include <fstream>
#include <mutex>
#include <string>
#include <optional>
#include <sstream>

namespace {
// Opt-in diagnostic for the selection-discard path, used when a hosted geometry
// change drops a live selection. Gated by TERMFORGE_SELECTION_DIAG=<jsonl path>;
// 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;
Expand Down Expand Up @@ -352,6 +385,7 @@ class ShellWindow {
});
}
void publish(std::optional<size_t> 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;
Expand Down