Skip to content

fix(core): only report recursive task invocations from ancestor processes - #36481

Draft
AgentEnder wants to merge 4 commits into
masterfrom
feature/nxc-4732-investigate-recursive-task-invocation-with-serve-static
Draft

fix(core): only report recursive task invocations from ancestor processes#36481
AgentEnder wants to merge 4 commits into
masterfrom
feature/nxc-4732-investigate-recursive-task-invocation-with-serve-static

Conversation

@AgentEnder

Copy link
Copy Markdown
Member

Current Behavior

Nx reports Recursive task invocation detected for task graphs that contain no recursion.

The loop detector keys the task_invocations table on PRIMARY KEY (root_pid, task_id), and root_pid is inherited by every nested process via NX_INVOCATION_ROOT_PID. That makes the table a set of every task seen anywhere in the process tree rather than a chain — there is a parent_pid column, but the check never reads it. So any two sibling Nx processes that run the same task collide on the primary key, and the collision is reported as recursion followed by process.exit(1).

This is easy to hit. Reproduced two ways, with no Cypress and no ports involved:

Discrete tasks — fails every run. Two sibling tasks each running nx run app-e2e:build:

app-e2e:build -> app-e2e:spec-a -> app-e2e:spec-b -> app-e2e:build

Continuous tasks — fails intermittently (~1 in 3). Two sibling e2e-ci--* specs each spawning nx run app-e2e:serve-static:

app-e2e:e2e-ci--a.cy.ts -> app-e2e:e2e-ci--b.cy.ts -> app-e2e:serve-static -> app-e2e:serve-static

In both cases neither process is an ancestor of the other.

The continuous case is additionally a check-then-act race. startContinuousTask asks getRunningTasks() whether another process already owns the task — and takes the correct SharedRunningTask path when it does — but only writes its own claim via addRunningTask() after forking the child. A sibling entering that window is told the task is not running and falls through into the collision. (With six siblings, five correctly print Waiting for ... in another nx process; the race just occasionally skips it.)

Two further problems in the same code path:

  • The detector treats any exception from registerTask as a loop, so a locked or full database would print the recursion error and exit 1.
  • The reported chain is ordered by created_at, but SQLite's CURRENT_TIMESTAMP has one-second granularity — so same-second rows order arbitrarily and an unordered set is printed as though it were a call path.

Expected Behavior

Only a task re-invoked by an actual ancestor process is a loop. Sibling Nx processes running the same task are legitimate and proceed normally.

  • task_invocations is re-keyed on (root_pid, pid, task_id) so siblings can each hold the same task id, with DB_VERSION bumped accordingly.
  • NX_INVOCATION_ANCESTOR_PIDS is plumbed through the task env, and the check is scoped to those pids.
  • unregisterTask is scoped by pid, so a sibling finishing the same task cannot drop another process's record.
  • registerTask returns the chain instead of signalling through an exception, so a DB failure is no longer misreported as recursion — loop detection fails open, since it is diagnostic only.
  • The reported chain is built from the ancestry rather than from created_at, so it is a truthful, ordered call path.
  • addRunningTask now claims the task immediately after the getRunningTasks() check rather than after the fork. This is load-bearing: once siblings are allowed past the detector, the claim is what keeps them sharing one server instead of starting several racing for the same port.

Verification

Check Before After
Sibling discrete tasks failed 3/3 5/5 ok
Sibling continuous tasks failed ~1/3 6/6 ok, shared-wait every run
Genuine self-recursion detected still detected, chain now really-recursive -> really-recursive
Rust suite 575 passed, 0 failed
tasks-runner jest 354 passed, 1 skipped
nx build,lint -p nx pass

Five Rust unit tests cover the semantics directly: siblings allowed, ancestor rejected, loop through an intermediate process, sibling cleanup isolation, and re-registration after completion.

Known remaining gap

Moving the claim narrows the check-then-act window to two adjacent statements but does not make it atomic. Closing it properly needs the read and claim as one operation in RunningTasksService — an INSERT ... ON CONFLICT DO NOTHING whose affected-row count decides ownership. That is a separate change and is not attempted here. Separately, discrete tasks have no SharedRunningTask equivalent at all, so two sibling processes running the same discrete task both run it; that is pre-existing behaviour and out of scope.

Related Issue(s)

Investigated as Linear NXC-4732 (customer report of Recursive task invocation detected between e2e-ci--* and serve-static in a self-serving Cypress e2e project).

Regression from #34820, which introduced the loop detector.


View session information ↗

@netlify

netlify Bot commented Jul 27, 2026

Copy link
Copy Markdown

Deploy Preview for nx-dev ready!

Name Link
🔨 Latest commit 5d9025b
🔍 Latest deploy log https://app.netlify.com/projects/nx-dev/deploys/6a67e24b242e380008f1869d
😎 Deploy Preview https://deploy-preview-36481--nx-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Jul 27, 2026

Copy link
Copy Markdown

Deploy Preview for nx-docs ready!

Name Link
🔨 Latest commit 5d9025b
🔍 Latest deploy log https://app.netlify.com/projects/nx-docs/deploys/6a67e24b4a9f7e00084c1c3a
😎 Deploy Preview https://deploy-preview-36481--nx-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@nx-cloud

nx-cloud Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit 5d9025b

Command Status Duration Result
nx affected --targets=lint,test,build,e2e,e2e-c... ✅ Succeeded 45m 12s View ↗
nx run-many -t check-imports check-lock-files c... ✅ Succeeded 4s View ↗
nx-cloud record -- pnpm nx-cloud conformance:check ✅ Succeeded 55s View ↗
nx build workspace-plugin ✅ Succeeded <1s View ↗
nx-cloud record -- nx sync:check ✅ Succeeded 17s View ↗
nx-cloud record -- nx format:check ✅ Succeeded 5s View ↗

☁️ Nx Cloud last updated this comment at 2026-07-27 23:46:52 UTC

nx-cloud[bot]

This comment was marked as outdated.

nx-cloud[bot]

This comment was marked as outdated.

AgentEnder and others added 3 commits July 27, 2026 17:49
…sses

The loop detector keyed `task_invocations` on `(root_pid, task_id)`, and
`root_pid` is inherited by every nested process. That made the table a set
of every task seen anywhere in the process tree rather than a chain, so two
*sibling* Nx processes running the same task collided and the collision was
reported as recursion.

This is easy to hit: N atomized `e2e-ci--*` specs each spawning the same
`serve-static` web server, or two parallel tasks that each shell out to
`nx run lib:build`. Sibling discrete tasks reproduced it every run.

- Re-key `task_invocations` on `(root_pid, pid, task_id)` so siblings can
  each hold the same task id, and bump DB_VERSION accordingly.
- Plumb `NX_INVOCATION_ANCESTOR_PIDS` down through the task env and scope
  the check to those pids, so only a genuine ancestor re-invoking a task is
  a loop.
- Scope `unregisterTask` by pid so a sibling finishing the same task cannot
  drop another process's record.
- Return the chain from `registerTask` instead of signalling through an
  exception, so a DB failure is no longer misreported as recursion.
- Build the reported chain from the ancestry rather than `created_at`,
  whose one-second granularity cannot order rows written in the same second.
`startContinuousTask` checked `getRunningTasks()` to decide whether another
Nx process already owns a continuous task, but only wrote its own claim via
`addRunningTask()` after forking the child. A sibling process asking the
same question inside that window is told the task is not running and starts
a duplicate of it — two `serve-static` servers racing for the same port.

Move the claim to immediately after the check so the `SharedRunningTask`
path wins reliably. With six siblings this makes the shared wait
deterministic instead of dependent on fork timing.

This is load-bearing alongside the ancestry fix for recursive task
invocation: once siblings are allowed past the loop detector, this claim is
what keeps them sharing one server instead of starting several.
Co-authored-by: AgentEnder <AgentEnder@users.noreply.github.com>
@AgentEnder
AgentEnder force-pushed the feature/nxc-4732-investigate-recursive-task-invocation-with-serve-static branch from 3b5a3aa to f893f11 Compare July 27, 2026 21:50

@nx-cloud nx-cloud Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nx Cloud has identified a flaky task in your failed CI:

🔂 Since the failure was identified as flaky, we triggered a CI rerun by adding an empty commit to this branch.

Nx Cloud View detailed reasoning in Nx Cloud ↗

🔔 Heads up, your workspace has pending recommendations ↗ to auto-apply fixes for similar failures.


🎓 Learn more about Self-Healing CI on nx.dev

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant