Problem

Two defects in one window. Under synchronous execution (the shipped default) a subprocess child is drained inside the call that started it, so the child can reach a terminal state before that caller has finished its own bookkeeping. Two callers start a child, and each mishandles that differently.

1. A child that does not complete strands its parent for ever. A subprocess node parks its parent token while the child runs. When the child ends inside the parent task's own execute(), the child's resumeParent() finds the parent still ACTIVE and does nothing, which is exactly the lost wakeup WorkflowExecutor::resumeFromCompletedChild() exists to catch right after the park. That guard only looks for a child in the completed state. A child that reaches failed or canceled in the same window is not caught, so the parent token stays PARKED for ever: no child is running, the node need declare no timeout, and neither recovery sweep matches it (reconcileStuckInstances() wants an instance with no live token, and PARKED is live; recoverStalledInstances() wants a WAITING token at a join).

The documented contract is the opposite. docs/subprocesses.md says of state_variable: "Named, the parent resumes even when the child does not complete, with this variable set to __subprocess_failed__ or __subprocess_canceled__ so an outgoing flow condition can route the error." That is what happens when the same child fails on a later cron turn; it does not happen when it fails inline.

2. A re-launch's backoff is written over by the node timeout. A subprocess node can carry both a re-launch budget and a timeout. SubprocessCoordinator::relaunchDueSubprocess() clears the deadline it was woken by, calls launchChild(), then re-arms the node timeout so a re-launched child that hangs still escalates. start() drains a synchronous child inline whatever the depth, deliberately, so that a subprocess started mid-advance still drains its own instance: the fresh child therefore runs to its end inside that call. A child that fails there arms the next re-launch on the parent it is still parked on, stamping the authored backoff, and the unconditional re-arm then writes the node timeout over it.

Both of the node's delays are lost at once. The next re-launch moves from its backoff out to the timeout instant, and the token is still armed for a re-launch, so the sweep that eventually finds it due re-launches rather than running the timeout action. The escalation never fires, and the window in between is spent waiting out a delay nobody authored.

Steps to reproduce

Both need execution_mode left at its shipped synchronous, and "When a step fails for good" set to Fail the whole run on the Orchestra settings page. Author a child workflow whose first step throws a fatal, non-retryable failure.

  1. For the first defect: a parent of start, then subprocess (that child, state_variable: child_state), then end. Start it. Observed: the child is failed, the parent instance is still running, its subprocess token is parked, and child_state was never written. Expected: the parent resumes with child_state = __subprocess_failed__ and routes on it, which is what it does when the child fails on a later cron turn.
  2. For the second: give that subprocess node relaunch_attempts: 2, relaunch_backoff: 60 and a node timeout of one day. Start it, then fire the parked token's timer so the re-launch runs. Observed: the parent's deadline is now plus one day. Expected: now plus sixty seconds, the authored backoff.

An explicit cancellation of the child inside the same advance strands the parent the same way as the first defect.

Proposed resolution

Have the post-park guard answer for every terminal child, not only a completed one. The word the parent routes on is not a lookup: it is the child instance's state, which the task spells into __subprocess_<state>__, so it is read off the child rather than tabulated. A table would be a fourth place holding those literals, beside the two instance-ending paths and the coordinator's own failed-child test, and the first to drift would make the inline path write a value the cron path does not, which is the divergence the guard exists to close. The guard stays a no-op while any child of that token is still running.

Give the re-arm the condition the claim four lines above it already uses: apply it only while the deadline is still the empty one that claim left, so a backoff armed in between wins and the parent is never left without a deadline either way.

AI-Generated: Yes (Claude Code was used to help draft this issue summary and to write both fixes and their tests. Each defect was reproduced against unpatched 1.x before it was written up, and every new test was confirmed to fail without its change and to pass with it. The re-arm that the second fix guards had no coverage at all, so a test pinning it was added alongside.)

Issue fork orchestra-3623325

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

mably created an issue. See original summary.

mably’s picture

Status: Active » Needs review
mably’s picture

Title: Resume a subprocess parent when its child fails or is canceled inside the parent's own advance » Resume a subprocess parent, and keep its re-launch backoff, when its child ends inside the call that started it
Issue summary: View changes

  • mably committed 9866da8b on 1.x
    fix: #3623325 Resume a subprocess parent when its child fails or is...
mably’s picture

Status: Needs review » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.