From 542d9b9c945b22cf9134a4a643c8dc763b34607d Mon Sep 17 00:00:00 2001 From: fy59 Date: Mon, 12 Oct 2026 09:12:05 +0200 Subject: [PATCH] docs(mosaic): preserve event-loop pending input probe evidence --- CHANGELOG.md | 1 + docs/ARCHITECTURE.md | 11 + docs/KNOWN_LIMITATIONS.md | 9 + docs/README.md | 5 +- docs/ROADMAP.md | 10 + .../STEP20C_EVENT_LOOP_PENDING_INPUT_PROBE.md | 228 +++++++++++++++++ .../README.md | 40 +++ .../analyze.py | 230 ++++++++++++++++++ .../client-flush.patch | 27 ++ .../prepare.sh | 22 ++ .../step20c-event-loop-pending-input/run.py | 106 ++++++++ 11 files changed, 688 insertions(+), 1 deletion(-) create mode 100644 docs/design/STEP20C_EVENT_LOOP_PENDING_INPUT_PROBE.md create mode 100644 tooling/probes/step20c-event-loop-pending-input/README.md create mode 100644 tooling/probes/step20c-event-loop-pending-input/analyze.py create mode 100644 tooling/probes/step20c-event-loop-pending-input/client-flush.patch create mode 100644 tooling/probes/step20c-event-loop-pending-input/prepare.sh create mode 100644 tooling/probes/step20c-event-loop-pending-input/run.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a51f7ea..892b335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### STEP20C experimental evidence +- Preserved and independently replayed a bounded event-loop pending-input probe. The client flush and server application were observed after decoupled request submission, but no input mutation fell between a matching native headless commit and present in five attempts per private mode. The temporal case remains INCONCLUSIVE / NOT EXERCISED; this research line is closed for the tested backend and geometry. No production or package files changed. - Preserved and independently replayed a commit/present/input ordering probe. A passive private top-level listener and native headless traces found 20 applied input-region mutations but no natural mutation between an accepted output commit and its matching present; sampled hit and client delivery followed effective input. The temporal verdict is INCONCLUSIVE and the generation-contract decision remains UNDECIDED. A complete outside-Git raw evidence manifest now covers all 94 run/result files. No production or package files changed. - Preserved a reproducible mutation-coverage fixture and source audit. A fresh-clone replay rebuilt private SceneFX/Mosaic and verified pinned wlroots sources in its own lab. Bounded trace-only runs separated camera/clip, popup lifecycle, same-size client content and input-region hits; the latter changed hit testing at an unchanged partial epoch. A real private output-destroy path released one held reference, while backend rejected-commit cleanup remains untested. The architectural generation decision is UNDECIDED; STEP20C stays NEXT. - Preserved a private applied-scene/input probe. A separate corrective patch scopes retained output buffers to their owner, samples three buffers through present, and logs partial applied-scene epochs at build and real pointer hits with client delivery kept separate. The tested matrix is PARTIALLY_FEASIBLE; STEP20C remains NEXT and multi-output Mosaic unsupported. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7554902..2806b4a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -184,6 +184,17 @@ generation contract. A later hit may legitimately use newer input than a previously committed output buffer; identity of these states must remain distinct in further probes. +The follow-up private [event-loop pending-input probe](design/STEP20C_EVENT_LOOP_PENDING_INPUT_PROBE.md) +submitted ordinary client region requests without waiting for each camera or +rendering IPC response. All 20 target mutations applied in two instrumented +modes, but none occurred between a matching native headless output commit +and present. This is **INCONCLUSIVE** for the unexercised temporal case; +neither a Mosaic clipping violation nor the need for an additional applied +generation was demonstrated. Client flush, server application, submitted +buffer and later hit remain separate identities. The temporal line is closed +for this backend/matrix after five attempts per mode, while the overall +cross-time architectural question remains **UNDECIDED**. + ## IPC On a Mosaic workspace, `get_tree` reports `layout: "mosaic"` and adds a `mosaic` object with `active_row` and `rows`. Each row entry contains ordered child node IDs, `scroll_x`, and focused node ID or null. Existing Sway fields retain their shapes; normal workspace/window events and `get_workspaces` paths remain in use. This is an extension to the Sway layout vocabulary: strict IPC consumers with a closed layout enum must recognize `mosaic`. Additional rows in the model are not a promise of usable multi-row IPC behavior. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 26eaa76..1275f85 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -141,6 +141,15 @@ rejected backend commit cleanup and broader output/effect paths remain **NOT TESTED**. Existing ordering sufficiency and any added-generation contract remain **UNDECIDED**. +The [pending-input event-loop follow-up](design/STEP20C_EVENT_LOOP_PENDING_INPUT_PROBE.md) +removed the runner's intermediate response waits during five bounded cycles +per private mode. It confirmed ten positive client flushes and ten applied +target input commits per mode, yet zero natural commit→mutation→present +interleavings. The sought same-interval hit and grabbed-popup variant are +**NOT TESTED**. The result remains **INCONCLUSIVE** for that temporal window; +repeat attempts on the same headless matrix are closed. Effects/damage, +scaling and resize/migration remain open STEP20C gates. + The [isolated feasibility probe](design/STEP20C_FEASIBILITY_PROBE.md) is **PARTIALLY FEASIBLE**, not a release qualification. On two horizontal headless outputs, real Mosaic content leaked visually into an empty adjacent output; that earlier disposable candidate also delivered pointer enter from a partly visible client outside its viewport. Negative-origin, scaled and rotated arrangements showed visual leakage in that candidate. Popups, effects, saved buffers and complete damage correctness remain unqualified across the full matrix. The later probes above narrow individual cases without qualifying multi-output Mosaic, which remains **NOT YET SUPPORTED**. The later [SceneFX subtree clipping spike](design/STEP20C_SCENEFX_CLIPPING_SPIKE.md) is also **PARTIALLY FEASIBLE**. Its private inherited tree clip stopped tested tiled pixels and pointer hits from crossing the output boundary, including a fully hidden cell, but a real GTK XDG popup rendered and received input on the neighboring output because its scene tree lives in the global popup layer. Dedicated subsurface, saved-buffer, nested-popup and complete effect/damage validation remain open. The spike changed no production library or compositor source; multi-output Mosaic remains **NOT YET SUPPORTED**. diff --git a/docs/README.md b/docs/README.md index f847f06..e7bc52c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,7 +37,9 @@ The [root README](../README.md) is the public summary. For a specific topic, use [Mutation source fixture](../tooling/probes/step20c-mutation-coverage/README.md) replays the disposable matrix without production integration. 21. [STEP20C commit/present/input ordering probe](design/STEP20C_COMMIT_PRESENT_INPUT_ORDERING_PROBE.md): independently replayed headless ordering evidence; 20 applied input-region mutations, no natural commit-to-present interleaving, temporal question INCONCLUSIVE. [Ordering source fixture](../tooling/probes/step20c-commit-present-input/README.md) rebuilds the passive private trace. -22. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence. +22. [STEP20C event-loop pending-input probe](design/STEP20C_EVENT_LOOP_PENDING_INPUT_PROBE.md): bounded asynchronous client submission and native callback order; no natural commit-to-present input mutation, INCONCLUSIVE. + [Pending-input source fixture](../tooling/probes/step20c-event-loop-pending-input/README.md) replays the disposable matrix. +23. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence. ## Project @@ -82,6 +84,7 @@ The [root README](../README.md) is the public summary. For a specific topic, use | [STEP20C applied state/input probe](design/STEP20C_APPLIED_STATE_INPUT_PROBE.md) | Experimental evidence | PARTIALLY_FEASIBLE; per-output holds survive cross-output repaint in the sampled matrix, and applied scene/hit/client streams are observed with a partial experimental epoch | | [STEP20C mutation coverage probe](design/STEP20C_MUTATION_COVERAGE_PROBE.md) | Experimental evidence | PARTIALLY_FEASIBLE overall; an input-region change alters the hit at unchanged partial epoch, client recolor changes pixels without geometry, and a held output reference is released on sampled output destruction; cross-time generation contract remains UNDECIDED | | [STEP20C commit/present/input ordering probe](design/STEP20C_COMMIT_PRESENT_INPUT_ORDERING_PROBE.md) | Experimental, independently replayed evidence | INCONCLUSIVE for the discriminating temporal window; headless input-region commits and hits agree in observed states, but none of 20 applied mutations occurred between a native output commit and its present | +| [STEP20C event-loop pending-input probe](design/STEP20C_EVENT_LOOP_PENDING_INPUT_PROBE.md) | Experimental, independently replayed evidence | INCONCLUSIVE / NOT EXERCISED for natural commit→input mutation→present despite decoupled client submission; the temporal line is closed for this backend/matrix | A proposal does not supersede accepted canonical contracts or assert implementation status. Its open feasibility items must be resolved before implementation. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 04a7e6b..a9d9245 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -134,6 +134,16 @@ next gate is passive event-loop phase tracing with a client request already pending during ordinary repaint, then a same-interval hit if naturally observable. STEP20C remains **NEXT** and multi-output **NOT YET SUPPORTED**. +The bounded [event-loop pending-input probe](design/STEP20C_EVENT_LOOP_PENDING_INPUT_PROBE.md) +decoupled normal client submission from IPC/client acknowledgements, then +stopped after five attempts in each private mode. All 20 target mutations +applied, with no matching commit-to-present interleaving. Close this +particular temporal line as **INCONCLUSIVE / NOT EXERCISED** for the tested +headless backend and fixed geometry. Do not infer an extra generation +contract or production readiness. The next separate STEP20C gate should +address complete effects/damage, scaling, or resize/migration. STEP20C stays +**NEXT** and multi-output Mosaic **NOT YET SUPPORTED**. + ## STEP20D — multi-row Spatial Canvas · PLANNED **Goal:** connect rows to runtime tree, transactions, scene, input, and IPC. **Planned deliverables:** active-row navigation, row-local scroll/focus, deterministic insertion/reflow, in-row reorder, and between-row movement. **Exit criteria:** real tiled containers preserve identity and order across row changes; focus and camera restore correctly; headless multi-row tests pass. **Docs first:** settle exact interactions and row invariants in UX and Architecture. Unset key combinations remain TBD. diff --git a/docs/design/STEP20C_EVENT_LOOP_PENDING_INPUT_PROBE.md b/docs/design/STEP20C_EVENT_LOOP_PENDING_INPUT_PROBE.md new file mode 100644 index 0000000..d60849a --- /dev/null +++ b/docs/design/STEP20C_EVENT_LOOP_PENDING_INPUT_PROBE.md @@ -0,0 +1,228 @@ +# STEP20C event-loop pending input probe + +**Status: EXPERIMENTAL / INCONCLUSIVE after bounded execution.** STEP20C remains +**NEXT** and multi-output Mosaic **NOT YET SUPPORTED**. This probe changes no +production compositor, package, UX or backend scheduling policy. + +## Question and historical limit + +The [ordering probe](STEP20C_COMMIT_PRESENT_INPUT_ORDERING_PROBE.md) observed +20 applied input-region changes, with none between a matching native output +commit and headless present. Its runner waited for each camera IPC response, +client confirmation and pointer confirmation. Those cross-process waits can +outlast a commit-to-present interval. The old observations remain valid for +their executed order. This probe asks whether a normal Wayland request, +submitted without all those intermediate runner waits, is naturally applied +in that interval. + +## Dispatch and repaint model + +The pinned wlroots 0.20.2 and installed Wayland server 1.26.0 source (tag +commit `87cc8a8728a923fc57938faa81ba0e74f34ecdc7`) show that +`wl_event_loop_dispatch` runs queued idles before polling, dispatches ready +epoll sources with timers first, then dispatches idles created by those +sources. Thus a client fd already in the same returned epoll batch can run +after a repaint timer and before its newly queued present idle; a request +arriving later may encounter the idle first. The event loop does not promise +that an already written client request wins over an idle callback. A Wayland client `flush` sends +queued bytes toward its connection; it does not run a server handler. Distinct +states are **written by client**, **possibly readable at server**, +**dispatched by server**, and **applied to effective input**. The latter is +observed in the server surface-commit callback, after wlroots has committed +the new `input_region`. No readiness is inferred solely from client logs. + +SwayFX's `output_repaint_timer_handler` builds scene output state and calls +`wlr_output_commit_state`. The accepted commit signal is synchronous. The +headless backend queues `output_defer_present`; wlroots adds a Wayland idle +callback for the matching native `commit_seq`. A later callback emits +`present`, but destruction or allocation failure can prevent it. A normal +surface `set_input_region` changes pending state and `wl_surface.commit` +makes it effective. SceneFX's scene hit predicate reads that effective region +through `wlr_surface_point_accepts_input`; SwayFX `node_at_coords` and seat +notifications follow. Surface input-only commits need not render a new +output buffer. Output build/commit/present and later input hit are different +identities; a later hit may legally use newer input than submitted pixels. + +## Identities and clocks + +Keep native `(output, commit_seq)` for accepted commits and presents; never +predict a sequence. Keep surface resource ID, observed `current.seq`, +`input_changed`, effective bounds and client command identity separately. +Private compositor callbacks use `CLOCK_MONOTONIC` nanoseconds to establish +server order. Runner and client logs have separate process order unless +their clocks are checked; a runner timestamp is not treated as a server +handler timestamp. `OUTPUT_BUILD`, `OUTPUT_COMMIT`, `OUTPUT_PRESENT`, +`SURFACE_COMMIT`, `SCENE_HIT` and `POINTER_DELIVERY` are passive existing +private trace sites. The unmodified SwayFX control supplies client events +and sampled pixels, not native output event traces. + +## Fixed experiment + +Two headless outputs remain HEADLESS-1 `(-800,0)` and HEADLESS-2 `(0,0)`, +both 800×600, scale 1, normal transform. The same mapped T4 client performs +normal `wl_surface.set_input_region`, `wl_surface.commit` and client flush. +Compare standard SwayFX `splith`, private `splith` and private `mosaic`, with +no popup or grab. Runs own private mode-0700 runtime directories, discovered +IPC sockets, logs and process groups. Captures sample fixed pixels only. + +* **E0:** verify outputs, mapping, full-region right button receipt, empty + region no target hit, and fixed pixels. +* **E1:** two sequential full/empty transitions using the historical waits. + Retain runner submissions/acknowledgements and server callback order. +* **E2:** submit a camera IPC request when applicable, then a client input + mutation without awaiting the IPC response or client confirmation. Await + completion only after both submissions. Preserve whether application was + before commit, between commit/present, after present, or without a new + output commit. Up to five transitions per private mode. +* **E3:** submit a normal client region request before an ordinary rendering + transition. Record client flush and server handler times; do not call the + request server-ready without proof. +* **E4:** attempt a same-interval hit only if E2 naturally creates the + commit/mutation/present order. Record actual scene hit and client delivery; + no destination may be inferred for a hit that did not run. +* **E5:** restore full input on the same surface and verify client receipt, + target hit and stable sampled pixels. + +The runner offers a sequential control and an asynchronous submission mode. +It records each request and checks completion after submission; an IPC +request sent is not treated as an applied camera change. No sleep or special +idle callback is added to the compositor, headless backend, or Wayland server. +No recursive dispatch, source-priority change, direct `current.input` write, +forged `present` or output geometry change is allowed. At most five attempts +per experimental configuration; if none interleave, stop with +**INCONCLUSIVE / NOT EXERCISED**. The grabbed-popup camera variant is allowed +only after a natural E2 interleaving creates a Mosaic clipping question. + +## Analysis and stop rule + +Emit raw and structured `event-order.jsonl`, `client-requests.json`, +`surface-commits.json`, `output-builds.json`, `output-commits.json`, +`presentations.json`, `input-regions.json`, `scene-hits.json`, +`client-delivery.json`, `timelines.json`, `matrix.json` and `summary.json` +outside Git. Each timeline must state submitted request, observed server +application, last accepted commit, last observed and next present, actual +hit/input/client receipt, and which relations remain inferred or unknown. +Compare one minimal-trace run with a run disabling only the additional +surface log site: commit/present counts, duration, gap distribution, applied +surface mutations and button receipts. Functional agreement alone does not +prove timing neutrality. SHA-256 manifests cover all raw evidence used. + +If five attempts produce no natural interleaving, close this temporal line +as **INCONCLUSIVE** for this backend and matrix. Existing ordering may be +sufficient for sampled states, but a global architectural sufficiency claim +would require more evidence. An added Mosaic transaction contract requires +a concrete required clipping/hit invariant and a real counterexample, not +merely a later input state than an already committed output buffer. + +## Executed campaign and result + +The source fixture rebuilt pinned private SceneFX, Mosaic and wlroots in +`/tmp/swayfx-mosaic-nested-grab-6C8kpcQB/`. The first sequential run failed +because the generated runner lacked its `sequential` constant. A later +asynchronous control run failed because an untargeted `border pixel 3` IPC +command ran after a click on an empty input region had changed focus. Both +failed runs are retained under `failed-runs/`; neither supplies temporal +evidence. The corrected fixture then ran two sequential region cycles per +mode, followed by five asynchronous cycles per mode. The standard control +remained uninstrumented. The separate trace-off comparison used two +sequential cycles per private mode. + +After the five-attempt timing bound, a separate provenance check cloned +published `c1f0e3621ddfba29fde9660d044d595c00ee8610` into +`/tmp/step20c-ordering-clone-final2/`, overlaid only this uncommitted +fixture, and rebuilt both the historical and pending-input private sources +without rerunning timing attempts. Recursive source comparisons found no +differences between either clone-built private Mosaic/SceneFX tree and its +corresponding measured lab. The copied T4 client source SHA-256 matched +`2f06888fbb77d541545aa54280567b8571ef5001e6880686bc9667b4091eddeb`. +The clone-built private Meson model test passed. This establishes source +equivalence of the measured builds to reconstruction from a Git clone; it +does not make the runs byte-identical or add temporal observations. + +For the evidence freeze, another fresh clone at +`c1f0e3621ddfba29fde9660d044d595c00ee8610` independently rebuilt the +same pinned chain in `/tmp/swayfx-mosaic-nested-grab-9wm8BpSJ/`. One private +Mosaic sequential cycle yielded 45 accepted output commits, 43 observed +presents and two applied target input mutations; one asynchronous cycle +yielded 48/46 and two mutations. Both runs had two positive client flushes, +two actual top-level right-button receipts, eight captures, `failure=null` +and zero commit-to-mutation-to-present intervals. The asynchronous runner +recorded two distinct client submissions before their confirmations. This +small replay verifies fixture behavior, not a new scheduling conclusion. +Its outside-Git manifest covers all 56 regular raw run files, passed every +entry check and has SHA-256 +`f8b54f2ef7f9caca3837e956341ddb5a62f261d0115840d6fd8b0b3540c1e8b9`. + +Every successful mode had two correctly placed 800×600 outputs, the mapped +420×320 toplevel, eight captures, six received right-button presses (initial +full region plus five restorations), five empty and five full commands in +the asynchronous campaign, and zero popup grabs. The sampled left-output +pixel `(342,175)` was `(35,68,204)` at E0, E2 empty and E5 restored in all +three modes. The control verifies pixels and client receipt; it has no +native server commit/present trace. + +| Asynchronous mode | Accepted native commits | Observed presents | Applied target input mutations | Client flushes | Natural commit→mutation→present | +| --- | ---: | ---: | ---: | ---: | ---: | +| Standard SwayFX `splith` | Uninstrumented | Uninstrumented | Uninstrumented | 10 | Unobservable | +| Private `splith` | 82 | 80 | 10 | 10 | 0 | +| Private `mosaic` | 83 | 81 | 10 | 10 | 0 | + +All client flush calls reported positive byte counts (40 or 64), and the +server applied the 10 corresponding input-only mutations in each private +mode. Client and server both used `CLOCK_MONOTONIC`; paired flush-to-handler +gaps were 30,176–113,331 ns in private `splith` and 15,058–111,367 ns in +private Mosaic. In E2, the runner submitted the IPC request then the client +request before waiting for either completion. In E3 it submitted the client +request before the IPC rendering request. The server traces nevertheless +place every applied target mutation outside a matching accepted +commit-to-present interval. A submitted request is still not proof that its +fd was ready in a particular epoll batch. The native counts differ from the +historical 62/60 and 84/82 because this runner also changes border state in +standard layout and overlaps normal camera requests in Mosaic. Both private +modes again have two initial sequence-2 commits with **NO PRESENT OBSERVED**; +their cause remains **UNEXPLAINED**. + +E0 passed the sampled geometry, client and pixel controls. E1 passed two +sequential transitions per mode. E2 submitted five bounded overlapping +cycles per mode and found zero natural interleavings. E3 proved the client +submission and subsequent normal server application, without proving fd +readiness before repaint. E4 was **NOT TESTED**, because no E2 interval +existed in which to attempt a hit. E5 restored full input on the same +surface; actual button receipt resumed. Private scene hits after effective +empty input did not target the toplevel. Some transient full-state private +standard hits were nil during ordinary border changes, but later full-state +hits and the client deliveries succeeded; no distinct Mosaic clipping +failure was demonstrated. There was no popup/grab variant. + +Trace-on two-cycle sequential controls measured private `splith` 47/45 +commit/present, 4 applied target mutations, 3 buttons, 4.808 s and a +76,762 ns median commit→present gap; trace-off measured 48/46, +4 client flushes, 3 buttons, 4.799 s and 96,800.5 ns. Private Mosaic gave +59/57, 4 mutations, 3 buttons, 4.826 s and 86,591 ns trace-on; trace-off +gave 60/58, 4 client flushes, 3 buttons, 4.837 s and 86,020 ns. Applied +mutation counts cannot be observed directly with the new log site disabled; +the client and hit behavior still pass. Native counts and gap distributions +vary, so perfect timing neutrality is not claimed. + +The lab contains `event-order.jsonl`, `client-requests.json`, +`surface-commits.json`, `output-builds.json`, `output-commits.json`, +`presentations.json`, `input-regions.json`, `scene-hits.json`, +`client-delivery.json`, `timelines.json`, `matrix.json` and `summary.json`. +`timelines.json` records each submitted client flush, applied server handler, +surrounding native events and executed later hit; per-hit client delivery is +left unknown where the client log has order but no same-clock event stamp. +The outside-Git `raw-evidence-sha256.txt` covers all 280 regular files in +`runs/`, `sequential-runs/`, `trace-off-runs/`, `failed-runs/` and structured +results, excluding the two source/derived manifest files and itself. All +entries passed SHA-256 verification. The manifest hash is +`6eaeee3fcd77c4f2b7682924d2ff8ebdd5b306acab715bead68ce9e655f5490a`. +Builds, binaries, generated protocol source and sockets are outside this +raw-evidence scope and outside Git. + +**Verdict: INCONCLUSIVE / NOT EXERCISED** for the sought temporal order. +The request/confirmation decoupling worked, and applied mutations and later +hit/client behavior are real, but the discriminating interval was still not +exercised. No concrete Mosaic clipping invariant was violated and no added +transaction contract is established. **Architecture: UNDECIDED.** Stop this +temporal line for the headless backend and fixed matrix; STEP20C can next +examine effects/damage, scaling, or resize/migration as separate gates. diff --git a/tooling/probes/step20c-event-loop-pending-input/README.md b/tooling/probes/step20c-event-loop-pending-input/README.md new file mode 100644 index 0000000..e71d0e2 --- /dev/null +++ b/tooling/probes/step20c-event-loop-pending-input/README.md @@ -0,0 +1,40 @@ +# STEP20C pending-input event-loop fixture — EXPERIMENTAL + +See the [protocol](../../../docs/design/STEP20C_EVENT_LOOP_PENDING_INPUT_PROBE.md). +This fixture rebuilds the pinned private compositor and the same T4 client +in a disposable laboratory. `client-flush.patch` adds a client-side +`wl_display_flush` result and monotonic timestamp after each normal +input-region commit. It changes no server handler or scheduler. The existing +private commit, present, surface, hit and delivery traces remain in use. + +```sh +prep=$(sh tooling/probes/step20c-event-loop-pending-input/prepare.sh) +LAB=$(printf '%s\n' "$prep" | sed -n 's/^LAB=//p') +for mode in control private_standard private_mosaic; do + PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-event-loop-pending-input/run.py \ + "$LAB" "$mode" --cycles 5 +done +PYTHONDONTWRITEBYTECODE=1 python tooling/probes/step20c-event-loop-pending-input/analyze.py "$LAB" +``` + +The default runner submits an ordinary IPC rendering/camera request and a +normal client region request before waiting for both results. `--sequential` +keeps the earlier wait-after-each-operation control. `--surface-trace 0` +disables only the additional private surface log site for a limited timing +comparison. The control SwayFX is not instrumented. Up to five cycles per +mode are allowed. The runner logs submission and completion separately; +the client logs its flush result, and server traces determine application. + +The lab contains runtime-private sockets, process-owned logs, captures, +generated runner, builds and binaries; none belongs in Git. A manifest +should hash all regular files under the run and structured result directories, +excluding the manifest itself. Never target the daily Sway IPC socket. Stop +after five unsuccessful interleaving attempts; report +`INCONCLUSIVE / NOT EXERCISED` and leave the popup variant untested. + +The frozen five-cycle campaign is described in the protocol. A later +independent clone replay used one sequential and one asynchronous private +Mosaic cycle only. It confirmed two applied mutations per run, native +commit/present traces and restored client delivery without reopening the +timing search; its 56-file raw manifest hash is +`f8b54f2ef7f9caca3837e956341ddb5a62f261d0115840d6fd8b0b3540c1e8b9`. diff --git a/tooling/probes/step20c-event-loop-pending-input/analyze.py b/tooling/probes/step20c-event-loop-pending-input/analyze.py new file mode 100644 index 0000000..911ece1 --- /dev/null +++ b/tooling/probes/step20c-event-loop-pending-input/analyze.py @@ -0,0 +1,230 @@ +"""Order executed server callbacks; keep client receipt on its own clock.""" +import argparse +import hashlib +import json +from pathlib import Path +import re + +from PIL import Image + +ap = argparse.ArgumentParser() +ap.add_argument('lab', type=Path) +args = ap.parse_args() +lab = args.lab.resolve() +out = lab / 'event-loop-pending-input-results' +out.mkdir(exist_ok=True) +markers = { + 'ORDER_SURFACE_COMMIT': 'surface_commit', + 'BI_STAGE': 'output_build', + 'PI_OUTPUT_COMMIT': 'output_commit', + 'PI_OUTPUT_PRESENT': 'output_present', + 'ASI_HIT': 'scene_hit', + 'ASI_DELIVERY': 'pointer_delivery', +} + + +def save(name, value): + (out / name).write_text(json.dumps(value, indent=2) + '\n') + + +events = [] +matrix = {} +client_delivery = {} +pixels = {} +for mode in ('control', 'private_standard', 'private_mosaic'): + matches = sorted((lab / 'runs').glob(f'{mode}-pending-asi0-*')) + if len(matches) != 1: + raise RuntimeError(f'expected one {mode} run, found {len(matches)}') + run = matches[0] + status = json.loads((run / 'exit-status.json').read_text()) + if status['failure'] is not None: + raise RuntimeError(f'{mode} runner failed: {status["failure"]}') + for line_number, line in enumerate((run / 'compositor.log').read_text(errors='replace').splitlines()): + for marker, kind in markers.items(): + if marker + ' ' not in line: + continue + fields = dict(re.findall(r'(\w+)=([^ ]+)', line.split(marker + ' ', 1)[1])) + if 'ns' not in fields: + continue + events.append({'mode': mode, 'kind': kind, 'line': line_number, + **fields, 'ns': int(fields['ns'])}) + break + client = (run / 'client.log').read_text(errors='replace') + client_delivery[mode] = { + 'run': str(run), + 'top_button_presses': len(re.findall( + r'POINTER_BUTTON target=TOPLEVEL[^\n]*button=273 state=1', client)), + 'region_empty_commands': client.count('COMMAND region_empty_top DONE'), + 'region_full_commands': client.count('COMMAND region_full_top DONE'), + 'popup_grabs': client.count('GRAB_REQUEST'), + 'client_lines': [x for x in client.splitlines() if + 'COMMAND region_' in x or + 'POINTER_BUTTON target=TOPLEVEL' in x], + } + pixels[mode] = {} + for tag in ('pending-P0-reference', 'pending-P1-empty', 'pending-P4-restored'): + pixels[mode][tag] = Image.open(run / f'left-{tag}.png').getpixel((342, 175)) + matrix[mode] = {'run': str(run), 'runner_failure': None, + 'duration_seconds': status['duration_seconds'], + 'captures': status['captures'], + 'pixels': pixels[mode], **{k: v for k, v in + client_delivery[mode].items() if k != 'client_lines'}} + +# Same-process timestamps can be ordered. Equal timestamps retain log order. +events.sort(key=lambda e: (e['mode'], e['ns'], e['line'])) +target_mutations = [e for e in events if e['kind'] == 'surface_commit' + and e.get('input_changed') == '1' + and e.get('width') == '420' and e.get('height') == '320'] +with (out / 'event-order.jsonl').open('w') as dst: + for event in events: + dst.write(json.dumps(event) + '\n') +for name, kind in ( + ('surface-commits.json', 'surface_commit'), + ('output-commits.json', 'output_commit'), + ('presentations.json', 'output_present'), + ): + save(name, [e for e in events if e['kind'] == kind]) +save('input-regions.json', target_mutations) +save('output-builds.json', [e for e in events if e['kind'] == 'output_build']) +requests = {} +for mode in ('control', 'private_standard', 'private_mosaic'): + run_path = Path(matrix[mode]['run']) + requests[mode] = [json.loads(line) for line in (run_path / 'events.jsonl').read_text().splitlines() + if 'submitted' in json.loads(line)['kind'] or 'completed' in json.loads(line)['kind']] + requests[mode] += [line for line in (run_path / 'client.log').read_text().splitlines() + if 'CLIENT_FLUSH' in line] +save('client-requests.json', requests) +save('client-delivery.json', client_delivery) + +hits = [] +for hit in (e for e in events if e['kind'] == 'scene_hit'): + prior_inputs = [e for e in target_mutations if e['mode'] == hit['mode'] + and e['ns'] <= hit['ns']] + prior_commits = [e for e in events if e['mode'] == hit['mode'] + and e['kind'] == 'output_commit' + and e.get('output') == hit.get('output') and e['ns'] <= hit['ns']] + prior_presents = [e for e in events if e['mode'] == hit['mode'] + and e['kind'] == 'output_present' + and e.get('output') == hit.get('output') and e['ns'] <= hit['ns']] + next_presents = [e for e in events if e['mode'] == hit['mode'] + and e['kind'] == 'output_present' + and e.get('output') == hit.get('output') and e['ns'] > hit['ns']] + hits.append({**hit, + 'last_target_input': prior_inputs[-1] if prior_inputs else None, + 'last_output_commit_seq': prior_commits[-1].get('commit_seq') + if prior_commits else None, + 'last_present_seq': prior_presents[-1].get('commit_seq') + if prior_presents else None, + 'next_present_seq': next_presents[0].get('commit_seq') + if next_presents else None, + 'input_identity_inferred': hit.get('surface') == '(nil)'}) +save('scene-hits.json', hits) + +timelines = [] +for mode in ('control', 'private_standard', 'private_mosaic'): + group = [e for e in events if e['mode'] == mode] + commits = [e for e in group if e['kind'] == 'output_commit'] + presents = {(e.get('output'), e.get('commit_seq')): e for e in group + if e['kind'] == 'output_present'} + for commit in commits: + present = presents.get((commit.get('output'), commit.get('commit_seq'))) + between = [e for e in group if present and + commit['ns'] < e['ns'] < present['ns'] and + e['kind'] in ('surface_commit', 'scene_hit', 'pointer_delivery')] + timelines.append({'mode': mode, 'output': commit.get('output'), + 'commit_seq': commit.get('commit_seq'), + 'commit_ns': commit['ns'], + 'present_ns': present['ns'] if present else None, + 'presented': present.get('presented') if present else None, + 'gap_ns': present['ns'] - commit['ns'] if present else None, + 'between': between, + 'input_mutation_between': any(e in target_mutations + for e in between), + 'hit_between': any(e['kind'] == 'scene_hit' for e in between)}) +request_timelines = [] +for mode in ('control', 'private_standard', 'private_mosaic'): + run_path = Path(matrix[mode]['run']) + flushes = [dict(re.findall(r'(\w+)=([^ ]+)', line)) for line in + (run_path / 'client.log').read_text().splitlines() + if 'CLIENT_FLUSH ' in line] + applied = [e for e in target_mutations if e['mode'] == mode] + if mode != 'control' and len(flushes) != len(applied): + raise RuntimeError(f'{mode}: flush/application count mismatch') + for index, flush in enumerate(flushes): + mutation = applied[index] if mode != 'control' else None + if mutation and ((flush['region'] == 'empty') != + (mutation.get('sample_accepts') == '0')): + raise RuntimeError(f'{mode}: input state order mismatch') + ns = mutation['ns'] if mutation else None + mode_events = [e for e in events if e['mode'] == mode] + prior_commit = [e for e in mode_events if e['kind'] == 'output_commit' + and ns is not None and e['ns'] <= ns] + prior_present = [e for e in mode_events if e['kind'] == 'output_present' + and ns is not None and e['ns'] <= ns] + next_present = [e for e in mode_events if e['kind'] == 'output_present' + and ns is not None and e['ns'] > ns] + next_hit = [e for e in mode_events if e['kind'] == 'scene_hit' + and ns is not None and e['ns'] > ns] + enclosing = [t for t in timelines if t['mode'] == mode and ns is not None + and t['commit_ns'] < ns and t['present_ns'] is not None + and ns < t['present_ns']] + request_timelines.append({ + 'mode': mode, 'index': index + 1, 'region': flush['region'], + 'client_flush_ns': int(flush['ns']), 'client_flush_rc': int(flush['rc']), + 'server_apply_ns': ns, 'server_surface_seq': mutation.get('seq') if mutation else None, + 'flush_to_apply_ns': ns - int(flush['ns']) if ns else None, + 'last_accepted_commit': prior_commit[-1] if prior_commit else None, + 'last_observed_present': prior_present[-1] if prior_present else None, + 'next_observed_present': next_present[0] if next_present else None, + 'enclosing_commit_present': enclosing, + 'next_executed_hit': next_hit[0] if next_hit else None, + 'effective_target_input_at_hit': next((e.get('sample_accepts') for e in + reversed(applied) if next_hit and e['ns'] <= next_hit[0]['ns']), None), + 'client_delivery_for_this_hit': 'unknown; client log order only', + 'proven': 'client flush and server callback use CLOCK_MONOTONIC' if mutation else + 'client flush only; standard server uninstrumented', + 'unknown': ['server fd readiness', 'per-hit client delivery clock']}) +save('timelines.json', {'output_intervals': timelines, + 'request_applications': request_timelines}) +for mode in matrix: + group = [e for e in events if e['mode'] == mode] + intervals = [x for x in timelines if x['mode'] == mode] + matrix[mode].update({ + 'surface_input_commits': sum(e in target_mutations for e in group), + 'native_commits': sum(e['kind'] == 'output_commit' for e in group), + 'native_presents': sum(e['kind'] == 'output_present' for e in group), + 'commit_present_interleavings': sum(x['input_mutation_between'] for x in intervals), + 'commit_mutation_hit_present': sum(x['input_mutation_between'] and + any(e['kind'] == 'scene_hit' for e in x['between']) for x in intervals), + 'gap_ns_min': min((x['gap_ns'] for x in intervals if x['gap_ns'] is not None), + default=None), + 'gap_ns_max': max((x['gap_ns'] for x in intervals if x['gap_ns'] is not None), + default=None), + }) +save('matrix.json', matrix) +summary = {'modes': list(matrix), 'interleavings': sum(x['input_mutation_between'] + for x in timelines), 'commit_mutation_hit_present': sum( + x['input_mutation_between'] and x['hit_between'] for x in timelines), + 'clock_contract': 'client flush and server traces use CLOCK_MONOTONIC; ' + 'client delivery log has order without per-event timestamp', + 'e2': 'OBSERVED' if any(x['input_mutation_between'] for x in timelines) + else 'INCONCLUSIVE / NOT EXERCISED', 'rejected_backend_commit': 'NOT TESTED'} +summary['e0'] = 'PASS sampled geometry, client delivery and pixels' +summary['e1'] = 'PASS two sequential cycles in each mode; separate sequential-runs/' +summary['e3'] = 'PASS client submission preceded ordinary IPC request in five full transitions per mode' +summary['e4'] = 'NOT TESTED: E2 produced no natural interleaving' +summary['e5'] = 'PASS same-surface full-region restoration and six total right-button receipts per mode' +summary['popup_variant'] = 'NOT TESTED: E2 prerequisite absent' +summary['attempts_per_mode'] = 5 +summary['matrix'] = matrix +save('summary.json', summary) +with (out / 'source-sha256.txt').open('w') as dst: + for p in (Path(__file__), Path(__file__).parent / 'run.py', + Path(__file__).parent / 'prepare.sh', + Path(__file__).parent / 'client-flush.patch'): + dst.write(f'{hashlib.sha256(p.read_bytes()).hexdigest()} {p.resolve()}\n') +with (out / 'artifact-sha256.txt').open('w') as dst: + for p in sorted(out.glob('*.json')) + [out / 'event-order.jsonl', + out / 'source-sha256.txt']: + dst.write(f'{hashlib.sha256(p.read_bytes()).hexdigest()} {p}\n') +print(json.dumps(summary)) diff --git a/tooling/probes/step20c-event-loop-pending-input/client-flush.patch b/tooling/probes/step20c-event-loop-pending-input/client-flush.patch new file mode 100644 index 0000000..1310431 --- /dev/null +++ b/tooling/probes/step20c-event-loop-pending-input/client-flush.patch @@ -0,0 +1,27 @@ +--- a/nested-client.c ++++ b/nested-client.c +@@ -8,6 +8,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -268,6 +269,16 @@ + wl_surface_set_input_region(top.wl, region); + wl_surface_commit(top.wl); + wl_region_destroy(region); ++ /* CONTRACT: log an actual client flush separately from the ++ * later server handler; a successful write is not application. */ ++ int flushed = wl_display_flush(display); ++ struct timespec sent_at; ++ clock_gettime(CLOCK_MONOTONIC, &sent_at); ++ fprintf(stderr, "CLIENT_FLUSH ns=%lld region=%s rc=%d wl=%u\n", ++ (long long)sent_at.tv_sec * 1000000000LL + sent_at.tv_nsec, ++ empty ? "empty" : "full", flushed, ++ wl_proxy_get_id((struct wl_proxy *)top.wl)); ++ if (flushed < 0 && errno != EAGAIN) running = false; + fprintf(stderr,"COMMAND region_%s_top DONE wl=%u\n", + empty ? "empty" : "full", + wl_proxy_get_id((struct wl_proxy *)top.wl)); diff --git a/tooling/probes/step20c-event-loop-pending-input/prepare.sh b/tooling/probes/step20c-event-loop-pending-input/prepare.sh new file mode 100644 index 0000000..7aaf5b5 --- /dev/null +++ b/tooling/probes/step20c-event-loop-pending-input/prepare.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# EXPERIMENTAL: rebuild private sources only; no installed component is touched. +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +fixture="$repo/tooling/probes/step20c-event-loop-pending-input" +log=$(mktemp /tmp/epi-prepare-XXXXXXXX) +if ! sh "$repo/tooling/probes/step20c-commit-present-input/prepare.sh" > "$log" 2>&1; then + printf 'PRIVATE_BUILD_FAILED log=%s\n' "$log" >&2 + tail -40 "$log" >&2 + exit 1 +fi +lab=$(sed -n 's/^LAB=//p' "$log") +test -n "$lab" && test -d "$lab" +trap 'rm -rf -- "$lab"' 0 +# CONTRACT: only the disposable client reports its own flush result/time. +(cd "$lab" && git apply --check "$fixture/client-flush.patch" && + git apply "$fixture/client-flush.patch") +cc -std=c17 -Wall -Wextra -Werror -Wno-unused-parameter -I"$lab" \ + "$lab/nested-client.c" "$lab/xdg-shell-protocol.c" \ + -o "$lab/nested-client" $(pkg-config --cflags --libs wayland-client) +trap - 0 +printf 'LAB=%s\nBUILD_LOG=%s\n' "$lab" "$log" diff --git a/tooling/probes/step20c-event-loop-pending-input/run.py b/tooling/probes/step20c-event-loop-pending-input/run.py new file mode 100644 index 0000000..11b1263 --- /dev/null +++ b/tooling/probes/step20c-event-loop-pending-input/run.py @@ -0,0 +1,106 @@ +"""Bounded private headless runner with distinct submission and completion.""" +import argparse +from pathlib import Path +import subprocess +import sys + +ap = argparse.ArgumentParser() +ap.add_argument('lab', type=Path) +ap.add_argument('mode', choices=('control', 'private_standard', 'private_mosaic')) +ap.add_argument('--cycles', type=int, default=5) +ap.add_argument('--sequential', action='store_true') +ap.add_argument('--surface-trace', choices=('0', '1'), default='1') +args = ap.parse_args() +if not 1 <= args.cycles <= 5: + ap.error('at most five timing attempts') +lab = args.lab.resolve() +repo = Path(__file__).resolve().parents[3] +original = (repo / 'tooling/probes/step20c-applied-state-input/run.py').read_text() +old = "choices=('cycles', 'buffer_cycles', 'identity', 'outside', 'hidden_click', 'toggle')" +assert original.count(old) == 1 +adapted = original.replace(old, old[:-1] + ", 'pending')") +anchor = " if args.kind == 'cycles':\n" +assert adapted.count(anchor) == 1 +scenario = ''' if args.kind == 'pending': + # CONTRACT: requests retain normal IPC/Wayland paths. A submission + # record never claims server application; native traces decide order. + def submit_ipc(value): + record('ipc_submitted', value=value) + return subprocess.Popen(['swaymsg', '-s', str(sock), value], env=env, + text=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + + def complete_ipc(proc, value): + out, err = proc.communicate(timeout=8) + record('ipc_completed', value=value, exit_code=proc.returncode, + output=out, error=err) + result = json.loads(out) + if proc.returncode or not isinstance(result, list) or not result or not all( + x.get('success') for x in result): + raise RuntimeError(f'IPC command rejected: {value}: {out} {err}') + + def submit_client(value): + before = count(f'COMMAND {value} DONE') + record('client_submitted', value=value) + client.stdin.write(value + '\\n') + client.stdin.flush() + return before + + def complete_client(value, before): + wait(lambda: count(f'COMMAND {value} DONE') > before) + record('client_completed', value=value) + + o = owner() + camera = workspace()['mosaic']['rows'][0]['scroll_x'] if args.mode == 'private_mosaic' else 0 + x = int(o['rect']['x'] - camera + o['window_rect']['x'] + 100) + y = int(o['rect']['y'] + o['window_rect']['y'] + 150) + snapshot('pending-P0-reference') + inject('move', x, y) + inject('right', x, y) + wait(lambda: count('POINTER_BUTTON target=TOPLEVEL') >= 1) + record('pending_point', global_point=[x, y], owner_id=o['id']) + for i in range(args.cycles): + record('pending_cycle_start', cycle=i + 1) + if args.sequential: + if args.mode == 'private_mosaic': command('layout mosaic scroll 200') + client_command('region_empty_top') + else: + # E2: launch ordinary camera activity and submit the client + # request before waiting for either acknowledgement. + value = ('layout mosaic scroll 200' if args.mode == 'private_mosaic' + else '[app_id="org.mosaic.NestedGrabProbe"] border pixel 2') + proc = submit_ipc(value) + before = submit_client('region_empty_top') + complete_ipc(proc, value) + complete_client('region_empty_top', before) + inject('move', x, y) + inject('right', x, y) + if i == 0: snapshot('pending-P1-empty') + if args.sequential: + if args.mode == 'private_mosaic': command('layout mosaic scroll -200') + client_command('region_full_top') + else: + # E3: the client request is submitted first. This does not + # assert that the server fd is ready before repaint. + before = submit_client('region_full_top') + value = ('layout mosaic scroll -200' if args.mode == 'private_mosaic' + else '[app_id="org.mosaic.NestedGrabProbe"] border pixel 3') + proc = submit_ipc(value) + complete_client('region_full_top', before) + complete_ipc(proc, value) + prior = count('POINTER_BUTTON target=TOPLEVEL') + inject('move', x, y) + inject('right', x, y) + wait(lambda: count('POINTER_BUTTON target=TOPLEVEL') > prior) + record('pending_cycle_end', cycle=i + 1) + snapshot('pending-P4-restored') + elif args.kind == 'cycles': +''' +adapted = adapted.replace(anchor, scenario.replace('args.sequential', str(args.sequential))) +generated = lab / 'event-loop-pending-input-run-generated.py' +generated.write_text(adapted) +env = __import__('os').environ.copy() +env['CPI_SURFACE_TRACE'] = args.surface_trace +raise SystemExit(subprocess.call([sys.executable, str(generated), str(lab), + args.mode, 'pending', '--cycles', str(args.cycles), '--asi-mode', '0', + '--max-seconds', '120', '--max-captures', '12'], env=env))