mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
fix: harden stack management operations (#1046)
This commit is contained in:
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user