From 6fd8945d10d1072871bf66f639153a717d7ca758 Mon Sep 17 00:00:00 2001 From: fy59 Date: Mon, 12 Oct 2026 19:36:01 +0200 Subject: [PATCH] docs(mosaic): preserve saved target timeout and input evidence --- .gitattributes | 1 + CHANGELOG.md | 2 + docs/ARCHITECTURE.md | 14 + docs/KNOWN_LIMITATIONS.md | 11 + docs/README.md | 9 + docs/ROADMAP.md | 11 + ...TEP20C_SAVED_TARGET_TIMEOUT_INPUT_PROBE.md | 230 +++++++++++ .../README.md | 28 ++ .../analyze.py | 203 +++++++++ .../budget.py | 30 ++ .../prepare.sh | 23 ++ .../step20c-saved-target-timeout-input/run.py | 391 ++++++++++++++++++ .../saved-client.c | 224 ++++++++++ .../static-reference.c | 5 + .../target-hit-trace.patch | 39 ++ .../test-budget.py | 43 ++ 16 files changed, 1264 insertions(+) create mode 100644 docs/design/STEP20C_SAVED_TARGET_TIMEOUT_INPUT_PROBE.md create mode 100644 tooling/probes/step20c-saved-target-timeout-input/README.md create mode 100644 tooling/probes/step20c-saved-target-timeout-input/analyze.py create mode 100644 tooling/probes/step20c-saved-target-timeout-input/budget.py create mode 100644 tooling/probes/step20c-saved-target-timeout-input/prepare.sh create mode 100644 tooling/probes/step20c-saved-target-timeout-input/run.py create mode 100644 tooling/probes/step20c-saved-target-timeout-input/saved-client.c create mode 100644 tooling/probes/step20c-saved-target-timeout-input/static-reference.c create mode 100644 tooling/probes/step20c-saved-target-timeout-input/target-hit-trace.patch create mode 100644 tooling/probes/step20c-saved-target-timeout-input/test-budget.py diff --git a/.gitattributes b/.gitattributes index af7b966..57d6b81 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ # Preserved experimental patches include diff markers and source-context tabs. tooling/probes/step20c-valid-grab/*.patch -whitespace +tooling/probes/step20c-saved-target-timeout-input/target-hit-trace.patch -whitespace diff --git a/CHANGELOG.md b/CHANGELOG.md index fe5101a..3969d05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### STEP20C experimental evidence +- Preserved saved-target natural timeout gate: one Mosaic run used a static SHM H2 reference with four identical whole-RGB captures. The measured target saved node crossed x=0, rendered 182,214 retained-color pixels on H1, contributed no H2 pixels and was removed after a real 2500 ms timeout while the client held serial 50 alive. Two sampled pointer points found the H2 reference and visible H1 saved node. PARTIALLY_FEASIBLE because complete damage, broader input and GPU/texture lifetime remain unqualified; no production change. + - Local target-crossing saved-buffer probe: one measured signed camera delta placed the real target source and saved node across x=0. The target node rendered within H1's clip and retained its SHM pixels; H2 showed no target color but had a repeated 253-pixel reference difference. Mosaic E2 timeout stopped through stale fixture pointer correlation. PARTIALLY_FEASIBLE, architecture UNDECIDED; no production change, commit or push for this campaign. - Freeze audit of that target crossing: the repeated H2 difference is one filled 11×23 rectangle with Kitty cursor/background colors in pristine and Mosaic runs, strongly compatible with cursor blinking but not causally proven. The E2 visible bound at x=-30 matches the applied tiled clip narrowed by gaps. Timeout/input remain untested. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9e386c3..2d8d78e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -300,3 +300,17 @@ Offline freeze analysis strongly supports a blinking Kitty block cursor for the repeated H2 reference difference, without proving its cause. E2's `x=-30` visible bound matches the applied tiled clip narrowed by gaps; it is not evidence of a distinct content clip. Target timeout cleanup remains open. + +The separate [natural timeout and input gate](design/STEP20C_SAVED_TARGET_TIMEOUT_INPUT_PROBE.md) +uses a static SHM H2 reference and one Mosaic transaction. The actual target +source and saved node both crossed x=0; the saved node rendered only its H1 +portion and its tree was removed after a real 2500 ms timeout with serial 50 +held and the client alive. The whole H2 RGB image was identical across two +preflight, held and applied captures. Two sampled pointer positions found +the H2 reference surface and the visible H1 saved node respectively; the +latter is not a Wayland surface. This closes the sampled saved-target ACK and +timeout clipping matrix, **PARTIALLY_FEASIBLE**, without proving full damage, +all input paths or native GPU lifetime. The earlier crossing campaign's own +architectural verdict remains UNDECIDED. The new limited-matrix conclusion +does not authorize a production implementation. Optimized blur/cache and +SceneFX ABI/package remain separate gates. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index d3a79a4..c8661c9 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -244,3 +244,14 @@ rectangle in pristine and Mosaic H2 comparisons. Kitty's recorded local block-cursor color and blink setting strongly support a dynamic reference, but no effective cursor phase was logged. Whole-H2 equality is still unproven; the new timeout probe must use a static reference. + +The subsequent [static-reference saved-target timeout gate](design/STEP20C_SAVED_TARGET_TIMEOUT_INPUT_PROBE.md) +is **PARTIALLY_FEASIBLE**. In one private horizontal Mosaic run, all four +full H2 RGB captures matched exactly; the actual crossing saved target +rendered its permitted H1 pixels, remained absent on H2 and its saved tree +was removed after a natural timeout with the configure withheld. Two pointer +points showed no invisible H2 target hit. Full frame-by-frame damage, +unmeasured input paths, seat notification for the saved node and complete +GPU/texture reference lifetime remain **NOT TESTED**. The old Kitty cursor +explanation remains strongly probable, not causally proven. STEP20C remains +**NEXT** and production multi-output Mosaic **NOT YET SUPPORTED**. diff --git a/docs/README.md b/docs/README.md index 6113b55..55ea167 100644 --- a/docs/README.md +++ b/docs/README.md @@ -155,3 +155,12 @@ stays NEXT and production multi-output remains NOT YET SUPPORTED. The offline freeze audit found an identical filled 11×23 Kitty cursor-color rectangle across pristine and Mosaic captures. Cursor blinking is strongly supported, not causally proven; a static H2 reference is the next gate. + +[Saved target natural timeout and input gate](design/STEP20C_SAVED_TARGET_TIMEOUT_INPUT_PROBE.md) +and [local source fixture](../tooling/probes/step20c-saved-target-timeout-input/README.md): +EXPERIMENTAL / PARTIALLY_FEASIBLE. A static SHM H2 reference stayed +pixel-identical before, during and after one held Mosaic transaction. The +crossing target saved node rendered inside H1 and was removed after a real +timeout with the XDG client alive; sampled H2 input reached the reference. +Complete damage and GPU lifetime remain open. STEP20C stays NEXT and +production multi-output remains NOT YET SUPPORTED. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fba9981..4fce2b1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -248,3 +248,14 @@ as strongly compatible with Kitty's blinking block cursor, not causally proven. A separate static-reference Mosaic timeout/input gate is authorized before optimized blur/cache and SceneFX ABI/package work. No production readiness or changed UX contract follows. + +The completed [static-reference saved-target natural timeout gate](design/STEP20C_SAVED_TARGET_TIMEOUT_INPUT_PROBE.md) +then measured a real crossing target saved node, clipped H1 rendering, +stable full-image H2 RGB, two targeted pointer hits and natural timeout +cleanup with the XDG client alive and serial 50 withheld. Combined with the +earlier E1 ACK, the tested saved-target clipping matrix is viable, while +damage across every frame, broader input and GPU/texture lifetime remain +unqualified. Its verdict is **PARTIALLY_FEASIBLE**; the earlier probe's own +UNDECIDED conclusion is preserved. The next separate STEP20C gate is +optimized blur/cache, followed by SceneFX ABI/package. STEP20C is still +**NEXT**, production multi-output **NOT YET SUPPORTED**. diff --git a/docs/design/STEP20C_SAVED_TARGET_TIMEOUT_INPUT_PROBE.md b/docs/design/STEP20C_SAVED_TARGET_TIMEOUT_INPUT_PROBE.md new file mode 100644 index 0000000..d1629c5 --- /dev/null +++ b/docs/design/STEP20C_SAVED_TARGET_TIMEOUT_INPUT_PROBE.md @@ -0,0 +1,230 @@ +# STEP20C saved target natural timeout and input probe + +**EXPERIMENTAL / PARTIALLY_FEASIBLE.** STEP20C remains NEXT; +production multi-output Mosaic is NOT YET SUPPORTED. This is a new, single-run +laboratory. The prior [target crossing report](STEP20C_SAVED_BUFFER_TARGET_CROSSING_PROBE.md) +remains PARTIALLY_FEASIBLE and architecturally UNDECIDED. Its E1 established +one actual target saved node crossing `x=0`, clipped render in H1, 182,214 +retained target pixels and a real ACK/cleanup. Its E2 timeout stopped through +fixture pointer correlation; it did not test Mosaic target timeout cleanup. + +The old H2 Kitty reference changed one complete 11×23 rectangle from its +block-cursor color `#F5E0DC` to background `#303038` across pristine and +Mosaic runs. Blink causality is strongly supported but unproven. This probe +uses an opaque, static XDG SHM reference with app ID +`org.mosaic.StaticReference`, fixed RGB and no text cursor or animation. +The reference still handles real configure/ACK and Wayland events. Two full +H2 RGB captures separated by more than one historic Kitty blink interval +must match exactly before the target episode is reserved. No region is +masked, and reference failure stops the run. + +## Source identity and geometry + +Pinned inputs: SwayFX 0.6 `fd71a6bdc061bd633b488ae7b83e8a6981d22f86`, +private Mosaic `cd550382b311efdbfe3ed892f4e1d281d852e754`, SceneFX 0.5 +`37ccd723bef49e6891156ffafce8f549f01446cc`, wlroots 0.20.2. +Preserved clipping, saved-buffer and target-geometry trace patches retain +their bytes. Any further passive hit trace has its own patch and hash. +No production source, SceneFX algorithm, public ABI, package, UX or active +desktop session is changed. The private timeout policy is +`-Dtxn-timeout=2500`, parsed into `server.txn_timeout_ms`; no `-Dtxn-wait`, +`-Dnoatomic`, direct save call or scheduler change is used. + +Source audit of the pinned private copies located `transaction_commit`, +`should_configure`, `transaction_progress`, `apply_container_state` and +`handle_timeout` in `sway/desktop/transaction.c`: an ordinary changed-size +instruction calls `view_save_buffer`, and timeout advances the transaction +through the normal progress path. `sway/tree/view.c` implements +`view_save_buffer_iterator`, `view_save_buffer` and +`view_remove_saved_buffer`; its iterator creates the saved scene buffer and +passes the current source buffer to `wlr_scene_buffer_set_buffer`. +The private SceneFX `types/scene/wlr_scene.c` contains +`wlr_scene_buffer_create`, `wlr_scene_buffer_set_buffer`, +`wlr_scene_node_for_each_buffer`, `scene_node_intersect_clip`, +`scene_node_point_inside_clip`, `wlr_scene_node_at` and +`scene_entry_render`. The latter two paths are separately observed by the +passive hit and render traces. A saved scene buffer is distinct from its +source Wayland buffer, output buffer, and optimized blur cache. + +Only H1 `(-800,0),800×600` and H2 `(0,0),800×600`, scale 1, normal, are +allowed. Map and settle all clients first. Record H2 reference app ID, +output, geometry, two PNG hashes and full RGB equality. Then identify the +target app ID `org.mosaic.SavedBufferProbe`, workspace, output, tiled owner, +view, source scene node/buffer, destination/source dimensions, source box, +transform, applied camera, tiled clip and global source box. Compute one +signed relative scroll from the **current** camera and measured source node. +Remeasure after application. Require nonempty source and `left<0= start_ns) +destroyed = next(x for x in log if x.startswith('SB_TREE_DESTROY ') and + f'view={view}' in x and f'tree={tree}' in x and + int(fields(x)['ns']) > start_ns) +render = [x for x in log if x.startswith('SB_RENDER ') and f'node={node}' in x + and int(fields(x)['ns']) >= start_ns and + int(fields(x)['ns']) < int(fields(destroyed)['ns'])] +timeout = next(x for x in log if 'timed out (1 waiting)' in x) +timeout_event = first('timeout_observed') +held = next(x for x in client if x.startswith('HELD ') and 'serial=50' in x) +hits = [x for x in log if x.startswith('TARGET_HIT ') and + int(fields(x)['ns']) < int(fields(destroyed)['ns']) and + int(fields(x)['ns']) > int(fields(created)['ns'])] +surfaces = [next((log[i + 1] for i, line in enumerate(log) if line == hit and + i + 1 < len(log) and + log[i + 1].startswith('TARGET_HIT_SURFACE ')), None) + for hit in hits] +right_names = ['right-reference-a.png', 'right-reference-b.png', + 'right-held.png', 'right-timeout-applied.png'] +right = {name: {**diff(right_names[0], name), 'sha256': sha(run / name), + 'old_target_pixels': color(name, (32, 208, 160)), + 'new_target_pixels': color(name, (35, 68, 204))} + for name in right_names} +left = {name: {'old_target_pixels': color(name, (32, 208, 160)), + 'new_target_pixels': color(name, (35, 68, 204))} + for name in ['left-reference-a.png', 'left-reference-b.png', + 'left-held.png', 'left-timeout-applied.png']} +reference_events = [x for x in events if x['kind'] == 'capture' and + x['path'].startswith('right-reference')] +client_exit_ns = int(fields(next(x for x in client if x.startswith('EXIT ')))['ns']) +post_destroy_same_node = [x for x in log if x.startswith('SB_RENDER ') and + f'node={node}' in x and + int(fields(destroyed)['ns']) < int(fields(x)['ns']) < + client_exit_ns] + +write('reference-pixel-diff.json', {'preflight': stability, + 'capture_gap_seconds': (reference_events[1]['before_ns'] - + reference_events[0]['before_ns']) / 1e9, + 'full_rgb': right}) +write('camera-applied.json', {k: preflight[k] for k in + ('current_scroll', 'desired_scroll', 'delta')}) +write('source-buffer.json', {k: source[k] for k in + ('view', 'container', 'source_node', 'source_buffer', 'left', 'top', + 'width', 'right', 'source_size', 'dest', 'src_box', 'transform')}) +write('saved-target.json', {'view': view, 'tree': tree, 'node': node, + 'buffer': saved['buffer'], 'box': saved['box'], + 'source_buffer_equal': saved['buffer'] == source['source_buffer'], + 'created': created, 'enabled': enabled, 'destroyed': destroyed}) +write('scene-parentage.json', {'source_parent': fields(source['raw'])['parent'], + 'saved_parent': fields(created)['parent'], 'saved_tree': tree, + 'tree_parent': fields(saved['tree_trace'])['parent'], + 'source_node': source['source_node'], 'saved_node': node, + 'target_owner_id': source['owner']['id'], + 'target_workspace_id': source['workspace']['id']}) +write('render-entries.json', {'target_node': node, 'held_entries': render, + 'same_node_entries_between_destroy_and_client_exit': post_destroy_same_node, + 'pointer_reuse_after_client_exit_must_not_be_counted_as_same_tree': True}) +scene = [x for x in log if 'PI_SCENE output=HEADLESS-1' in x and + int(fields(x)['ns']) >= int(fields(created)['ns']) and + int(fields(x)['ns']) < int(fields(destroyed)['ns'])] +write('clip-analysis.json', {'saved_global_box': saved['box'], + 'held_render_visible': fields(render[0])['visible'], + 'outer_tiled_clip': scene[0] if scene else None, + 'post_timeout_clip': next((x for x in log if + 'PI_SCENE output=HEADLESS-1' in x and 'clip=160,0,740,540' in x), None), + 'interpretation': 'Held visible right edge x=0 matches outer Mosaic clip; ' + 'after timeout x=-30 matches gap-adjusted outer clip. No independent ' + 'content-clip causality is inferred.'}) +write('target-pixel-analysis.json', {'full_h1_target_color_counts': left, + 'saved_held_pixels': left['left-held.png']['old_target_pixels']}) +write('whole-h2-comparison.json', {'full_rgb': right, + 'all_equal': all(v['changed_pixels'] == 0 for v in right.values()), + 'no_target_color': all(v['old_target_pixels'] == 0 and + v['new_target_pixels'] == 0 for v in right.values())}) +write('input-hits.json', {'held_hits': hits, 'surface_associations': surfaces, + 'seat_notification': 'not directly traced', + 'h2_reference_motion_received': any(x.startswith('MOTION ') and + 'x=98.00 y=75.00' in x for x in reference), + 'target_motion_during_hold': [x for x in client if x.startswith('MOTION ') and + int(fields(x)['ns']) >= int(fields(held)['ns']) and + int(fields(x)['ns']) < int(fields(destroyed)['ns'])]}) +write('client-delivery.json', {'held_configure': held, + 'target_ack_serial_50': [x for x in client if x.startswith('ACK ') and + 'serial=50' in x], 'reference_motion': [x for x in reference if + x.startswith('MOTION ')], 'target_exit': [x for x in client if + x.startswith('EXIT ')]}) +write('transaction-timeline.json', {'held': held, 'saved_created': created, + 'render': render[0], 'timeout': timeout, + 'saved_destroyed': destroyed, + 'hold_to_destroy_seconds': (int(fields(destroyed)['ns']) - + int(fields(held)['ns'])) / 1e9, + 'other_transactions_after_controlled_client_cleanup': True}) +write('timeout.json', {'natural_timeout': timeout, 'event': timeout_event, + 'no_target_ack': not any(x.startswith('ACK ') and 'serial=50' in x + for x in client), 'client_alive_at_timeout': timeout_event['client_alive'], + 'target_tree_destroyed': destroyed}) +write('lifetime.json', {'source_buffer': source['source_buffer'], + 'saved_buffer': saved['buffer'], 'saved_tree_destroyed': destroyed, + 'no_same_node_render_between_destroy_and_client_exit': + not bool(post_destroy_same_node), + 'gpu_texture_release': 'NOT TESTED'}) +write('damage-analysis.json', {'held_target_render': render[0], + 'held_damage_extent': fields(render[0])['damage'], + 'post_timeout_h1_target_pixels': left['left-timeout-applied.png'], + 'h2_changed_pixels': right['right-timeout-applied.png']['changed_pixels'], + 'all_intermediate_frames': 'NOT TESTED'}) +write('budget.json', {'reserved': budget, 'limits': {'run': 1, 'episode': 1, + 'camera': 1, 'capture': 12, 'seconds': 150}, + 'duration_seconds': status['duration_seconds'], 'failure': status['failure']}) +core = (stability['stable'] and saved['buffer'] == source['source_buffer'] and + saved['box'][0] < 0 < saved['box'][2] and bool(render) and + left['left-held.png']['old_target_pixels'] > 0 and + all(v['changed_pixels'] == 0 for v in right.values()) and + bool(destroyed) and timeout_event['client_alive']) +verdict = 'PARTIALLY_FEASIBLE' if core else 'INCONCLUSIVE' +write('matrix.json', {'ack_e1_historical': 'PARTIALLY_FEASIBLE', + 'new_timeout_core': 'PASS' if core else 'FAIL', + 'input_sampled': len(hits) == 2, 'damage_complete': False, + 'native_gpu_lifetime_complete': False}) +write('summary.json', {'verdict': verdict, 'core_pass': core, + 'architecture': 'SAVED_TARGET_TIMEOUT_CLIPPING_FEASIBLE_FOR_TESTED_MATRIX' + if core else 'UNDECIDED', 'run': str(run), + 'limits': ['full transactional damage', 'all GPU/texture references', + 'intermediate frames', 'seat notification on saved node', + 'unmeasured input paths']}) + +# Source manifest covers exact executed fixture and private source trees; no +# build products enter this source list. Evidence manifest covers all retained +# raw run inputs, captures and derived JSON without a self-hash cycle. +fixture = repo / 'tooling/probes/step20c-saved-target-timeout-input' +sources = [p for p in fixture.iterdir() if p.is_file()] + [run / 'fixture-source.py'] +for root in (lab / 'mosaic', lab / 'standard', lab / 'scenefx-git'): + sources.extend(p for p in root.rglob('*') if p.is_file() and + '.git' not in p.parts and 'subprojects/packagecache' not in str(p)) +sources.extend(p for p in (lab / 'nested-client.c', lab / 'pointer-daemon.c', + lab / 'virtual-pointer-client.h', lab / 'virtual-pointer-protocol.c', + lab / 'xdg-shell-client.h', lab / 'xdg-shell-protocol.c') + if p.is_file()) +sources = sorted(set(sources)) +evidence = sorted(set(p for p in run.iterdir() if p.is_file()) | + set(p for p in out.iterdir() if p.is_file() and + not p.name.endswith('-sha256.txt')) | + {lab / 'timeout-budget.json', lab / 'saved-timeout-build-log.txt'}) +for name, paths in [('source-sha256.txt', sources), ('evidence-sha256.txt', evidence)]: + (out / name).write_text(''.join(f'{sha(p)} {p}\n' for p in paths)) +print(f'{verdict} source={len(sources)} evidence={len(evidence)}') diff --git a/tooling/probes/step20c-saved-target-timeout-input/budget.py b/tooling/probes/step20c-saved-target-timeout-input/budget.py new file mode 100644 index 0000000..47c206c --- /dev/null +++ b/tooling/probes/step20c-saved-target-timeout-input/budget.py @@ -0,0 +1,30 @@ +"""Persistent one-run target-timeout budget; every reservation is durable.""" +import fcntl +import json +import os +from pathlib import Path + +LIMITS = {'run': 1, 'episode': 1, 'camera': 1, 'capture': 12} + + +def reserve(lab: Path, resource: str) -> dict: + if resource not in LIMITS: + raise ValueError('unknown budget resource') + path = lab / 'timeout-budget.json' + with path.open('a+') as file: + fcntl.flock(file, fcntl.LOCK_EX) + file.seek(0) + raw = file.read() + state = json.loads(raw) if raw else {key: 0 for key in LIMITS} + if state[resource] >= LIMITS[resource]: + raise RuntimeError(f'{resource} budget exhausted') + if resource != 'run' and state['run'] != 1: + raise RuntimeError('run must be reserved first') + state[resource] += 1 + file.seek(0) + file.truncate() + json.dump(state, file, sort_keys=True) + file.write('\n') + file.flush() + os.fsync(file.fileno()) + return state diff --git a/tooling/probes/step20c-saved-target-timeout-input/prepare.sh b/tooling/probes/step20c-saved-target-timeout-input/prepare.sh new file mode 100644 index 0000000..409cace --- /dev/null +++ b/tooling/probes/step20c-saved-target-timeout-input/prepare.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# EXPERIMENTAL source-only build; never launches a compositor. +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +fixture="$repo/tooling/probes/step20c-saved-target-timeout-input" +log=$(mktemp /tmp/saved-timeout-prepare-XXXXXXXX) +if ! sh "$repo/tooling/probes/step20c-saved-buffer-target-crossing/prepare.sh" > "$log" 2>&1; then + printf 'BUILD_FAILED log=%s\n' "$log" >&2 + tail -40 "$log" >&2 + exit 1 +fi +lab=$(sed -n 's/^LAB=//p' "$log" | head -1) +test -n "$lab" && test -d "$lab" +git -C "$lab/mosaic" apply --check "$fixture/target-hit-trace.patch" +git -C "$lab/mosaic" apply "$fixture/target-hit-trace.patch" +meson compile -C "$lab/mosaic-build" -j 8 >> "$log" 2>&1 +for client in saved-client static-reference; do + cc -std=c17 -Wall -Wextra -Werror -Wno-unused-parameter -I"$lab" \ + "$fixture/$client.c" "$lab/xdg-shell-protocol.c" -o "$lab/$client" \ + $(pkg-config --cflags --libs wayland-client) >> "$log" 2>&1 +done +cp "$log" "$lab/saved-timeout-build-log.txt" +printf 'LAB=%s\nBUILD_LOG=%s\n' "$lab" "$log" diff --git a/tooling/probes/step20c-saved-target-timeout-input/run.py b/tooling/probes/step20c-saved-target-timeout-input/run.py new file mode 100644 index 0000000..a4b5bd3 --- /dev/null +++ b/tooling/probes/step20c-saved-target-timeout-input/run.py @@ -0,0 +1,391 @@ +"""One reserved headless run with a real held XDG configure per episode.""" +import argparse +import json +import os +import re +from pathlib import Path +import shutil +import signal +import subprocess +import tempfile +import time +from PIL import Image, ImageChops +from budget import reserve + +parser = argparse.ArgumentParser() +parser.add_argument('lab', type=Path) +args = parser.parse_args() +args.mode = 'private_mosaic' +lab = args.lab.resolve() +if not (lab / 'saved-client').is_file() or not (lab / 'static-reference').is_file(): + parser.error('prepared LAB required') +state = reserve(lab, 'run') +run = lab / 'runs' / f'timeout-mosaic-{time.time_ns()}' +run.mkdir(parents=True, mode=0o700) +(run / 'fixture-source.py').write_text(Path(__file__).read_text()) +runtime = Path(tempfile.mkdtemp(prefix='mst-', dir='/tmp')) +(run / 'runtime-path').write_text(str(runtime) + '\n') +(run / 'config').write_text( + 'output HEADLESS-1 resolution 800x600 scale 1 transform normal position -800 0\n' + 'output HEADLESS-2 resolution 800x600 scale 1 transform normal position 0 0\n' + 'workspace 1 output HEADLESS-1\nworkspace 2 output HEADLESS-2\n' + 'focus_follows_mouse no\n') +env = os.environ.copy() +for key in ('SWAYSOCK', 'I3SOCK', 'WAYLAND_DISPLAY', 'LD_LIBRARY_PATH'): + env.pop(key, None) +env.update(XDG_RUNTIME_DIR=str(runtime), WLR_BACKENDS='headless', + WLR_HEADLESS_OUTPUTS='2', WLR_RENDERER='pixman', SAVED_TRACE='1', SAVED_TARGET_TRACE='1', + SAVED_HIT_TRACE='1') +binary = lab / 'mosaic-build/sway/swayfx-mosaic' +env['LD_LIBRARY_PATH'] = str(lab / 'scenefx-build') +started = time.monotonic() +captures = 0 +events = [] +processes = [] +logs = [] +failure = None +compositor = None + + +def record(kind, **kw): + event = {'ns': time.monotonic_ns(), 'kind': kind, **kw} + events.append(event) + with (run / 'events.jsonl').open('a') as file: + file.write(json.dumps(event) + '\n') + + +def spawn(argv, name, *, stdin=False, use_env=None): + file = (run / name).open('w') + logs.append(file) + proc = subprocess.Popen(argv, env=use_env or env, stdout=file, + stderr=subprocess.STDOUT, start_new_session=True, + stdin=subprocess.PIPE if stdin else None, text=stdin) + processes.append(proc) + return proc + + +def wait(predicate, seconds=8): + limit = time.monotonic() + seconds + while time.monotonic() < limit: + if time.monotonic() - started > 150: + raise RuntimeError('150 second run bound exceeded') + value = predicate() + if value: + return value + if compositor and compositor.poll() is not None: + raise RuntimeError(f'compositor exit {compositor.returncode}') + time.sleep(.025) + raise RuntimeError('wait timed out') + + +def ipc(*argv): + result = subprocess.run(['swaymsg', '-s', str(sock), *argv], env=env, + capture_output=True, text=True, timeout=5, check=True) + value = json.loads(result.stdout) + record('ipc', argv=list(argv), result=value) + return value + + +def command(value): + result = ipc(value) + if not result or not all(x.get('success') for x in result): + raise RuntimeError(f'IPC rejected {value}: {result}') + + +def find(node, predicate): + if isinstance(node, dict): + if predicate(node): + return node + for child in node.get('nodes', []) + node.get('floating_nodes', []): + match = find(child, predicate) + if match: + return match + return None + + +def client_log(): + return (run / 'client.log').read_text() + + +def compositor_log(): + return (run / 'compositor.log').read_text() + + +def client_command(value): + marker = f'COMMAND {value} DONE' + before = client_log().count(marker) + client.stdin.write(value + '\n') + client.stdin.flush() + wait(lambda: client_log().count(marker) > before) + record('client_command', value=value) + + +def inject(action, x, y): + if not -800 <= x < 800 or not 0 <= y < 600: + raise ValueError('pointer point outside applied outputs') + marker = f'INJECT_DONE {action} {x + 800},{y}' + before = (run / 'pointer.log').read_text().count(marker) + pointer.stdin.write(f'{action} {x + 800} {y} 1600 600\n') + pointer.stdin.flush() + wait(lambda: (run / 'pointer.log').read_text().count(marker) > before) + record('pointer', action=action, global_point=[x, y], + client_tail=client_log().splitlines()[-5:]) + + +def snapshot(tag): + global captures + outputs = ipc('-t', 'get_outputs') + actual = {o['name']: (o['rect']['x'], o['rect']['y'], + o['rect']['width'], o['rect']['height']) for o in outputs if o['active']} + if actual != {'HEADLESS-1': (-800, 0, 800, 600), 'HEADLESS-2': (0, 0, 800, 600)}: + raise RuntimeError(f'output geometry invalid: {actual}') + for name, side in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')): + reserve(lab, 'capture') + path = run / f'{side}-{tag}.png' + before = time.monotonic_ns() + subprocess.run(['grim', '-s', '1', '-o', name, str(path)], env=env, + check=True, timeout=5, stdout=subprocess.DEVNULL) + after = time.monotonic_ns() + captures += 1 + size = Image.open(path).size + if size != (800, 600): + raise RuntimeError(f'PNG dimensions invalid: {size}') + record('capture', tag=tag, output=name, path=path.name, + before_ns=before, after_ns=after, size=size) + (run / f'tree-{tag}.json').write_text(json.dumps(ipc('-t', 'get_tree'), indent=2) + '\n') + + +def source_measure(): + tree = ipc('-t', 'get_tree') + workspace = find(tree, lambda n: n.get('type') == 'workspace' and n.get('name') == '1') + owner = find(tree, lambda n: n.get('app_id') == 'org.mosaic.SavedBufferProbe') + if not owner or not workspace: + raise RuntimeError('target view or workspace absent') + lines = [line for line in compositor_log().splitlines() if 'TARGET_SOURCE ns=' in line] + if not lines: + raise RuntimeError('target source scene buffer unmeasured') + line = lines[-1] + def field(name): + match = re.search(rf'{name}=([^ ]+)', line) + if not match: + raise RuntimeError(f'missing {name}: {line}') + return match.group(1) + xy = [int(x) for x in field('global').split(',')] + size = [int(x) for x in field('source_size').split(',')] + dest = [int(x) for x in field('dest').split(',')] + width = dest[0] or size[0] + result = {'view': field('view'), 'container': field('con'), + 'source_node': field('node'), 'source_buffer': field('buffer'), + 'global_valid': field('global_valid') == '1', + 'left': xy[0], 'top': xy[1], 'width': width, + 'right': xy[0] + width, 'source_size': size, 'dest': dest, + 'src_box': field('src_box'), 'transform': field('transform'), + 'enabled': field('enabled'), 'owner': owner, + 'workspace': workspace, 'raw': line} + record('source_measure', measurement=result) + return result + + +def preflight_target(): + global target_source + before = source_measure() + target_source = before + if not before['global_valid'] or before['width'] <= 0 or before['enabled'] != '1': + raise RuntimeError('source buffer geometry invalid') + row = before['workspace']['mosaic']['rows'][0] + current = row['scroll_x'] + desired_left = -max(64, min(before['width'] // 2, before['width'] - 64)) + delta = int(round(before['left'] - desired_left)) + desired = current + delta + if before['width'] < 128 or desired < 0: + raise RuntimeError(f'crossing unreachable from measured source: current={current} delta={delta}') + record('camera_calculation', current_scroll=current, desired_scroll=desired, + delta=delta, desired_left=desired_left, source=before) + if delta: + reserve(lab, 'camera') + command(f'layout mosaic scroll {delta}') + time.sleep(.2) + after = source_measure() + target_source = after + applied = after['workspace']['mosaic']['rows'][0]['scroll_x'] + record('camera_applied', scroll=applied, source=after) + if applied != desired or after['left'] >= -64 or after['right'] <= 64: + raise RuntimeError(f'target source failed boundary preflight: {after["left"]},{after["right"]}, scroll={applied}') + if after['source_node'] != before['source_node'] or after['source_buffer'] != before['source_buffer']: + raise RuntimeError('source identity changed during camera adjustment') + (lab / 'results').mkdir(exist_ok=True) + (lab / 'results' / 'target-preflight.json').write_text(json.dumps({ + 'before': before, 'after': after, 'current_scroll': current, + 'desired_scroll': desired, 'delta': delta}, indent=2) + '\n') + + +def reference_stability(): + snapshot('reference-a') + time.sleep(.7) + snapshot('reference-b') + left = Image.open(run / 'right-reference-a.png').convert('RGB') + right = Image.open(run / 'right-reference-b.png').convert('RGB') + difference = ImageChops.difference(left, right) + box = difference.getbbox() + stable = box is None + target_colors = {(35, 68, 204), (32, 208, 160)} + target_pixels = sum(pixel in target_colors for pixel in left.get_flattened_data()) + (lab / 'results').mkdir(exist_ok=True) + (lab / 'results' / 'reference-stability.json').write_text(json.dumps({ + 'stable': stable, 'diff_box': box, 'target_pixels': target_pixels, + 'first_sha256': sha256(run / 'right-reference-a.png'), + 'second_sha256': sha256(run / 'right-reference-b.png'), + 'first_app_id': 'org.mosaic.StaticReference'}, indent=2) + '\n') + record('reference_stability', stable=stable, box=box, target_pixels=target_pixels) + if not stable or target_pixels: + raise RuntimeError('static H2 reference gate failed before transaction') + + +def sha256(path): + import hashlib + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def episode(): + global target_source + # WHY: the source pointer belongs to this one episode, not a past ACK. + target_source = source_measure() + if not target_source['left'] < 0 < target_source['right']: + raise RuntimeError('current source does not cross x=0') + reserve(lab, 'episode') + record('episode_start', source=target_source) + client_command('hold_next_configure') + old_held = client_log().count('HELD ns=') + old_timeout = compositor_log().count('timed out') + before_log = len(compositor_log()) + command('gaps inner current set 30') + wait(lambda: client_log().count('HELD ns=') > old_held, 3) + held_line = [x for x in client_log().splitlines() if x.startswith('HELD ns=')][-1] + held_ns = int(re.search(r'ns=(\d+)', held_line).group(1)) + view = target_source['view'] + def target_trace(): + text = compositor_log()[before_log:] + trees = [x for x in text.splitlines() if 'SB_TREE_CREATE ' in x and f'view={view}' in x] + saved = [x for x in text.splitlines() if 'TARGET_SAVED ' in x and f'view={view}' in x] + return (trees[-1], saved[-1]) if trees and saved else None + tree_line, saved_line = wait(target_trace, 2) + def field(line, name): + match = re.search(rf'{name}=([^ ]+)', line) + if not match: + raise RuntimeError(f'missing {name}: {line}') + return match.group(1) + saved_node = field(saved_line, 'node') + saved_buffer = field(saved_line, 'buffer') + saved_tree = field(tree_line, 'tree') + x, y = map(int, field(saved_line, 'global').split(',')) + sw, sh = map(int, field(saved_line, 'source_size').split(',')) + dw, dh = map(int, field(saved_line, 'dest').split(',')) + box = [x, y, x + (dw or sw), y + (dh or sh)] + record('saved_target', view=view, tree=saved_tree, node=saved_node, + buffer=saved_buffer, source=target_source, box=box, + tree_trace=tree_line, saved_trace=saved_line) + if (saved_buffer != target_source['source_buffer'] or + field(saved_line, 'global_valid') != '1' or + field(saved_line, 'enabled') != '1' or sw <= 0 or sh <= 0 or + not box[0] < 0 < box[2]): + raise RuntimeError('actual saved target identity/geometry gate failed') + wait(lambda: any('SB_RENDER ' in line and f'node={saved_node}' in line + for line in compositor_log()[before_log:].splitlines()), 1) + if f'view={view}' not in tree_line: + raise RuntimeError('saved target view mismatch') + snapshot('held') + if time.monotonic_ns() - held_ns < 1200000000: + # The H2 move precedes the H1 move so final H2 can be compared whole. + inject('move', 100, 100) + inject('move', max(-700, min(-100, (box[0] + min(box[2], 0)) // 2)), + max(80, min(500, (box[1] + box[3]) // 2))) + record('input_sampled', reference_tail=(run / 'reference.log').read_text().splitlines()[-10:], + target_tail=client_log().splitlines()[-10:]) + else: + record('input_not_tested', reason='insufficient natural timeout window') + wait(lambda: compositor_log().count('timed out') > old_timeout, 4) + if client.poll() is not None: + raise RuntimeError('target client exited before natural timeout') + held_tail = client_log().split(held_line, 1)[-1] + if 'ACK ns=' in held_tail: + raise RuntimeError('target client ACKed held configure') + record('timeout_observed', held_serial=field(held_line, 'serial'), + client_alive=True, no_ack=True) + wait(lambda: any('SB_TREE_DESTROY ' in line and f'view={view}' in line and + f'tree={saved_tree}' in line for line in + compositor_log()[before_log:].splitlines()), 2) + record('target_tree_destroyed', tree=saved_tree) + time.sleep(.2) + snapshot('timeout-applied') + if client.poll() is not None: + raise RuntimeError('target client exited after timeout') + + +try: + file = (run / 'compositor.log').open('w') + logs.append(file) + compositor = subprocess.Popen([str(binary), '-c', str(run / 'config'), + '-d', '-Dtxn-timeout=2500'], env=env, + stdout=file, stderr=subprocess.STDOUT, + start_new_session=True) + def sockets(): + s = next(iter(runtime.glob('sway-ipc.*.sock')), None) + w = next((p for p in runtime.glob('wayland-*') if p.suffix != '.lock'), None) + return (s, w) if s and w else None + sock, way = wait(sockets) + env.update(SWAYSOCK=str(sock), WAYLAND_DISPLAY=way.name) + pointer = spawn([str(lab / 'pointer-daemon'), '1600', '600'], + 'pointer.log', stdin=True) + wait(lambda: 'POINTER_READY' in (run / 'pointer.log').read_text()) + command('workspace 1') + command('layout mosaic') + spawn(['kitty', '--class', 'A', '/usr/bin/sleep', '900'], 'A.log') + wait(lambda: len(find(ipc('-t', 'get_tree'), + lambda n: n.get('type') == 'workspace' and n.get('name') == '1')['nodes']) >= 1) + client = spawn([str(lab / 'saved-client')], 'client.log', stdin=True) + wait(lambda: 'COMMIT ns=' in client_log()) + for name in ('C', 'D'): + spawn(['kitty', '--class', name, '/usr/bin/sleep', '900'], f'{name}.log') + time.sleep(.2) + command('workspace 2') + reference = spawn([str(lab / 'static-reference')], 'reference.log', stdin=True) + wait(lambda: 'COMMIT ns=' in (run / 'reference.log').read_text()) + tree = ipc('-t', 'get_tree') + ref = find(tree, lambda n: n.get('app_id') == 'org.mosaic.StaticReference') + if not ref or ref['rect']['x'] != 0: + raise RuntimeError('static reference not mapped on H2') + record('static_reference', node=ref) + command('workspace 1') + time.sleep(.2) + reference_stability() + preflight_target() + episode() +except Exception as exc: + failure = repr(exc) + record('failure', message=failure) +finally: + for proc in processes + ([compositor] if compositor else []): + if proc.poll() is None: + try: + os.killpg(proc.pid, signal.SIGTERM) + except ProcessLookupError: + pass + for proc in processes + ([compositor] if compositor else []): + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(proc.pid, signal.SIGKILL) + proc.wait(timeout=5) + for file in logs: + file.close() + (run / 'exit-status.json').write_text(json.dumps({ + 'failure': failure, 'captures': captures, + 'duration_seconds': time.monotonic() - started, + 'compositor': compositor.returncode if compositor else None, + 'children': [{'args': p.args, 'code': p.returncode} for p in processes]}, + indent=2) + '\n') + shutil.rmtree(runtime) + record('cleanup', runtime_removed=not runtime.exists()) +print(f'RUN={run} FAILURE={failure} CAPTURES={captures}') +if failure: + raise SystemExit(1) diff --git a/tooling/probes/step20c-saved-target-timeout-input/saved-client.c b/tooling/probes/step20c-saved-target-timeout-input/saved-client.c new file mode 100644 index 0000000..c5aa1de --- /dev/null +++ b/tooling/probes/step20c-saved-target-timeout-input/saved-client.c @@ -0,0 +1,224 @@ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "xdg-shell-client.h" + +/* WHY: the compositor must wait on a real, responsive XDG client. + * CONTRACT: hold only a received configure; ACK the latest received serial. + * INVARIANT: Wayland dispatch and ping replies continue while ACK is held. */ +static struct wl_display *display; +static struct wl_compositor *compositor; +static struct wl_shm *shm; +static struct xdg_wm_base *wm; +static struct wl_surface *surface; +static struct xdg_surface *xdg; +static struct xdg_toplevel *toplevel; +static struct wl_seat *seat; +static struct wl_pointer *pointer; +static volatile sig_atomic_t running = 1; +static bool mapped, hold_next, held; +static uint32_t latest_serial; +static int32_t width = 420, height = 320, configured_width = 420, configured_height = 320; +static unsigned generation; + +static long long stamp(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec; +} + +static void draw(void) { + if (configured_width > 0) width = configured_width; + if (configured_height > 0) height = configured_height; + if (width <= 0 || height <= 0 || width > 4096 || height > 4096) { + fprintf(stderr, "INVALID_SIZE %d %d\n", width, height); + exit(2); + } + size_t bytes = (size_t)width * (size_t)height * 4; + char name[] = "/tmp/mosaic-saved-shm-XXXXXX"; + int fd = mkstemp(name); + if (fd < 0 || ftruncate(fd, (off_t)bytes) < 0) { perror("shm"); exit(2); } + unlink(name); + uint32_t *pixels = mmap(NULL, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (pixels == MAP_FAILED) { perror("mmap"); exit(2); } + uint32_t color = (generation++ % 2) ? 0x0020d0a0 : 0x002344cc; +#ifdef STATIC_REFERENCE + /* CONTRACT: H2 uses one opaque SHM color across every real configure. */ + color = 0x00303038; +#endif + for (size_t i = 0; i < bytes / 4; ++i) pixels[i] = color; + struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, (int)bytes); + struct wl_buffer *buffer = wl_shm_pool_create_buffer(pool, 0, width, height, + width * 4, WL_SHM_FORMAT_XRGB8888); + wl_surface_attach(surface, buffer, 0, 0); + wl_surface_damage_buffer(surface, 0, 0, width, height); + wl_surface_commit(surface); + fprintf(stderr, "COMMIT ns=%lld serial=%u size=%dx%d color=%08x\n", + stamp(), latest_serial, width, height, color); + wl_buffer_destroy(buffer); + wl_shm_pool_destroy(pool); + munmap(pixels, bytes); + close(fd); + mapped = true; +} + +static void ack_and_commit(void) { + if (!latest_serial) return; + xdg_surface_ack_configure(xdg, latest_serial); + fprintf(stderr, "ACK ns=%lld serial=%u size=%dx%d\n", + stamp(), latest_serial, configured_width, configured_height); + draw(); + held = false; +} + +static void ping(void *data, struct xdg_wm_base *base, uint32_t serial) { + xdg_wm_base_pong(base, serial); + fprintf(stderr, "PONG ns=%lld serial=%u\n", stamp(), serial); +} +static const struct xdg_wm_base_listener wm_listener = {.ping = ping}; + +static void role_configure(void *data, struct xdg_toplevel *role, + int32_t w, int32_t h, struct wl_array *states) { + if (w > 0) configured_width = w; + if (h > 0) configured_height = h; + fprintf(stderr, "ROLE_CONFIGURE ns=%lld size=%dx%d\n", stamp(), w, h); +} +static void role_close(void *data, struct xdg_toplevel *role) { running = 0; } +static const struct xdg_toplevel_listener role_listener = { + .configure = role_configure, .close = role_close, +}; +static void configure(void *data, struct xdg_surface *s, uint32_t serial) { + latest_serial = serial; + fprintf(stderr, "XDG_CONFIGURE ns=%lld serial=%u size=%dx%d hold=%d previous=%d\n", + stamp(), serial, configured_width, configured_height, hold_next || held, held); + if (hold_next || held) { + held = true; + hold_next = false; + fprintf(stderr, "HELD ns=%lld serial=%u\n", stamp(), serial); + } else { + ack_and_commit(); + } +} +static const struct xdg_surface_listener surface_listener = {.configure = configure}; +static void pointer_enter(void *data, struct wl_pointer *p, uint32_t serial, + struct wl_surface *s, wl_fixed_t x, wl_fixed_t y) { + fprintf(stderr, "ENTER ns=%lld x=%.2f y=%.2f\n", stamp(), + wl_fixed_to_double(x), wl_fixed_to_double(y)); +} +static void pointer_leave(void *data, struct wl_pointer *p, uint32_t serial, + struct wl_surface *s) { fprintf(stderr, "LEAVE ns=%lld\n", stamp()); } +static void pointer_motion(void *data, struct wl_pointer *p, uint32_t time, + wl_fixed_t x, wl_fixed_t y) { + fprintf(stderr, "MOTION ns=%lld x=%.2f y=%.2f\n", stamp(), + wl_fixed_to_double(x), wl_fixed_to_double(y)); +} +static void pointer_button(void *data, struct wl_pointer *p, uint32_t serial, + uint32_t time, uint32_t button, uint32_t state) { + fprintf(stderr, "BUTTON ns=%lld serial=%u button=%u state=%u\n", + stamp(), serial, button, state); +} +static void pointer_axis(void *data, struct wl_pointer *p, uint32_t t, + uint32_t axis, wl_fixed_t value) {} +static void pointer_frame(void *data, struct wl_pointer *p) {} +static const struct wl_pointer_listener pointer_listener = { + .enter = pointer_enter, .leave = pointer_leave, .motion = pointer_motion, + .button = pointer_button, .axis = pointer_axis, .frame = pointer_frame, +}; +static void capabilities(void *data, struct wl_seat *s, uint32_t caps) { + if ((caps & WL_SEAT_CAPABILITY_POINTER) && !pointer) { + pointer = wl_seat_get_pointer(s); + wl_pointer_add_listener(pointer, &pointer_listener, NULL); + } +} +static void seat_name(void *data, struct wl_seat *s, const char *name) {} +static const struct wl_seat_listener seat_listener = { + .capabilities = capabilities, .name = seat_name, +}; +static void global(void *data, struct wl_registry *registry, uint32_t name, + const char *interface, uint32_t version) { + if (!strcmp(interface, wl_compositor_interface.name)) + compositor = wl_registry_bind(registry, name, &wl_compositor_interface, version < 4 ? version : 4); + else if (!strcmp(interface, wl_shm_interface.name)) + shm = wl_registry_bind(registry, name, &wl_shm_interface, 1); + else if (!strcmp(interface, xdg_wm_base_interface.name)) + wm = wl_registry_bind(registry, name, &xdg_wm_base_interface, version < 3 ? version : 3); + else if (!strcmp(interface, wl_seat_interface.name)) { + seat = wl_registry_bind(registry, name, &wl_seat_interface, version < 5 ? version : 5); + wl_seat_add_listener(seat, &seat_listener, NULL); + } +} +static void global_remove(void *data, struct wl_registry *registry, uint32_t name) {} +static const struct wl_registry_listener registry_listener = { + .global = global, .global_remove = global_remove, +}; +static void stop(int signum) { running = 0; } + +int main(void) { + signal(SIGTERM, stop); + display = wl_display_connect(NULL); + if (!display) { fprintf(stderr, "CONNECT_FAILED\n"); return 1; } + struct wl_registry *registry = wl_display_get_registry(display); + wl_registry_add_listener(registry, ®istry_listener, NULL); + wl_display_roundtrip(display); + wl_display_roundtrip(display); + if (!compositor || !shm || !wm) { fprintf(stderr, "MISSING_GLOBAL\n"); return 2; } + xdg_wm_base_add_listener(wm, &wm_listener, NULL); + surface = wl_compositor_create_surface(compositor); + xdg = xdg_wm_base_get_xdg_surface(wm, surface); + xdg_surface_add_listener(xdg, &surface_listener, NULL); + toplevel = xdg_surface_get_toplevel(xdg); + xdg_toplevel_add_listener(toplevel, &role_listener, NULL); +#ifdef STATIC_REFERENCE + xdg_toplevel_set_title(toplevel, "Mosaic Static Reference"); + xdg_toplevel_set_app_id(toplevel, "org.mosaic.StaticReference"); +#else + xdg_toplevel_set_title(toplevel, "Mosaic Saved Buffer Probe"); + xdg_toplevel_set_app_id(toplevel, "org.mosaic.SavedBufferProbe"); +#endif + wl_surface_commit(surface); + fprintf(stderr, "READY ns=%lld\n", stamp()); + while (running) { + wl_display_flush(display); + struct pollfd fds[2] = { + {.fd = wl_display_get_fd(display), .events = POLLIN}, + {.fd = STDIN_FILENO, .events = POLLIN}, + }; + int ready = poll(fds, 2, 100); + if (ready < 0) { if (errno == EINTR) continue; perror("poll"); break; } + if (fds[0].revents & (POLLIN | POLLERR | POLLHUP)) { + if (wl_display_dispatch(display) < 0) break; + } + if (fds[1].revents & POLLIN) { + char cmd[64]; + if (!fgets(cmd, sizeof(cmd), stdin)) break; + if (!strncmp(cmd, "hold_next_configure", 19)) { + hold_next = true; + fprintf(stderr, "COMMAND hold_next_configure DONE ns=%lld\n", stamp()); + } else if (!strncmp(cmd, "release_configure", 17)) { + if (held) ack_and_commit(); + fprintf(stderr, "COMMAND release_configure DONE ns=%lld\n", stamp()); + } else if (!strncmp(cmd, "close", 5)) { + running = 0; + fprintf(stderr, "COMMAND close DONE ns=%lld\n", stamp()); + } + } + } + fprintf(stderr, "EXIT ns=%lld mapped=%d held=%d error=%d\n", + stamp(), mapped, held, wl_display_get_error(display)); + xdg_toplevel_destroy(toplevel); + xdg_surface_destroy(xdg); + wl_surface_destroy(surface); + wl_display_flush(display); + wl_display_disconnect(display); + return 0; +} diff --git a/tooling/probes/step20c-saved-target-timeout-input/static-reference.c b/tooling/probes/step20c-saved-target-timeout-input/static-reference.c new file mode 100644 index 0000000..c6818ba --- /dev/null +++ b/tooling/probes/step20c-saved-target-timeout-input/static-reference.c @@ -0,0 +1,5 @@ +/* WHY: a deterministic H2 reference cannot contain Kitty's blinking cursor. + * CONTRACT: use the same real XDG configure/ACK loop with fixed opaque SHM. + * INVARIANT: once configured and stable, this client changes no pixels. */ +#define STATIC_REFERENCE 1 +#include "saved-client.c" diff --git a/tooling/probes/step20c-saved-target-timeout-input/target-hit-trace.patch b/tooling/probes/step20c-saved-target-timeout-input/target-hit-trace.patch new file mode 100644 index 0000000..a44f9ab --- /dev/null +++ b/tooling/probes/step20c-saved-target-timeout-input/target-hit-trace.patch @@ -0,0 +1,39 @@ +--- a/sway/input/cursor.c ++++ b/sway/input/cursor.c +@@ -1,5 +1,7 @@ + #include + #include ++#include ++#include + #include + #include + #include +@@ -58,6 +60,17 @@ + } + + if (scene_node) { ++ /* WHY: one sampled motion must distinguish saved node from live surface. ++ * CONTRACT: passive private trace; no seat, scene or hit result changes. ++ * INVARIANT: log borrowed identities only during this lookup. */ ++ struct timespec hit_ts; ++ clock_gettime(CLOCK_MONOTONIC, &hit_ts); ++ if (getenv("SAVED_HIT_TRACE")) { ++ fprintf(stderr, "TARGET_HIT ns=%lld point=%.2f,%.2f node=%p saved=%d\n", ++ (long long)hit_ts.tv_sec * 1000000000LL + hit_ts.tv_nsec, ++ lx, ly, (void *)scene_node, ++ scene_descriptor_try_get(scene_node, SWAY_SCENE_DESC_SAVED_BUFFER) != NULL); ++ } + // determine what wlr_surface we clicked on + if (scene_node->type == WLR_SCENE_NODE_BUFFER) { + struct wlr_scene_buffer *scene_buffer = +@@ -68,6 +81,10 @@ + if (scene_surface) { + *surface = scene_surface->surface; + } ++ if (getenv("SAVED_HIT_TRACE")) { ++ fprintf(stderr, "TARGET_HIT_SURFACE node=%p surface=%p\n", ++ (void *)scene_node, scene_surface ? (void *)scene_surface->surface : NULL); ++ } + } + + // determine what container we clicked on diff --git a/tooling/probes/step20c-saved-target-timeout-input/test-budget.py b/tooling/probes/step20c-saved-target-timeout-input/test-budget.py new file mode 100644 index 0000000..98fca27 --- /dev/null +++ b/tooling/probes/step20c-saved-target-timeout-input/test-budget.py @@ -0,0 +1,43 @@ +"""Offline ledger limits and lock contention; never touches a campaign LAB.""" +import json +import multiprocessing +import tempfile +from pathlib import Path +from budget import reserve + +with tempfile.TemporaryDirectory() as root: + lab = Path(root) + reserve(lab, 'run') + reserve(lab, 'episode') + reserve(lab, 'camera') + for _ in range(12): + reserve(lab, 'capture') + assert json.loads((lab / 'timeout-budget.json').read_text()) == { + 'run': 1, 'episode': 1, 'camera': 1, 'capture': 12} + for resource in ('run', 'episode', 'camera', 'capture'): + try: + reserve(lab, resource) + raise AssertionError(f'{resource} oversubscription accepted') + except RuntimeError: + pass +print('PASS one run/episode/camera and twelve captures') + + +def contender(path, queue): + try: + reserve(Path(path), 'run') + queue.put('accepted') + except RuntimeError: + queue.put('rejected') + +with tempfile.TemporaryDirectory() as root: + context = multiprocessing.get_context('fork') + queue = context.Queue() + children = [context.Process(target=contender, args=(root, queue)) for _ in range(2)] + for child in children: + child.start() + for child in children: + child.join(5) + assert child.exitcode == 0 + assert sorted(queue.get() for _ in children) == ['accepted', 'rejected'] +print('PASS exclusive reservation')