Close the collapse's two dropped guarantees: latch applyMode against re-entrancy, pin its project once; correct three overclaiming doc lines

This commit is contained in:
2026-08-06 05:07:31 -04:00
parent 8c06383fe7
commit 2269fd80b6
8 changed files with 136 additions and 54 deletions
+57 -21
View File
@@ -157,6 +157,11 @@ applies the resulting lane state to live tracks.
- The pure mode model (`ViewModeModel`, membership, `reconcile(liveGuids)`, the
snapshot-based park/restore planner) lives in `core/view` — reference it, do not
duplicate its spec here.
- **`TrackPlan::fxOffline` is read on the RESTORE side only.** `planToggle` builds
every park plan with `fxCount=0`, and this shell's park loop passes `tp.flags`
alone to `parkTrack`, which expands the real writes itself from
`TrackFX_GetCount` — so the park-side field is populated only by tests. A shell
change that starts reading it on the park path is reading an empty vector.
- The Two-canvas sub-phase (Phase D2/E)'s settled DAW-application rules
(fixed-lane mechanics, mode-aware capture placement, hidden-AND-silenced) are
reflected in Invariants above; the membership/lane-ownership model concepts
@@ -169,10 +174,14 @@ applies the resulting lane state to live tracks.
the solo cache inherits it rather than introducing it. Not fixed here.
- **Which routes reach the no-snapshot-plus-parked-chain pair, re-derived for the
one-point design.** Three of the routes that used to reach it were artifacts of
the retired deferral window and are GONE BY CONSTRUCTION: there is no longer a
moment in which a track's flags have been written but its FX have not, so no
save, no reload and no discard can land between the two halves, and there is no
queued intent for an undo to drop. What survives:
the retired deferral window, and are now NARROWED TO THE RE-ENTRANCY WINDOW
rather than gone: the flags-written-FX-not moment still exists inside
`parkTrack`, but nothing can observe it except code re-entered through the
pump premise below, and `applyMode` fails closed when re-entered
(`modeApplyInProgress`). The window that remains is what a call REACHABLE FROM
a pumped message loop but not routed through `applyMode` could see — no save,
no reload and no discard reaches it, and there is no queued intent for an undo
to drop. What survives:
- A `view_state` that PARSED but carries no snapshot for the track — a snapshot
`reconcile` pruned while its track was out of the live enumeration, then
undo/redo-restored with the track but not the snapshot. (Asserted by the
@@ -184,21 +193,39 @@ applies the resulting lane state to live tracks.
- A track the USER keeps hidden from both panels, out of the mix and
FX-bypassed. It reads identically to both of the above and is refused too —
no lost state there at all, which is why the report asserts no cause.
- A REDO of a mode switch, hypothetically — the mechanism and its two
unverified links are in the `view_state` gotcha below, which owns that
question; do not re-derive it here.
An undo of a mode switch (Ctrl-Z) is NOT a route, and no longer needs an
An UNDO of a mode switch (Ctrl-Z) is NOT a route, and no longer needs an
ordering premise to say so: flags and FX moved inside ONE block, so they roll
back together and no intermediate state exists for a park to misread.
back together and no intermediate state exists for a park to misread. The redo
direction is not symmetric with it — see below.
- **The `view_state` write is NOT inside `applyMode`'s block.** `persistViewState`
(`design_view_actions.cpp`) runs after `applyMode` has returned, so
`SetProjExtState` lands past `Undo_EndBlock2`. The UNDO direction still comes
out right — the point closed over the ext state as it stood BEFORE the switch,
which is what a Ctrl-Z should restore — but by position, not by design. The
REDO direction is what this leaves open: the point never saw the new
`view_state`, so a redo may re-park the tracks while restoring the model that
describes the mode the user undid back to. `[verify — DAW]`; if it does bite,
the result is the pre-existing model-vs-undo split the solo-cache gotcha above
already describes, not a new refusal route. Do not "fix" it by moving
`persistViewState` inside the block — it can open a modal Save-As.
REDO direction is what this leaves open, and the cost is worse than the
model-vs-undo split the solo-cache gotcha describes: **a redo can plausibly
produce a NEW refusal route.** As a HYPOTHESIS — the redo restores the parked
flags AND the ext state as the point closed over it, i.e. the PRE-switch
`view_state`, carrying no snapshot for those tracks (`UNDO_STATE_MISCCFG`
covers extension state, `reaper_plugin.h:1544`). The `projectconfig` reload
then rebuilds the model from that stale value, and the reapply it triggers
plans NEITHER a park (the track is a member of the restored active mode) nor a
restore (no snapshot to restore from) — verified in `planToggle`, which pushes
nothing at all for an active leaf without a snapshot. The tracks stay parked
with no snapshot, so the next real switch parks them and `decidePark` refuses.
TWO links are unverified and the route dies if either fails: (1) that the
point's stored state is captured at `Undo_EndBlock2` and therefore excludes the
`persistViewState` write that follows it — the SDK documents no capture-time
semantics for the state mask; (2) that a redo fires `BeginLoadProjectState`
with `isUndo == true` so the session really does reload the stale ext state
(asserted in `main.cpp`, never observed). If (2) is false the in-memory model
keeps its snapshots and the redo is consistent. `[verify — DAW]`. Do not "fix"
it by moving `persistViewState` inside the block — it can open a modal Save-As;
whether to split the persist is a separate decision.
- A DIFFERENT strand entirely, which the refusal report does NOT cover: an
absent or malformed `view_state` (`loadViewModel`, `ext_state_io.cpp`) is not
a parked-chain reading at all — it falls back to a DEFAULT model (active mode
@@ -212,15 +239,24 @@ applies the resulting lane state to live tracks.
on REAPER coalescing nested blocks, which the SDK header does not document.
The inverse is also open: a plugin unloading is ASSUMED to pump the message
loop, so an action fired mid-apply could open its own block inside this one.
Nothing guards against that (the retired queue's `g_draining` never did either
— it only stopped a second drain). Both are `[verify — DAW]`.
That same premise leaves a STATED RESIDUAL on track handles. The park and
restore loops re-`ValidatePtr2` per track (`trackStillLive`), because they are
the ones interleaved with the FX writes. The passes that run AFTER them — lane
ops, parent visibility, the solo replay — still use handles resolved before the
first FX write, so a track deleted mid-apply would reach them stale. Not closed:
the premise it depends on is itself unobserved, and a partial guard covering
three of those four passes would read as complete.
`applyMode` itself cannot be that action — it fails closed when re-entered
(`modeApplyInProgress`, `view.cpp`'s `ApplyLatch`) — but nothing else is
latched, `mintManagedLanes` included, and it opens a block of its own. Both are
`[verify — DAW]`.
That same premise put a residual on track handles, now CLOSED by construction
rather than by a partial guard. The park and restore loops re-`ValidatePtr2`
per track (`trackStillLive`) because they are the ones interleaved with the FX
writes; the whole enumeration is then re-validated ONCE where those loops end,
so every pass after them — lane ops, parent visibility, the solo replay — sees
only handles REAPER still recognizes. A dead track's handle is nulled in place
rather than erased from the vector, which is what `view_solo`'s writers already
skip on.
The latch's own cost, stated: a mode switch or a membership reapply fired while
another apply is on the stack is DROPPED, not queued. The one caller with
nothing to fall back on is `main.cpp`'s load glue — it spends a one-shot signal
— so it tests `modeApplyInProgress` before consuming and retries next tick. The
action callers just no-op, silently (`reportModeSwitchRefused` speaks only for
the transport gate); the user re-fires.
- `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays
attached to its plugin across a chain mutation while parked. That is
`[verify — DAW]` (see `fxGuidString` in `view_fx_park.cpp`) and SWS issue #802 is a