fix(locking): a parked run keeps its locks, and the lock nodes exist in the editor - #3454
Merged
Merged
Conversation
…in the editor Three defects in the run-scoped locking that #3444 shipped, all three found by walking a live instance and none by the suite. A RUN LOST EVERY LOCK THE MOMENT IT PARKED. The dispatch predicate really is `isTerminal()` on the persisted row, and the row really did say `completed` — transiently, in the middle of a pass that went on to store `suspended`. `FlowEngine::fireOnStream()` computes `enabledAfter` by asking `FlowStreamWalk::workRemains()` for the transitions enabled on the marking it has just advanced, but that method answers from the walk's in-memory stream picture, which `commitFiring()` only re-reads AFTER the commit. So it compared the NEW enabled transitions against the OLD places and answered "no work remains" at every ordinary mid-flow firing. `applyDerivedStatus()` then took its "nothing enabled, nothing parked, nothing terminal" arm and wrote `completed`, `FlowRunMapper::update()` announced terminality, and the lock listener released the run's locks — every firing, of every run, not just ones that lock. The park path had the mirror bug: `workRemains()` was evaluated before `park()` marked the stream parked, so a parking run was derived `queued` with no wake time until `finalize()` corrected it. `workRemains()` now takes what the caller's commit is about to change: `produced`, the places the firing takes, and `settling`, a stream whose place stops counting because it is parking. Over-counting is the safe direction here — it yields `queued`, which the next pass corrects — and under-counting is what produced a false terminal. A RUN-KIND LOCK REFUSED THE RUN THAT HELD IT. `SaveObject` asked the guard "which user", never "which run", so a flow that locked a case was turned away by its own lock at its next write. The run identity now reaches the guard through the ambient `FlowRunContext`, for the same reason attribution does: the write is routinely several calls deep inside code that has never heard of flows. Absent, it reads as a person, which is the fail-closed answer. The D-6 sweep is re-done over the GUARD's callers, not just `lockObject()`'s: `SaveObject`, `RevertHandler` and `ObjectsController::update` now pass the caller's run; the three post-save auto-unlock tests in the controller deliberately do not, and say so — they decide a RELEASE, and a run's lock must outlive every write the run makes. `LockObjectNode` and `UnlockObjectNode` already passed theirs. The same predicate had the opposite hole: a user lock did not refuse a run executing as its holder, so a run passing over a person's locked object took the extend branch, rewrote the payload as its own run lock, and destroyed the person's lock when it ended. A run and the person it runs as are different holders in both directions. BOTH NODES WERE INVISIBLE IN THE EDITOR. `core/img/actions/lock.svg` and `unlock.svg` do not exist in NC 33 or 34, `imagePath()` throws for an image the server does not ship, and `palette()` caught that with everything else — so the catalogue held 25 nodes rather than 27 and neither node could be added to a flow at all. The icons are now app-owned, and the silent skip is loud: an icon is resolved on its own, an unresolvable one is an ERROR naming the node, and the node is served with the app icon instead of being deleted from the catalogue. A node that survives with the wrong picture beats a node that does not exist. Tests, each proven red first, driving the real engine rather than a fake — the existing coverage mocked `FlowRunMapper::update()` and restated `workRemains()` in a fake, so both agreed with the bug: - a suspended run announces nothing and keeps its locks, and each of `FlowRun::TERMINAL` releases them (iterated from the constant) - the holding run writes to its own locked object; another run and a person are refused - a person's lock survives a run passing over the object, payload byte-identical - every registered node's icon resolves, as a sweep over all 27 rather than a check of these two, plus the palette behaviour that stops the next one vanishing
gate-16 counts a changed method with no `@spec` as an untraceable change, and these two are exactly the methods the palette requirement is about.
Contributor
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ❌ | ||||
| phpcs | ✅ | ||||
| phpmd | ❌ | ||||
| psalm | ❌ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ❌ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ❌ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| test-l10n-parity | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 174/174 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-09-05 15:17 UTC
Download the full PDF report from the workflow artifacts.
Contributor
Quality Report — ConductionNL/openregister @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| test-l10n-parity | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 174/174 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ❌ | ||||
| Newman | ✅ | ||||
| Playwright | ⏭️ deferred — runs on the promotion into beta/main, not on a pull request into development | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-09-05 15:28 UTC
Download the full PDF report from the workflow artifacts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three defects in the run-scoped object locking that #3444 merged. All three were found by walking a live instance; none by the suite. The first one means the feature does not work at all.
1. A run lost every lock the moment it parked
The PR's claim was right as far as it went: the dispatch predicate really is
FlowRunMapper::update()asking the persisted rowisTerminal(), and there is no status whitelist. The row really did saycompleted— transiently, in the middle of a pass that went on to storesuspended.FlowEngine::fireOnStream()computesenabledAfterby askingFlowStreamWalk::workRemains()for the transitions enabled on the marking it has just advanced. That method answers from the walk's in-memory stream picture, andcommitFiring()only re-reads that picture after the commit. So it compared the NEW enabled transitions against the OLD places, they never intersect on a linear flow, and it answered "no work remains" at every ordinary mid-flow firing.FlowRunCommit::applyDerivedStatus()then took its "nothing enabled, nothing parked, nothing terminal" arm and wrotecompleted; the mapper announced terminality;FlowRunLockReleaseListenerreleased the run's locks. Every firing, of every run — the locks are simply the first thing that noticed.The park path had the mirror of the same bug:
workRemains()is evaluated as an argument topark(), beforepark()marks the stream parked, so its token still enabled the transition it was waiting on and the run was derivedqueuedwith no wake time untilfinalize()corrected it. Harmless while the pass survives; a run leftqueuedwith noresumeAtif it does not.workRemains()now takes what the caller's commit is about to change:produced(the places the firing takes) andsettling(a stream whose place stops counting because it is parking). Over-counting is the safe direction — it yieldsqueued, which the next pass corrects; under-counting is what produced a false terminal.2. A run-kind lock refused the run that held it
SaveObjectasked the guard "which user", never "which run", so a flow that locked a case was turned away by its own lock at its next write. The run identity now reaches the guard through the ambientFlowRunContext, for the same reason attribution does: the write is routinely several calls deep inside code that has never heard of flows. Absent, it reads as a person — the fail-closed answer a run lock already gives.The D-6 sweep, re-done over the guard's callers rather than
lockObject()'s:SaveObject::findAndValidateExistingObjectRevertHandler::revertObjectsController::update(L2859)ObjectsControllerpost-save auto-unlock ×3LockHandler::callerMayUnlockLockObjectNode/UnlockObjectNodeMagicMapper::lockObject/unlockObjectlib/The same predicate had the opposite hole. A user lock did not refuse a run executing as its holder, so a run passing over a person's locked object did not merely pass the guard:
ObjectEntity::lock()took the extend branch, rewrote the payload as its own run lock, and destroyed the person's lock when the run ended. A run and the person it runs as are different holders in both directions.3. Both new nodes were invisible in the flow editor
core/img/actions/lock.svgandunlock.svgexist in neither NC 33 nor NC 34,IURLGenerator::imagePath()throws for an image the server does not ship, andpalette()caught that along with everything else. The catalogue held 25 nodes instead of 27 and neither node could be added to a flow at all.The icons are now app-owned (
img/lock.svg,img/unlock.svg), which cannot go missing under us. And the silent skip is loud: the icon is resolved on its own, an unresolvable one is an error naming the node and the icon, and the node is served with the app icon rather than deleted from the catalogue. A node the author can see and place with the wrong picture beats a node that does not exist. The remaining outer catch — a node whose display name or description throws — now logs at error too, saying the node was dropped and cannot be added to a flow.Tests
Each proven red first, and each calling the production predicate rather than restating it. The existing coverage could not catch any of this:
FlowRunCommitTestmocksFlowRunMapper::update(), so the dispatch never happens, andFlowEngineStreamWalkTest's fake restatesworkRemains(), so it agreed with the bug.RunLockReleaseTerminalityTestdrives the real engine, the realFlowStreamWalk, the realFlowRunCommit, a realFlowRunMapperand the real listener, with only the database replaced. A suspended run announces nothing and keeps its locks; each status inFlowRun::TERMINALreleases them, iterated from the constant so a fifth terminal status fails the test rather than going untested. Red before the fix withannounced = ['completed','completed']— once per firing.SaveObjectTest: the holding run writes to its own locked object; a different run and a person are both refused; a run is refused by a person's lock even when it runs as that person.ObjectEntityRunLockTest: a person's lock survives a run passing over the object, payload byte-identical, and the lock is still a user lock afterwards.FlowNodePaletteIconsTestsweeps every registered node (27 of them, not these two) and asserts its icon names an image that exists — against the real server tree when the tests can see one, which they always can in CI, and against a recorded inventory otherwise, so a standalone clone gets a verdict rather than a skip. Its second half asserts the behaviour that stops this recurring: a node whose icon throws stays in the palette and is reported.Verified locally, by exit code
phpunit(19,201 tests),phpcs,psalm,phpstan,phpmdon the changed files (both rulesets), andhydra-gateson the whole tree withHYDRA_GATE_BASE_REF=origin/development. CI is bottlenecked, so this is local verification, not a CI green.Verified on a rig, not only in tests
Own compose project, NC 34.0.3 + PostgreSQL 16 on a free port (never :8080), the working tree installed as the app. Fresh install, so all migrations ran and
oc_openregister_run_object_locksis present — the skipped-migration trap did not apply. Each leg run twice, once with the fix and once with the pre-fix file swapped back in:_lockednull, log:Released 1 object lock(s) of run 8fc8b735-… (completed)for a run storedsuspendedsuspended, lock held by the run, one registry rownull) when the run endedThe palette leg is the server-side catalogue the editor reads (
GET /api/flow/node-catalog), which is where the node was being dropped; the JS bundle was not built on the rig. Rig torn down withdocker compose down -v.🤖 Generated with Claude Code