fix: harden stack management operations (#1046)

This commit is contained in:
Anso
2026-05-14 10:21:18 -04:00
committed by GitHub
parent 8dd0fce621
commit 5461bc316b
5 changed files with 325 additions and 58 deletions
+128 -42
View File
@@ -31,12 +31,22 @@ export class ComposeRollbackError extends Error {
}
}
export function getComposeRollbackInfo(error: unknown): { attempted: boolean; rolledBack: boolean } | null {
if (!(error instanceof ComposeRollbackError)) {
return null;
}
return { attempted: error.rollbackAttempted, rolledBack: error.rolledBack };
}
export function getComposeRollbackInfo(error: unknown): { attempted: boolean; rolledBack: boolean } | null {
if (!(error instanceof ComposeRollbackError)) {
return null;
}
return { attempted: error.rollbackAttempted, rolledBack: error.rolledBack };
}
const DEFAULT_COMPOSE_COMMAND_TIMEOUT_MS = 30 * 60 * 1000;
function getComposeCommandTimeoutMs(): number {
const configured = Number(process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS);
if (Number.isFinite(configured) && configured > 0) {
return configured;
}
return DEFAULT_COMPOSE_COMMAND_TIMEOUT_MS;
}
/**
* ComposeService - local docker compose CLI execution.
@@ -87,47 +97,123 @@ export class ComposeService {
ws?: WebSocket,
throwOnError = true,
env?: Record<string, string | undefined>
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: env ?? {
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: env ?? {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
let errorLog = '';
const onData = (data: Buffer) => {
const text = data.toString();
errorLog += text;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(text);
}
};
});
let errorLog = '';
let settled = false;
let exited = false;
let pendingTerminationError: Error | null = null;
const timeoutMs = getComposeCommandTimeoutMs();
let timeout: ReturnType<typeof setTimeout> | null = null;
let forceKillTimeout: ReturnType<typeof setTimeout> | null = null;
const sendOutput = (text: string) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(text);
}
};
const cleanup = () => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
if (forceKillTimeout) {
clearTimeout(forceKillTimeout);
forceKillTimeout = null;
}
if (ws) {
ws.removeListener('close', onClientDisconnect);
}
};
const finish = (complete: () => void) => {
if (settled) return;
settled = true;
cleanup();
complete();
};
const terminateChild = (error: Error) => {
pendingTerminationError = pendingTerminationError ?? error;
if (exited) return;
try {
child.kill('SIGTERM');
} catch (error) {
console.warn('[ComposeService] Failed to terminate compose command:', sanitizeForLog(getErrorMessage(error, 'unknown')));
}
forceKillTimeout = setTimeout(() => {
if (exited) return;
try {
child.kill('SIGKILL');
} catch (error) {
console.warn('[ComposeService] Failed to force terminate compose command:', sanitizeForLog(getErrorMessage(error, 'unknown')));
}
}, 5000);
};
const onClientDisconnect = () => {
const message = 'Command cancelled because the client disconnected';
terminateChild(new Error(message));
};
timeout = setTimeout(() => {
const message = `Command timed out after ${Math.round(timeoutMs / 1000)}s`;
sendOutput(`${message}\n`);
terminateChild(new Error(message));
}, timeoutMs);
if (ws) {
ws.once('close', onClientDisconnect);
}
const onData = (data: Buffer) => {
const text = data.toString();
errorLog += text;
sendOutput(text);
};
child.stdout.on('data', onData);
child.stderr.on('data', onData);
child.on('close', (code: number | null) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(`Command exited with code ${code}\n`);
}
if (code === 0) resolve();
else if (throwOnError) reject(new Error(redactSensitiveText(errorLog.trim()) || `Command failed with code ${code}`));
else resolve();
});
child.on('error', (error: Error) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(`Error: ${redactSensitiveText(error.message)}\n`);
}
if (throwOnError) reject(new Error(redactSensitiveText(error.message)));
else resolve();
});
});
}
child.stderr.on('data', onData);
child.on('close', (code: number | null) => {
exited = true;
finish(() => {
sendOutput(`Command exited with code ${code}\n`);
if (pendingTerminationError) {
if (throwOnError) reject(pendingTerminationError);
else resolve();
return;
}
if (code === 0) resolve();
else if (throwOnError) reject(new Error(redactSensitiveText(errorLog.trim()) || `Command failed with code ${code}`));
else resolve();
});
});
child.on('error', (error: Error) => {
exited = true;
finish(() => {
sendOutput(`Error: ${redactSensitiveText(error.message)}\n`);
if (pendingTerminationError) {
if (throwOnError) reject(pendingTerminationError);
else resolve();
return;
}
if (throwOnError) reject(new Error(redactSensitiveText(error.message)));
else resolve();
});
});
});
}
private async withRegistryAuth<T>(
fn: (env: Record<string, string | undefined>) => Promise<T>,