final dev

This commit is contained in:
2026-03-11 12:06:52 +00:00
parent 7979b353aa
commit 2cf523b8e3
3 changed files with 89 additions and 65 deletions

View File

@@ -37,7 +37,8 @@ function spawnTrackedProcess({
const child = spawn(cmd, args, { const child = spawn(cmd, args, {
cwd, cwd,
env: process.env, env: process.env,
stdio: ['ignore', 'pipe', 'pipe'] stdio: ['ignore', 'pipe', 'pipe'],
detached: true
}); });
if (onStart) { if (onStart) {
@@ -72,6 +73,23 @@ function spawnTrackedProcess({
}); });
let cancelCalled = false; let cancelCalled = false;
const killProcessTree = (signal) => {
const pid = Number(child.pid);
if (Number.isFinite(pid) && pid > 0) {
try {
process.kill(-pid, signal);
return true;
} catch (_error) {
// fallback below
}
}
try {
child.kill(signal);
return true;
} catch (_error) {
return false;
}
};
const cancel = () => { const cancel = () => {
if (cancelCalled) { if (cancelCalled) {
return; return;
@@ -79,17 +97,8 @@ function spawnTrackedProcess({
cancelCalled = true; cancelCalled = true;
logger.warn('spawn:cancel:requested', { cmd, args, context, pid: child.pid }); logger.warn('spawn:cancel:requested', { cmd, args, context, pid: child.pid });
child.kill('SIGINT'); // Instant cancel by user request.
killProcessTree('SIGKILL');
setTimeout(() => {
try {
process.kill(child.pid, 0);
logger.warn('spawn:cancel:force-kill', { cmd, args, context, pid: child.pid });
child.kill('SIGKILL');
} catch (_e) {
// Process already terminated
}
}, 3000);
}; };
return { return {

View File

@@ -54,27 +54,26 @@ function mapStepRow(row) {
}; };
} }
function terminateChildProcess(child) { function terminateChildProcess(child, { immediate = false } = {}) {
if (!child) { if (!child) {
return; return;
} }
const signal = immediate ? 'SIGKILL' : 'SIGTERM';
const pid = Number(child.pid);
if (Number.isFinite(pid) && pid > 0) {
try { try {
child.kill('SIGTERM'); // For detached children this targets the full process group.
process.kill(-pid, signal);
return;
} catch (_error) {
// Fall through to direct child signal.
}
}
try {
child.kill(signal);
} catch (_error) { } catch (_error) {
return; return;
} }
const forceKillTimer = setTimeout(() => {
try {
if (!child.killed) {
child.kill('SIGKILL');
}
} catch (_error) {
// ignore
}
}, 2000);
if (typeof forceKillTimer.unref === 'function') {
forceKillTimer.unref();
}
} }
function validateSteps(rawSteps) { function validateSteps(rawSteps) {
@@ -459,7 +458,7 @@ class ScriptChainService {
controlState.activeWaitResolve('cancel'); controlState.activeWaitResolve('cancel');
} else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) { } else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
controlState.activeChildTermination = 'cancel'; controlState.activeChildTermination = 'cancel';
terminateChildProcess(controlState.activeChild); terminateChildProcess(controlState.activeChild, { immediate: true });
} }
return { accepted: true, message: 'Abbruch angefordert.' }; return { accepted: true, message: 'Abbruch angefordert.' };
}; };
@@ -483,7 +482,7 @@ class ScriptChainService {
} }
if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) { if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
controlState.activeChildTermination = 'skip'; controlState.activeChildTermination = 'skip';
terminateChildProcess(controlState.activeChild); terminateChildProcess(controlState.activeChild, { immediate: true });
runtimeActivityService.updateActivity(activityId, { runtimeActivityService.updateActivity(activityId, {
message: 'Nächster Schritt angefordert (aktuelles Skript wird übersprungen)' message: 'Nächster Schritt angefordert (aktuelles Skript wird übersprungen)'
}); });
@@ -619,7 +618,8 @@ class ScriptChainService {
const run = await new Promise((resolve, reject) => { const run = await new Promise((resolve, reject) => {
const child = spawn(prepared.cmd, prepared.args, { const child = spawn(prepared.cmd, prepared.args, {
env: process.env, env: process.env,
stdio: ['ignore', 'pipe', 'pipe'] stdio: ['ignore', 'pipe', 'pipe'],
detached: true
}); });
controlState.activeChild = child; controlState.activeChild = child;
controlState.activeChildTermination = null; controlState.activeChildTermination = null;

View File

@@ -160,13 +160,37 @@ function appendWithCap(current, chunk, maxChars) {
}; };
} }
function killChildProcessTree(child, signal = 'SIGTERM') {
if (!child) {
return false;
}
const pid = Number(child.pid);
if (Number.isFinite(pid) && pid > 0) {
try {
// If spawned as detached=true this targets the full process group.
process.kill(-pid, signal);
return true;
} catch (_error) {
// Fallback below.
}
}
try {
child.kill(signal);
return true;
} catch (_error) {
return false;
}
}
function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd = process.cwd(), onChild = null }) { function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd = process.cwd(), onChild = null }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const startedAt = Date.now(); const startedAt = Date.now();
let ended = false;
const child = spawn(cmd, args, { const child = spawn(cmd, args, {
cwd, cwd,
env: process.env, env: process.env,
stdio: ['ignore', 'pipe', 'pipe'] stdio: ['ignore', 'pipe', 'pipe'],
detached: true
}); });
if (typeof onChild === 'function') { if (typeof onChild === 'function') {
try { try {
@@ -184,10 +208,10 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
timedOut = true; timedOut = true;
child.kill('SIGTERM'); killChildProcessTree(child, 'SIGTERM');
setTimeout(() => { setTimeout(() => {
if (!child.killed) { if (!ended) {
child.kill('SIGKILL'); killChildProcessTree(child, 'SIGKILL');
} }
}, 2000); }, 2000);
}, Math.max(1000, Number(timeoutMs || SCRIPT_TEST_TIMEOUT_MS))); }, Math.max(1000, Number(timeoutMs || SCRIPT_TEST_TIMEOUT_MS)));
@@ -208,11 +232,13 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
child.stderr?.on('data', (chunk) => onData('stderr', chunk)); child.stderr?.on('data', (chunk) => onData('stderr', chunk));
child.on('error', (error) => { child.on('error', (error) => {
ended = true;
clearTimeout(timeout); clearTimeout(timeout);
reject(error); reject(error);
}); });
child.on('close', (code, signal) => { child.on('close', (code, signal) => {
ended = true;
clearTimeout(timeout); clearTimeout(timeout);
const endedAt = Date.now(); const endedAt = Date.now();
resolve({ resolve({
@@ -508,23 +534,8 @@ class ScriptService {
message: 'Abbruch angefordert' message: 'Abbruch angefordert'
}); });
if (controlState.child) { if (controlState.child) {
try { // User cancel should stop instantly.
controlState.child.kill('SIGTERM'); killChildProcessTree(controlState.child, 'SIGKILL');
} catch (_error) {
// ignore
}
const forceKillTimer = setTimeout(() => {
try {
if (controlState.child && !controlState.child.killed) {
controlState.child.kill('SIGKILL');
}
} catch (_error) {
// ignore
}
}, 2000);
if (typeof forceKillTimer.unref === 'function') {
forceKillTimer.unref();
}
} }
return { accepted: true, message: 'Abbruch angefordert.' }; return { accepted: true, message: 'Abbruch angefordert.' };
} }
@@ -539,30 +550,34 @@ class ScriptService {
controlState.child = child; controlState.child = child;
} }
}); });
const cancelledByUser = controlState.cancelRequested; const exitCode = Number.isFinite(Number(run.code)) ? Number(run.code) : null;
const success = !cancelledByUser && run.code === 0 && !run.timedOut; const finishedSuccessfully = exitCode === 0 && !run.timedOut;
const cancelledByUser = Boolean(controlState.cancelRequested) && !finishedSuccessfully;
const success = finishedSuccessfully;
const message = cancelledByUser
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
: (run.timedOut
? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s`
: (success ? 'Skript-Test abgeschlossen' : `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`));
const errorMessage = success
? null
: (cancelledByUser
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
: (run.timedOut
? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s`
: `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`));
runtimeActivityService.completeActivity(activityId, { runtimeActivityService.completeActivity(activityId, {
status: success ? 'success' : 'error', status: success ? 'success' : 'error',
success, success,
outcome: cancelledByUser ? 'cancelled' : (success ? 'success' : 'error'), outcome: cancelledByUser ? 'cancelled' : (success ? 'success' : 'error'),
cancelled: cancelledByUser, cancelled: cancelledByUser,
exitCode: Number.isFinite(Number(run.code)) ? Number(run.code) : null, exitCode,
stdout: run.stdout || null, stdout: run.stdout || null,
stderr: run.stderr || null, stderr: run.stderr || null,
stdoutTruncated: Boolean(run.stdoutTruncated), stdoutTruncated: Boolean(run.stdoutTruncated),
stderrTruncated: Boolean(run.stderrTruncated), stderrTruncated: Boolean(run.stderrTruncated),
errorMessage: !success errorMessage,
? (cancelledByUser message
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
: (run.timedOut
? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s`
: `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`))
: null,
message: cancelledByUser
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
: (run.timedOut
? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s`
: (success ? 'Skript-Test abgeschlossen' : `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`))
}); });
return { return {
scriptId: script.id, scriptId: script.id,