Skip to content

Commit c334349

Browse files
leosvelperezFrozenPandaz
authored andcommitted
fix(core): propagate continuous task failures to dependent tasks (#33492)
## Current Behavior When a continuous task depends on another continuous task and the dependent task exits with an error, the parent task continues running indefinitely. The task execution never terminates, leaving processes running in the background. For example, if task `a` (continuous) depends on task `b` (continuous), and task `b` exits with error code 1, task `a` will continue running even though its dependency failed. ## Expected Behavior When a continuous task exits (with any exit code), the failure should be propagated to dependent tasks: 1. The failed continuous task should be marked as failed 2. Dependent continuous tasks should be marked as skipped 3. All affected continuous tasks should be killed 4. Task execution should terminate with an error --- ## Changes Made ### 1. Restore Error Handling in Continuous Task Exit Handlers - Re-added the `cleaningUp` flag that was removed in a previous TUI-related commit - Modified `onExit` handlers for both regular and shared continuous tasks to: - Check if the task exited during normal cleanup vs. unexpectedly - Call `complete()` with 'failure' status for unexpected exits - Log error messages for debugging ### 2. Fix `cleanUpUnneededContinuousTasks()` Logic The previous implementation always added `initializingTaskIds` to the needed set, even when those tasks were already completed. This prevented dependency tasks from being killed when the top-level task exited. Fixed by: - Only adding tasks from `initializingTaskIds` if they are still incomplete - Keeping dependencies of incomplete tasks alive - This ensures continuous tasks are killed when no longer needed, whether a dependency fails or a top-level task exits ### 3. Prevent Status Overwrites Added a check in `onExit` handlers to only set status to `Stopped` if the task hasn't already been completed. This prevents the async `onExit` callback from overwriting the correct status (like 'skipped' or 'failure') with 'Stopped'. ### 4. Fix Signal Handling in `PseudoTtyProcess.kill()` The Rust pseudo-terminal defaults to SIGINT when no signal is provided, which does not reliably terminate child processes in PTY sessions. Fixed by: - Defaulting to SIGTERM in the JavaScript wrapper (rather than changing the Rust default) - The JS wrapper is the API boundary that should match Node.js semantics, where `childProcess.kill(undefined)` defaults to SIGTERM - The Rust default of SIGINT is appropriate for interactive use (Ctrl-C), while programmatic cleanup needs SIGTERM - This ensures child processes are properly killed when `runningTask.kill()` is called **File:** `packages/nx/src/tasks-runner/pseudo-terminal.ts` ## Technical Details The fix leverages the existing task failure propagation mechanism in `complete()` instead of using `process.exit(1)`, which: - Allows proper cleanup through normal execution flow - Respects the `--bail` flag configuration - Works correctly with both TUI and non-TUI modes - Maintains consistency with how other task failures are handled
1 parent e993d05 commit c334349

2 files changed

Lines changed: 30 additions & 6 deletions

File tree

packages/nx/src/tasks-runner/pseudo-terminal.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ export class PseudoTtyProcess implements RunningTask {
191191
kill(s?: NodeJS.Signals): void {
192192
if (this.isAlive) {
193193
try {
194-
this.childProcess.kill(s);
194+
this.childProcess.kill(s || 'SIGTERM');
195195
} catch {
196196
// when the child process completes before we explicitly call kill, this will throw
197197
// do nothing

packages/nx/src/tasks-runner/task-orchestrator.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export class TaskOrchestrator {
8585
private groups = [];
8686

8787
private bailed = false;
88+
private cleaningUp = false;
8889

8990
private runningContinuousTasks = new Map<string, RunningTask>();
9091
private runningRunCommandsTasks = new Map<string, RunningTask>();
@@ -732,14 +733,22 @@ export class TaskOrchestrator {
732733
);
733734

734735
this.runningContinuousTasks.set(task.id, runningTask);
735-
runningTask.onExit(() => {
736-
if (this.tuiEnabled) {
736+
runningTask.onExit((code) => {
737+
if (this.tuiEnabled && !this.completedTasks[task.id]) {
737738
this.options.lifeCycle.setTaskStatus(
738739
task.id,
739740
NativeTaskStatus.Stopped
740741
);
741742
}
742743
this.runningContinuousTasks.delete(task.id);
744+
745+
// we're not cleaning up, so this is an unexpected exit, fail the task
746+
if (!this.cleaningUp) {
747+
console.error(
748+
`Task "${task.id}" is continuous but exited with code ${code}`
749+
);
750+
this.complete([{ taskId: task.id, status: 'failure' }]);
751+
}
743752
});
744753

745754
// task is already running by another process, we schedule the next tasks
@@ -802,13 +811,22 @@ export class TaskOrchestrator {
802811
this.runningTasksService.addRunningTask(task.id);
803812
this.runningContinuousTasks.set(task.id, childProcess);
804813

805-
childProcess.onExit(() => {
806-
if (this.tuiEnabled) {
814+
childProcess.onExit((code) => {
815+
// Only set status to Stopped if task hasn't been completed yet
816+
if (this.tuiEnabled && !this.completedTasks[task.id]) {
807817
this.options.lifeCycle.setTaskStatus(task.id, NativeTaskStatus.Stopped);
808818
}
809819
if (this.runningContinuousTasks.delete(task.id)) {
810820
this.runningTasksService.removeRunningTask(task.id);
811821
}
822+
823+
// we're not cleaning up, so this is an unexpected exit, fail the task
824+
if (!this.cleaningUp) {
825+
console.error(
826+
`Task "${task.id}" is continuous but exited with code ${code}`
827+
);
828+
this.complete([{ taskId: task.id, status: 'failure' }]);
829+
}
812830
});
813831
await this.scheduleNextTasksAndReleaseThreads();
814832
if (this.initializingTaskIds.has(task.id)) {
@@ -1025,6 +1043,7 @@ export class TaskOrchestrator {
10251043
// endregion utils
10261044

10271045
private async cleanup() {
1046+
this.cleaningUp = true;
10281047
this.forkedProcessTaskRunner.cleanup();
10291048
await Promise.all([
10301049
...Array.from(this.runningContinuousTasks).map(async ([taskId, t]) => {
@@ -1054,8 +1073,13 @@ export class TaskOrchestrator {
10541073

10551074
private cleanUpUnneededContinuousTasks() {
10561075
const incompleteTasks = this.tasksSchedule.getIncompleteTasks();
1057-
const neededContinuousTasks = new Set(this.initializingTaskIds);
1076+
const neededContinuousTasks = new Set<string>();
10581077
for (const task of incompleteTasks) {
1078+
// Keep initiating tasks that are still incomplete
1079+
if (task.continuous && this.initializingTaskIds.has(task.id)) {
1080+
neededContinuousTasks.add(task.id);
1081+
}
1082+
10591083
const continuousDependencies =
10601084
this.taskGraph.continuousDependencies[task.id];
10611085
for (const continuousDependency of continuousDependencies) {

0 commit comments

Comments
 (0)