Merge pull request #237 from levineuwirth/gui-stage1-pre
GUI Stage 1-pre — the input seam: window_event from 655 lines to four
This commit is contained in:
commit
d038f7144e
|
|
@ -250,7 +250,188 @@ hazard in a shape that looks committed. **A documented error message
|
||||||
that never appears is worse than no documentation**, because the reader
|
that never appears is worse than no documentation**, because the reader
|
||||||
waits for a signal that is not coming.
|
waits for a signal that is not coming.
|
||||||
|
|
||||||
## The GUI arc — Stage 0 branch OPEN, absorption COMPLETE, ready for PR
|
## GUI arc Stage 1 — 1-pre OPEN as PR #237
|
||||||
|
|
||||||
|
**Written at the branch's first commit**, with the framing, as the arc's
|
||||||
|
§5 requires of every PR in it.
|
||||||
|
|
||||||
|
- **PR #237** — https://github.com/levineuwirth/pmacs/pull/237.
|
||||||
|
- **Branch `gui-stage1-pre`**, base `githubsucks/main` @ `f8ad3e7` (the
|
||||||
|
Stage 0 merge, #236). **`githubsucks/gui-stage1-pre` is the
|
||||||
|
authoritative tip** — the ref, not a SHA. Recover with
|
||||||
|
`git fetch githubsucks && git checkout gui-stage1-pre`.
|
||||||
|
- **Framing `docs/gui-stage1-input-framing.md`, revision 11, APPROVED**
|
||||||
|
after **eight rejected revisions**. Revision 9 is the approved design;
|
||||||
|
revision 10 recorded a scope correction found against this
|
||||||
|
implementation and **also argued P2 was satisfied by classification
|
||||||
|
alone, which review overturned — revision 11 retracts it**. It is
|
||||||
|
Stage 1's framing for **all** slices and governs the later branches;
|
||||||
|
only Stage 0 was framed by the arc document itself.
|
||||||
|
- **Scope of THIS branch: 1-pre only — the input seam. No behaviour
|
||||||
|
change.** `App::window_event` was **655 lines** and is now **four** —
|
||||||
|
call `dispatch_window_event`, exit if it asks. The dispatch it calls
|
||||||
|
is one `route_event` call and one arm per route. Nothing below it
|
||||||
|
could be witnessed without a display, which is why the seam precedes
|
||||||
|
every other slice.
|
||||||
|
- **IMPLEMENTED in four commits, one per event family**, so each lands
|
||||||
|
with its own witnesses and mutations rather than as one 600-line
|
||||||
|
diff: `014110f` lifecycle (close / modifiers / resize), `7f0f9db`
|
||||||
|
redraw, `0705564` keyboard, `e955645` the four pointer arms. Review
|
||||||
|
round 1 added `f976dc1`, the P2 effect harness.
|
||||||
|
- **P2 HAS TWO HARNESSES, and the second was review round 1's blocker.**
|
||||||
|
The routing harness answers *where did this event go*; `EffectHarness`
|
||||||
|
answers *what did it do*. Classification alone could not satisfy P2 —
|
||||||
|
**a wheel route carries a delta, and whether that becomes a viewport
|
||||||
|
update, a panel event, a terminal event or nothing depends on
|
||||||
|
`State`.** The effect harness drives a real `AttachClient` over a
|
||||||
|
`socketpair` (real handshake, outbox, writer thread and encoder, so
|
||||||
|
the transcript is the wire), a real windowless `State`, and
|
||||||
|
`App::dispatch_window_event`.
|
||||||
|
- **`App::dispatch_window_event` is what made P2 reachable.** Left inside
|
||||||
|
`window_event`, the dispatch would force the harness to re-implement
|
||||||
|
it, and a harness that re-implements what it tests witnesses its own
|
||||||
|
copy. `window_event` is now **four lines**, so **P3 shrinks from a
|
||||||
|
33-line match to a single `if`**.
|
||||||
|
- **Steps are delimited by a non-coalesceable sentinel key, not a
|
||||||
|
sleep** — otherwise "this step sent nothing" is undecidable without
|
||||||
|
waiting, and a fixed-duration wait against a writer thread is exactly
|
||||||
|
the core-count assumption behind PR #235's CI red.
|
||||||
|
- **The effect rows never skip**: a missing wgpu adapter is an assertion
|
||||||
|
failure. M21 confirms all **nine** effect rows fail loudly while the
|
||||||
|
**thirteen** GPU-free routing rows stay green. The assert is
|
||||||
|
**unconditional**, not `PMACS_REQUIRE_GPU`-gated, so no invocation
|
||||||
|
anywhere can turn a missing adapter into a quiet `ok`.
|
||||||
|
- **They execute in exactly ONE CI job, and that is checked rather than
|
||||||
|
assumed.** `cargo metadata` reports `workspace_default_members` as the
|
||||||
|
root `pmacs` package alone, so the `test` matrix and `crdt-test` —
|
||||||
|
both bare `cargo test --all-targets` — never compile `pmacs-gpu`'s
|
||||||
|
unit tests at all. Only **`gpu-render`** runs them, and it installs
|
||||||
|
lavapipe, proves the adapter with `vulkaninfo`, and sets
|
||||||
|
`PMACS_REQUIRE_GPU=1`. This is the handoff's "`gpu-render` runs a
|
||||||
|
DIFFERENT PACKAGE" fact showing up as a dependency: these rows live or
|
||||||
|
die with that one job.
|
||||||
|
- **Three manufactured absences, all found by running the rows**, and
|
||||||
|
each the same shape: the harness withheld something production
|
||||||
|
supplies, then witnessed its own omission. `resumed` sets the frontend
|
||||||
|
id and session version before any geometry flush (the resize row);
|
||||||
|
the fixture document was two lines and could not scroll; a headless
|
||||||
|
`State` has no attached buffer, so `scroll_by_lines` returned `None`.
|
||||||
|
A fourth was a vacuous assertion — `.all(|e| matches!(..))` over an
|
||||||
|
empty transcript is true — caught by the outbound-blind mutation.
|
||||||
|
- **The shape.** Deciding is `route_event(&WindowEvent) -> Route`, a
|
||||||
|
free function composing one decision function per family
|
||||||
|
(`route_lifecycle`, `route_keyboard` + `route_key_action`,
|
||||||
|
`route_pointer`). Performing stays on `App` in seven `apply_*`
|
||||||
|
methods. A route carries the **decision**, not the effect: a wheel
|
||||||
|
route holds a delta, and whether that becomes a viewport update, a
|
||||||
|
panel event, a terminal event or nothing depends on `State`. Effects
|
||||||
|
are witnessed separately — see the P2 bullet below.
|
||||||
|
- **Every moved body verified as the original, mechanically.** The
|
||||||
|
keyboard body is byte-identical modulo two named conversions; the four
|
||||||
|
pointer bodies were checked by re-running rustfmt on the pre-move text
|
||||||
|
at the new indent level and diffing, since de-indenting by 8 columns
|
||||||
|
lets rustfmt rejoin lines. **22 witnesses — 13 routing, 9 effect — and
|
||||||
|
24 mutations, M1–M24.** Twenty-three fail their own rows; **M6 is the
|
||||||
|
P3 exception check and must stay green**.
|
||||||
|
- **P3 IS NOW MEASURED, NOT ASSUMED — and re-measured after the effect
|
||||||
|
harness landed.** Replacing `window_event`'s whole body with `let _ =
|
||||||
|
(event_loop, event);` — a GUI that responds to no input at all —
|
||||||
|
leaves **all 265 `pmacs-gpu` tests green** under `PMACS_REQUIRE_GPU=1`
|
||||||
|
at the current shape (it was 256 before the effect rows; the number is
|
||||||
|
re-run, not carried forward). That is the exception's true extent:
|
||||||
|
`ActiveEventLoop` cannot exist outside a live event loop, so **no**
|
||||||
|
headless test in the crate observes the delegation. What it covers is
|
||||||
|
now **one `if`**, not a 33-line match.
|
||||||
|
- **A SECOND ACCEPTED STRUCTURAL EXCEPTION, found here and winit's
|
||||||
|
rather than ours.** `KeyEvent` carries a `pub(crate)
|
||||||
|
platform_specific` field, so **no `WindowEvent::KeyboardInput` can be
|
||||||
|
constructed outside winit** and the keyboard family's routing arm
|
||||||
|
cannot be fed by a test. Bounded three ways: it does **not** extend to
|
||||||
|
the pointer families (`DeviceId::dummy()` exists for exactly this, and
|
||||||
|
all three pointer events are constructible — checked before writing
|
||||||
|
the exception down); the family's only real decision is factored into
|
||||||
|
`route_key_action(ElementState)` and witnessed directly; and what
|
||||||
|
stays unwitnessed is one pattern arm containing a match and a call.
|
||||||
|
- **The crate now has exactly ONE executable `event_loop.exit()`**, in
|
||||||
|
`window_event`. Bodies return an `EventOutcome` rather than taking an
|
||||||
|
`&ActiveEventLoop`, which is what keeps them reachable in principle.
|
||||||
|
**`EventOutcome` has TWO producers and they differ in kind**: a native
|
||||||
|
close (`LifecycleRoute::Exit`), which must always exit, and
|
||||||
|
`apply_keyboard`'s idle Escape, which is a local quit. **Stage 1a's A4
|
||||||
|
removes the keyboard producer only**, leaving **one** `Exit`
|
||||||
|
producer — the native close. **One producer is not one variant**:
|
||||||
|
`EventOutcome` survives because `dispatch_window_event` must still
|
||||||
|
distinguish `Continue` from `Exit` on every event, and only the close
|
||||||
|
exits. A4 changes `apply_keyboard`'s signature, not the type. An
|
||||||
|
earlier revision of this bullet claimed the type would collapse and
|
||||||
|
should go with the Escape branch; that was wrong.
|
||||||
|
- **Slice order (each its own branch and PR):** `1-pre` → `1a`\* → `1b`
|
||||||
|
→ `1c` → `1d` → `1e`\*. **`1a` and `1e` are protocol-bearing (v24
|
||||||
|
`TextInput`, v25 `OpenTarget`/`OpenTargetResult`) and are
|
||||||
|
SERIALIZED.** 1c is **not** protocol-bearing under Q#S1-8.
|
||||||
|
- **Q#S1-7 obligation carried by THIS PR:** Meta/Super policy moves to
|
||||||
|
Stage 2, so the arc framing's §2.5 and the standing backlog are
|
||||||
|
amended here. Stage 1 keeps the deliberate OS reservation and adds no
|
||||||
|
island.
|
||||||
|
- **Gates:** `./scripts/gate --acceptance gpu_invocation_acceptance`.
|
||||||
|
That one invocation already runs `PMACS_REQUIRE_GPU=1 cargo test -p
|
||||||
|
pmacs-gpu` (step `gpu`) and the full `--workspace --no-fail-fast`
|
||||||
|
sweep in both feature configurations, so the framing §11 phrase "plus
|
||||||
|
touched input suites" is satisfied by the sweep rather than by a
|
||||||
|
hand-picked list. **No `--protocol`** — 1-pre changes no wire.
|
||||||
|
- **THE FIRST GATE RUN WENT RED ON A STRAY `/tmp/.git`, NOT ON THIS
|
||||||
|
BRANCH.** `m4` and the sweep failed
|
||||||
|
`m4_24_bare_string_glob_stays_relative` and
|
||||||
|
`m4_24_d3_fallback_base_is_the_smallest_attachment_dir` — two LSP
|
||||||
|
file-watcher tests — with every other target green. Established as
|
||||||
|
environmental three ways, in increasing strength:
|
||||||
|
- **Structurally impossible for this branch to cause.** The branch
|
||||||
|
changes seven files, five of them under `docs/`; **the whole
|
||||||
|
executable diff is inside the `pmacs-gpu` crate**
|
||||||
|
(`src/main.rs` and `src/attach.rs`), and `pmacs-gpu` is a workspace
|
||||||
|
**member but not a dependency** of the root package, so the
|
||||||
|
`m4_acceptance` binary never links it.
|
||||||
|
- **The marker is older than the session.** `/tmp/.git` is empty and
|
||||||
|
was created at 20:14 CEST, **3.5 h before** the gate run at 23:45;
|
||||||
|
`/tmp` held 8,920 entries. That is handoff §1's recorded hazard
|
||||||
|
exactly.
|
||||||
|
- **A discriminating pair compared on SIGNATURE, not test name**, same
|
||||||
|
binary and commit, one variable — run as **four literal `--exact`
|
||||||
|
invocations, one test each**, since `m4_24_` is a prefix matching
|
||||||
|
**18** tests. Contaminated: `0 passed; 1 failed` each, panicking at
|
||||||
|
`m4_acceptance.rs:5668:5` and `:6615:5` with `.received = ""`,
|
||||||
|
**matching the gate red's own signature**. Clean: `1 passed; 0
|
||||||
|
failed` each, **zero panics**. The exact commands are in handoff
|
||||||
|
§1's hazard bullet. A rerun would have established only
|
||||||
|
intermittence; the pair establishes the cause.
|
||||||
|
|
||||||
|
**`scripts/gate` isolates the target dir and five ambient roots but
|
||||||
|
NOT `TMPDIR`** — recorded in handoff §1, where the standing fix is
|
||||||
|
assigned to the gate lane rather than to this PR.
|
||||||
|
|
||||||
|
**The marker was left in place**: it is foreign, deleting it is
|
||||||
|
unnecessary, and the isolated `TMPDIR` is the correct remedy. It must
|
||||||
|
be **outside `/tmp` and outside every git worktree** — a child of
|
||||||
|
`/tmp` is not isolated, because `/tmp/.git` remains its ancestor.
|
||||||
|
- **GREEN under an isolated `TMPDIR`: all nine gates pass on the final
|
||||||
|
EXECUTABLE tree** (log `20260812T090034Z-2989598`, review round 2) —
|
||||||
|
executable, not final, because prose and doc comments changed after
|
||||||
|
it, as the sentence below records. fmt, clippy,
|
||||||
|
lib, lib-crdt, `gpu_invocation_acceptance`, **m4 168/0/3**,
|
||||||
|
`PMACS_REQUIRE_GPU=1 -p pmacs-gpu` (**265 tests, none filtered and
|
||||||
|
none skipped**, all nine effect rows included), the **117-target
|
||||||
|
`--workspace --no-fail-fast` sweep with zero failures anywhere**
|
||||||
|
(`m4_acceptance` running all 171 in it), and `diff-check`. Earlier
|
||||||
|
full-green runs at `20260811T215605Z-2664352` (round 1) and
|
||||||
|
`20260812T083735Z-2869707` (the P2 harness) are superseded by this
|
||||||
|
one. **What changed after it is doc comments and prose only** — the
|
||||||
|
`EventOutcome` correction below and this paragraph; `cargo fmt
|
||||||
|
--check`, `clippy -D warnings` and the 22 routing/effect rows were
|
||||||
|
re-run on the result. Stated rather than glossed, because "the gate
|
||||||
|
was green" and "the gate was green on exactly this tree" are
|
||||||
|
different claims.
|
||||||
|
|
||||||
|
## The GUI arc — Stage 0 MERGED as #236 (`f8ad3e7`)
|
||||||
|
|
||||||
**Written at the branch's first commit**, with the framing, which is
|
**Written at the branch's first commit**, with the framing, which is
|
||||||
what this arc's own §5 requires of every PR in it. The standing
|
what this arc's own §5 requires of every PR in it. The standing
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,68 @@ commands, read `docs/active-work.md` immediately after this file.
|
||||||
server-rooted watcher then faithfully watched. A
|
server-rooted watcher then faithfully watched. A
|
||||||
markerless-fixture red that looks like a watcher bug may be an
|
markerless-fixture red that looks like a watcher bug may be an
|
||||||
ancestor marker, on any machine.
|
ancestor marker, on any machine.
|
||||||
|
|
||||||
|
**SEEN AGAIN 2026-08-11, and `scripts/gate` DOES NOT PROTECT YOU
|
||||||
|
FROM IT.** The gate isolates the target directory and five ambient
|
||||||
|
roots but **not `TMPDIR`**, so `tempfile::tempdir()` still lands
|
||||||
|
under a `/tmp` that may carry a marker. It surfaced inside a gate
|
||||||
|
run on an unrelated lane (GUI 1-pre, whose whole **executable** diff
|
||||||
|
is inside the `pmacs-gpu` crate) as **`m4_24_bare_string_glob_stays_relative`
|
||||||
|
and `m4_24_d3_fallback_base_is_the_smallest_attachment_dir`**, in
|
||||||
|
both the `m4` step and the `--workspace` sweep, with every other
|
||||||
|
target in the corpus green.
|
||||||
|
|
||||||
|
**Diagnose it with the discriminating pair, not a rerun** — same
|
||||||
|
binary, same commit, one variable. **Four literal `--exact`
|
||||||
|
invocations, one test each**, because `m4_24_` is a PREFIX matching
|
||||||
|
**18** tests and a prefix run reports ~16/2 and 18/0 rather than the
|
||||||
|
0/1 and 1/0 that make the pair readable:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
C=<marker-free dir> # outside /tmp AND outside every git worktree
|
||||||
|
|
||||||
|
TMPDIR=/tmp cargo test --test m4_acceptance -- \
|
||||||
|
--exact m4_24_bare_string_glob_stays_relative # 0 passed; 1 failed
|
||||||
|
TMPDIR=$C cargo test --test m4_acceptance -- \
|
||||||
|
--exact m4_24_bare_string_glob_stays_relative # 1 passed; 0 failed
|
||||||
|
|
||||||
|
TMPDIR=/tmp cargo test --test m4_acceptance -- \
|
||||||
|
--exact m4_24_d3_fallback_base_is_the_smallest_attachment_dir # 0 passed; 1 failed
|
||||||
|
TMPDIR=$C cargo test --test m4_acceptance -- \
|
||||||
|
--exact m4_24_d3_fallback_base_is_the_smallest_attachment_dir # 1 passed; 0 failed
|
||||||
|
```
|
||||||
|
|
||||||
|
Each prints `running 1 test` and `171 filtered out`. **Read that
|
||||||
|
line** — see the libtest-filter bullet below for why a filter that
|
||||||
|
reaches nothing still prints green.
|
||||||
|
|
||||||
|
Check the ancestors of the temp root for `.git`, `Cargo.toml` and
|
||||||
|
friends before believing any markerless-fixture red, and **compare
|
||||||
|
SIGNATURES, not test names** — the contaminated legs panic at
|
||||||
|
`m4_acceptance.rs:5668:5` and `:6615:5` with `.received = ""`,
|
||||||
|
matching the gate red's own signature, and the clean legs panic
|
||||||
|
nowhere. That is what makes the pair evidence rather than
|
||||||
|
coincidence.
|
||||||
|
|
||||||
|
**The isolated `TMPDIR` must be outside `/tmp` AND outside every git
|
||||||
|
worktree.** A child of `/tmp` is not isolated: `/tmp/.git` is still
|
||||||
|
its ancestor. Verify with `git -C <dir> rev-parse --show-toplevel`
|
||||||
|
failing, not by eye. **Do not delete a foreign marker** — isolating
|
||||||
|
is sufficient and deletion is someone else's call. **Isolating
|
||||||
|
`TMPDIR` inside `scripts/gate` is the standing fix and belongs to
|
||||||
|
the gate lane**, not to whichever feature PR happens to trip over
|
||||||
|
it.
|
||||||
|
- **A libtest filter that matches nothing reports `test result: ok`.**
|
||||||
|
`0 passed; 0 failed; N filtered out` and a zero exit code are what a
|
||||||
|
*typo'd or mis-quoted filter* looks like, and it is indistinguishable
|
||||||
|
from success at a glance. **This shell is zsh, which does NOT
|
||||||
|
word-split unquoted parameter expansions**, so `NAMES="a b"; cargo
|
||||||
|
test -- $NAMES` passes ONE argument `"a b"`, matches no test, and
|
||||||
|
prints a green line. Seen 2026-08-11 while running a
|
||||||
|
contaminated/clean pair: the contaminated leg "passed" and briefly
|
||||||
|
looked like the environmental hypothesis collapsing. **Read the
|
||||||
|
`running N tests` line, not just `test result`** — it is the only
|
||||||
|
place the filter's actual reach is stated.
|
||||||
- **Deliberately unbuilt**: kernel notification (framing option E) —
|
- **Deliberately unbuilt**: kernel notification (framing option E) —
|
||||||
a framed option, not residue; its trigger is the 4 s worst-case
|
a framed option, not residue; its trigger is the 4 s worst-case
|
||||||
external-change latency mattering in practice.
|
external-change latency mattering in practice.
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,8 @@ reference** — each keeps its own framing.
|
||||||
|
|
||||||
| Backlog item | Disposition |
|
| Backlog item | Disposition |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Command/minibuffer chord forwarding; Meta/Super chords | Stage 1a |
|
| Command/minibuffer chord forwarding | **SHIPPED** (`bc32332`) — `Char`/`Enter`/`Tab` with Ctrl or Alt reach the daemon keymap, subsuming the old per-feature allowlists |
|
||||||
|
| **Meta/Super chords** | **Stage 2 (Q#S1-7 ruling, 2026-08-11)** — *moved from Stage 1a.* Blind forwarding would turn platform Command/Super shortcuts into daemon Meta chords **before** capability-aware and local-binding policy exists. Stage 1 keeps the deliberate, tested OS reservation (`pmacs-gpu/src/main.rs:11050`) and adds no island |
|
||||||
| Rebindable local `Ctrl-V`/`Escape` | Escape half → Stage 1a; `Ctrl-V` half → Stage 2 (it is a keymap-vocabulary question, not an input-plumbing one) |
|
| Rebindable local `Ctrl-V`/`Escape` | Escape half → Stage 1a; `Ctrl-V` half → Stage 2 (it is a keymap-vocabulary question, not an input-plumbing one) |
|
||||||
| Middle-click paste | Stage 1b |
|
| Middle-click paste | Stage 1b |
|
||||||
| Right-click context menu | **Already shipped** (§2.1) — the backlog item is stale and Stage 0 retires the line |
|
| Right-click context menu | **Already shipped** (§2.1) — the backlog item is stale and Stage 0 retires the line |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,541 @@
|
||||||
|
# GUI arc, Stage 1 — input foundation (framing)
|
||||||
|
|
||||||
|
**Status: revision 11 — APPROVED.** Revisions 1–8 rejected; revision 9
|
||||||
|
is the approved design. Revision 10 recorded a scope correction found
|
||||||
|
against the 1-pre implementation and **also made a claim about P2 that
|
||||||
|
review overturned; revision 11 retracts it and P2 is implemented as
|
||||||
|
written** (§6). **Q#S1-8, Q#S1-9 and Q#S1-10 are RULED.** **1-pre is
|
||||||
|
IMPLEMENTED**; 1a onward may begin from this document.
|
||||||
|
|
||||||
|
**Verification base:** checked in the `gui-arc-stage0` worktree at
|
||||||
|
`a994f37`, whose tree for these files is what `f8ad3e7` merged.
|
||||||
|
|
||||||
|
## 1. What this stage closes
|
||||||
|
|
||||||
|
Journey **step 5**. **Not step 12** (Stage 4b, P2-gated). Five of nine
|
||||||
|
§3.1 blockers die here.
|
||||||
|
|
||||||
|
## 2. Ground truth at `a994f37`
|
||||||
|
|
||||||
|
`App::window_event` (`main.rs:2734`) is **655 lines**; **eight**
|
||||||
|
`WindowEvent` arms handled, the rest fall to `_`.
|
||||||
|
|
||||||
|
**Two text producers.** `translate_key(logical: &Key, …)`
|
||||||
|
(`main.rs:10975`) reads the **logical key** and truncates via
|
||||||
|
`chars().next()`; **`KeyEvent.text` is never read.**
|
||||||
|
`WindowEvent::Ime::Commit(String)` is a **separate event, ignored
|
||||||
|
entirely** (`set_ime_allowed`/`WindowEvent::Ime`: zero occurrences).
|
||||||
|
**Today's two failures are therefore: multi-scalar keyboard input is
|
||||||
|
truncated to its first scalar, and an IME commit produces nothing.**
|
||||||
|
|
||||||
|
**`FrontendEvent`: sixteen variants, none carrying an open path or
|
||||||
|
command invocation.** `PROTOCOL_VERSION = 23`.
|
||||||
|
|
||||||
|
**The handshake precedes any window** — `EventLoop` built, client
|
||||||
|
constructed, handshake done **before `run_app`** (`main.rs:696`).
|
||||||
|
|
||||||
|
**TUI wheel arms**: `EditorState::dispatch_mouse` (`src/editor.rs:3052`),
|
||||||
|
`ScrollUp`/`ScrollDown` at **`:3203`**.
|
||||||
|
|
||||||
|
**1c is producer-side only for Focus/Detach** — those variants exist on
|
||||||
|
the wire. **Title, Bell and `Goodbye` are GPU consumer work.**
|
||||||
|
|
||||||
|
**`Outbox::enqueue` returns `false` once closed** (`attach.rs:414`) and
|
||||||
|
**coalesces by kind**.
|
||||||
|
|
||||||
|
## 3. PR topology
|
||||||
|
|
||||||
|
`1-pre` → `1a`\* → `1b` → `1c` → `1d` → `1e`\* (\* `--protocol`)
|
||||||
|
|
||||||
|
**1c is NOT protocol-bearing** under Q#S1-8's ruling. Protocol slices
|
||||||
|
are serialized.
|
||||||
|
|
||||||
|
## 4. Q#S1-8 — RULED: (A), preserve pre-window readiness
|
||||||
|
|
||||||
|
`AttachRequest.initial_size` for a semantic session is a **named,
|
||||||
|
provisional `SEMANTIC_BOOTSTRAP_GRID` of 24×80**. It is **not measured
|
||||||
|
geometry** and **must never become semantic frame or panel authority**.
|
||||||
|
**`FrontendCellGeometry`, sent after window creation, is the sole real
|
||||||
|
frame declaration.**
|
||||||
|
|
||||||
|
This **codifies current daemon behaviour**, so **1c stays
|
||||||
|
non-protocol-bearing** — unless implementation changes that behaviour,
|
||||||
|
which would be a wire-contract change even with no bytes moved.
|
||||||
|
|
||||||
|
## 5. Q#S1-9 — RULED: `TextInput` precedence
|
||||||
|
|
||||||
|
**A `KeyboardInput` stays `Key` unless a rule below moves it.**
|
||||||
|
|
||||||
|
1. **Named keys and control text remain `Key`**, regardless of
|
||||||
|
`KeyEvent.text` — so `Enter`'s `"\r"` never becomes text.
|
||||||
|
2. **Ctrl/Alt chords remain `Key`**, except **printable Ctrl+Alt
|
||||||
|
recognized by the existing AltGr rule**.
|
||||||
|
3. **Meta/Super-only text stays reserved to the OS.**
|
||||||
|
4. **Plain printable SINGLE-scalar remains `Key`** — preserving mode
|
||||||
|
keymaps and today's typed provenance.
|
||||||
|
5. **Printable MULTI-scalar becomes one `TextInput`.**
|
||||||
|
6. **Every non-empty `Ime::Commit` becomes one `TextInput`**, even
|
||||||
|
single-scalar.
|
||||||
|
7. **`Key::Dead` is 1d-owned; 1a buffers nothing.**
|
||||||
|
8. **`Shift` is already reflected in resolved text** and is **not**
|
||||||
|
carried on `TextInput`.
|
||||||
|
|
||||||
|
**Provenance and chain.** A **single-scalar** `TextInput` rotates to
|
||||||
|
`buffer.self-insert` and creates **today's one-codepoint typed
|
||||||
|
provenance**. A **multi-scalar** `TextInput` **breaks the command chain
|
||||||
|
and creates no typed provenance**. Both are **one edit, one undo unit,
|
||||||
|
one hook, one eligible CRDT op**.
|
||||||
|
|
||||||
|
**Modal precedence is preserved:** terminals take **raw UTF-8**;
|
||||||
|
search and minibuffer **consume** text; menu and query-replace **retain
|
||||||
|
their shadow behaviour**; **only the ordinary document path performs the
|
||||||
|
atomic edit**.
|
||||||
|
|
||||||
|
**Payload cap: 64 KiB UTF-8, oversize REJECTED, never truncated.**
|
||||||
|
|
||||||
|
## 6. Evidence
|
||||||
|
|
||||||
|
**The promise, corrected.** Revision 4 claimed every mutation fails only
|
||||||
|
its own clause. That is **false and cannot be made true**: clauses have
|
||||||
|
real dependencies — D1 gates every 1d row, D3's failure surfaces at A6,
|
||||||
|
and **E0 and E2–E6 all presuppose E1** (no transport, no receiver). The promise is therefore
|
||||||
|
**dependency-aware, and split by clause kind** — revision 5 still said
|
||||||
|
"every clause fails today", which C6 falsifies by design:
|
||||||
|
|
||||||
|
- **CHANGE clauses** have a witness that **fails today** and a mutation
|
||||||
|
that fails **at least** its own clause.
|
||||||
|
- **PRESERVATION clauses** (marked **[P]**) **pass today**; their
|
||||||
|
witness pins behaviour that must not regress, and their mutation is
|
||||||
|
the change that would break it.
|
||||||
|
- Where a mutation necessarily breaks dependents, **the dependency is
|
||||||
|
named**.
|
||||||
|
|
||||||
|
P3 remains an accepted structural exception: not headlessly testable.
|
||||||
|
|
||||||
|
### 1-pre
|
||||||
|
|
||||||
|
| # | Contract | Witness (fails today because) | Mutation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P1 | Every handled family routes through an extracted function | no extracted functions exist | misroute one family → that family's row |
|
||||||
|
| P2 | Harness records outbound events **and local effects** (exit, redraw, resize, state mutation) | no harness | record outbound only → exit/redraw rows |
|
||||||
|
| P3 | `window_event` is a thin call-through | — | **structural/code-review invariant; not testable headlessly** (no `ActiveEventLoop`) |
|
||||||
|
|
||||||
|
**Revision 10 — one finding against the implementation, not the
|
||||||
|
design.** The 1-pre seam is built and the table above holds, with one
|
||||||
|
scope correction that could not be seen from the design. *(Revision 10
|
||||||
|
also argued P2 was satisfied by classification alone. It is not — see
|
||||||
|
revision 11 at the end of this section.)*
|
||||||
|
|
||||||
|
**P1 has a SECOND structural exception, and it is winit's rather than
|
||||||
|
this seam's.** `KeyEvent` carries a `pub(crate) platform_specific`
|
||||||
|
field (`winit-0.30.13/src/event.rs:655`), so **no
|
||||||
|
`WindowEvent::KeyboardInput` can be constructed outside winit** and no
|
||||||
|
headless test can feed one to the router. P1's mutation — misroute a
|
||||||
|
family, fail that family's row — is therefore unavailable for the
|
||||||
|
**keyboard** family alone.
|
||||||
|
|
||||||
|
Three things bound it, so it is a measured exception rather than a
|
||||||
|
blanket one:
|
||||||
|
|
||||||
|
- **The exception does not extend to the pointer families.** Winit
|
||||||
|
provides `DeviceId::dummy()` for exactly this purpose, and
|
||||||
|
`CursorMoved` / `MouseInput` / `MouseWheel` are constructible. Checked
|
||||||
|
before the exception was written down; all three are witnessed.
|
||||||
|
- **What stays unwitnessed is one pattern arm with no logic in it.** The
|
||||||
|
family's only decision — a press is acted on, a release is claimed and
|
||||||
|
discarded — is factored into `route_key_action(ElementState) ->
|
||||||
|
KeyAction`, which takes a constructible argument and is witnessed
|
||||||
|
directly, with both misroute mutations failing that row alone.
|
||||||
|
- **P3 is now measured, not assumed.** Replacing `window_event`'s entire
|
||||||
|
body with `let _ = (event_loop, event);` — a GUI that responds to no
|
||||||
|
input at all — leaves **every `pmacs-gpu` test green**. That is the
|
||||||
|
exception's true extent: no headless test anywhere in the crate
|
||||||
|
observes the delegation. **Re-measured at the current shape after
|
||||||
|
revision 11: 265/265 under `PMACS_REQUIRE_GPU=1`** (it was 256 before
|
||||||
|
the effect rows existed, and the number is re-run rather than carried
|
||||||
|
forward). Revision 11 also shrinks what the exception *covers*:
|
||||||
|
`window_event` is four lines, so the unwitnessed residue is one `if`
|
||||||
|
rather than a 33-line match.
|
||||||
|
|
||||||
|
**Revision 11 — P2 IS IMPLEMENTED AS WRITTEN. Revision 10's argument
|
||||||
|
here was wrong and is retracted.**
|
||||||
|
|
||||||
|
Revision 10 claimed a route-classification transcript covered both
|
||||||
|
halves of P2 because a route "names its local effect". **The wheel
|
||||||
|
falsifies that.** A wheel route carries a delta; whether that delta
|
||||||
|
becomes a viewport update, a panel event, a terminal event or nothing at
|
||||||
|
all depends on `State`. The route names the *family*, and only running
|
||||||
|
the body names the *effect* — so classification could not have
|
||||||
|
satisfied P2, and arguing that it did was a narrowing wearing the
|
||||||
|
costume of a mechanism.
|
||||||
|
|
||||||
|
P2 now has a second harness beside the routing one:
|
||||||
|
|
||||||
|
- **`EffectHarness` drives production end to end** — a real
|
||||||
|
`AttachClient` over a `socketpair` (real handshake, outbox, writer
|
||||||
|
thread, encoder, so the transcript is the wire), a real windowless
|
||||||
|
`State`, and `App::dispatch_window_event`.
|
||||||
|
- **`App::dispatch_window_event` is what made this reachable.** Left
|
||||||
|
inside `window_event`, the dispatch would force a harness to
|
||||||
|
re-implement it, and a harness that re-implements what it tests
|
||||||
|
witnesses its own copy. **P3 therefore narrows from a 33-line match to
|
||||||
|
a single `if`**: `window_event` is now `call dispatch, exit if it
|
||||||
|
asks`.
|
||||||
|
- **Local effects are read where they land**: exit from the returned
|
||||||
|
`EventOutcome`, redraw from a test-only `render_calls`, resize from
|
||||||
|
the surface config, modifiers from `App`, scroll from `scroll_top`.
|
||||||
|
- **Steps are delimited by a non-coalesceable sentinel key, not a
|
||||||
|
sleep** — "this step sent nothing" is otherwise undecidable without
|
||||||
|
waiting, and a fixed-duration wait against a writer thread is the
|
||||||
|
core-count assumption of PR #235's CI red.
|
||||||
|
- **The rows never skip.** A missing wgpu adapter is an assertion
|
||||||
|
failure; mutation M21 confirms all nine effect rows fail loudly while
|
||||||
|
the thirteen GPU-free routing rows stay green.
|
||||||
|
|
||||||
|
`M22` (blind to outbound) and `M23` (blind to local) fail rows in both
|
||||||
|
directions, which is P2's contract executable rather than asserted.
|
||||||
|
|
||||||
|
The routing rows stay, and the division of labour is deliberate: the
|
||||||
|
routing harness answers *where did this event go*, the effect harness
|
||||||
|
answers *what did it do*. **P2 is owned by the effect rows.** The
|
||||||
|
routing transcript row is only the sole owner of the *routing*
|
||||||
|
harness's own recording, which is what keeps that one mutation
|
||||||
|
surgical — revision 10 claimed it owned P2, and it never did.
|
||||||
|
|
||||||
|
**One further correction revision 11 carries, and it is the design
|
||||||
|
consequence 1a inherits.** Revision 10 stated that Stage 1a's A4 would
|
||||||
|
leave `EventOutcome` with a single variant and that the type should go
|
||||||
|
with the Escape branch. **Both are wrong.**
|
||||||
|
|
||||||
|
`EventOutcome` has **two producers today**: `LifecycleRoute::Exit`, a
|
||||||
|
native window close that must always exit, and `apply_keyboard`'s idle
|
||||||
|
Escape, a local quit. **A4 removes the keyboard one**, leaving **exactly
|
||||||
|
one `Exit` producer** — the native close.
|
||||||
|
|
||||||
|
**One producer is not one variant.** The type survives because
|
||||||
|
`dispatch_window_event` still has to distinguish `Continue` from
|
||||||
|
`Exit` on every event it handles: the overwhelming majority of
|
||||||
|
dispatches must *not* exit, and the native close must. What A4 actually
|
||||||
|
removes is `apply_keyboard`'s need to return an outcome at all, which is
|
||||||
|
a change to that one signature rather than to this type.
|
||||||
|
|
||||||
|
The crate has **exactly one** executable `event_loop.exit()`, in
|
||||||
|
`window_event`.
|
||||||
|
|
||||||
|
### 1a — `TextInput` (v24)
|
||||||
|
|
||||||
|
| # | Contract | Witness (fails today because) | Mutation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A1 | `F1`–`F35` → `F(1..=35)` | `_ => return None` | map `F13+` → `None` → F13–F35 rows |
|
||||||
|
| A2 | Shift+Tab → `BackTab` with `Shift` set | produces `Tab` | drop `Shift` → A2 only |
|
||||||
|
| A3 | `ContextMenu` → `Menu` | produces nothing | map to `Char('\0')` → A3 only |
|
||||||
|
| A4 | Idle Escape reaches the daemon, never exits | exits (`main.rs:2771`) | restore the quit branch → A4 only |
|
||||||
|
| A5 | Precedence per §5 (1–8) | multi-scalar truncated; IME ignored | move rule 1 (control text → text) → the `Enter`-in-dired row |
|
||||||
|
| A6 | One commit = one edit, undo unit, hook, eligible CRDT op | commit truncated to one scalar | one edit per scalar → undo-unit row (**and D3 surfaces here**) |
|
||||||
|
| A7 | Prompts consume scalars **in order** | multi-scalar never arrives | reverse order → A7's prompt transcript |
|
||||||
|
| A8 | Terminals get **raw UTF-8, never bracketed paste** | multi-scalar never arrives | route via `Paste` → terminal row shows bracket markers |
|
||||||
|
| A9 | **64 KiB cap; oversize rejected, not truncated** | no cap exists | truncate instead → the oversize row observes silent loss |
|
||||||
|
|
||||||
|
### 1b — pointer and scroll
|
||||||
|
|
||||||
|
| # | Contract | Witness | Mutation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B1 | Residual per **axis and surface** | deltas discarded | share one accumulator → surface-switch jump |
|
||||||
|
| B2 | Wheel-right raises leftmost column; wheel-down raises top line | `x` discarded | invert a sign → that axis's row |
|
||||||
|
| B3 | Clamps at content bounds; never a negative origin | no horizontal scroll to clamp | remove clamp → **at-bounds row: origin goes negative and the view blanks** |
|
||||||
|
| B4 | Middle-click paste uses **PRIMARY on Linux** | no middle-click path | use `CLIPBOARD` → B4 only |
|
||||||
|
| B5 | I-beam over text content only | no I-beam | extend over the gutter → B5 only |
|
||||||
|
| B6 | Wheel over the minimap scrolls the **document viewport** with **its own residual accumulator**; click/drag remains scrub | **a FULL tick already scrolls today** — minimap pixels are `Elsewhere` (`main.rs:2061`) and the wheel falls through to `scroll_by_lines` (`main.rs:3373`). What fails is **fractional accumulation**, and **residual ownership distinct from the document's**: sub-tick minimap deltas are discarded, and a **surface-switch fractional witness** (part-tick over the minimap, then over the document) must not carry residue across | share the document's accumulator → the surface-switch fractional row jumps |
|
||||||
|
| B7 | **TUI horizontal**: **three columns per wheel tick**, sign per B2; **left origin clamps at 0 and at (widest display-line width − text viewport width), SATURATING AT ZERO**; **wrap pins the origin to 0** | events arrive at `:3203` and are dropped | step of one → the three-column row; clamp at the widest line's **full width** → **the right-bound row blanks the viewport**; drop the wrap guard → the wrap row scrolls a wrapped buffer sideways |
|
||||||
|
|
||||||
|
**B7's right bound corrected.** Clamping at the widest line's full width
|
||||||
|
lets the origin pass every glyph and leave the viewport **entirely
|
||||||
|
blank**. The bound is *width − viewport*, saturating at zero for buffers
|
||||||
|
narrower than the viewport, and **the right-bound witness asserts the
|
||||||
|
final display column is still visible**.
|
||||||
|
|
||||||
|
**Why B6 changed — and revision 5's reason was wrong.** Scrubbing on
|
||||||
|
wheel is not *impossible*: the wheel handler already reads the cached
|
||||||
|
`state.pointer_pos` for surface routing (`main.rs:3337`), so an absolute
|
||||||
|
target is available. It is the **wrong semantics**: a wheel is a
|
||||||
|
**relative** gesture, and mapping relative ticks onto an absolute
|
||||||
|
position would make one notch jump to wherever the pointer happens to
|
||||||
|
rest. Click and drag remain scrub because those *are* absolute.
|
||||||
|
|
||||||
|
### 1c — session and window signals
|
||||||
|
|
||||||
|
| # | Contract | Witness | Mutation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C1 | Title `"<buffer> — pmacs"`, `"pmacs"` when unnamed | title is static | drop the name → C1 only |
|
||||||
|
| C2 | **Visible bell: 120 ms, WHOLE CLIENT AREA visibly changes**; repeats **neither queue nor extend** the first deadline | `Bell` unconsumed | let repeats extend → the repeat row's flash outlasts 120 ms; flash a sub-region → the headless render witness cannot see it |
|
||||||
|
| C3 | `Goodbye` names the daemon's reason, else an explicitly **locally-classified** transport/EOF reason; never blank | live-loop reason discarded | blank the fallback → EOF row |
|
||||||
|
| C4 | `FocusLost` precedes `Detach`; `FocusGained` only after attach completes | `Focused` unhandled | swap → C4 only |
|
||||||
|
| C5a | **Local DPI correctness**: at scale 1→2 with **unchanged logical size**, logical wrapping, row count and hit testing are **stable** while **physical pixels double** — glyphs, clips, caret, hit tests and overlays all rescale | `scale: 1.0` hardcoded (`main.rs:8950`) | rescale glyphs only → **caret, clip and hit-test rows fail while text still looks right**, which is the bug this splits out |
|
||||||
|
| C5b | **Geometry declaration**: the epoch advances and `FrontendCellGeometry` is emitted | no `ScaleFactorChanged` arm | suppress the emit → C5b only, **C5a still passing** |
|
||||||
|
| C6 **[P]** | `initial_size` = `SEMANTIC_BOOTSTRAP_GRID` (24×80), **never** frame or panel authority | **passes today** — this codifies current daemon behaviour; the witness pins that a semantic frame/panel is sized from `FrontendCellGeometry` alone | derive a frame or panel extent from `initial_size` → the panel-authority row |
|
||||||
|
| C7 | Close contract — §7 | see §7 | see §7 |
|
||||||
|
|
||||||
|
**C5 was one row and hid half the defect** — suppressing the wire emit
|
||||||
|
says nothing about glyphs or hit tests staying at scale 1.
|
||||||
|
|
||||||
|
### 1d — IME
|
||||||
|
|
||||||
|
| # | Contract | Witness | Mutation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D1 | `set_ime_allowed(true)` | zero occurrences — **no composition arrives at all** | omit → **every 1d row (stated dependency, not a matrix defect)** |
|
||||||
|
| D2 | Preedit overlay with caret and selection; **indices are BYTE OFFSETS** into the preedit string | no overlay | treat as char indices → multibyte row |
|
||||||
|
| D3 | `Ime::Commit` emits A5's `TextInput` | commit ignored | emit per-scalar `Key`s → **A6's undo row (named dependency)** |
|
||||||
|
| D4 | **`set_ime_cursor_area` updated** after caret motion, scroll, resize, font/DPI change, and every preedit change | never called → candidates misplaced | update on caret only → scroll and resize rows |
|
||||||
|
| D5 | Overlay clears on **empty `Preedit`, `Ime::Disabled`, and focus loss** — all three | no overlay | clear on focus loss only → `Disabled` row leaves stale text |
|
||||||
|
| D6 | Dead-key state owned here; 1a buffers nothing | dead keys dropped | buffer in 1a → D6 by construction |
|
||||||
|
|
||||||
|
### 1e — `OpenTarget` (v25)
|
||||||
|
|
||||||
|
| # | Contract | Witness (fails today because) | Mutation |
|
||||||
|
|---|---|---|---|
|
||||||
|
| E0 | **SUCCESS**: a **successfully handled valid target** is **resolved, installed, hooked and dispatched through the existing file/directory pipeline**, and the originating frontend receives **exactly one terminal result** — `Opened { request_id, buffer_id }` when a commit lands, **or `Handled` when an extension legitimately claims it** | no receiver — a drop does nothing | dispatch without firing the open hooks → the hook row; settle before the deferred commit lands → the async-directory row (Q#S1-10) |
|
||||||
|
| E1 | Versioned `OpenTarget { request_id: u64, cwd, path }` carrying `InitialTarget`'s raw shape. **`request_id` is unique among a frontend's OUTSTANDING requests; a duplicate is REJECTED at the protocol boundary** and must never replace or settle the original completion | no variant exists | **omit `request_id`** → the two-concurrent-drops row misattributes; **accept a duplicate id** → the reuse row settles the first drop's completion with the second drop's outcome |
|
||||||
|
| E2 | Source is **authenticated** | no receiver | accept an unauthenticated sender → E2's forged-source row |
|
||||||
|
| E3 | Primary document `ViewDestination` captured **immediately on receipt**, before any await | no receiver | capture after the open resolves → **frontend-switch row: the file lands in whichever frontend is ambient** (the #231 defect) |
|
||||||
|
| E4 | **No window identity trusted from the wire** | no receiver | accept a window id → E4's forged-window row |
|
||||||
|
| E5 | Failures **before terminal disposition** — including completion-aware `Deferred` work — are **visible to the ORIGINATING frontend** as bounded `Failed { request_id, message }`. After `Handled`, responsibility and any later failure belong to the claiming extension, and no second result is sent — see §8 | no receiver | swallow the error → the permission and embedded-NUL rows |
|
||||||
|
| E6 | `InitialTarget` limits enforced: **32 KiB per raw path**, **non-empty path**, **absolute non-empty cwd**, **embedded NUL rejected** | no receiver | drop the NUL check → the embedded-NUL row |
|
||||||
|
|
||||||
|
**The failure taxonomy was wrong in revision 5.** **A missing path is
|
||||||
|
NOT a failure**: the resolver deliberately creates an **empty
|
||||||
|
path-backed buffer** on `NotFound` (`src/editor_core.rs:1177`), which is
|
||||||
|
how "open a file that does not exist yet" works. **Directories are valid
|
||||||
|
targets too.** Genuine failures are permission denial, validation
|
||||||
|
rejection (E6), and open errors that are not `NotFound`. Revision 5
|
||||||
|
listed "missing-path" and "not-a-directory" rows that would have pinned
|
||||||
|
the opposite of the intended behaviour.
|
||||||
|
|
||||||
|
### Q#S1-10 — RULED: the result is TERMINAL
|
||||||
|
|
||||||
|
Directory opening completes **asynchronously**, so `OpenTargetResult`
|
||||||
|
either waits for the captured-destination commit or merely acknowledges
|
||||||
|
dispatch. **Ruling: terminal.** The result is sent **when the request
|
||||||
|
reaches a terminal disposition** — for `Opened`, after the commit
|
||||||
|
resolves; for `Handled`, at the moment responsibility transfers; for
|
||||||
|
`Failed`, at the failure, **which for several paths has no commit at
|
||||||
|
all**. Asynchronous failure is reported through the same result — an acknowledgement-plus-later-channel design would need a
|
||||||
|
second source-scoped mechanism to carry exactly the failures that matter
|
||||||
|
most.
|
||||||
|
|
||||||
|
```
|
||||||
|
OpenTargetResult::Opened { request_id: u64, buffer_id: BufferId }
|
||||||
|
OpenTargetResult::Handled { request_id: u64 } // claimed; no buffer attributable
|
||||||
|
OpenTargetResult::Failed { request_id: u64, message: String } // 4 KiB cap
|
||||||
|
```
|
||||||
|
|
||||||
|
**Terminal completion must be TOTAL over the existing pipeline, and
|
||||||
|
"after the commit resolves" is not** — three legitimate paths reach
|
||||||
|
neither a commit nor a result:
|
||||||
|
|
||||||
|
- **Claimed**: a `path.open-directory` listener returns `proceed =
|
||||||
|
false` and the dispatch **returns without committing**
|
||||||
|
(`src/editor.rs:1375`).
|
||||||
|
- **Disabled**: the `directory_handler` slot is clear, which is a
|
||||||
|
supported configuration — the dispatch emits a **status message and no
|
||||||
|
commit** (`src/editor.rs:1389`).
|
||||||
|
- **Asynchronous fallback**: the default handler calls `open_async` and
|
||||||
|
**returns immediately** (`builtin/runtime/dired.lua:750`), so the
|
||||||
|
commit lands long after the dispatch unwinds.
|
||||||
|
|
||||||
|
**Mechanism: a request-scoped ONE-SHOT COMPLETION with an EXPLICIT
|
||||||
|
STATE MACHINE**, carried through the directory pipeline, settled
|
||||||
|
**exactly once**, and **discarded on source detach** — a frontend that
|
||||||
|
left cannot be told anything.
|
||||||
|
|
||||||
|
```
|
||||||
|
Pending ──(defer before scheduling)──▶ Deferred ──▶ Settled
|
||||||
|
│ │
|
||||||
|
└──────────(commit / failure)────────┴──▶ Settled
|
||||||
|
│
|
||||||
|
└──(source detach, from Pending or Deferred)──▶ Cancelled
|
||||||
|
```
|
||||||
|
|
||||||
|
**`open_async` transitions to `Deferred` BEFORE handing the completion
|
||||||
|
to scheduled work.** The ownership transfer is explicit and does not
|
||||||
|
depend on whether today's scheduler starts a coroutine synchronously or
|
||||||
|
a future scheduler defers its first step. Without the transition the
|
||||||
|
dispatch unwinds while the completion is still `Pending`, the
|
||||||
|
end-of-turn fallback fires, and the request is settled *before* the
|
||||||
|
commit it was waiting for.
|
||||||
|
|
||||||
|
**The end-of-turn fallback acts ONLY on `Pending`.** A `Deferred`
|
||||||
|
completion is owned by the scheduled work. **Any later settlement is
|
||||||
|
exactly-once**: a second attempt against `Settled` or `Cancelled` is a
|
||||||
|
no-op, never a second message.
|
||||||
|
|
||||||
|
*Mutation:* omit the `Deferred` transition, or delay it until after the
|
||||||
|
end-of-turn fallback → the async-directory row receives a premature
|
||||||
|
`Handled`/`Failed`; exactly-once settlement then suppresses the later
|
||||||
|
`Opened` attempt when the commit lands.
|
||||||
|
|
||||||
|
`open_async` carrying the completion to the commit is what makes the
|
||||||
|
asynchronous path terminal rather than silent.
|
||||||
|
|
||||||
|
**Total disposition, so no path can fall through:**
|
||||||
|
|
||||||
|
| path | settles as |
|
||||||
|
|---|---|
|
||||||
|
| commit lands | `Opened { buffer_id }` |
|
||||||
|
| listener **claimed** and did not settle | `Handled` |
|
||||||
|
| **replacement handler** ran and did not settle | `Handled` |
|
||||||
|
| handler slot **disabled** | `Failed` naming that directory opening is disabled |
|
||||||
|
| **synchronous error** (listener raised, validation, permission) | `Failed` with the reason |
|
||||||
|
| pipeline unwinds **still `Pending`** at end of dispatch turn | `Handled` if a listener claimed or a replacement handler ran; **otherwise `Failed`** |
|
||||||
|
| **source detached** before settlement | discarded — nothing is sent, and the completion is dropped rather than leaked |
|
||||||
|
|
||||||
|
**`Handled` exists because `Opened` cannot be honest there.** A claim
|
||||||
|
means a user listener took responsibility and **no buffer is
|
||||||
|
attributable**; reporting `Opened` would require inventing a
|
||||||
|
`buffer_id`, and reporting `Failed` would mislabel a supported
|
||||||
|
extension point as an error. `Handled` is the terminal responsibility
|
||||||
|
transfer: any later extension-owned failure uses the extension's own
|
||||||
|
reporting surface and cannot emit a second `OpenTargetResult`.
|
||||||
|
|
||||||
|
*Witness:* one case per **live-source** row, each asserting **exactly
|
||||||
|
one** result. **The detach row asserts the opposite and must not be
|
||||||
|
read as "one result":** zero messages sent, **no completion retained**,
|
||||||
|
and a later settlement attempt **ignored** rather than delivered.
|
||||||
|
*Mutation:* drop the end-of-turn fallback → the claimed and disabled
|
||||||
|
rows hang with no result at all, which is the defect this ruling
|
||||||
|
closes.
|
||||||
|
|
||||||
|
`message` uses the **existing 4 KiB error cap**. **Both affected enums
|
||||||
|
get an independent frozen-byte pin on their own preceding final
|
||||||
|
variant** — `FrontendEvent` for `OpenTarget`, `InstanceMessage` for
|
||||||
|
`OpenTargetResult` — because an appended variant's own round-trip cannot
|
||||||
|
detect a discriminant shift in either.
|
||||||
|
|
||||||
|
## 7. The close contract
|
||||||
|
|
||||||
|
`send_event` only enqueues (`attach.rs:1145`); the writer takes a batch
|
||||||
|
and releases the lock before blocking writes (`:671`); **`enqueue`
|
||||||
|
returns `false` once closed** (`:414`).
|
||||||
|
|
||||||
|
**So revision 4's order was literally unexecutable** — closing first
|
||||||
|
would have rejected the `Detach` it then tried to enqueue.
|
||||||
|
|
||||||
|
**Contract — state inspection plus any append/transition is ONE atomic
|
||||||
|
critical section under a single lock hold, dispatched on the outbox
|
||||||
|
STATE. The lock is released before notifying the writer, waiting on an
|
||||||
|
acknowledgement, returning, or shutting down.** Holding it while waiting
|
||||||
|
would prevent the writer from taking the suffix or transitioning to
|
||||||
|
`Drained`. The four states are tabulated below; this paragraph is their
|
||||||
|
normative statement, and revision 6's "append `Detach` /
|
||||||
|
already-closed → fallback" wording is superseded:
|
||||||
|
|
||||||
|
- **Open** → under the lock, append the **complete suffix** (`FocusLost`
|
||||||
|
when currently focused, then `Detach`), transition to `Sealed`, and
|
||||||
|
capture its acknowledgement handle; release the lock, wake the writer,
|
||||||
|
then wait.
|
||||||
|
- **Sealed** → under the lock, capture the **existing acknowledgement**;
|
||||||
|
release the lock, then join it. Do not re-append, do not seal again,
|
||||||
|
do not shut down.
|
||||||
|
- **Drained** → release the lock, then return immediately.
|
||||||
|
- **Failed** → release the lock, then take the socket-shutdown fallback.
|
||||||
|
|
||||||
|
**An ordinary post-seal `send_event` REJECTS and does nothing else** —
|
||||||
|
it must not invoke shutdown, because a healthy drain is in flight.
|
||||||
|
**`Detach` is exempt from coalescing.** **Wake the writer after
|
||||||
|
append-and-seal**, or
|
||||||
|
a sealed outbox with a pending `Detach` waits on a condvar nobody
|
||||||
|
signals and the 250 ms bound expires on a daemon that was reading fine.
|
||||||
|
|
||||||
|
**The exactly-full case, which revision 5 left undefined.**
|
||||||
|
`OUTBOX_MAX` is **8192**, and a queue at exactly that length is a
|
||||||
|
**valid OPEN state**: the *next* ordinary `enqueue` both **sets
|
||||||
|
`closed`** and **rejects the event** (`attach.rs:427`). So terminal
|
||||||
|
close must not go through the ordinary path. **Ruling: the terminal operation appends the whole required SUFFIX
|
||||||
|
atomically** — **`FocusLost` when currently focused, then `Detach`** —
|
||||||
|
reserving **at most `OUTBOX_MAX + 2`**.
|
||||||
|
|
||||||
|
**One slot was not enough, and C4 is why.** C4 requires `FocusLost`
|
||||||
|
before `Detach`; at exactly `OUTBOX_MAX` an *ordinary* `FocusLost`
|
||||||
|
enqueue is **rejected and sets `closed`**, so the terminal append would
|
||||||
|
then find a closed outbox and fall back — losing both events. Revision 6
|
||||||
|
reserved one slot and so contradicted a contract two sections above it.
|
||||||
|
|
||||||
|
**Exact-cap witness:** fill to exactly `OUTBOX_MAX` **while focused**,
|
||||||
|
then close. The transcript ends **`… FocusLost, Detach`**, the writer is
|
||||||
|
woken, and acknowledgement precedes exit. *Mutation:* reserve one slot →
|
||||||
|
`FocusLost` is dropped and C4's ordering row fails at exact cap only.
|
||||||
|
|
||||||
|
**Outbox states — clean SEALED is not failed CLOSED.** Revision 6 had
|
||||||
|
one `closed` flag doing both jobs, so a duplicate close or a post-seal
|
||||||
|
send would see "closed" and **invoke the socket-shutdown fallback,
|
||||||
|
aborting the drain it should have joined**. Four states, with distinct
|
||||||
|
behaviour:
|
||||||
|
|
||||||
|
| state | ordinary enqueue | close called again |
|
||||||
|
|---|---|---|
|
||||||
|
| **Open** | ordinary policy: accepted below the cap; a cap-crossing lossless append is rejected and transitions to `Failed` | performs the terminal append-and-seal |
|
||||||
|
| **Sealed** (suffix appended, drain in flight) | rejected | **waits on the existing acknowledgement** — never re-seals, never falls back |
|
||||||
|
| **Drained** (acknowledged) | rejected | returns immediately |
|
||||||
|
| **Failed** (overflow, or transport error) | rejected | **takes the socket-shutdown fallback** |
|
||||||
|
|
||||||
|
*Mutation:* collapse `Sealed` into `Failed` → the duplicate-close witness
|
||||||
|
aborts a healthy drain and exits before `Detach` is written.
|
||||||
|
|
||||||
|
**Acknowledgement point:** the batch containing `Detach` **fully written
|
||||||
|
and flushed to the socket**. **Bound: 250 ms.**
|
||||||
|
|
||||||
|
**Two witnesses, mutually exclusive:**
|
||||||
|
|
||||||
|
- **Responsive reader** — all preceding lossless events **and** `Detach`
|
||||||
|
are written, acknowledgement occurs, **then** exit. *Mutation:* drop
|
||||||
|
the drain → exit-before-`Detach` is observable.
|
||||||
|
- **Stalled reader** — the deadline fires, socket shutdown yields EOF
|
||||||
|
cleanup, the frontend **exits anyway**. *Mutation:* remove the bound
|
||||||
|
→ this test hangs.
|
||||||
|
|
||||||
|
*Seal mutation:* permit a late enqueue → an event appears after
|
||||||
|
`Detach` in the responsive transcript.
|
||||||
|
|
||||||
|
## 8. Wire contracts
|
||||||
|
|
||||||
|
| | **1a — `TextInput`** | **1e — `OpenTarget` + `OpenTargetResult`** |
|
||||||
|
|---|---|---|
|
||||||
|
| **floor** | **v24** | **v25**, after v24, serialized |
|
||||||
|
| **encoding** | **appended variant**; never widen a field in place — postcard is positional | appended variants |
|
||||||
|
| **byte pin** | frozen-byte fixture on the **previous final variant** | same |
|
||||||
|
| **gate** | daemon accepts from `>= 24`; producer withholds below | `>= 25`; producer withholds below |
|
||||||
|
| **old peer** | a `< 24` frontend **retains its existing `Key` behaviour and its existing limitations** — it truncates multi-scalar input today and ignores IME, and continues to. **The guarantee is NO REGRESSION, not retroactive correctness** | a `< 25` frontend cannot drop-open; nothing it already had degrades |
|
||||||
|
| **bounds** | **64 KiB** UTF-8; oversize **rejected** | **32 KiB** per raw path; non-empty path; absolute non-empty cwd; **embedded NUL rejected**; `Failed.message` capped at the **existing 4 KiB** error cap |
|
||||||
|
| **pins** | frozen bytes on `FrontendEvent`'s previous final variant | **two independent pins** — `FrontendEvent` for `OpenTarget`, `InstanceMessage` for `OpenTargetResult` |
|
||||||
|
|
||||||
|
**E5's delivery mechanism.** `StatusFacts.message` is **global and can
|
||||||
|
be cleared before the originating frontend observes it**, so it cannot
|
||||||
|
carry this. **1e adds a source-scoped `OpenTargetResult`, correlated by
|
||||||
|
a frontend request ID**, so a failure reaches the frontend that dropped
|
||||||
|
the file and no other.
|
||||||
|
|
||||||
|
## 9. Coherence impact (§20)
|
||||||
|
|
||||||
|
- **Journey steps**: **5**; **3** (1e); **6(e)** on the GPU column.
|
||||||
|
- **Islands**: Escape ceasing to be a local quit **removes** one;
|
||||||
|
Q#S1-7 adds none → the census **falls by one**.
|
||||||
|
- **Config registry**: none. The bell's 120 ms is a constant.
|
||||||
|
- **Background work**: **1e adds no new worker**, but it **attributes
|
||||||
|
the existing asynchronous directory operation to an originating
|
||||||
|
frontend and request** until terminal settlement — the one-shot
|
||||||
|
completion is that attribution — **and drops it on source detach**.
|
||||||
|
That is §9's ownership question appearing in miniature, and it is
|
||||||
|
answered here for this one operation rather than in general.
|
||||||
|
|
||||||
|
## 10. Rulings
|
||||||
|
|
||||||
|
**Q#S1-1** native close detaches, `editor.quit` shuts down the daemon
|
||||||
|
and its attachments, Escape only cancels/round-trips · **Q#S1-5** A/`1e`
|
||||||
|
· **Q#S1-6** B/`TextInput` · **Q#S1-7** Meta/Super → Stage 2, arc §2.5
|
||||||
|
and the backlog amended by 1-pre's first PR · **Q#S1-8** (A),
|
||||||
|
`SEMANTIC_BOOTSTRAP_GRID` · **Q#S1-9** precedence per §5 · **Q#S1-10** terminal `OpenTargetResult`.
|
||||||
|
|
||||||
|
## 11. Gates
|
||||||
|
|
||||||
|
`./scripts/gate --acceptance gpu_invocation_acceptance` plus touched
|
||||||
|
input suites, and `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`.
|
||||||
|
**`--protocol` for 1a and 1e only.**
|
||||||
|
|
@ -193,9 +193,12 @@ external-change detection (verify-modtime-while-open, revert?).
|
||||||
|
|
||||||
## GPU frontend mechanics (non-theme)
|
## GPU frontend mechanics (non-theme)
|
||||||
|
|
||||||
- **Input:** full command/minibuffer chord forwarding to the GUI,
|
- **Input:** ~~full command/minibuffer chord forwarding to the GUI~~
|
||||||
Meta/Super chords, rebindable local `Ctrl-V`/`Escape`, middle-click
|
(**shipped**, `bc32332`), **Meta/Super chords — now GUI arc Stage 2**,
|
||||||
paste, right-click context menu, frontend-local provisional selection.
|
not Stage 1a (Q#S1-7, 2026-08-11: forwarding them before
|
||||||
|
capability-aware policy exists would capture platform Command/Super
|
||||||
|
shortcuts), rebindable local `Ctrl-V`/`Escape`, middle-click paste,
|
||||||
|
right-click context menu, frontend-local provisional selection.
|
||||||
- **Minibuffer:** `i/total` hint (already on the wire), Telescope-style
|
- **Minibuffer:** `i/total` hint (already on the wire), Telescope-style
|
||||||
preview pane, candidate kind/doc annotations, unify TUI inline vs GPU
|
preview pane, candidate kind/doc annotations, unify TUI inline vs GPU
|
||||||
dropdown, multibyte-exact band caret, the nav highlight-wrap bug.
|
dropdown, multibyte-exact band caret, the nav highlight-wrap bug.
|
||||||
|
|
|
||||||
|
|
@ -1169,6 +1169,24 @@ impl AttachClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a real [`AttachClient`] over an already-connected stream, for
|
||||||
|
/// the GUI 1-pre effect harness in `main.rs`.
|
||||||
|
///
|
||||||
|
/// The harness needs to observe the protocol messages a dispatch
|
||||||
|
/// actually produces. Faking the client would only witness the fake, so
|
||||||
|
/// it drives the **real** handshake, outbox, writer thread and encoder
|
||||||
|
/// over a `socketpair`, and reads the encoded `FrontendEvent`s off the
|
||||||
|
/// other end. This wrapper exists solely because
|
||||||
|
/// [`connect_stream_with_sink`] is private to this module and the
|
||||||
|
/// harness is a sibling; it adds no behaviour of its own.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn connect_stream_for_test(
|
||||||
|
stream: UnixStream,
|
||||||
|
sink: impl Fn(AttachEvent) -> bool + Send + 'static,
|
||||||
|
) -> Result<AttachClient, AttachClientError> {
|
||||||
|
connect_stream_with_sink(stream, None, sink)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue