diff --git a/CHANGELOG.md b/CHANGELOG.md index bcf94b1..8fa7502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### STEP20C experimental evidence +- Preserved dynamic-output ownership probe: real 800→960→800 logical resize and H1→H2→H1 workspace migration preserve IDs and sampled applied clips. Exact vacated-output backgrounds and measured physical damage are compatible; two standard-control final calibrations fail through reference occlusion. PARTIALLY_FEASIBLE; maximum clamp, migrated clipped-popup input, opaque spill and effects remain open. Six reconfigurations/six migrations, zero extra camera pairs; no production change. The original temporary evidence manifest was verified before an offline analyzer rerun replaced it; the report records the provenance limit. + - Preserved fractional-scale probe: actual 1.25 uniform/mixed and 1.5 mixed outputs, calibrated logical input, sampled root/nested popup and effects clipping, two exact camera returns and physical settled-damage coverage. PARTIALLY_FEASIBLE; opaque tiled controls, interior shadow, restored Mosaic hit and broader gates remain unqualified. No production/package change. - Added a source-only private effects/damage probe with a real translucent SHM buffer and striped background. Both standard controls show 27,594 shadow-overflow pixels on the neighboring output; private Mosaic excludes that sampled spill, renders a visibly filtered blur within its viewport and restores exact settled pixels after three camera pairs. Sampled private damage covers changed pixels, while interior shadow, optimized cache, popup effects and saved buffers remain unqualified. The result is PARTIALLY_FEASIBLE; no production or package files changed. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dd9c097..fb0d4ce 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -247,3 +247,17 @@ are retained. The two-pair global budget was respected. The next distinct gate should test resize/migration of owner/output clips; rotation, saved buffers and ABI/package remain separate. This is evidence only: STEP20C stays **NEXT**, production multi-output **NOT YET SUPPORTED**, STEP20D–G **PLANNED**. + +The preserved [dynamic output ownership probe](design/STEP20C_DYNAMIC_OUTPUT_OWNERSHIP_PROBE.md) +is **PARTIALLY_FEASIBLE**: actual logical resize 800→960→800 and workspace +migration H1→H2→H1 retain workspace/tiled identities and update sampled applied +tiled/root-popup clips. Vacated outputs show exact background after stabilization. +Two standard controls fail final reference calibration through fixture occlusion; +Mosaic's corrected calibration passes. Maximum-scroll clamp, migrated clipped +popup input, independent opaque tiled spill, effects and intermediate frames +remain unqualified. Budgets are exhausted at six reconfigurations and six +migrations, with zero additional camera pairs. No production change follows. +Rotation is the next separate geometric gate; other open STEP20C gates persist. +The freeze audit verified the original evidence manifest before an offline +replay rewrote the temporary inventory; the report records that limitation. +STEP20C stays **NEXT**, multi-output **NOT YET SUPPORTED**. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 7fa0cc6..03cbdee 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -186,3 +186,17 @@ are retained. The two-pair global budget was respected. The next distinct gate should test resize/migration of owner/output clips; rotation, saved buffers and ABI/package remain separate. This is evidence only: STEP20C stays **NEXT**, production multi-output **NOT YET SUPPORTED**, STEP20D–G **PLANNED**. + +The preserved [dynamic output ownership probe](design/STEP20C_DYNAMIC_OUTPUT_OWNERSHIP_PROBE.md) +is **PARTIALLY_FEASIBLE**: actual logical resize 800→960→800 and workspace +migration H1→H2→H1 retain workspace/tiled identities and update sampled applied +tiled/root-popup clips. Vacated outputs show exact background after stabilization. +Two standard controls fail final reference calibration through fixture occlusion; +Mosaic's corrected calibration passes. Maximum-scroll clamp, migrated clipped +popup input, independent opaque tiled spill, effects and intermediate frames +remain unqualified. Budgets are exhausted at six reconfigurations and six +migrations, with zero additional camera pairs. No production change follows. +Rotation is the next separate geometric gate; other open STEP20C gates persist. +The original temporary evidence manifest was verified before an offline +analyzer rerun replaced it; its exact historical bytes are not retained. +STEP20C stays **NEXT**, multi-output **NOT YET SUPPORTED**. diff --git a/docs/README.md b/docs/README.md index 867f890..7581dda 100644 --- a/docs/README.md +++ b/docs/README.md @@ -105,3 +105,10 @@ Preserved experimental evidence: [fractional scaling probe](design/STEP20C_FRACT and [source fixture](../tooling/probes/step20c-fractional-scale/README.md). PARTIALLY_FEASIBLE for the sampled static scale matrix, independently replayed without new camera pairs; no production qualification. + +Preserved experimental evidence: [dynamic output ownership report](design/STEP20C_DYNAMIC_OUTPUT_OWNERSHIP_PROBE.md) +and [source fixture](../tooling/probes/step20c-dynamic-output/README.md). +PARTIALLY_FEASIBLE for measured stable geometry/ownership, with explicit +fixture and unexercised-input limitations. The original temporary evidence +manifest was verified before an offline analyzer rerun replaced its bytes; +the report records this provenance limit. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2069290..4b9d1cc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -190,3 +190,17 @@ are retained. The two-pair global budget was respected. The next distinct gate should test resize/migration of owner/output clips; rotation, saved buffers and ABI/package remain separate. This is evidence only: STEP20C stays **NEXT**, production multi-output **NOT YET SUPPORTED**, STEP20D–G **PLANNED**. + +The preserved [dynamic output ownership probe](design/STEP20C_DYNAMIC_OUTPUT_OWNERSHIP_PROBE.md) +is **PARTIALLY_FEASIBLE**: actual logical resize 800→960→800 and workspace +migration H1→H2→H1 retain workspace/tiled identities and update sampled applied +tiled/root-popup clips. Vacated outputs show exact background after stabilization. +Two standard controls fail final reference calibration through fixture occlusion; +Mosaic's corrected calibration passes. Maximum-scroll clamp, migrated clipped +popup input, independent opaque tiled spill, effects and intermediate frames +remain unqualified. Budgets are exhausted at six reconfigurations and six +migrations, with zero additional camera pairs. No production change follows. +Rotation is the next separate geometric gate; other open STEP20C gates persist. +The original temporary evidence manifest passed verification before an +offline analyzer rerun replaced its bytes; see the report for provenance. +STEP20C stays **NEXT**, multi-output **NOT YET SUPPORTED**. diff --git a/docs/design/STEP20C_DYNAMIC_OUTPUT_OWNERSHIP_PROBE.md b/docs/design/STEP20C_DYNAMIC_OUTPUT_OWNERSHIP_PROBE.md new file mode 100644 index 0000000..e9bddb7 --- /dev/null +++ b/docs/design/STEP20C_DYNAMIC_OUTPUT_OWNERSHIP_PROBE.md @@ -0,0 +1,381 @@ +# STEP20C dynamic output geometry and workspace ownership probe + +**EXPERIMENTAL / PARTIALLY_FEASIBLE measured subset.** The protocol below +was recorded before dynamic runs; the measured campaign follows it. +STEP20A DONE, STEP20B EXPERIMENTAL, STEP20C NEXT, STEP20D–G PLANNED. +Production multi-output remains NOT YET SUPPORTED. No UX change, production +repair or package/dependency change follows from this probe. The later +dynamic-evidence freeze ticket authorizes publication of this report and its +source-only fixture, without publishing raw laboratory captures. +The fractional evidence freeze passed at `8345aff0f97d381b768233021fc5c881ec794094` +on local main, Forgejo and GitHub, with a clean worktree at the gate. + +## Scope and historical evidence + +The [fractional probe](STEP20C_FRACTIONAL_SCALING_PROBE.md) qualified sampled +static 800×600 logical boxes with scales 1/1.25/1.5, real nested grabs, +negative origins, boundary hovers, visible blur and excluded shadow spill. +Its result remains PARTIALLY_FEASIBLE. Independent opaque tiled spill, +interior shadow, restored camera hit, optimized caches, source sampling, +saved buffers and intermediate frames remain unqualified. This campaign +asks whether applied viewports and popup clips follow real output resize +and workspace migration. It does not infer atomicity from IPC success. + +## Pinned source audit before measurements + +SwayFX control: `fd71a6bdc061bd633b488ae7b83e8a6981d22f86` (0.6). +Private Mosaic base: `cd550382b311efdbfe3ed892f4e1d281d852e754` plus the +preserved popup/hidden-owner, temporal damage and presentation patches. +SceneFX: `37ccd723bef49e6891156ffafce8f549f01446cc` (0.5) plus its preserved +private inherited clip and damage trace. wlroots source: +`d783533489e1f75d6886c2ab5c5960090ef268f8` (0.20.2), matching linked version. +The private reconstruction uses the fractional preparer; no old /tmp lab is +a dependency. New instrumentation only reads state in existing callbacks. + +| Path | Ownership and ordering established by source | +| --- | --- | +| sway/config/output.c, apply_output_configs | Pending mode/scale is built (scale rounded to 1/120), output states built then backend committed. finalize_output_config places each output in wlr_output_layout; a second pass output_update_position copies the actual logical layout box, then arrange_layers, arrange_root and transaction_commit_dirty run. Optimized blur layer is marked dirty. Input mappings/cursor images are reconfigured, pointer focus cleared and cursor_rebase called. Backend commit precedes final layout/scene arrangement: no atomic applied-scene claim. | +| wlroots types/output/output.c | effective_resolution divides transformed physical dimensions by scale into integer logical dimensions; normal 1200×750 at 1.25 yields 960×600. | +| sway/commands/output.c and output/mode.c | Normal output command parser supports mode/resolution, scale and position subcommands; the command and resulting IPC geometry must both be checked. | +| sway/commands/move.c | move workspace to output uses handler_context.workspace, detaches/reattaches it, arranges old/new outputs, focuses an inactive node within that workspace and considers pointer warp. The usual command/transaction path applies dirty state. | +| sway/tree/workspace.c, workspace_move_to_output | workspace_detach removes old list membership, clears pending owner and dirties workspace/output. output_add_workspace preserves the object ID and attaches the new pending owner. Moving the last old workspace creates a replacement workspace with a different ID. Destination's old empty workspace can be destroyed by workspace_consider_destroy. | +| sway/tree/arrange.c | arrange_workspace takes new owner usable_area and global origin; floating coordinates adjust separately. apply_mosaic_layout computes cells/gaps and clamps logical scroll to real row overflow. Four 640-wide cells without gaps occupy 2560; viewport 800/960 gives maximum 1760/1600. Initial camera 400 remains within both bounds; the clamp branch is not promised exercised. | +| sway/desktop/transaction.c private candidate | Current output/workspace lists reparent tiling layers; tiling node local x=usable x+gap-scroll. Its clip local x=scroll, width/height=usable area minus gaps. Thus scene global position plus clip origin gives viewport. arrange_root subsequently calls arrange_popups. | +| private root popup clip | Actual view container current workspace/output and current top tiled ancestor identify owner. Applied tiling clip/global scene coordinates are intersected with output box. Hidden-owner guard tests applied owner rectangle. Root popup clip becomes global viewport minus root-popup scene origin. No owner pointer is retained in clip state. | +| sway/desktop/xdg_shell.c | Nested popup_create inherits view; parent scene tree preserves hierarchy. Root descriptor uses view content_tree as relative node; configure/unconstrain uses pending owner on initial commit or explicit reposition. Destroy listeners free popup tree. Migration alone must not be assumed to reconfigure XDG geometry. | +| SceneFX types/scene/wlr_scene.c | set_position and reparent trigger scene_node_update. Cached visibility and new bounds determine affected output damage. set_clip updates inherited clip and scene visibility. node_coords sums ancestors; node_at checks inherited logical clip before surface input. Damage subtracts output origin then scales/expands/transforms into physical buffer-local bounds. Output commits/resizes can invalidate whole damage. | +| wlroots types/wlr_cursor.c | Unmapped virtual pointer normalizes into actual combined logical output-layout box. No physical scale in pointer coordinates. Focus/grab dismissal and scene hit remain separate observations. | + +These paths identify pending owner mutation, output logical geometry update, +scroll clamp, transaction application, tiled reparent/position, global popup +rearrangement, clip calculation, pointer rebase and old/new damage. They do +not establish a universal generation ID or a distinct frame for every IPC. + +## Geometry, scenarios and hypotheses + +D0: H1 1000×750 scale 1.25 at (-800,0), logical 800×600; H2 800×600 scale 1 +at (0,0), logical 800×600. Both normal. Workspace 1 starts on H1; workspace 2 +contains an ordinary reference on H2. Retain workspace IDs, tiled IDs/order, +XDG map/grab serials, focus and output ownership throughout. + +D1 requests `output HEADLESS-1 mode 1200x750 scale 1.25 position -960 0`. +Require physical 1200×750 and logical (-960,0,960,600), H2 unchanged. +D2 restores mode 1000×750 and position -800,0 at the same scale. +D3 moves the verified workspace 1 to H2; D4 observes its pre-opened A/B +hierarchy and any legitimate popup_done. If destroyed, distinct newly grabbed +objects may be created after migration, without asserting persistence. +D5 returns the same workspace to H1. Do not compare the replacement old +workspace with the moved workspace. No additional output/workspace transitions +are allowed for fixture repair after budget consumption. + +Hypotheses: viewport follows applied usable area; scroll remains logical and +within recalculated extent; root clip follows current owner, nested clip is +inherited; old output redraws its new active workspace without Mosaic residue; +logical pointer calibration survives changed negative origin. A normal XDG +close or focus warp is not itself a clipping defect. + +## References, input, stabilization and damage + +Use stable dark background, deterministic red ordinary tiled client, distinct +SHM toplevel, yellow A and green B, plus ordinary wev references. Identify +opaque toplevel geometry/pixels explicitly; translucency from the historical +fractional client is not an opaque-overflow control. A new opaque client build +may replace only SHM alpha, without changing the compositor. Standard tiling +is not a negative opaque-spill control. Independent tiled overflow without +clip ablation remains PARTIAL if not exercised. + +Before each capture retain get_outputs/get_workspaces/get_tree and current +private scene/clip trace. Require stable repeated output/workspace geometry, +then retain native accepted commit/present where available and capture twice +for settled equality; timestamps do not imply atomic input/frame identity. +Unestablished stability is INCONCLUSIVE. Compare the vacated old output to its +new active workspace/reference, not the pre-migration image. + +Pointer normalization is recomputed from active output rectangles on every +injection: min_x/min_y, max right/bottom, extent and global-minus-min. The +injector accepts that logical extent per event; it never applies scale. After +resize/restoration/migration/return, empirically calibrate ordinary references +on both outputs using enter OR motion and independent (+40,+20) local delta; +retain button delivery and boundary crossing. Calibration failure invalidates +dependent input assertions. Visible owner, popup interior, clipped portion, +new/vacated viewport points and old/new output points use applied coordinates. +Scene hit and client delivery under grabs are distinct. Exterior click tests +are done after persistence observations so normal dismissal cannot obscure D4. + +Damage traces are already physical buffer-local extents. Compare changes only +between matching physical-size settled images; for a changed-size buffer, +report new-area coverage separately. No second scale conversion. Optimized +blur, every intermediate frame, minimal damage, all effects and saved buffers +remain NOT TESTED unless directly exercised. + +## Budgets and stopping rules + +One persistent campaign ledger and exclusive flock spans all three modes. +Reserve each dynamic action before IPC launch; failed launched actions consume +the reservation, interrupted reservations remain consumed. Each mode gets at +most one D1, D2, D3 and D5: six resize/restoration actions and six migrations +globally. Camera maximum one pair globally; planned consumption zero pairs. +Initial camera setup 400 is not an extra hide/return pair. Never reset ledger +or use another lab to bypass counts. Failed modes remain in matrix. Static +analysis and offline budget tests need no dynamic reservation. + +All private sources/builds/runs/PNGs/logs/manifests stay in one disposable LAB; +only source fixtures and passive patches live under tooling/probes. +No algorithm, shader, damage policy, callback order, hit testing, headless +backend or public ABI change. No commit/push in B. + +FEASIBLE requires applied requested transitions, identity/owner/clip correctness, +settled pixel/hit agreement and compatible measured damage, with standard +controls passing. PARTIALLY_FEASIBLE leaves qualified transitions but open XDG, +effects/input/damage cells. INCONCLUSIVE means refused geometry or inadequate +fixture/state evidence. BLOCKED requires reproduced forbidden pixels/hits, +wrong-owner clip, corruption/crash or blocking ordinary-layout regression. +Preserve minimal reproducer and stop affected assertions; do not repair +production. A refused command, lawful popup_done, absent redundant enter, +normal focus change or correct camera clamp alone is not BLOCKED. + +## Retained fixture incident before the Mosaic run + +The ordinary control completed all four dynamic actions and retained D5 popup +captures, then timed out during D5 left-reference calibration. Both references +had migrated with workspace 1. The sequential adapter moved the left reference +to its target before moving the right reference away; the latter occluded the +former. The right log recorded the requested (20,30) enter while the left log +did not. This is reference-placement ambiguity, not evidence of a Mosaic input +failure. The private-standard run already launched with the same source is +retained. No dynamic action is replayed. The final fixture moves both references +before scoring either, and uniquely retained per-run sources distinguish the +versions. Earlier D5 input-dependent assertions remain NOT TESTED if unscored. + +## Measured campaign — 2026-10-12 + +**CURRENT evidence: EXPERIMENTAL / PARTIALLY_FEASIBLE.** The protocol above +was written before measurement. Laboratory: +`/tmp/swayfx-mosaic-nested-grab-0zJrQnLj`. All sources were reconstructed from +pinned Git inputs, not copied from the historical fractional lab. Private +SceneFX/Mosaic and pristine SwayFX built; patched clients passed C17 -Werror. +Model Meson test passed 1/1. Runtime uses actual FX GLES 3.2 Mesa +26.2.4-arch1.1 / Radeon 780M; the inherited pixman environment request does not +identify the renderer actually selected by SwayFX. + +Three attempted runs retained 230 PNGs: ordinary control 72, private splith 72, +private Mosaic 86. Both controls ended with the documented reference-occlusion +calibration failure after all four transitions and D5 captures. Their compositor +exit status was zero; they are **not** whole-run PASS. Mosaic completed with +failure=null. No failed action/run was replayed or removed. Per-run generated +source SHA-256: both controls +`795dd53d7bcc2ea40f35cba7d88c97af316a6057d7631f214fdbd1a38dc0da4c`; +Mosaic corrected-reference fixture +`63cff2a4521e06f1d7f9ba04f4a7049956134fcfe589e89e1352ee03d31e5096`. + +### Actual geometry, identity and camera + +| Stage | H1 actual physical / scale / logical global rect | Workspace 1 owner | Workspace ID / tiled ID | +| --- | --- | --- | --- | +| D0 | 1000×750 / 1.25 / (-800,0,800,600) | H1 | 6 / 8 | +| D1 | 1200×750 / 1.25 / (-960,0,960,600) | H1 | 6 / 8 | +| D2 | 1000×750 / 1.25 / (-800,0,800,600) | H1 | 6 / 8 | +| D3 | restored D0 geometry | H2 | 6 / 8 | +| D5 | restored D0 geometry | H1 | 6 / 8 | + +H2 stayed physical 800×600 / scale 1 / (0,0,800,600), normal transform. +All three modes applied this matrix, confirmed by outputs/workspaces/tree, +physical PNG dimensions and retained repeated-state observations. Workspace +1's tiled order stayed [7,8,9,10]. In standard layouts its owner width changed +200→240→200; Mosaic remained four 640-wide cells, row extent 2560. + +The base fixture initially set camera 400; calibration focus/temporary reference +insertion subsequently produced **observed camera 640** before D0 scoring. +No additional explicit camera scroll or hide/return pair was issued. The +camera stayed 640 in D0/D1/D2/D3/D5 scored snapshots. Actual maximum overflow +was 1760 at width 800 and 1600 at width 960, so conservation is valid and the +maximum-bound clamp branch is **NOT TESTED**. Do not claim scroll 400 persisted +or that a clamp event was experimentally demonstrated. + +| Stage | Applied tiling global origin | Local tiled clip | Derived global viewport | +| --- | --- | --- | --- | +| D0 | (-1440,0) | (640,0,800,600) | (-800,0,800,600) | +| D1 | (-1600,0) | (640,0,960,600) | (-960,0,960,600) | +| D2 | (-1440,0) | (640,0,800,600) | (-800,0,800,600) | +| D3 | (-640,0) | (640,0,800,600) | (0,0,800,600) | +| D5 | (-1440,0) | (640,0,800,600) | (-800,0,800,600) | + +All 42 pre-capture tiled-clip observations matched their actual output rect. +DO_POPUP records for actual owner 8/workspace 6 derive the same three global +viewport rectangles from root position plus local clip, and change owner_output +H1→H2→H1. Ignore view-less popup-layer descriptors with owner_id=0 as tiled-XDG +ownership evidence. A callback output label is the observer, not necessarily +the popup's owner. No stale old-owner clip is observed in retained stable states. + +The last H1 workspace's departure created workspace 3, ID 13, on H1. On return, +H2 acquired a replacement workspace 2 with **new ID 14**; its initial workspace +2 had ID 5. Workspace 1 remained ID 6 throughout. Calibration later moves the +ordinary floating references between these active workspaces; that is not a +workspace migration or a scientific camera action. + +### Popups, input and pixels + +Every recorded grab used the real unchanged received button serial: 10 grabs +in each control, 12 in Mosaic. A/B were pre-opened for each transition. Both +migrations retained the then-open root/nested hierarchy in sampled captures; +D3 had no popup_done. The D3 exterior right click later produced one A and one +B popup_done in all modes, then a distinct valid hierarchy was recreated before +D5. D5's cumulative done counts remained 1/1: no new migration dismissal was +observed. Wire IDs can be reused after explicit destruction; recreated chains +are distinct objects. Configure/ack/map, client logs and private root-tree +identities are retained; this does not promise universal popup persistence. + +Mosaic ordinary-reference calibration passed on both outputs at D0/D1/D2/D3/D5, +with independent logical (+40,+20) local motion and left/right delivery. Pointer +normalization changed 1600×600→1760×600→1600×600 from applied output bounds, +with min_x -800→-960→-800, never physical scaling. Both standard controls passed +D0/D1/D2/D3; their final D5 calibration/hit is NOT TESTED because of the retained +occlusion incident. Mosaic owner right buttons arrived at all five stages, +and visible popup interactions used the new owner's applied coordinates. + +Exterior (-20,190) on old H1 after D3 and (20,190) on H2 after D5 delivered no +Mosaic popup button. **These are outside points, not demonstrated clipped +popup portions**: at the measured camera 640 the Mosaic b_cross A/B boxes fit +inside the viewport. No extra camera pair was spent to force spill. Thus +migration clip reassociation is directly traced, but clipped-portion input +and a real Mosaic popup overflow challenge after migration remain NOT EXERCISED. +Controls did show real nested spill on H2 before migration: 15,860 green pixels +in D0/D2/D5 and 260 after D1. Do not substitute those differently arranged +standard boxes for a same-geometry negative Mosaic control. + +Mosaic D3's vacated H1 was exactly 750,000 dark background pixels, with zero +owner/A/B colors; D5's vacated H2 was exactly 480,000 background pixels. +These full-image checks support absence of any stale rendered residue in those +settled samples, rather than checking only exact popup colors. The destination +showed owner and permitted yellow/green popup pixels: D3 H2 owner 264,148, +A 42,200, B 39,000; D5 H1 owner 412,930, A 66,075, B 61,125. All 24 Mosaic +capture-pair comparisons were identical. All 72 corresponding standard/private-standard PNGs were pixel-identical, +including their retained final D5 captures. + +The restored opaque XRGB SHM owner is actually visible and interactive, unlike +using the historical translucent S1 name as opaque proof. Its source buffer +stays 420×320 while configure sizes differ; compositor applied container, +surface and rendering sizes must remain distinct. Ordinary wev references also +retain their 640×480 surface despite smaller requested floating frames. No +independent unclipped **tiled opaque spill** control was exercised. That +assertion remains PARTIAL; a private clip ablation was not performed. Newly +included/vacated viewport-specific hit points were not independently scored. +Shadows/blur were disabled here; dynamic effects, optimized cache correctness, +saved buffers and intermediate frames remain NOT TESTED. + +### Damage, commit/present and boundedness + +Private splith recorded 1,760 damage events; Mosaic recorded 1,585. +Mosaic native traces contained 412 accepted commit identities, 404 present +identities, 404 matching pairs. Private splith recorded 362 / 354 / 354. +The terminal unpresented commits include teardown; counts alone are not a +render defect. Ordinary control has no native damage/commit instrumentation. +No frame identity is inferred for a screenshot or input event. + +Physical extents covered every measured equal-size settled pixel difference. +For Mosaic migration D3, H1/H2 changed 625,428 / 370,119 pixels; D5 changed +578,428 / 400,119. Each had zero changed pixels outside available extents. +For resize, compare common buffer-local pixels and all new-buffer area rather +than asserting equal-sized images: D1 adds 150,000 physical pixels and changes +223,000 common pixels; D2 changes 52,974 common pixels. Coverage outside the +available physical extent union was zero. Retired old-only buffer area, minimal +damage and each intermediate frame are not qualified by this calculation. +Broad/full-output invalidation is compatible with these observations. + +The persistent locked ledger contains exactly D1/D2/D3/D5 once per mode: +**reconfiguration 6/6, migration 6/6, camera pairs 0/1**. Both failed controls +consume all their launched actions. Offline tests exercised the actual lock, +persistence, duplicate/exhaustion and missing-ledger rejection. The inherited +lock descriptor also stays open in the generated child. No automatic refunds, +reset, additional lab or dynamic replay was used. + +All three runtime directories were removed and all owned client/compositor +processes terminated. No production files, historical patches, cumulative +patch, PKGBUILD, UX, AGENTS, system libraries, desktop session or dotfiles +changed. No live IPC, install, STEP20D, commit or push occurred in B. + +### Architectural answers and remaining gate + +| Question | Evidence-backed answer | +| --- | --- | +| Q1 viewport after logical resize | Yes in measured D1/D2: 800→960→800 logical width with corresponding origins. | +| Q2 camera | Conserved 640 within real maxima 1760/1600; maximum clamp branch NOT TESTED. | +| Q3 tiled clip | Yes, local clip width and global scene translation yield each applied output rectangle. | +| Q4 popup output owner | Yes for mapped owner-8 root/nested hierarchies in the sampled states; current owner follows workspace H1→H2→H1. | +| Q5 stale clip | None in stable retained traces/captures; no every-intermediate-frame proof. | +| Q6 workspace identity | Workspace 6/name 1 and tiled 8 survive both migrations; order/layout retained. | +| Q7 last old workspace | Departure creates workspace 13/name 3 on H1; return creates workspace 14/name 2 on H2. These are not workspace 6. | +| Q8 old pixels | Vacated outputs are exactly their new empty workspace's dark background before reference calibration. | +| Q9 new rendering/effects | Owner and permitted A/B pixels appear at the new scale; effects disabled and NOT TESTED. | +| Q10 hit/viewport | Calibrated visible owner/popups and exterior points agree; newly included points and clipped-popup portions NOT EXERCISED. | +| Q11 negative-origin pointer | Yes, measured logical extent changes to 1760 and both reference deltas/buttons pass in Mosaic. | +| Q12 damage coverage | Available physical extent unions cover settled changes/new area; no minimal, retired-buffer or per-frame guarantee. | +| Q13 ordinary rules | Cursor rebase/focus changes, replacement workspaces, explicit popup destruction/recreation and exterior-click popup_done are ordinary paths, not clip defects. | +| Q14 redesign | No reproduced wrong-owner clip, invisible hit, crash or persistent pixel leak requires redesign here. Incomplete assertions are not a proof of complete correctness. | +| Q15 next gate | Rotation is the next distinct geometric gate: this campaign changes owner and scale/origin/width but uses only normal transforms. Before production, separately close opaque overflow, maximum-clamp, migrated clipped-popup input, remaining effects/saved buffers and SceneFX ABI/package gates. Repair the reference fixture before future comparisons; do not replay this exhausted campaign. | + +**DYNAMIC_OUTPUT_OWNERSHIP_FEASIBLE_FOR_TESTED_MATRIX**, limited to the stable +normal-transform geometry/ownership subset actually measured. Overall probe +**PARTIALLY_FEASIBLE** due to the standard D5 fixture failures, unexercised +maximum clamp/migrated popup spill and effects/input/damage scope limits. +STEP20C remains NEXT; production multi-output NOT YET SUPPORTED. + +`SWAYFX_MOSAIC_DYNAMIC_OUTPUT_OWNERSHIP_PROBE=PARTIALLY_FEASIBLE` + +### Final provenance and technical validation + +The 916-entry source manifest `results/source-sha256.txt` has SHA-256 +`a4071f6bf3d9a0825954c1b0c504502a3d4c007d83eab2bae8de51fb58594ee4`. +The 529-entry complete raw-evidence manifest `results/evidence-sha256.txt` +has SHA-256 +`48d8126b84a000ca2ac6ca3e7e7d1b465b6e63bda18c168a128fe6fe660c695a`. +Both verified entry by entry. Source inventory includes the complete pinned +wlroots 0.20.2 source archive used for audit; replay depends on pinned sources +and installed matching wlroots, not on an old temporary lab. + +The subsequent freeze audit verified all 529 original evidence-manifest entries +and the expected manifest SHA-256 before invoking the offline analyzer. That +analyzer rewrote `results/evidence-sha256.txt` in the temporary lab, replacing +the original 529-entry file with a 528-entry inventory of the files present at +replay time (SHA-256 `3da3a499d5e398fbc85e85c0bd07bf0bf2bc9778e111b4ac7bae2e036e8af6a2`). +The raw runs and their 230 PNGs were not replayed or removed. The original +manifest bytes were not retained separately; its pre-replay verification is +recorded by the audit transcript and the closeout digest above. This is an +audit-tool side effect and a provenance limitation, not a new measurement or +a reason to upgrade the scientific verdict. +The source manifest likewise verified before this documentation freeze. Its +historical fixture-README entry records SHA-256 +`0b35a83a1fb92e83be5d27c9acf9a38a4d017dc5a1018bf3a706b76b52f695f4`; +the published README now documents the later freeze and therefore no longer +matches that one historical source entry. The scripts and experimental patches +were not changed. + +Final adapter reconstruction reproduced the measured corrected Mosaic generated +runner byte-for-byte offline, without reservation or launch. New private patches +reverse-applied then forward-applied in isolated files and reproduced measured +source bytes. All Python modules and retained generated runners passed AST; +shell syntax, C17 -Werror, private/pristine builds and model 1/1 passed. +Documentation links and ordinary git diff --check passed. Complete source-only +diff evidence is retained outside Git; new unified patch context whitespace +must be distinguished from whitespace in C/Python source additions. + +Phase A publication remains `8345aff0f97d381b768233021fc5c881ec794094` on +local main, Forgejo origin/main and GitHub github/main. The gate worktree was +clean. B changes are confined to this report, its source fixture, README index, +Architecture, Known Limitations, Roadmap and Changelog, with an empty Git index. +No B commit or push occurred. All source/patch/package/session invariants above +remain in force. + +A post-publication recheck of the historical fractional source manifest still +matches its recorded SHA-256 and verifies 38/39 paths against the current tree. +The sole changed path is the fractional fixture README: its historical bytes +(`6e800abfa2d48c047640fde787747fb9a6e6021152cb3718aafa7abea09f3bf5`) +remain in the independent freeze clone, while the published README includes +later freeze replay instructions. The historical 1,986-entry evidence manifest +and both independent freeze manifests verify completely. No historical +manifest was rewritten and no measurement source was changed by this README +update. + +`SWAYFX_MOSAIC_FRACTIONAL_SCALING_FREEZE=PASS` + +`SWAYFX_MOSAIC_STEP20C_FRACTIONAL_FREEZE_AND_DYNAMIC_OUTPUT_OWNERSHIP_V1=PARTIALLY_FEASIBLE` diff --git a/tooling/probes/step20c-dynamic-output/README.md b/tooling/probes/step20c-dynamic-output/README.md new file mode 100644 index 0000000..97fe280 --- /dev/null +++ b/tooling/probes/step20c-dynamic-output/README.md @@ -0,0 +1,74 @@ +# Dynamic output ownership source fixture + +**EXPERIMENTAL / preserved source-only fixture.** Read the +[protocol and source audit](../../../docs/design/STEP20C_DYNAMIC_OUTPUT_OWNERSHIP_PROBE.md). +No production support or UX contract follows. Reconstruct in a full clone +containing `8345aff0f97d381b768233021fc5c881ec794094` and copy this local fixture. +Old temporary laboratories are not dependencies. No install or live IPC. + +The preparer reuses pinned source-only fractional dependencies, builds private +SceneFX/Mosaic and pristine SwayFX 0.6, then compiles the per-event logical-extent +pointer and opaque SHM client with C17. Passive output traces preserve callback +order and clip/render decisions. All artifacts stay in its printed LAB. + +Run each mode once in the same campaign. Never delete/reset the ledger or +create another lab to evade failed-action consumption. The runner reserves +D1/D2/D3/D5 before IPC; camera pairs are zero. Each run owns private runtime, +Wayland/IPC sockets and children, which its finally block removes. + +```sh +sh tooling/probes/step20c-dynamic-output/prepare.sh +python tooling/probes/step20c-dynamic-output/test-budget.py +python tooling/probes/step20c-dynamic-output/run.py "$LAB" control +python tooling/probes/step20c-dynamic-output/run.py "$LAB" private_standard +python tooling/probes/step20c-dynamic-output/run.py "$LAB" private_mosaic +python tooling/probes/step20c-dynamic-output/analyze.py "$LAB" +``` + +Every IPC uses the private socket. Pointer coordinates derive from current +get_outputs logical bounds per event. No physical scaling in injection. +Failures remain scientific exclusions, not automatic clipping defects. + +## Observed campaign + +The one measured campaign at `/tmp/swayfx-mosaic-nested-grab-0zJrQnLj` applied +D1/D2/D3/D5 in all three modes: logical H1 width 800→960→800, workspace 6 and +tiled 8 preserved, H1→H2→H1 ownership and applied clips reassociated. It retained +230 PNGs. Mosaic completed; both controls completed transitions but failed +final calibration through temporary reference occlusion. Their generated +sources remain in their run directories; the final fixture relocates both +references before testing either. No failed dynamic action was replayed. + +Result PARTIALLY_FEASIBLE. Stable vacated outputs are exact background; +Mosaic owner hits and both-output calibration pass at all scored stages. +Observed camera 640 remains within recalculated bounds; max-clamp is NOT TESTED. +Mapped popup boxes fit inside the Mosaic viewport at that camera: exterior +points are not proof of a clipped portion after migration. Opaque tiled spill +without a negative clip control, effects (disabled), newly included point hits, +intermediate frames, saved buffers and ABI/package remain unqualified. + +Consumed budgets: six reconfigurations, six migrations, zero camera pairs. +The ledger is exhausted for dynamic operations: do not run any mode again in +this campaign. Only offline analysis/validation can be repeated. New campaign +measurements require a separately authorized scope and budget. + +`scenario.py` stores source text as a Python constant; `run.py` inserts it into +the inherited bounded runner. Python AST checks cover the modules and all +retained generated runners. Clients compile under C17 -Werror. New patch hashes: + +- pointer-dynamic-extents.patch: `b07b1549f541cc55e1170737ea7aa4b869cd97f3411c1b103be1b96b21971eef` +- ownership-trace.patch: `4a4a48566bd5d5fe8edf6146548cbcf4f93500cc62421780b75cd1dcb82abcb6` + +The report distinguishes passive callback observation, settled capture and +client delivery. Raw runs, source/evidence manifests and structured analyses +remain outside Git. The original 529-entry evidence manifest and its expected +SHA-256 were verified before an offline analyzer rerun replaced that temporary +manifest with a 528-entry replay inventory. See the report's provenance note; +the original manifest bytes are no longer present in the lab. + +For an independent wlroots source audit, fetch the authoritative wlroots Git +repository and inspect commit `d783533489e1f75d6886c2ab5c5960090ef268f8` +(tag 0.20.2). The campaign retains its complete source archive outside Git as +`wlroots-audited-source.tar`; replay binaries link installed wlroots 0.20.2, +without rebuilding or modifying that system library. pkg-config names are +`wlroots-0.20`, `scenefx-0.5` and `wayland-client`. diff --git a/tooling/probes/step20c-dynamic-output/analyze.py b/tooling/probes/step20c-dynamic-output/analyze.py new file mode 100644 index 0000000..3bfbf2e --- /dev/null +++ b/tooling/probes/step20c-dynamic-output/analyze.py @@ -0,0 +1,119 @@ +"""Offline full-image, applied ownership, clip and physical damage evidence.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +from PIL import Image, ImageChops, ImageDraw +from campaign import read, ACTIONS + +COLORS={'owner':(35,68,204),'A':(224,192,32),'B':(32,208,160),'background':(30,30,46)} + +def sha(p):return hashlib.sha256(p.read_bytes()).hexdigest() +def nz(im): + r,g,b=im.split();return ImageChops.lighter(ImageChops.lighter(r,g),b) +def difference(a,b): + if a.size!=b.size:return {'status':'DIFFERENT_PHYSICAL_BUFFERS','before':list(a.size),'after':list(b.size)} + mask=nz(ImageChops.difference(a,b)) + return {'status':'COMPARED','changed_pixels':a.width*a.height-mask.histogram()[0],'bbox':mask.getbbox()} +def fields(line): + # Contract: retain raw traces as well as parsed passive state, no inferred frames. + return dict(re.findall(r'(\w+)=([^\s]+)',line)) +def analyze(run): + events=[json.loads(x) for x in (run/'events.jsonl').read_text().splitlines()] + log=(run/'compositor.log').read_text(errors='replace') + traces=[{'kind':k,**fields(line),'raw':line} for line in log.splitlines() for k in ('DO_STATE','DO_POPUP','PI_SCENE','PI_OWNER','PI_POPUP','PI_OUTPUT_COMMIT','PI_OUTPUT_PRESENT') if k+' ' in line] + damages=[{'ns':int(ns),'output':out,'extent':list(map(int,box))} for ns,out,*box in re.findall(r'TEMPORAL_DAMAGE ordinal=\d+ ns=(\d+) output=(\S+) extent=(-?\d+),(-?\d+)\.\.(-?\d+),(-?\d+)',log)] + geoms={(e['tag'],e['output']):e for e in events if e['kind']=='capture_geometry'} + captures=[];previous={};images={};transitions=[];stable=[];clip_checks=[] + for e in events: + if e['kind']!='capture':continue + g=geoms[(e['tag'],e['output'])];im=Image.open(run/e['path']).convert('RGB');images[(e['tag'],e['output'])]=im + assert list(im.size)==g['dimensions']==[g['mode']['width'],g['mode']['height']] + counts={name:sum(v==col for v in im.get_flattened_data()) for name,col in COLORS.items()} + observations={k:[t for t in traces if t['kind']==k and t.get('observer',t.get('output'))==e['output'] and int(t.get('ns',0))<=e['before_ns']][-1:] for k in ('DO_STATE','PI_SCENE')} + observation={'tag':e['tag'],'output':e['output'],'sha256':sha(run/e['path']),'geometry':g,'colors':counts,'scene':observations} + captures.append(observation) + state=observations['DO_STATE'];scene=observations['PI_SCENE'] + if state and scene and state[0]['workspace']==scene[0]['workspace'] and scene[0].get('clip_valid')=='1': + sx,sy=map(int,state[0]['tile_global'].split(','));cx,cy,cw,ch=map(int,scene[0]['clip'].split(',')) + clip_checks.append({'tag':e['tag'],'output':e['output'],'workspace':scene[0]['workspace'],'applied_global_viewport':[sx+cx,sy+cy,cw,ch],'output_rect':g['rect'],'matches_output': [sx+cx,sy+cy,cw,ch]==[g['rect'][k] for k in ('x','y','width','height')],'scope':'last observed pre-capture commit callback, no atomic capture identity'}) + if e['output'] in previous: + older,oldim=previous[e['output']];d=difference(oldim,im) + intervals=[v for v in damages if v['output']==e['output'] and older['after_ns']x1 and y2>y1:draw.rectangle([x1,y1,x2-1,y2-1],fill=255) + changed=nz(ImageChops.difference(oldim,im)).point(lambda v:255 if v else 0) + uncovered=ImageChops.subtract(changed,mask).histogram()[255] + resized = None + if d['status']=='DIFFERENT_PHYSICAL_BUFFERS' and damages: + # WHY: a resized physical buffer cannot be compared as equal-sized PNGs. + # CONTRACT: compare common buffer-local pixels; score all new-area pixels. + # INVARIANT: retired old-only pixels are not part of the new buffer. + width,height=min(oldim.width,im.width),min(oldim.height,im.height) + changed=Image.new('L',im.size,255) + common=nz(ImageChops.difference(oldim.crop((0,0,width,height)),im.crop((0,0,width,height)))).point(lambda v:255 if v else 0) + changed.paste(common,(0,0)) + mask=Image.new('L',im.size,0);draw=ImageDraw.Draw(mask) + for v in intervals: + x1,y1,x2,y2=v['extent'] + if x2>x1 and y2>y1:draw.rectangle([x1,y1,x2-1,y2-1],fill=255) + resized={'common_buffer_local_pixels':width*height, + 'new_area_pixels':im.width*im.height-width*height, + 'changed_common_pixels':width*height-common.histogram()[0], + 'uncovered_changed_or_new_pixels':ImageChops.subtract(changed,mask).histogram()[255], + 'scope':'buffer-local common pixel delta and all new area, no global-world equivalence or retired-buffer claim'} + transitions.append({'output':e['output'],'from':older['tag'],'to':e['tag'],**d,'physical_damage_extents':intervals,'uncovered_changed_pixels':uncovered,'resized_buffer_coverage':resized}) + previous[e['output']]=(e,im) + if e['tag'].endswith('-repeat'): + base=e['tag'][:-7] + if (base,e['output']) in images:stable.append({'tag':base,'output':e['output'],**difference(images[(base,e['output'])],im)}) + client=(run/'client.log').read_text(errors='replace') + grabs=[{'popup':name,'target':target,'serial':int(serial),'used':int(used),'valid':serial==used and bool(re.search(rf'POINTER_BUTTON target={target} serial={serial} button=272 state=1',client))} for name,target,serial,used in re.findall(r'(POPUP_[AB]) GRAB_REQUEST origin=wl_pointer.button target=(\S+) serial=(\d+) used=(\d+)',client)] + popup_checks=[] + for t in traces: + if t['kind']=='DO_POPUP' and t.get('clip_valid')=='1' and t.get('global_valid')=='1': + px,py=map(int,t['global'].split(','));cx,cy,cw,ch=map(int,t['clip'].split(',')) + applicable=[e for e in events if e['kind']=='state' and e['monotonic_ns']>=int(t['ns'])] + popup_checks.append({'ns':int(t['ns']),'owner_id':t['owner_id'],'workspace':t['workspace'],'owner_output':t['owner_output'],'tree':t['tree'],'global_clip':[px+cx,py+cy,cw,ch],'local_clip':[cx,cy,cw,ch]}) + commits={(t['output'],t['commit_seq']) for t in traces if t['kind']=='PI_OUTPUT_COMMIT'} + presents={(t['output'],t['commit_seq']) for t in traces if t['kind']=='PI_OUTPUT_PRESENT'} + return {'run':run.name,'source_sha256':sha(run/'fixture-source.py'),'exit_status':json.loads((run/'exit-status.json').read_text()),'states':[e for e in events if e['kind']=='state'],'identities':[e for e in events if e['kind']=='identity'],'calibrations':[e for e in events if e['kind']=='pointer_calibration'],'inputs':[e for e in events if e['kind'] in ('owner_hit','forbidden_click','chain','pointer_request')],'popup_lifecycle':[e for e in events if e['kind']=='popup_lifecycle'],'grabs':grabs,'captures':captures,'stability':stable,'transitions':transitions,'tiled_clip_checks':clip_checks,'popup_clip_checks':popup_checks,'traces':traces,'damage_events':len(damages),'native_events':{'commits':len(commits),'presents':len(presents),'paired':len(commits&presents),'scope':'native private headless identity, no captured buffer identity'},'client_events':re.findall(r'^(?:POPUP_|POINTER_|TOPLEVEL)[^\n]*',client,re.M),'cleanup':[e for e in events if e['kind']=='cleanup']} + +p=argparse.ArgumentParser();p.add_argument('lab',type=Path);a=p.parse_args();lab=a.lab.resolve();out=lab/'results';out.mkdir(exist_ok=True) +results=[analyze(r) for r in sorted((lab/'runs').glob('*-dynamic-*')) if (r/'exit-status.json').exists()] +state=read(lab);budget={'ledger':state,'consumed':{k:sum(ACTIONS[r['action']]==k for r in state['reservations']) for k in ('reconfiguration','migration')},'camera_pairs':0} +assert budget['consumed']['reconfiguration']<=6 and budget['consumed']['migration']<=6 +mapping={'outputs-before-after':'states','workspaces':'identities','owner-identities':'identities','camera-states':'identities','popup-ownership':'popup_lifecycle','scene-clips':'tiled_clip_checks','pointer-calibration':'calibrations','input-delivery':'inputs','damage-analysis':'transitions','pixel-analysis':'captures','transition-timeline':'traces'} +(out/'analysis.json').write_text(json.dumps(results,indent=2)+'\n') +for filename,key in mapping.items():(out/(filename+'.json')).write_text(json.dumps([{'run':r['run'],'observations':r[key]} for r in results],indent=2)+'\n') +(out/'campaign-budget.json').write_text(json.dumps(budget,indent=2)+'\n') +summary={'runs':len(results),'failed_fixtures':sum(bool(r['exit_status']['failure']) for r in results),'budget':budget,'not_tested':['max-scroll clamp branch','optimized blur','all effects','clip ablation opaque overflow','every intermediate frame','atomic applied generation','rotation','saved buffers','ABI/package']} +matrix=[] +for r in results: + geometry={} + for tag in ('D0','D1-popup','D2-popup','D3-popup','D5-popup'): + samples=[e for e in r['states'] if e['tag']==tag] + if not samples:geometry[tag]='NOT TESTED';continue + e=samples[-1];h1=next(o for o in e['outputs'] if o['name']=='HEADLESS-1') + wanted={'x':-960,'y':0,'width':960,'height':600} if tag=='D1-popup' else {'x':-800,'y':0,'width':800,'height':600} + geometry[tag]='PASS' if h1['rect']==wanted and h1['current_mode']['width']==(1200 if tag=='D1-popup' else 1000) else 'INCONCLUSIVE' + matrix.append({'run':r['run'],'applied_geometry':geometry,'identities_preserved':len(set(e['workspace_id'] for e in r['identities']))==1 and len(set(e['owner_id'] for e in r['identities']))==1, + 'settled_captures_identical':all(s.get('changed_pixels')==0 for s in r['stability']), + 'D5_pointer_calibration':'PASS' if len([e for e in r['calibrations'] if e['tag']=='D5'])==2 else 'NOT TESTED / fixture occlusion', + 'all_grabs_valid':all(g['valid'] for g in r['grabs']), + 'max_scroll_clamp_branch':'NOT TESTED','newly_included_point_hit':'NOT TESTED','popup_clipped_portion_after_migration':'NOT EXERCISED: measured Mosaic camera fully contains popup rectangles', + 'opaque_owner_visibility':'sampled XRGB pixels and owner hits; independent unclipped tiled spill control NOT TESTED', + 'effects':'NOT TESTED: shadows and blur disabled'}) +summary['result']='PARTIALLY_FEASIBLE' if len(results)==3 and all(all(v=='PASS' for v in m['applied_geometry'].values()) and m['identities_preserved'] and m['settled_captures_identical'] and m['all_grabs_valid'] for m in matrix) else 'INCONCLUSIVE' +summary['architectural_conclusion']='DYNAMIC_OUTPUT_OWNERSHIP_FEASIBLE_FOR_TESTED_MATRIX' if summary['result']=='PARTIALLY_FEASIBLE' else 'UNDECIDED' +summary['not_tested'] += ['clipped-popup portion after migration at measured camera','newly included viewport point hit','D5 standard-control calibration due reference occlusion'] +(out/'matrix.json').write_text(json.dumps(matrix,indent=2)+'\n') +(out/'summary.json').write_text(json.dumps(summary,indent=2)+'\n') +files=sorted(set([p for p in (lab/'runs').rglob('*') if p.is_file()]+[p for p in out.iterdir() if p.is_file() and p.name!='evidence-sha256.txt']+list(lab.glob('dynamic-*.py'))+[lab/'dynamic-campaign-budget.json',lab/'dynamic-build-log.txt'])) +manifest=out/'evidence-sha256.txt';manifest.write_text(''.join(sha(p)+' '+str(p)+'\n' for p in files)) +print(json.dumps({'runs':len(results),'summary':summary,'entries':len(files),'manifest_sha256':sha(manifest)})) diff --git a/tooling/probes/step20c-dynamic-output/campaign.py b/tooling/probes/step20c-dynamic-output/campaign.py new file mode 100644 index 0000000..ed155f9 --- /dev/null +++ b/tooling/probes/step20c-dynamic-output/campaign.py @@ -0,0 +1,79 @@ +"""Persistent finite campaign budget; only caller holding Campaign.lock may mutate.""" +import fcntl +import json +import os +from pathlib import Path + +MODES = ('control', 'private_standard', 'private_mosaic') +ACTIONS = {'D1': 'reconfiguration', 'D2': 'reconfiguration', + 'D3': 'migration', 'D5': 'migration'} +LIMITS = {'reconfiguration': 6, 'migration': 6, 'camera_pair': 1} + + +def read(lab): + path = Path(lab) / 'dynamic-campaign-budget.json' + if not path.exists(): + if any((Path(lab) / 'runs').glob('*-dynamic-*')): + raise ValueError('missing ledger in existing campaign; no reset') + return {'version': 1, 'runs': [], 'reservations': []} + state = json.loads(path.read_text()) + if state['version'] != 1 or len(state['runs']) != len(set(state['runs'])): + raise ValueError('invalid campaign runs/version') + if any(m not in MODES for m in state['runs']): + raise ValueError('invalid mode') + seen = set() + for r in state['reservations']: + key = (r['mode'], r['action']) + if key in seen or r['mode'] not in state['runs'] or r['action'] not in ACTIONS: + raise ValueError('invalid/duplicate persisted reservation') + seen.add(key) + return state + + +def write(lab, state): + # WHY: reservations survive launch failure and restart; never refund. + # CONTRACT: lock is held across write, launch, cleanup; atomic replacement. + # INVARIANT: preserve every old entry, persist before IPC is launched. + path = Path(lab) / 'dynamic-campaign-budget.json' + tmp = path.with_suffix('.tmp') + with tmp.open('w') as f: + f.write(json.dumps(state, indent=2) + '\n') + f.flush() + os.fsync(f.fileno()) + tmp.replace(path) + fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +class Campaign: + def __init__(self, lab): + self.lab = Path(lab) + self.lock = (self.lab / 'dynamic-campaign.lock').open('a') + fcntl.flock(self.lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + + def begin(self, mode): + state = read(self.lab) + if mode not in MODES or mode in state['runs']: + raise ValueError('mode already attempted or invalid; no dynamic replay') + state['runs'].append(mode) + write(self.lab, state) + + +def reserve(lab, mode, action): + # CONTRACT: generated child runs under its parent's exclusive campaign lock. + state = read(lab) + if mode not in state['runs'] or action not in ACTIONS: + raise ValueError('unregistered mode/action') + if any(r['mode'] == mode and r['action'] == action for r in state['reservations']): + raise ValueError('action budget exhausted for mode') + kind = ACTIONS[action] + if sum(ACTIONS[r['action']] == kind for r in state['reservations']) >= LIMITS[kind]: + raise ValueError('campaign budget exhausted') + import time + state['reservations'].append({'mode': mode, 'action': action, + 'reserved_ns': time.monotonic_ns()}) + write(lab, state) + return state diff --git a/tooling/probes/step20c-dynamic-output/ownership-trace.patch b/tooling/probes/step20c-dynamic-output/ownership-trace.patch new file mode 100644 index 0000000..c5abfe5 --- /dev/null +++ b/tooling/probes/step20c-dynamic-output/ownership-trace.patch @@ -0,0 +1,43 @@ +--- a/sway/desktop/output.c ++++ b/sway/desktop/output.c +@@ -384,6 +384,20 @@ + output->wlr_output->name, presentation_probe_now_ns()); + return; + } ++ // WHY: correlate actual scene coordinates with current workspace ownership. ++ // CONTRACT: passive callback reads only; no state or callback-order changes. ++ // INVARIANT: output label is the observing callback, owner is current.output. ++ int tile_x = 0, tile_y = 0; ++ bool tile_valid = wlr_scene_node_coords(&ws->layers.tiling->node, &tile_x, &tile_y); ++ struct wlr_output *wlr = output->wlr_output; ++ sway_log(SWAY_INFO, "DO_STATE ns=%" PRIu64 " observer=%s workspace=%zu owner=%s" ++ " origin=%d,%d logical=%d,%d physical=%d,%d scale=%.2f transform=%d" ++ " tile_valid=%d tile_global=%d,%d", ++ presentation_probe_now_ns(), wlr->name, ws->node.id, ++ ws->current.output ? ws->current.output->wlr_output->name : "none", ++ output->lx, output->ly, output->width, output->height, ++ wlr->width, wlr->height, wlr->scale, wlr->transform, ++ tile_valid, tile_x, tile_y); + struct wlr_box clip = {0}; + bool has_clip = wlr_scene_tree_get_clip(ws->layers.tiling, &clip); + double scroll = -1; +@@ -422,6 +436,19 @@ + struct wlr_scene_tree *tree = wlr_scene_tree_from_node(node); + struct wlr_box box = {0}; + bool valid = wlr_scene_tree_get_clip(tree, &box); ++ struct sway_popup_desc *desc = scene_descriptor_try_get(node, SWAY_SCENE_DESC_POPUP); ++ struct sway_container *owner = desc && desc->view ? desc->view->container : NULL; ++ struct sway_workspace *owner_ws = owner ? owner->current.workspace : NULL; ++ int popup_x = 0, popup_y = 0; ++ bool popup_valid = wlr_scene_node_coords(node, &popup_x, &popup_y); ++ // CONTRACT: tree identity is process-local; view/current owner is authoritative. ++ sway_log(SWAY_INFO, "DO_POPUP ns=%" PRIu64 " tree=%p owner_id=%zu workspace=%zu" ++ " owner_output=%s global_valid=%d global=%d,%d clip_valid=%d clip=%d,%d,%d,%d", ++ presentation_probe_now_ns(), (void *)tree, ++ owner ? owner->node.id : 0, owner_ws ? owner_ws->node.id : 0, ++ owner_ws && owner_ws->current.output ? owner_ws->current.output->wlr_output->name : "none", ++ popup_valid, popup_x, popup_y, valid, box.x, box.y, box.width, box.height); ++ + sway_log(SWAY_INFO, "PI_POPUP output=%s ns=%" PRIu64 + " local_index=%u tree=%p enabled=%d clip_valid=%d clip=%d,%d,%d,%d", + output->wlr_output->name, presentation_probe_now_ns(), diff --git a/tooling/probes/step20c-dynamic-output/pointer-dynamic-extents.patch b/tooling/probes/step20c-dynamic-output/pointer-dynamic-extents.patch new file mode 100644 index 0000000..c48da7e --- /dev/null +++ b/tooling/probes/step20c-dynamic-output/pointer-dynamic-extents.patch @@ -0,0 +1,24 @@ +--- a/pointer-daemon.c ++++ b/pointer-daemon.c +@@ -46,13 +46,18 @@ + char line[128]; + uint32_t stamp=1000; + while (fgets(line,sizeof(line),stdin)) { +- char action[16];int x,y; +- if (sscanf(line,"%15s %d %d",action,&x,&y)!=3 || ++ /* WHY: output layout bounds can change without recreating the pointer. ++ * CONTRACT: per-event extents are logical, supplied from applied IPC. ++ * INVARIANT: reject invalid dimensions before unsigned conversion. */ ++ char action[16];int x,y,event_width,event_height; ++ if (sscanf(line,"%15s %d %d %d %d",action,&x,&y,&event_width,&event_height)!=5 || ++ event_width <= 0 || event_height <= 0 || + (strcmp(action,"move") && strcmp(action,"left") && strcmp(action,"right")) || +- x < 0 || y < 0 || (uint32_t)x >= width || (uint32_t)y >= height) { ++ x < 0 || y < 0 || x >= event_width || y >= event_height) { + fprintf(stderr,"INVALID_INJECTION %s",line);fflush(stderr); + return 3; + } ++ width = (uint32_t)event_width; height = (uint32_t)event_height; + fprintf(stderr,"INJECT %s %d,%d extent=%u,%u\n",action,x,y,width,height); + fflush(stderr); + zwlr_virtual_pointer_v1_motion_absolute(pointer,stamp++,x,y,width,height); diff --git a/tooling/probes/step20c-dynamic-output/prepare.sh b/tooling/probes/step20c-dynamic-output/prepare.sh new file mode 100644 index 0000000..6664456 --- /dev/null +++ b/tooling/probes/step20c-dynamic-output/prepare.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# EXPERIMENTAL: disposable sources only; caller owns successful LAB. +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +fixture="$repo/tooling/probes/step20c-dynamic-output" +log=$(mktemp /tmp/dynamic-output-prepare-XXXXXXXX) +if ! sh "$repo/tooling/probes/step20c-fractional-scale/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") +test -n "$lab" && test -d "$lab" +# CONTRACT: reversing the glass fixture yields an opaque XRGB owner; +# no compositor algorithm or historical patch is changed. +(cd "$lab" && git apply --check -R "$repo/tooling/probes/step20c-effects-damage/effect-glass.patch" && + git apply -R "$repo/tooling/probes/step20c-effects-damage/effect-glass.patch" && + git apply --check "$fixture/pointer-dynamic-extents.patch" && + git apply "$fixture/pointer-dynamic-extents.patch") +(cd "$lab/mosaic" && git apply --check "$fixture/ownership-trace.patch" && git apply "$fixture/ownership-trace.patch") +meson compile -C "$lab/mosaic-build" -j 8 >> "$log" 2>&1 +for client in nested-client pointer-daemon; do + protocol=xdg-shell + test "$client" != pointer-daemon || protocol=virtual-pointer + cc -std=c17 -Wall -Wextra -Werror -Wno-unused-parameter -I"$lab" \ + "$lab/$client.c" "$lab/$protocol-protocol.c" -o "$lab/$client" \ + $(pkg-config --cflags --libs wayland-client) >> "$log" 2>&1 +done +cp "$log" "$lab/dynamic-build-log.txt" +printf 'LAB=%s\nBUILD_LOG=%s\n' "$lab" "$log" diff --git a/tooling/probes/step20c-dynamic-output/run.py b/tooling/probes/step20c-dynamic-output/run.py new file mode 100644 index 0000000..7f4a3d0 --- /dev/null +++ b/tooling/probes/step20c-dynamic-output/run.py @@ -0,0 +1,66 @@ +"""EXPERIMENTAL adapter; inherited bounded process/socket cleanup stays intact.""" +import argparse +from pathlib import Path +import subprocess +import sys +from campaign import Campaign +from scenario import SCENARIO + +p = argparse.ArgumentParser() +p.add_argument('lab', type=Path) +p.add_argument('mode', choices=('control', 'private_standard', 'private_mosaic')) +a = p.parse_args() +lab = a.lab.resolve() +repo = Path(__file__).resolve().parents[3] +# WHY: one lock covers all modes, dynamic reservations and complete child cleanup. +# CONTRACT: repeated/failed runs cannot silently replenish campaign budgets. +# INVARIANT: no hide/return camera pair is generated by this adapter. +campaign = Campaign(lab) +campaign.begin(a.mode) +s = (repo / 'tooling/probes/step20c-applied-state-input/run.py').read_text() +s = s.replace("f'{args.mode}-{args.kind}-asi{args.asi_mode}-{time.time_ns()}'", + "f'{args.mode}-dynamic-{time.time_ns()}'") +s = s.replace('output HEADLESS-1 resolution 800x600 position -800 0', + 'output HEADLESS-1 resolution 1000x750 scale 1.25 position -800 0') +s = s.replace("Path('/usr/bin/sway')", "lab / 'standard-build/sway/sway'") +s = s.replace('import argparse', 'import argparse\nfrom PIL import Image\nimport sys\nsys.path.insert(0, '+repr(str(Path(__file__).parent.resolve()))+')\nfrom campaign import reserve') +s = s.replace("run.mkdir(parents=True, mode=0o700)", "run.mkdir(parents=True, mode=0o700)\n(run / 'fixture-source.py').write_text(Path(__file__).read_text())") +start = s.index(" expected = {'HEADLESS-1':") +end = s.index(" for output, side in", start) +s = s[:start]+''' expected = {'HEADLESS-1': (-960, 0, 960, 600) if globals().get('dynamic_stage') == 'D1' else (-800, 0, 800, 600), 'HEADLESS-2': (0, 0, 800, 600)} + actual = {o['name']: tuple(o['rect'][k] for k in ('x','y','width','height')) for o in outputs if o['active']} + if actual != expected or any(o['transform'] != 'normal' or o['scale'] != (1.25 if o['name']=='HEADLESS-1' else 1) for o in outputs if o['active']): + raise RuntimeError(f'output geometry/scale/transform mismatch: {actual}') + record('state', tag=tag, workspaces=ipc('-t','get_workspaces'), workspace=workspace(), owner=owner(), outputs=outputs) +'''+s[end:] +s = s.replace("['grim', '-o', output, str(path)]", "['grim', '-s', str(next(o['scale'] for o in outputs if o['name']==output)), '-o', output, str(path)]") +s = s.replace(" record('capture', tag=tag", """ out = next(o for o in outputs if o['name']==output) + dimensions = list(Image.open(path).size) + if dimensions != [out['current_mode']['width'],out['current_mode']['height']]: + raise RuntimeError('physical PNG/mode mismatch') + record('capture_geometry',tag=tag,output=output,dimensions=dimensions,rect=out['rect'],scale=out['scale'],transform=out['transform'],mode=out['current_mode']) + record('capture', tag=tag""") +start=s.index('def inject(action, x, y):');end=s.index('\ndef client_command',start) +s=s[:start]+'''def inject(action, x, y): + outputs = [o for o in ipc('-t','get_outputs') if o['active']] + min_x = min(o['rect']['x'] for o in outputs) + min_y = min(o['rect']['y'] for o in outputs) + max_x = max(o['rect']['x']+o['rect']['width'] for o in outputs) + max_y = max(o['rect']['y']+o['rect']['height'] for o in outputs) + if not any(o['rect']['x']<=xbefore) + +'''+s[end:] +start=s.index(" if args.kind == 'cycles':\n");end=s.index('except Exception as exc:',start) +scenario=SCENARIO +s=s[:start]+scenario+'\n'+s[end:] +generated=lab/f'dynamic-{a.mode}.py' +generated.write_text(s) +raise SystemExit(subprocess.call([sys.executable,str(generated),str(lab),a.mode,'cycles','--max-seconds','240','--max-captures','100','--max-bytes','60000000'], pass_fds=(campaign.lock.fileno(),))) diff --git a/tooling/probes/step20c-dynamic-output/scenario.py b/tooling/probes/step20c-dynamic-output/scenario.py new file mode 100644 index 0000000..50ae0ae --- /dev/null +++ b/tooling/probes/step20c-dynamic-output/scenario.py @@ -0,0 +1,121 @@ +"""Private scenario source inserted inside the bounded runner try/finally.""" +SCENARIO = r''' # EXPERIMENTAL fragment inserted inside the inherited bounded runner's try. + # WHY: persistence observations precede reference clicks that can dismiss grabs. + # CONTRACT: four reserved dynamic actions per mode; no additional camera pair. + dynamic_stage = 'D0' + initial_ws_id = workspace()['id'] + initial_owner_id = owner()['id'] + ref_right = find(tree(), lambda n: n.get('app_id') == 'wev')['id'] + command('workspace 1') + left_proc = spawn(['stdbuf','-oL','wev'], 'wev-left.log') + wait(lambda: find(tree(), lambda n: n.get('app_id') == 'wev' and n['id'] != ref_right)) + ref_left = find(tree(), lambda n: n.get('app_id') == 'wev' and n['id'] != ref_right)['id'] + command('shadows disable'); command('blur disable') + command('output * bg #1e1e2e solid_color') + + def closed(): + client_command('close_a') + time.sleep(.12) + + def calibrate(tag): + outputs = {o['name']: o for o in ipc('-t','get_outputs') if o['active']} + active = {w['output']:w for w in ipc('-t','get_workspaces') if w['visible']} + for out, con_id, logname in [('HEADLESS-1',ref_left,'wev-left.log'),('HEADLESS-2',ref_right,'wev.log')]: + name = active[out]['name'] + command(f'[con_id={con_id}] move container to workspace {name}') + command(f'[con_id={con_id}] floating enable') + command(f'[con_id={con_id}] border none') + command(f'[con_id={con_id}] resize set 200 150') + ox=outputs[out]['rect']['x']+50; oy=400 + command(f'[con_id={con_id}] move absolute position {ox} {oy}') + time.sleep(.25) + # WHY: after return both references can temporarily share workspace 1. + # CONTRACT: relocate BOTH before scoring input, avoiding mutual occlusion. + for out, con_id, logname in [('HEADLESS-1',ref_left,'wev-left.log'),('HEADLESS-2',ref_right,'wev.log')]: + ox=outputs[out]['rect']['x']+50; oy=400 + text=lambda: (run/logname).read_text() + # Force a different surface, then accept actual enter or motion. + inject('move', outputs['HEADLESS-2' if out=='HEADLESS-1' else 'HEADLESS-1']['rect']['x']+10,10) + before=len(text());inject('move',ox+20,oy+30) + pattern=r'(?:enter:[^\n]*|motion:[^\n]*)x, y: ([\d.]+), ([\d.]+)' + wait(lambda: re.search(pattern,text()[before:])) + ex,ey=map(float,re.findall(pattern,text()[before:])[-1]) + if ex != 20 or not 0 <= ey < 100: + raise RuntimeError(f'calibration interior mismatch {tag} {out}: {ex},{ey}') + before=len(text());inject('move',ox+60,oy+50) + wait(lambda: f'x, y: {ex+40:.6f}, {ey+20:.6f}' in text()[before:]) + before=len(text()) + inject('left',ox+60,oy+50);inject('right',ox+60,oy+50) + wait(lambda: 'button: 272' in text()[before:] and 'button: 273' in text()[before:]) + record('pointer_calibration',tag=tag,output=out,con_id=con_id,global_point=[ox+60,oy+50],local_expected=[ex+40,ey+20],surface_origin=[ox+20-ex,oy+30-ey],passed=True) + for x in (-2,-1,0,1,2):inject('move',x,550) + inject('move',outputs['HEADLESS-1']['rect']['x']+110,450) + command('workspace 1') + + def stable(tag, expected_owner): + # CONTRACT: repeated geometry and paired settled captures are evidence, + # not proof that intermediate configurations each had a presentation. + time.sleep(.35) + before=ipc('-t','get_outputs') + time.sleep(.12) + after=ipc('-t','get_outputs') + if before != after: + raise RuntimeError(f'unstable output IPC at {tag}') + ws=workspace();o=owner() + current=next(w for w in ipc('-t','get_workspaces') if w['id']==initial_ws_id) + if ws['id']!=initial_ws_id or o['id']!=initial_owner_id or current['output']!=expected_owner: + raise RuntimeError(f'workspace/owner identity mismatch at {tag}') + snapshot(tag) + time.sleep(.16);snapshot(tag+'-repeat') + record('identity',tag=tag,workspace_id=ws['id'],owner_id=o['id'],output=current['output'],layout=ws['layout'],order=[n['id'] for n in ws['nodes']],mosaic=ws.get('mosaic'),focused=current['focused']) + + def owner_hit(tag): + o=owner();ws=workspace() + scroll_x=ws['mosaic']['rows'][0]['scroll_x'] if args.mode=='private_mosaic' else 0 + point=[int(o['rect']['x']-scroll_x+o['window_rect']['x']+100),150] + before=len(client_text());inject('move',*point);inject('right',*point) + delivered='POINTER_BUTTON target=TOPLEVEL' in client_text()[before:] + record('owner_hit',tag=tag,point=point,delivered=delivered,events=client_text()[before:]) + if not delivered:raise RuntimeError(f'visible owner input failed {tag}') + + calibrate('D0') + command('[app_id="org.mosaic.NestedGrabProbe"] focus') + stable('D0','HEADLESS-1');owner_hit('D0') + open_chain('D0-chain') + stable('D0-popup','HEADLESS-1') + reserve(lab,args.mode,'D1') + record('dynamic_launch',action='D1') + dynamic_stage='D1' + command('output HEADLESS-1 mode 1200x750 scale 1.25 position -960 0') + stable('D1-popup','HEADLESS-1') + closed();calibrate('D1');owner_hit('D1') + open_chain('D1-chain');stable('D1-reopened','HEADLESS-1') + reserve(lab,args.mode,'D2');record('dynamic_launch',action='D2') + dynamic_stage='D0' + command('output HEADLESS-1 mode 1000x750 scale 1.25 position -800 0') + stable('D2-popup','HEADLESS-1') + closed();calibrate('D2');owner_hit('D2') + open_chain('D2-chain');stable('D3-before','HEADLESS-1') + # Context is explicitly verified before moving the target workspace. + if not next(w for w in ipc('-t','get_workspaces') if w['name']=='1')['focused']: + raise RuntimeError('workspace 1 is not migration context') + reserve(lab,args.mode,'D3');record('dynamic_launch',action='D3') + command('move workspace to output HEADLESS-2') + stable('D3-popup','HEADLESS-2') + record('popup_lifecycle',tag='D3-before-calibration',done_a=count('POPUP_A POPUP_DONE'),done_b=count('POPUP_B POPUP_DONE'),client_events=client_text()) + closed();calibrate('D3');owner_hit('D3') + open_chain('D3-new-chain');stable('D3-reopened','HEADLESS-2') + # New-owner forbidden region is on the old H1, not historical x=20. + before=len(client_text());inject('move',-20,190);inject('right',-20,190) + record('forbidden_click',tag='D3',global_point=[-20,190],events=client_text()[before:],delivered=bool(re.search(r'POINTER_BUTTON target=POPUP_[AB]',client_text()[before:]))) + closed();open_chain('D3-return-chain');stable('D5-before','HEADLESS-2') + reserve(lab,args.mode,'D5');record('dynamic_launch',action='D5') + command('move workspace to output HEADLESS-1') + stable('D5-popup','HEADLESS-1') + record('popup_lifecycle',tag='D5-before-calibration',done_a=count('POPUP_A POPUP_DONE'),done_b=count('POPUP_B POPUP_DONE'),client_events=client_text()) + closed();calibrate('D5');owner_hit('D5') + open_chain('D5-new-chain');stable('D5-reopened','HEADLESS-1') + before=len(client_text());inject('move',20,190);inject('right',20,190) + record('forbidden_click',tag='D5',global_point=[20,190],events=client_text()[before:],delivered=bool(re.search(r'POINTER_BUTTON target=POPUP_[AB]',client_text()[before:]))) + closed();stable('D5-closed','HEADLESS-1') +''' diff --git a/tooling/probes/step20c-dynamic-output/test-budget.py b/tooling/probes/step20c-dynamic-output/test-budget.py new file mode 100644 index 0000000..5eefcaa --- /dev/null +++ b/tooling/probes/step20c-dynamic-output/test-budget.py @@ -0,0 +1,32 @@ +"""Offline execution of actual campaign budget; no dynamic output operations.""" +import json +from pathlib import Path +import subprocess +import sys +import tempfile +from campaign import Campaign, reserve, read + +root=Path(tempfile.mkdtemp(prefix='dynamic-budget-test-')) +c=Campaign(root);c.begin('control');reserve(root,'control','D1') +# A second process must not launch a contradicting campaign while lock is held. +p=subprocess.run([sys.executable,'-c','from campaign import Campaign; Campaign('+repr(str(root))+')'],cwd=Path(__file__).parent,capture_output=True,text=True) +assert p.returncode and 'BlockingIOError' in p.stderr +for mode in ('control','private_standard','private_mosaic'): + if mode != 'control':c.begin(mode) + for action in ('D1','D2','D3','D5'): + if mode=='control' and action=='D1':continue + reserve(root,mode,action) + try:reserve(root,mode,'D1') + except ValueError:pass + else:raise AssertionError('duplicate accepted') + try:c.begin(mode) + except ValueError:pass + else:raise AssertionError('run reset accepted') +state=read(root);assert len(state['reservations'])==12 +# Persisted reservations survive caller failure; no refund or ledger reset. +c.lock.close();assert read(root)==state +old=root/'old';(old/'runs/control-dynamic-failed').mkdir(parents=True) +try:read(old) +except ValueError:pass +else:raise AssertionError('missing ledger accepted') +print('PASS lock/persistence/duplicate/exhaustion/failed-run/missing-ledger; zero compositor launches')