diff --git a/CHANGELOG.md b/CHANGELOG.md index cab7724..fe5101a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### STEP20C experimental evidence +- 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. + - Preserved saved-transaction-buffer probe: real held XDG configures produced nonempty saved scene buffers; private saved nodes entered rendering and sampled ACK/timeout paths removed their trees. Mosaic's target was outside H1 after the applied camera reached 1760, so partial-boundary clipping is NOT EXERCISED and the verdict is INCONCLUSIVE. Three distinct executed runner copies and the private-standard fixture timeout are retained; no production change. - Local static-R90 partial-popup probe: a legal nested B popup crossed the Mosaic viewport by 72 logical pixels; 46,455 B pixels and a real button survived on H1, while H2 was pixel-identical to its reference and delivered no B button. Both standard controls showed 35,360 B pixels and a B button on H2. A crossed visually, but its forbidden-side input was not independently clicked. PARTIALLY_FEASIBLE for the tested matrix; no production change or phase-B commit/push. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2e4aa4e..9e386c3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -288,3 +288,15 @@ The experimental Mosaic tiled clip is an ancestor of the view tree, so a saved tree has a plausible inherited path. The measured target was outside H1 after the applied camera reached 1760, preventing a boundary-clipping conclusion. This remains INCONCLUSIVE, with no production architecture change. + +The separate [target-crossing saved-buffer probe](design/STEP20C_SAVED_BUFFER_TARGET_CROSSING_PROBE.md) +then measured the actual source and saved scene buffer crossing x=0 after a +single calculated camera correction. Its target saved node rendered with a +visible region ending at x=0 and retained target pixels on H1; no target SHM +color appeared on H2. A 253-pixel whole-H2 reference difference and incomplete +Mosaic timeout/input/damage/lifetime evidence limit the result to +PARTIALLY_FEASIBLE and architecture UNDECIDED. No production contract changes. +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. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 0003f31..d3a79a4 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -229,3 +229,18 @@ clipped input, transaction-scoped damage, destruction while pending and reference lifetime after texture conversion remain unqualified. One private-standard episode failed through fixture timing. STEP20C remains **NEXT** and production multi-output **NOT YET SUPPORTED**. + +The [new target-crossing probe](design/STEP20C_SAVED_BUFFER_TARGET_CROSSING_PROBE.md) +is **PARTIALLY_FEASIBLE** for one real saved target that crossed x=0 and +rendered only its permitted H1 portion in the sampled ACK episode. H2 had no +target-color pixels but differed from its whole-image reference by 253 pixels +of unproven cause, also seen in pristine controls. A stale fixture pointer +stopped Mosaic E2 before natural timeout; target input, complete damage and +GPU reference lifetime are unqualified. STEP20C remains **NEXT** and +production multi-output **NOT YET SUPPORTED**. + +Offline RGB analysis found the same filled 11×23 `#F5E0DC`→`#303038` +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. diff --git a/docs/README.md b/docs/README.md index 9147c7a..6113b55 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,7 +45,9 @@ The [root README](../README.md) is the public summary. For a specific topic, use [R90 source fixture](../tooling/probes/step20c-rotated-partial-popup/README.md) preserves the published experimental inputs. 25. [STEP20C saved transaction buffer probe](design/STEP20C_SAVED_TRANSACTION_BUFFER_PROBE.md): real saved nodes and lifetime evidence, but target partial-overflow geometry NOT EXERCISED; result INCONCLUSIVE. [Saved-buffer source fixture](../tooling/probes/step20c-saved-transaction-buffer/README.md) preserves the experimental inputs. -26. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence. +26. [STEP20C saved target crossing probe](design/STEP20C_SAVED_BUFFER_TARGET_CROSSING_PROBE.md): measured saved target crossing and clipped H1 render; H2 reference difference and incomplete timeout/input gate keep it PARTIALLY_FEASIBLE. + [Target-crossing source fixture](../tooling/probes/step20c-saved-buffer-target-crossing/README.md) remains local and experimental. +27. [STEP20A](STEP20A.md) and [STEP20B](STEP20B.md): historical evidence. ## Project @@ -94,6 +96,7 @@ The [root README](../README.md) is the public summary. For a specific topic, use | [STEP20C effects/damage probe](design/STEP20C_EFFECTS_DAMAGE_PROBE.md) | Experimental, independent private replay | PARTIALLY_FEASIBLE for visible blur, excluded sampled shadow overflow, settled camera restoration and sampled damage; shadow interior, optimized cache, popup effects and saved buffers remain unqualified | | [STEP20C static R90 partial-popup probe](design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md) | Experimental evidence | PARTIALLY_FEASIBLE; sampled B crossing clips under R90, independent A input remains untested | | [STEP20C saved transaction buffer probe](design/STEP20C_SAVED_TRANSACTION_BUFFER_PROBE.md) | Experimental, local evidence | INCONCLUSIVE; real saved nodes rendered, target partial crossing not exercised | +| [STEP20C saved target crossing probe](design/STEP20C_SAVED_BUFFER_TARGET_CROSSING_PROBE.md) | Experimental, local evidence | PARTIALLY_FEASIBLE; target saved node crossed and rendered within H1, but whole-H2 reference and timeout/input evidence remain open | A proposal does not supersede accepted canonical contracts or assert implementation status. Its open feasibility items must be resolved before implementation. @@ -141,3 +144,14 @@ EXPERIMENTAL / INCONCLUSIVE. Real held XDG configures created nonempty saved scene buffers; private render entries and ACK/timeout removal were observed. The Mosaic target was wholly off H1 after an applied relative camera scroll, so the partial-boundary clipping gate was NOT EXERCISED. STEP20C stays NEXT. + +[Saved target crossing probe](design/STEP20C_SAVED_BUFFER_TARGET_CROSSING_PROBE.md) +and [local source fixture](../tooling/probes/step20c-saved-buffer-target-crossing/README.md): +EXPERIMENTAL / PARTIALLY_FEASIBLE. One calculated camera correction produced +a real saved target crossing x=0 with clipped render and retained H1 pixels. +H2's target color was absent, but its whole-image reference differed by 253 +pixels; Mosaic target timeout and held input remain unqualified. STEP20C +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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c253bf0..fba9981 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -231,3 +231,20 @@ authorized saved-buffer experiment must calibrate the applied camera and target crossing before the first transaction. Optimized blur/cache and SceneFX ABI/package remain separate unqualified gates. STEP20C stays **NEXT**, multi-output **NOT YET SUPPORTED**. + +The independent [saved target crossing campaign](design/STEP20C_SAVED_BUFFER_TARGET_CROSSING_PROBE.md) +used one measured signed camera correction and observed the actual target +saved node cross x=0, render inside H1 and retain permitted pixels. H2 showed +no target color, but whole-image equality failed by a repeated 253-pixel +reference difference. Mosaic timeout cleanup and held input remain untested +after an E2 fixture pointer error. Result **PARTIALLY_FEASIBLE**, architecture +**UNDECIDED**. The next narrow gate should first resolve the H2 reference +artifact and complete a fresh target timeout/input observation; optimized +blur/cache and SceneFX ABI/package remain later separate gates. STEP20C is +still **NEXT** and multi-output **NOT YET SUPPORTED**. + +The saved-target freeze audit identifies the repeated 253-pixel H2 difference +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. diff --git a/docs/design/STEP20C_SAVED_BUFFER_TARGET_CROSSING_PROBE.md b/docs/design/STEP20C_SAVED_BUFFER_TARGET_CROSSING_PROBE.md new file mode 100644 index 0000000..252eec3 --- /dev/null +++ b/docs/design/STEP20C_SAVED_BUFFER_TARGET_CROSSING_PROBE.md @@ -0,0 +1,243 @@ +# STEP20C saved target boundary crossing probe + +**EXPERIMENTAL / PARTIALLY_FEASIBLE, measured 2026-10-12.** STEP20C remains NEXT and +production multi-output Mosaic is NOT YET SUPPORTED. This is a new, independent +campaign following the [saved transaction buffer freeze](STEP20C_SAVED_TRANSACTION_BUFFER_PROBE.md), +whose verdict remains INCONCLUSIVE. No Mosaic production, cumulative patch, +package, UX or SceneFX algorithm change is authorized here. + +## Scientific identity and source path + +The target is the XDG toplevel with `app_id=org.mosaic.SavedBufferProbe`. +Its Sway tiled container, borrowed workspace tiling row, view scene tree, +live content tree, temporary saved tree and saved scene buffer are distinct. +The source buffer of this view must be identified before the transaction. +An output buffer or optimized blur cache is never evidence for that source. + +`transaction_commit` configures a changed-size visible view when +`should_configure` holds, then `view_save_buffer` creates a sibling saved tree +under `view->scene_tree`. `view_save_buffer_iterator` visits enabled live +buffers via `wlr_scene_node_for_each_buffer`, creates saved scene nodes via +`wlr_scene_buffer_create`, and calls `wlr_scene_buffer_set_buffer` with the +source buffer. Current live content is disabled and the saved tree enabled. +`transaction_progress` applies on readiness or `handle_timeout`; the normal +`apply_container_state` path calls `view_remove_saved_buffer`. SceneFX's +`scene_node_intersect_clip` intersects ancestral tree clips for visible/damage +regions and `scene_node_point_inside_clip` guards node-at; `scene_entry_render` +is the actual render-entry observation. This source path warrants a test, not +a clipping conclusion. The target node ID must be correlated from source to +saved creation to render entry and destruction. Parentage and enabled state +must be recorded. Pointer equality alone does not prove all GPU references +were released. + +## Geometry gate before reservation + +Use only two normal scale-1 headless outputs: H1 `(-800,0),800x600` and H2 +`(0,0),800x600`. Map the deterministic C17 XDG client and ordinary reference +clients, settle focus, query actual outputs and workspace Mosaic state, and +measure the target **source scene buffer**: node pointer, source buffer +pointer, global node coordinates, destination width/height, source box, +transform, owner container/view, applied camera and tiled clip. A container +rect alone is insufficient. The previous runner issued relative +`layout mosaic scroll 400` and reached applied scroll 1760, leaving its target +outside H1. Here `desired_scroll` is computed from the measured source buffer +left/right and applied `current_scroll`; issue exactly one signed +`delta=desired_scroll-current_scroll`, then remeasure. Require +`left<0 1 else None}) +write('saved-buffer-identity', {'source_node': preflight['after']['source_node'], 'source_buffer': preflight['after']['source_buffer'], + 'ack_saved_buffer': ack_buffer, 'ack_same_source': ack_buffer == preflight['after']['source_buffer'], + 'e2_saved_buffer': saved[1]['buffer'] if len(saved) > 1 else None, + 'e2_note': 'new source buffer after E1 ACK; executed runner compared it with stale pre-E1 pointer'}) +write('scene-parentage', {'ack_creation': [line for line in log.splitlines() if 'SB_TREE_CREATE ' in line and f'view={preflight["after"]["view"]}' in line][-2:], + 'ack_saved_node_trace': [line for line in log.splitlines() if 'TARGET_SAVED ' in line and f'node={ack_node}' in line][-1:], + 'source_path_contract': 'saved tree under view scene tree; Mosaic tiled clip is ancestor per source audit'}) +write('saved-target-crossing', {'e1': saved[0]['global_box'], 'e1_crosses_x0': saved[0]['global_box'][0] < 0 < saved[0]['global_box'][2], + 'e2': saved[1]['global_box'] if len(saved) > 1 else None, + 'e2_crosses_x0': len(saved) > 1 and saved[1]['global_box'][0] < 0 < saved[1]['global_box'][2], + 'e2_status': 'NOT EXERCISED through timeout due to fixture pointer correlation'}) +write('transaction-timeline', {mode: [e for e in events(mode) if e['kind'] in ('episode_start', 'saved_target', 'held', 'timeout_observed', 'failure', 'cleanup')] + for mode in run_dirs}) +write('configure-ack', {mode: [line for line in (run / 'client.log').read_text().splitlines() + if line.startswith(('HELD ', 'ACK ', 'COMMIT ', 'PONG '))] + for mode, run in run_dirs.items()}) +write('render-entries', {'e1_target': render, 'e1_observed': bool(render), 'e2_target': [line for line in log.splitlines() + if len(saved) > 1 and 'SB_RENDER ' in line and f'node={saved[1]["node"]}' in line]}) +write('clip-observations', {'e1_target_render_visible': [re.search(r'visible=([^ ]+)', x).group(1) for x in render], + 'e1_applied_clip': [line for line in log.splitlines() if 'PI_SCENE output=HEADLESS-1' in line and 'scroll=160 tile_scene=-160,0 clip_valid=1 clip=160,0,800,600' in line][:2], + 'source_audit': 'scene_node_intersect_clip and scene_node_point_inside_clip walk ancestors'}) +write('pointer-hits', {'status': 'NOT TESTED during target hold', 'reason': 'ACK window prioritized target snapshot and protocol'}) +write('client-delivery', {'status': 'NOT TESTED during target hold'}) +write('damage-analysis', {'status': 'LIMITED', 'render_damage_e1': [re.search(r'damage=([^ ]+)', x).group(1) for x in render], + 'complete_intermediate_frames': 'NOT TESTED'}) +write('lifetime', {'e1_target_saved_tree_destroy_observed': bool(destroy), 'destroy_trace': destroy, + 'e2_timeout_cleanup': 'NOT TESTED', 'all_gpu_references_released': 'NOT PROVEN', + 'mapped_view_destroy_during_transaction': 'NOT TESTED'}) +write('timeout', {mode: [line for line in (run / 'compositor.log').read_text().splitlines() if 'timed out' in line] + for mode, run in run_dirs.items()}) +write('matrix', {'C2_E1': 'TARGET CROSSING/RENDER/ACK OBSERVED; H2 whole-image mismatch 253 pixels', + 'C2_E2': 'TARGET CREATED AND CROSSES; fixture stale-buffer correlation stopped before timeout', + 'C0_E1_E2': 'COMPLETED', 'C1_E1_E2': 'COMPLETED', + 'target_hold_input': 'NOT TESTED', 'complete_damage': 'NOT TESTED'}) +write('summary', {'result': 'PARTIALLY_FEASIBLE', 'architectural_conclusion': 'UNDECIDED', + 'target_ack_crossing_and_render': True, 'target_ack_h1_color_pixels': pixels['private_mosaic']['target_color_on_h1_ack_held'], + 'target_ack_h2_color_pixels': pixels['private_mosaic']['target_color_on_h2_held'], + 'target_ack_h2_full_diff': pixels['private_mosaic']['right-ack-held'], + 'target_timeout_cleanup': 'NOT TESTED', + 'reason': 'E1 target crossing/render and allowed H1 pixels observed; H2 reference mismatch, input/damage and target E2 cleanup remain unqualified'}) + +source_files = sorted((repo / 'tooling/probes/step20c-saved-buffer-target-crossing').glob('*')) +source_files += [repo / 'tooling/probes/step20c-saved-transaction-buffer/sway-saved-trace.patch', + repo / 'tooling/probes/step20c-saved-transaction-buffer/scenefx-saved-trace.patch'] +for source_root in (lab / 'mosaic', lab / 'standard', lab / 'scenefx'): + source_files.extend(source_root.rglob('*')) +source_files.extend(lab.glob('*.c')) +source_files.extend(lab.glob('*.h')) +source_files = [p for p in source_files if p.is_file()] + +def manifest(paths, dest): + dest.write_text(''.join(f'{hashlib.sha256(p.read_bytes()).hexdigest()} {p}\n' for p in sorted(paths))) + +manifest(source_files, results / 'source-sha256.txt') +evidence_files = (list((lab / 'runs').glob('**/*')) + list(results.glob('*.json')) + + list(lab.glob('*-log.txt')) + + [lab / 'target-budget.json', lab / 'source-sha256.txt']) +manifest([p for p in evidence_files if p.is_file()], results / 'evidence-sha256.txt') +print('RESULT=PARTIALLY_FEASIBLE', 'RUNS=3', 'CAPTURES=' + str(sum(budget['captures'].values()))) diff --git a/tooling/probes/step20c-saved-buffer-target-crossing/budget.py b/tooling/probes/step20c-saved-buffer-target-crossing/budget.py new file mode 100644 index 0000000..5b68a91 --- /dev/null +++ b/tooling/probes/step20c-saved-buffer-target-crossing/budget.py @@ -0,0 +1,46 @@ +"""One persistent, exclusive ledger for the independent target campaign.""" +import fcntl +import json +import os +from pathlib import Path + +MODES = ('control', 'private_standard', 'private_mosaic') + + +def reserve(lab: Path, mode: str, episode: str | None = None, + *, camera: bool = False, capture: bool = False) -> dict: + if mode not in MODES or episode not in (None, 'ack', 'timeout'): + raise ValueError('invalid reservation') + if int(camera) + int(capture) + int(episode is not None) > 1: + raise ValueError('reserve one resource per call') + path = lab / 'target-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 { + 'runs': [], 'episodes': [], 'camera': [], 'captures': {m: 0 for m in MODES}} + if camera: + if mode != 'private_mosaic' or mode not in state['runs'] or state['camera']: + raise RuntimeError('camera budget exhausted') + state['camera'].append(mode) + elif capture: + if mode not in state['runs'] or state['captures'][mode] >= 16 or sum(state['captures'].values()) >= 48: + raise RuntimeError('capture budget exhausted') + state['captures'][mode] += 1 + elif episode is None: + if mode in state['runs'] or len(state['runs']) >= 3: + raise RuntimeError('run budget exhausted') + state['runs'].append(mode) + else: + key = [mode, episode] + if mode not in state['runs'] or key in state['episodes'] or len(state['episodes']) >= 6: + raise RuntimeError('episode budget exhausted') + state['episodes'].append(key) + 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-buffer-target-crossing/prepare.sh b/tooling/probes/step20c-saved-buffer-target-crossing/prepare.sh new file mode 100644 index 0000000..07a12d6 --- /dev/null +++ b/tooling/probes/step20c-saved-buffer-target-crossing/prepare.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# EXPERIMENTAL source-only reconstruction; never launches a compositor. +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +fixture="$repo/tooling/probes/step20c-saved-buffer-target-crossing" +old="$repo/tooling/probes/step20c-saved-transaction-buffer" +log=$(mktemp /tmp/saved-target-prepare-XXXXXXXX) +if ! sh "$repo/tooling/probes/step20c-rotated-partial-popup/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" +for source in standard mosaic; do + git -C "$lab/$source" apply --check "$old/sway-saved-trace.patch" + git -C "$lab/$source" apply "$old/sway-saved-trace.patch" +done +git -C "$lab/scenefx" apply --check "$old/scenefx-saved-trace.patch" +git -C "$lab/scenefx" apply "$old/scenefx-saved-trace.patch" +if test -f "$fixture/target-geometry-trace.patch"; then + for source in standard mosaic; do + git -C "$lab/$source" apply --check "$fixture/target-geometry-trace.patch" + git -C "$lab/$source" apply "$fixture/target-geometry-trace.patch" + done +fi +meson compile -C "$lab/scenefx-build" -j 8 >> "$log" 2>&1 +meson compile -C "$lab/standard-build" -j 8 >> "$log" 2>&1 +meson compile -C "$lab/mosaic-build" -j 8 >> "$log" 2>&1 +cc -std=c17 -Wall -Wextra -Werror -Wno-unused-parameter -I"$lab" \ + "$fixture/saved-client.c" "$lab/xdg-shell-protocol.c" -o "$lab/saved-client" \ + $(pkg-config --cflags --libs wayland-client) >> "$log" 2>&1 +cp "$log" "$lab/saved-target-build-log.txt" +printf 'LAB=%s\nBUILD_LOG=%s\n' "$lab" "$log" diff --git a/tooling/probes/step20c-saved-buffer-target-crossing/run.py b/tooling/probes/step20c-saved-buffer-target-crossing/run.py new file mode 100644 index 0000000..bc829ea --- /dev/null +++ b/tooling/probes/step20c-saved-buffer-target-crossing/run.py @@ -0,0 +1,343 @@ +"""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 +from budget import reserve + +parser = argparse.ArgumentParser() +parser.add_argument('lab', type=Path) +parser.add_argument('mode', choices=('control', 'private_standard', 'private_mosaic')) +args = parser.parse_args() +lab = args.lab.resolve() +if not (lab / 'saved-client').is_file(): + parser.error('prepared LAB required') +state = reserve(lab, args.mode) +run = lab / 'runs' / f'saved-{args.mode}-{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') +binary = lab / ('standard-build/sway/sway' if args.mode == 'control' + else 'mosaic-build/sway/swayfx-mosaic') +if args.mode != 'control': + 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, args.mode, capture=True) + 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') + if args.mode != 'private_mosaic': + record('standard_preflight', source=before) + return + 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, args.mode, camera=True) + 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 episode(kind, gaps): + global target_source + # WHY: an ACK commits a new source buffer before the next episode. + # CONTRACT: remeasure it before reserving; never reuse the first pointer. + target_source = source_measure() + if args.mode == 'private_mosaic' and not ( + target_source['left'] < 0 < target_source['right']): + raise RuntimeError('current target source no longer crosses x=0') + reserve(lab, args.mode, kind) + record('episode_start', episode=kind, gaps=gaps) + client_command('hold_next_configure') + old_held = client_log().count('HELD ns=') + old_tree = compositor_log().count('SB_TREE_CREATE') + command(f'gaps inner current set {gaps}') + wait(lambda: client_log().count('HELD ns=') > old_held, 3) + wait(lambda: compositor_log().count('SB_TREE_CREATE') > old_tree, 3) + record('held', client=client_log().splitlines()[-12:], + saved=compositor_log().splitlines()[-30:]) + if args.mode == 'private_mosaic': + saved = [line for line in compositor_log().splitlines() + if 'TARGET_SAVED ns=' in line and f'view={target_source["view"]}' in line] + if not saved: + raise RuntimeError('target saved scene buffer absent') + line = saved[-1] + match = re.search(r'node=(0x[0-9a-f]+).*buffer=(0x[0-9a-f]+).*global_valid=(\d+) global=(-?\d+),(-?\d+).*source_size=(\d+),(\d+) dest=(\d+),(\d+).*enabled=(\d+)', line) + if not match: + raise RuntimeError(f'target saved trace incomplete: {line}') + node, buffer, valid, x, y, sw, sh, dw, dh, enabled = match.groups() + right = int(x) + (int(dw) or int(sw)) + record('saved_target', node=node, buffer=buffer, source=target_source, + global_box=[int(x), int(y), right, int(y) + (int(dh) or int(sh))], + raw=line) + if buffer != target_source['source_buffer'] or valid != '1' or enabled != '1' or int(x) >= 0 or right <= 0: + raise RuntimeError('actual target saved buffer does not cross x=0') + snapshot(f'{kind}-held') + if args.mode == 'private_mosaic': + if not any(f'node={node}' in line for line in compositor_log().splitlines() if 'SB_RENDER ' in line): + raise RuntimeError('target saved node had no render entry') + if kind == 'ack': + old_destroy = compositor_log().count('SB_TREE_DESTROY') + client_command('release_configure') + wait(lambda: compositor_log().count('SB_TREE_DESTROY') > old_destroy, 3) + snapshot('ack-applied') + else: + wait(lambda: 'timed out' in compositor_log().split('SB_TREE_CREATE')[-1], 3) + record('timeout_observed') + snapshot('timeout-applied') + + +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) + command('workspace 1') + command('layout mosaic' if args.mode == 'private_mosaic' else 'layout splith') + 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') + spawn(['kitty', '--class', 'REFERENCE', '-o', 'background=#303038', + '/usr/bin/sleep', '900'], 'reference.log') + wait(lambda: bool(find(ipc('-t', 'get_tree'), + lambda n: n.get('type') == 'workspace' and n.get('name') == '2')['nodes'])) + command('workspace 1') + time.sleep(.2) + tree = ipc('-t', 'get_tree') + owner = find(tree, lambda n: n.get('app_id') == 'org.mosaic.SavedBufferProbe') + workspace = find(tree, lambda n: n.get('type') == 'workspace' and n.get('name') == '1') + if not owner or not workspace: + raise RuntimeError('owner/workspace calibration failed') + record('identity', owner=owner, workspace=workspace) + preflight_target() + snapshot('reference') + episode('ack', 30) + episode('timeout', 50) +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-buffer-target-crossing/saved-client.c b/tooling/probes/step20c-saved-buffer-target-crossing/saved-client.c new file mode 100644 index 0000000..803c4af --- /dev/null +++ b/tooling/probes/step20c-saved-buffer-target-crossing/saved-client.c @@ -0,0 +1,215 @@ +#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; + 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); + xdg_toplevel_set_title(toplevel, "Mosaic Saved Buffer Probe"); + xdg_toplevel_set_app_id(toplevel, "org.mosaic.SavedBufferProbe"); + 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-buffer-target-crossing/target-geometry-trace.patch b/tooling/probes/step20c-saved-buffer-target-crossing/target-geometry-trace.patch new file mode 100644 index 0000000..e53bbab --- /dev/null +++ b/tooling/probes/step20c-saved-buffer-target-crossing/target-geometry-trace.patch @@ -0,0 +1,95 @@ +--- a/sway/ipc-json.c ++++ b/sway/ipc-json.c +@@ -3,6 +3,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -820,6 +821,33 @@ + #endif + } + ++/* WHY: pre-transaction geometry must be measured from the actual source node. ++ * CONTRACT: this private trace reads the mapped target only during get_tree. ++ * INVARIANT: no scene state, buffer reference or transaction is changed. */ ++struct target_trace_context { ++ struct sway_container *container; ++}; ++ ++static void trace_target_source(struct wlr_scene_buffer *buffer, ++ int sx, int sy, void *data) { ++ struct target_trace_context *ctx = data; ++ int gx = 0, gy = 0; ++ bool valid = wlr_scene_node_coords(&buffer->node, &gx, &gy); ++ struct timespec ts; ++ clock_gettime(CLOCK_MONOTONIC, &ts); ++ fprintf(stderr, "TARGET_SOURCE ns=%lld con=%p con_id=%zu view=%p node=%p buffer=%p parent=%p global_valid=%d global=%d,%d local=%d,%d source_size=%d,%d dest=%d,%d src_box=%.3f,%.3f,%.3f,%.3f transform=%d enabled=%d\n", ++ (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec, ++ (void *)ctx->container, ctx->container->node.id, ++ (void *)ctx->container->view, (void *)&buffer->node, ++ (void *)buffer->buffer, (void *)buffer->node.parent, valid, gx, gy, sx, sy, ++ buffer->buffer ? buffer->buffer->width : 0, ++ buffer->buffer ? buffer->buffer->height : 0, ++ buffer->dst_width, buffer->dst_height, ++ buffer->src_box.x, buffer->src_box.y, ++ buffer->src_box.width, buffer->src_box.height, ++ buffer->transform, buffer->node.enabled); ++} ++ + static void ipc_json_describe_container(struct sway_container *c, json_object *object) { + json_object_object_add(object, "name", + c->title ? json_object_new_string(c->title) : NULL); +@@ -886,6 +914,14 @@ + json_object_object_add(object, "marks", marks); + + if (c->view) { ++ const char *app_id = view_get_app_id(c->view); ++ if (getenv("SAVED_TARGET_TRACE") && app_id && ++ strcmp(app_id, "org.mosaic.SavedBufferProbe") == 0 && ++ c->view->content_tree) { ++ struct target_trace_context ctx = {.container = c}; ++ wlr_scene_node_for_each_buffer(&c->view->content_tree->node, ++ trace_target_source, &ctx); ++ } + ipc_json_describe_view(c, object); + } + } +--- a/sway/tree/view.c ++++ b/sway/tree/view.c +@@ -1328,6 +1328,23 @@ + buffer->buffer ? buffer->buffer->height : 0, sx, sy, (void *)sbuf->node.parent); + } + ++/* WHY: copied local offsets cannot prove a global boundary crossing. ++ * CONTRACT: private observation only; the node and buffer remain borrowed. ++ * INVARIANT: no buffer lock or scene mutation is introduced. */ ++static void trace_target_saved_buffer(struct wlr_scene_buffer *buffer, ++ int sx, int sy, void *data) { ++ struct sway_view *view = data; ++ int gx = 0, gy = 0; ++ bool valid = wlr_scene_node_coords(&buffer->node, &gx, &gy); ++ fprintf(stderr, "TARGET_SAVED ns=%lld view=%p con=%p node=%p buffer=%p parent=%p global_valid=%d global=%d,%d local=%d,%d source_size=%d,%d dest=%d,%d enabled=%d\n", ++ saved_ns(), (void *)view, (void *)view->container, ++ (void *)&buffer->node, (void *)buffer->buffer, ++ (void *)buffer->node.parent, valid, gx, gy, sx, sy, ++ buffer->buffer ? buffer->buffer->width : 0, ++ buffer->buffer ? buffer->buffer->height : 0, ++ buffer->dst_width, buffer->dst_height, buffer->node.enabled); ++} ++ + void view_save_buffer(struct sway_view *view) { + if (!sway_assert(!view->saved_surface_tree, "Didn't expect saved buffer")) { + view_remove_saved_buffer(view); +@@ -1354,6 +1371,9 @@ + + wlr_scene_node_set_enabled(&view->content_tree->node, false); + wlr_scene_node_set_enabled(&view->saved_surface_tree->node, true); ++ /* CONTRACT: measure the actual saved node after enabling the tree. */ ++ wlr_scene_node_for_each_buffer(&view->saved_surface_tree->node, ++ trace_target_saved_buffer, view); + fprintf(stderr, "SB_ENABLE ns=%lld view=%p tree=%p content_enabled=%d saved_enabled=%d\n", + saved_ns(), (void *)view, (void *)view->saved_surface_tree, + view->content_tree->node.enabled, view->saved_surface_tree->node.enabled); diff --git a/tooling/probes/step20c-saved-buffer-target-crossing/test-budget.py b/tooling/probes/step20c-saved-buffer-target-crossing/test-budget.py new file mode 100644 index 0000000..b680448 --- /dev/null +++ b/tooling/probes/step20c-saved-buffer-target-crossing/test-budget.py @@ -0,0 +1,62 @@ +"""Offline exclusive-ledger regression; 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, 'control') + reserve(lab, 'control', 'ack') + reserve(lab, 'control', 'timeout') + for episode in (None, 'ack', 'timeout'): + try: + reserve(lab, 'control', episode) + raise AssertionError('duplicate accepted') + except RuntimeError: + pass + for mode in ('private_standard', 'private_mosaic'): + reserve(lab, mode) + reserve(lab, mode, 'ack') + reserve(lab, mode, 'timeout') + reserve(lab, 'private_mosaic', camera=True) + try: + reserve(lab, 'private_mosaic', camera=True) + raise AssertionError('second camera adjustment accepted') + except RuntimeError: + pass + for mode in ('control', 'private_standard', 'private_mosaic'): + for _ in range(16): + reserve(lab, mode, capture=True) + try: + reserve(lab, mode, capture=True) + raise AssertionError('seventeenth capture accepted') + except RuntimeError: + pass + state = json.loads((lab / 'target-budget.json').read_text()) + assert len(state['episodes']) == 6 + assert sum(state['captures'].values()) == 48 +print('PASS persistent run/episode limits') + + +def contender(path, queue): + try: + reserve(Path(path), 'control') + 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')