Skip to content

Commit 68cf0fb

Browse files
authored
fix(locking): a parked run keeps its locks, and the lock nodes exist in the editor (#3454)
* fix(locking): a parked run keeps its locks, and the lock nodes exist 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 * fix(locking): trace the two icon methods to the palette requirement 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. * test(locking): say why each terminal leg rebuilds the harness
1 parent d85a0b8 commit 68cf0fb

18 files changed

Lines changed: 1540 additions & 23 deletions

File tree

img/lock.svg

Lines changed: 1 addition & 0 deletions
Loading

img/unlock.svg

Lines changed: 1 addition & 0 deletions
Loading

lib/Controller/ObjectsController.php

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2856,7 +2856,7 @@ public function update(
28562856
// `lock()` actually writes; the service-layer guard read a key
28572857
// that never existed. Both now delegate to the same predicate, so
28582858
// there is one comparison rather than two spellings of it.
2859-
if ($existingObject->isLockedBySomeoneElse(userId: $this->container->get('userId')) === true) {
2859+
if ($existingObject->isLockedBySomeoneElse(userId: $this->container->get('userId'), runUuid: $this->callerRunUuid()) === true) {
28602860
// Return a "locked" error naming the holder.
28612861
return new JSONResponse(
28622862
data: [
@@ -2923,6 +2923,14 @@ public function update(
29232923
// lock must survive somebody else's write: without this test
29242924
// an administrator's write would silently strip a run's lock
29252925
// as a side effect of a guard it had just passed.
2926+
//
2927+
// NO `runUuid` HERE, DELIBERATELY, and it is the one guard in
2928+
// this file that omits it. This decides a RELEASE, not a
2929+
// refusal: asking it as the run would make a run's own write
2930+
// drop the run's own lock the moment it saved — the lock is
2931+
// meant to outlive every write the run makes. Asked as a
2932+
// person, a run-held lock reads as somebody else's and is
2933+
// left alone, which is what this test is for.
29262934
if ($objectEntity->isLocked() === true
29272935
&& $objectEntity->isLockedBySomeoneElse(userId: $this->container->get('userId')) === false
29282936
) {
@@ -3152,6 +3160,14 @@ public function patch(
31523160
// lock must survive somebody else's write: without this test
31533161
// an administrator's write would silently strip a run's lock
31543162
// as a side effect of a guard it had just passed.
3163+
//
3164+
// NO `runUuid` HERE, DELIBERATELY, and it is the one guard in
3165+
// this file that omits it. This decides a RELEASE, not a
3166+
// refusal: asking it as the run would make a run's own write
3167+
// drop the run's own lock the moment it saved — the lock is
3168+
// meant to outlive every write the run makes. Asked as a
3169+
// person, a run-held lock reads as somebody else's and is
3170+
// left alone, which is what this test is for.
31553171
if ($objectEntity->isLocked() === true
31563172
&& $objectEntity->isLockedBySomeoneElse(userId: $this->container->get('userId')) === false
31573173
) {
@@ -3334,6 +3350,14 @@ public function postPatch(
33343350
// lock must survive somebody else's write: without this test
33353351
// an administrator's write would silently strip a run's lock
33363352
// as a side effect of a guard it had just passed.
3353+
//
3354+
// NO `runUuid` HERE, DELIBERATELY, and it is the one guard in
3355+
// this file that omits it. This decides a RELEASE, not a
3356+
// refusal: asking it as the run would make a run's own write
3357+
// drop the run's own lock the moment it saved — the lock is
3358+
// meant to outlive every write the run makes. Asked as a
3359+
// person, a run-held lock reads as somebody else's and is
3360+
// left alone, which is what this test is for.
33373361
if ($objectEntity->isLocked() === true
33383362
&& $objectEntity->isLockedBySomeoneElse(userId: $this->container->get('userId')) === false
33393363
) {
@@ -4844,4 +4868,35 @@ private function folderAccessDeniedResponse(FolderAccessDeniedException $excepti
48444868
statusCode: FolderAccessDeniedException::HTTP_STATUS
48454869
);
48464870
}//end folderAccessDeniedResponse()
4871+
4872+
/**
4873+
* The flow run this write is being made for, or null when a person is
4874+
* writing.
4875+
*
4876+
* A run-scoped lock refuses every caller but the holding run, so a guard
4877+
* that cannot name the caller's run refuses the run that took the lock.
4878+
* Resolved from the container rather than injected because the ambient
4879+
* stack is a shared service and this is the only thing here that needs it.
4880+
*
4881+
* A container that cannot serve it answers "a person", which is the
4882+
* FAIL-CLOSED direction: a run lock refuses a caller with no run uuid, so
4883+
* the worst outcome is a refusal, never a lock walked through.
4884+
*
4885+
* @return string|null The executing run's uuid, or null.
4886+
*
4887+
* @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-ownership-is-decided-by-one-predicate
4888+
*/
4889+
private function callerRunUuid(): ?string {
4890+
try {
4891+
$flowContext = $this->container->get(\OCA\OpenRegister\Service\Flow\FlowRunContext::class);
4892+
} catch (\Throwable $unavailable) {
4893+
return null;
4894+
}
4895+
4896+
if ($flowContext instanceof \OCA\OpenRegister\Service\Flow\FlowRunContext === false) {
4897+
return null;
4898+
}
4899+
4900+
return $flowContext->currentRunUuid();
4901+
}//end callerRunUuid()
48474902
}//end class

lib/Db/ObjectEntity.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,6 +1163,12 @@ private function getFormattedDate(?DateTime $date): ?string {
11631163
* - Run lock with no `runUuid`: held against everybody. A malformed lock
11641164
* fails CLOSED, because the alternative is silently converting a
11651165
* writer's bug into an open door.
1166+
* - User lock, run caller: held against the run. A run is not the person
1167+
* it runs as, in EITHER direction. Reading them as one holder let a run
1168+
* walk into a person's lock, take the extend branch and rewrite the
1169+
* payload as its own — and then release it at the end of the run. The
1170+
* person's lock was destroyed by a flow merely passing over the object,
1171+
* with no error and no audit of a displacement.
11661172
*
11671173
* @param string|null $userId The caller's user id, or null when anonymous.
11681174
* @param string|null $runUuid The caller's flow-run uuid, when the caller is a run.
@@ -1189,6 +1195,15 @@ public function isLockedBySomeoneElse(?string $userId, ?string $runUuid = null):
11891195
return ($runUuid === null || trim($runUuid) !== trim($holder));
11901196
}
11911197

1198+
// A USER lock and a caller acting for a run: different holders, so the
1199+
// lock is held against it. The kinds are compared BEFORE the user id
1200+
// because they are what distinguishes the holders — a run under
1201+
// `alice` matching a lock alice took is exactly the confusion that let
1202+
// a flow take over a person's lock.
1203+
if ($runUuid !== null && trim($runUuid) !== '') {
1204+
return true;
1205+
}
1206+
11921207
return (($lock['user'] ?? null) !== $userId);
11931208
}//end isLockedBySomeoneElse()
11941209

lib/Service/Flow/FlowEngine.php

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -938,7 +938,15 @@ private function walkStreams(
938938
resumeAt: $suspension->getResumeAt(),
939939
reason: $suspension->getMessage(),
940940
claimed: $claimed,
941-
enabled: $streams->workRemains(transitions: $workflow->getEnabledTransitions(subject: $subject))
941+
// `settling`: this stream is the one parking, and the walk
942+
// does not know that yet — its token still enables the very
943+
// transition it is waiting ON. Counted, the park derives
944+
// `queued` with no wake time, which a parallel worker picks
945+
// up immediately.
946+
enabled: $streams->workRemains(
947+
transitions: $workflow->getEnabledTransitions(subject: $subject),
948+
settling: $streamId
949+
)
942950
);
943951
continue;
944952
} catch (Throwable $e) {
@@ -1123,7 +1131,16 @@ private function fireOnStream(
11231131
placeItems: $placeItems,
11241132
claimed: $claimed,
11251133
logEntry: $entry,
1126-
enabledAfter: $streams->workRemains(transitions: $workflow->getEnabledTransitions(subject: $subject)),
1134+
// The places this firing TAKES, handed over so the answer describes
1135+
// the marking AFTER the commit rather than the one the walk still
1136+
// holds: the stream picture is only re-read inside commitFiring().
1137+
// Without them every ordinary mid-flow firing reported "no work
1138+
// remains", the commit derived `completed` for a run that was still
1139+
// walking, and every terminal listener fired on it.
1140+
enabledAfter: $streams->workRemains(
1141+
transitions: $workflow->getEnabledTransitions(subject: $subject),
1142+
produced: $takenTos
1143+
),
11271144
streamStatus: FlowRun::STATUS_RUNNING,
11281145
streamError: $streamError
11291146
);

lib/Service/Flow/FlowNodeRegistry.php

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
namespace OCA\OpenRegister\Service\Flow;
3333

3434
use OCP\EventDispatcher\IEventDispatcher;
35+
use OCP\IURLGenerator;
3536
use OCP\WorkflowEngine\IManager;
3637
use Psr\Log\LoggerInterface;
3738
use UnexpectedValueException;
@@ -108,10 +109,17 @@ public static function renamedTypes(): array {
108109
*
109110
* @param IEventDispatcher $dispatcher Dispatches the contribution event.
110111
* @param LoggerInterface $logger The logger.
112+
* @param IURLGenerator|null $urls Resolves the fallback icon for a node
113+
* whose own icon does not resolve. Optional
114+
* so the registry stays constructible
115+
* without a container; absent, such a node
116+
* is served with no icon rather than being
117+
* dropped.
111118
*/
112119
public function __construct(
113120
private readonly IEventDispatcher $dispatcher,
114121
private readonly LoggerInterface $logger,
122+
private readonly ?IURLGenerator $urls = null,
115123
) {
116124

117125
}//end __construct()
@@ -204,7 +212,7 @@ public function palette(int $scope = IManager::SCOPE_ADMIN): array {
204212
'id' => $id,
205213
'displayName' => $node->getDisplayName(),
206214
'description' => $node->getDescription(),
207-
'icon' => $node->getIcon(),
215+
'icon' => $this->iconFor(node: $node, type: $id),
208216
// ALWAYS present, and always one of trigger/step/end. An
209217
// editor that had to infer this fell back to matching the
210218
// id against a naming convention, which mis-labels every
@@ -250,19 +258,75 @@ public function palette(int $scope = IManager::SCOPE_ADMIN): array {
250258

251259
$palette[] = $entry;
252260
} catch (\Throwable $e) {
253-
// One node whose metadata throws (a missing icon, a broken
254-
// translation) must not blank the whole palette — the author
255-
// would lose every node, not just the bad one.
256-
$this->logger->warning(
257-
message: '[FlowNodeRegistry] Skipping a node whose palette metadata failed: ' . $e->getMessage(),
258-
context: ['file' => __FILE__, 'line' => __LINE__, 'type' => $id]
261+
// One node whose metadata throws must not blank the whole
262+
// palette — the author would lose every node, not just the bad
263+
// one. A DROPPED NODE IS NOT A COSMETIC PROBLEM, though: it
264+
// cannot be added to a flow at all, from anywhere, and the
265+
// author is given no reason. So this is an error naming the
266+
// type, not a warning, and the commonest cause — an icon the
267+
// server does not ship — no longer reaches here at all
268+
// ({@see self::iconFor()}).
269+
$this->logger->error(
270+
message: sprintf(
271+
'[FlowNodeRegistry] DROPPED the node "%s" from the palette: its metadata threw (%s). It cannot be added to a flow until this is fixed.',
272+
$id,
273+
$e->getMessage()
274+
),
275+
context: ['file' => __FILE__, 'line' => __LINE__, 'type' => $id, 'exception' => $e]
259276
);
260277
}//end try
261278
}//end foreach
262279

263280
return $palette;
264281
}//end palette()
265282

283+
/**
284+
* A node's icon, or the app's own when the node's does not resolve.
285+
*
286+
* 🔴 A MISSING ICON USED TO DELETE THE NODE. `IURLGenerator::imagePath()`
287+
* throws for an image the server does not ship, `palette()` caught that
288+
* along with everything else, and the node simply was not in the
289+
* catalogue: `openregister.lock-object` and `openregister.unlock-object`
290+
* shipped pointing at `actions/lock.svg` and `actions/unlock.svg`, which
291+
* NEITHER NC 33 NOR NC 34 has, so neither node could be added to a flow
292+
* from the editor at all. Nothing failed; they were absent. Core's icon
293+
* set is not a stable API and the next node to name a retired icon would
294+
* have vanished the same way.
295+
*
296+
* So an icon is now resolved on its own, an unresolvable one is an ERROR
297+
* naming the type and the icon, and the node is served with the app's icon
298+
* instead of being dropped. A node the author can see and place with the
299+
* wrong picture is strictly better than a node that does not exist.
300+
*
301+
* @param IFlowNode $node The node.
302+
* @param string $type Its type id, for the message.
303+
*
304+
* @return string|null The icon path, the app's icon, or null when neither resolves.
305+
*
306+
* @spec openspec/changes/or-flow-nodes/specs/flow-nodes/spec.md
307+
*/
308+
private function iconFor(IFlowNode $node, string $type): ?string {
309+
try {
310+
return $node->getIcon();
311+
} catch (\Throwable $missing) {
312+
$this->logger->error(
313+
message: sprintf(
314+
'[FlowNodeRegistry] The node "%s" names an icon this server does not have (%s); '
315+
. 'it is served with the app icon instead. Point it at an image that exists.',
316+
$type,
317+
$missing->getMessage()
318+
),
319+
context: ['file' => __FILE__, 'line' => __LINE__, 'type' => $type, 'exception' => $missing]
320+
);
321+
}
322+
323+
try {
324+
return $this->urls?->imagePath('openregister', 'app-dark.svg');
325+
} catch (\Throwable $noFallback) {
326+
return null;
327+
}
328+
}//end iconFor()
329+
266330
/**
267331
* The links one run-log entry earns, from the node that wrote it.
268332
*

lib/Service/Flow/FlowRunContext.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,40 @@ public function current(): ?array {
160160
return $this->frames[(count($this->frames) - 1)];
161161
}//end current()
162162

163+
/**
164+
* The uuid of the run currently executing, or null outside any run.
165+
*
166+
* WHY A WRITE GUARD ASKS THIS. A run-scoped lock refuses every caller but
167+
* the holding run — the run's own `runAs` user included, which is the
168+
* point. A guard that cannot name the caller's run therefore refuses the
169+
* run that took the lock: the flow locks a case and is then refused by its
170+
* own lock at the next write. This is the seam that answers "which run is
171+
* writing", and it is ambient for the same reason attribution is: the
172+
* write may be several calls deep inside a leaf app that has never heard
173+
* of flows.
174+
*
175+
* Null outside a run, and null for a hop that is not attributable. Both
176+
* read as "a person is writing", which is the FAIL-CLOSED direction: a run
177+
* lock refuses a caller with no run uuid.
178+
*
179+
* @return string|null The executing run's uuid, or null.
180+
*
181+
* @spec openspec/changes/run-scoped-object-locking/specs/run-scoped-object-locking/spec.md#requirement-ownership-is-decided-by-one-predicate
182+
*/
183+
public function currentRunUuid(): ?string {
184+
$frame = $this->current();
185+
if ($frame === null) {
186+
return null;
187+
}
188+
189+
$run = trim((string)$frame['run']);
190+
if ($run === '') {
191+
return null;
192+
}
193+
194+
return $run;
195+
}//end currentRunUuid()
196+
163197
/**
164198
* How deep the stack is. Test and diagnostic use only.
165199
*

lib/Service/Flow/FlowStreamWalk.php

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -253,23 +253,33 @@ private function isAdvanceable(string $id): bool {
253253
* Petri net, but that transition is a wait, not work — counting it would
254254
* make every parked run read as `queued` and spin the worker.
255255
*
256+
* 🔴 THE ANSWER MUST DESCRIBE THE STATE AFTER THE THING BEING COMMITTED,
257+
* not the state the walk still holds in memory. It is called as an
258+
* ARGUMENT to `commitFiring()` and to `park()`, and both of those are what
259+
* move the stream — so at the moment this runs, `$this->streams` still has
260+
* the firing stream on the place the firing just consumed and `$this->parked`
261+
* does not yet know about the stream that is parking. Answering from that
262+
* stale picture said "no work remains" at EVERY ordinary mid-flow firing,
263+
* `FlowRunCommit::applyDerivedStatus()` then derived `completed` for a run
264+
* that was still working, `FlowRunMapper::update()` announced it, and
265+
* `FlowRunLockReleaseListener` released the run's object locks — the
266+
* defect this parameter pair exists to close. `$produced` and `$settling`
267+
* are how the caller says what its commit is about to change.
268+
*
256269
* @param array<int, object> $transitions The enabled transitions.
270+
* @param array<int, string> $produced The places the commit is about to put tokens on.
271+
* @param string|null $settling A stream whose current place is about to stop counting: it is parking, or ending.
257272
*
258273
* @return bool True when an unparked stream has an enabled transition.
259274
*
260275
* @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-runs-status-must-stay-derivable-from-its-streams-with-no-new-value
261276
*/
262-
public function workRemains(array $transitions): bool {
263-
$unparked = [];
264-
foreach ($this->streams as $id => $stream) {
265-
if (isset($this->parked[$id]) === false && $stream['place'] !== null) {
266-
$unparked[$stream['place']] = true;
267-
}
268-
}
277+
public function workRemains(array $transitions, array $produced = [], ?string $settling = null): bool {
278+
$live = $this->livePlacesAfter(produced: $produced, settling: $settling);
269279

270280
foreach ($transitions as $transition) {
271281
foreach ($transition->getFroms() as $from) {
272-
if (isset($unparked[(string)$from]) === true) {
282+
if (isset($live[(string)$from]) === true) {
273283
return true;
274284
}
275285
}
@@ -278,6 +288,37 @@ public function workRemains(array $transitions): bool {
278288
return false;
279289
}//end workRemains()
280290

291+
/**
292+
* The places a token will sit on once the caller's commit lands, excluding
293+
* the parked streams' — see {@see self::workRemains()} for why the answer
294+
* cannot be read off the walk's own picture.
295+
*
296+
* @param array<int, string> $produced The places the commit is about to mark.
297+
* @param string|null $settling A stream whose place stops counting.
298+
*
299+
* @return array<string, true> The places, as a set.
300+
*
301+
* @spec openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md#requirement-a-runs-status-must-stay-derivable-from-its-streams-with-no-new-value
302+
*/
303+
private function livePlacesAfter(array $produced, ?string $settling): array {
304+
$live = [];
305+
foreach ($this->streams as $id => $stream) {
306+
if ($id === $settling || isset($this->parked[$id]) === true) {
307+
continue;
308+
}
309+
310+
if ($stream['place'] !== null) {
311+
$live[$stream['place']] = true;
312+
}
313+
}
314+
315+
foreach ($produced as $place) {
316+
$live[(string)$place] = true;
317+
}
318+
319+
return $live;
320+
}//end livePlacesAfter()
321+
281322
/**
282323
* The ordinal path of a stream, for a log entry that is not a firing (a
283324
* suspension, a stop, a terminal failure) and so is written by the step

0 commit comments

Comments
 (0)