Skip to content

Commit 875d978

Browse files
committed
Reapply "fix(core): kill discrete tasks and use tree-kill for batch cleanup on SIGINT (#35175)"
This reverts commit c9bcccb.
1 parent 468621f commit 875d978

5 files changed

Lines changed: 390 additions & 21 deletions

File tree

e2e/nx/src/run.test.ts

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
checkFilesExist,
33
cleanupProject,
44
fileExists,
5+
getStrippedEnvironmentVariables,
56
isWindows,
67
newProject,
78
readJson,
@@ -14,6 +15,7 @@ import {
1415
updateFile,
1516
updateJson,
1617
} from '@nx/e2e-utils';
18+
import { execSync } from 'child_process';
1719
import { PackageJson } from 'nx/src/utils/package-json';
1820
import * as path from 'path';
1921

@@ -773,6 +775,301 @@ describe('Nx Running Tests', () => {
773775
});
774776
});
775777

778+
describe('SIGINT process cleanup', () => {
779+
if (isWindows()) {
780+
it('skipped on windows', () => {});
781+
} else {
782+
it('should kill executor-based (discrete) task processes on SIGINT', async () => {
783+
const lib = uniq('sigint-lib');
784+
runCLI(`generate @nx/js:lib libs/${lib}`);
785+
786+
const pidFile = path.join(tmpProjPath(), `libs/${lib}/build.pid`);
787+
const scriptBody = writePidScript('build.pid');
788+
789+
updateFile(`libs/${lib}/slow-build.js`, scriptBody);
790+
791+
// nx:run-script executor goes through the discrete/forked process path
792+
updateJson(`libs/${lib}/project.json`, (config) => {
793+
config.targets = {
794+
build: {
795+
executor: 'nx:run-script',
796+
options: { script: 'build' },
797+
},
798+
};
799+
return config;
800+
});
801+
802+
updateJson(`libs/${lib}/package.json`, (pkg) => {
803+
pkg.scripts = { build: 'node slow-build.js' };
804+
return pkg;
805+
});
806+
807+
const pids = await runNxAndSigint(
808+
['build', lib, '--skip-nx-cache'],
809+
pidFile
810+
);
811+
812+
for (const pid of pids) {
813+
expect(isProcessAlive(pid)).toBe(false);
814+
}
815+
}, 60_000);
816+
817+
it('should kill run-commands task processes on SIGINT', async () => {
818+
const lib = uniq('sigint-rc');
819+
runCLI(`generate @nx/js:lib libs/${lib}`);
820+
821+
const pidFile = path.join(tmpProjPath(), `libs/${lib}/build.pid`);
822+
823+
updateFile(`libs/${lib}/slow-build.js`, writePidScript('build.pid'));
824+
825+
updateJson(`libs/${lib}/project.json`, (config) => {
826+
config.targets = {
827+
build: {
828+
command: 'node slow-build.js',
829+
options: { cwd: `libs/${lib}` },
830+
},
831+
};
832+
return config;
833+
});
834+
835+
const pids = await runNxAndSigint(
836+
['build', lib, '--skip-nx-cache'],
837+
pidFile
838+
);
839+
840+
for (const pid of pids) {
841+
expect(isProcessAlive(pid)).toBe(false);
842+
}
843+
}, 60_000);
844+
845+
it('should kill continuous task processes on SIGINT', async () => {
846+
const lib = uniq('sigint-cont');
847+
runCLI(`generate @nx/js:lib libs/${lib}`);
848+
849+
const pidFile = path.join(tmpProjPath(), `libs/${lib}/serve.pid`);
850+
851+
updateFile(`libs/${lib}/serve.js`, writePidScript('serve.pid'));
852+
853+
updateJson(`libs/${lib}/project.json`, (config) => {
854+
config.targets = {
855+
serve: {
856+
command: 'node serve.js',
857+
options: { cwd: `libs/${lib}` },
858+
continuous: true,
859+
},
860+
};
861+
return config;
862+
});
863+
864+
const pids = await runNxAndSigint(
865+
['serve', lib, '--skip-nx-cache'],
866+
pidFile
867+
);
868+
869+
for (const pid of pids) {
870+
expect(isProcessAlive(pid)).toBe(false);
871+
}
872+
}, 60_000);
873+
874+
it('should kill executor-based (discrete) task processes on SIGINT (TUI mode)', async () => {
875+
const lib = uniq('sigint-tui');
876+
runCLI(`generate @nx/js:lib libs/${lib}`);
877+
878+
const pidFile = path.join(tmpProjPath(), `libs/${lib}/build.pid`);
879+
880+
updateFile(`libs/${lib}/slow-build.js`, writePidScript('build.pid'));
881+
882+
updateJson(`libs/${lib}/project.json`, (config) => {
883+
config.targets = {
884+
build: {
885+
executor: 'nx:run-script',
886+
options: { script: 'build' },
887+
},
888+
};
889+
return config;
890+
});
891+
892+
updateJson(`libs/${lib}/package.json`, (pkg) => {
893+
pkg.scripts = { build: 'node slow-build.js' };
894+
return pkg;
895+
});
896+
897+
const pids = await runNxAndSigint(
898+
['build', lib, '--skip-nx-cache'],
899+
pidFile,
900+
{ useTui: true }
901+
);
902+
903+
for (const pid of pids) {
904+
expect(isProcessAlive(pid)).toBe(false);
905+
}
906+
}, 60_000);
907+
908+
it('should kill run-commands task processes on SIGINT (TUI mode)', async () => {
909+
const lib = uniq('sigint-tui-rc');
910+
runCLI(`generate @nx/js:lib libs/${lib}`);
911+
912+
const pidFile = path.join(tmpProjPath(), `libs/${lib}/build.pid`);
913+
914+
updateFile(`libs/${lib}/slow-build.js`, writePidScript('build.pid'));
915+
916+
updateJson(`libs/${lib}/project.json`, (config) => {
917+
config.targets = {
918+
build: {
919+
command: 'node slow-build.js',
920+
options: { cwd: `libs/${lib}` },
921+
},
922+
};
923+
return config;
924+
});
925+
926+
const pids = await runNxAndSigint(
927+
['build', lib, '--skip-nx-cache'],
928+
pidFile,
929+
{ useTui: true }
930+
);
931+
932+
for (const pid of pids) {
933+
expect(isProcessAlive(pid)).toBe(false);
934+
}
935+
}, 60_000);
936+
937+
it('should kill continuous task processes on SIGINT (TUI mode)', async () => {
938+
const lib = uniq('sigint-tui-cont');
939+
runCLI(`generate @nx/js:lib libs/${lib}`);
940+
941+
const pidFile = path.join(tmpProjPath(), `libs/${lib}/serve.pid`);
942+
943+
updateFile(`libs/${lib}/serve.js`, writePidScript('serve.pid'));
944+
945+
updateJson(`libs/${lib}/project.json`, (config) => {
946+
config.targets = {
947+
serve: {
948+
command: 'node serve.js',
949+
options: { cwd: `libs/${lib}` },
950+
continuous: true,
951+
},
952+
};
953+
return config;
954+
});
955+
956+
const pids = await runNxAndSigint(
957+
['serve', lib, '--skip-nx-cache'],
958+
pidFile,
959+
{ useTui: true }
960+
);
961+
962+
for (const pid of pids) {
963+
expect(isProcessAlive(pid)).toBe(false);
964+
}
965+
}, 60_000);
966+
}
967+
968+
/** Script that writes its PID to a file and keeps running. */
969+
function writePidScript(filename: string): string {
970+
return `
971+
const fs = require('fs');
972+
const path = require('path');
973+
fs.writeFileSync(path.join(__dirname, '${filename}'), String(process.pid));
974+
setInterval(() => {}, 1000);
975+
`;
976+
}
977+
978+
/**
979+
* Runs nx inside a real pseudo-terminal, polls for a PID file,
980+
* sends SIGINT (identical to Ctrl+C), and asserts nx exits
981+
* promptly with all processes in the tree dead.
982+
*/
983+
async function runNxAndSigint(
984+
args: string[],
985+
pidFile: string,
986+
{ useTui }: { useTui?: boolean } = {}
987+
): Promise<number[]> {
988+
const { existsSync, readFileSync, unlinkSync } = require('fs');
989+
const { RustPseudoTerminal } = require('nx/src/native');
990+
991+
try {
992+
unlinkSync(pidFile);
993+
} catch {}
994+
995+
const nxBin = path.join(tmpProjPath(), 'node_modules', '.bin', 'nx');
996+
const command = `${nxBin} ${args.join(' ')}`;
997+
998+
const pty = new RustPseudoTerminal();
999+
const childProcess = pty.runCommand(command, tmpProjPath(), {
1000+
...getStrippedEnvironmentVariables(),
1001+
CI: 'true',
1002+
FORCE_COLOR: 'false',
1003+
NX_DAEMON: 'false',
1004+
NX_TUI: useTui ? 'true' : 'false',
1005+
...(useTui ? { NX_TUI_SKIP_CAPABILITY_CHECK: 'true' } : {}),
1006+
});
1007+
1008+
const nxPid = childProcess.getPid();
1009+
console.log(`[sigint-test] started PID=${nxPid}, useTui=${!!useTui}`);
1010+
1011+
// Track exit immediately so we don't miss it
1012+
let exited = false;
1013+
let exitResolve: (clean: boolean) => void;
1014+
const exitPromise = new Promise<boolean>((r) => (exitResolve = r));
1015+
childProcess.onExit((msg) => {
1016+
console.log(`[sigint-test] onExit: ${msg}`);
1017+
exited = true;
1018+
exitResolve(true);
1019+
});
1020+
1021+
// Poll for PID file written by the task script
1022+
let taskPid: number | undefined;
1023+
const startTime = Date.now();
1024+
while (Date.now() - startTime < 30_000 && !exited) {
1025+
if (existsSync(pidFile)) {
1026+
taskPid = parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
1027+
if (taskPid && !isNaN(taskPid)) break;
1028+
}
1029+
await new Promise((r) => setTimeout(r, 200));
1030+
}
1031+
1032+
if (!taskPid) {
1033+
childProcess.kill('SIGKILL');
1034+
throw new Error(
1035+
exited
1036+
? 'nx exited before task script wrote PID file'
1037+
: 'Timed out waiting for PID file from task'
1038+
);
1039+
}
1040+
1041+
expect(isProcessAlive(taskPid)).toBe(true);
1042+
1043+
// Capture entire process tree before sending SIGINT
1044+
const processTree = getProcessTree(nxPid);
1045+
1046+
console.log(
1047+
`[sigint-test] taskPid=${taskPid} alive=${isProcessAlive(taskPid)}, tree=${JSON.stringify(processTree)}`
1048+
);
1049+
1050+
// Send SIGINT — identical to Ctrl+C in a real terminal
1051+
console.log(`[sigint-test] sending SIGINT`);
1052+
childProcess.kill('SIGINT');
1053+
1054+
// Nx should exit promptly. Without the fix, cleanup hangs
1055+
// waiting for unkilled discrete tasks.
1056+
const exitTimeout = setTimeout(() => {
1057+
console.log(`[sigint-test] TIMEOUT — sending SIGKILL`);
1058+
childProcess.kill('SIGKILL');
1059+
exitResolve(false);
1060+
}, 10_000);
1061+
const exitedCleanly = await exitPromise;
1062+
clearTimeout(exitTimeout);
1063+
1064+
console.log(`[sigint-test] exitedCleanly=${exitedCleanly}`);
1065+
expect(exitedCleanly).toBe(true);
1066+
1067+
await new Promise((r) => setTimeout(r, 2000));
1068+
1069+
return processTree;
1070+
}
1071+
});
1072+
7761073
describe('exec', () => {
7771074
let pkg: string;
7781075
let pkg2: string;
@@ -945,3 +1242,46 @@ describe('Nx Running Tests', () => {
9451242
});
9461243
});
9471244
});
1245+
1246+
function isProcessAlive(pid: number): boolean {
1247+
try {
1248+
process.kill(pid, 0);
1249+
return true;
1250+
} catch {
1251+
return false;
1252+
}
1253+
}
1254+
1255+
/**
1256+
* Get all descendant PIDs of a process (recursive).
1257+
* Uses `pgrep -P` on macOS/Linux.
1258+
*/
1259+
function getProcessTree(rootPid: number): number[] {
1260+
const pids: number[] = [rootPid];
1261+
try {
1262+
const children = execSync(`pgrep -P ${rootPid}`, { encoding: 'utf-8' })
1263+
.trim()
1264+
.split('\n')
1265+
.filter(Boolean)
1266+
.map(Number);
1267+
for (const child of children) {
1268+
pids.push(...getProcessTree(child));
1269+
}
1270+
} catch {
1271+
// No children or process already exited
1272+
}
1273+
return pids;
1274+
}
1275+
1276+
/**
1277+
* Kill an entire process tree by walking descendants.
1278+
* Used as a fallback when cleanup times out.
1279+
*/
1280+
function killProcessTree(pid: number) {
1281+
const pids = getProcessTree(pid);
1282+
for (const p of pids) {
1283+
try {
1284+
process.kill(p, 'SIGKILL');
1285+
} catch {}
1286+
}
1287+
}

0 commit comments

Comments
 (0)