Fix silent hangs in CLI by adding timeouts to daemon communication

sendToDaemon() had no timeout — if the daemon was stuck, the CLI would
hang forever with zero output. Added per-callsite timeouts (5s ping,
10s state, 30s default, 120s exec) and early stderr progress messages
so automated agents always see output or a bounded error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Opus 4.6 committed 2026-03-10 10:55:37 -04:00
1 parent 65d052e9e0
commit 70193f11a5
1 file changed
+35 -12
+35 -12
View File
@@ -99,10 +99,19 @@ function isDaemonRunning(botName: string): boolean {
/**
* Send a JSON message to the daemon and return the parsed response.
*/
function sendToDaemon(botName: string, msg: any): Promise<any> {
function sendToDaemon(botName: string, msg: any, timeoutMs = 30_000): Promise<any> {
return new Promise((resolve, reject) => {
const sock = connect(socketPath(botName));
let buf = '';
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
sock.destroy();
reject(new Error(`Daemon did not respond within ${timeoutMs / 1000}s`));
}
}, timeoutMs);
sock.on('connect', () => {
sock.write(JSON.stringify(msg) + '\n');
@@ -111,7 +120,9 @@ function sendToDaemon(botName: string, msg: any): Promise<any> {
sock.on('data', (data) => {
buf += data.toString();
const nlIndex = buf.indexOf('\n');
if (nlIndex !== -1) {
if (nlIndex !== -1 && !settled) {
settled = true;
clearTimeout(timer);
const line = buf.slice(0, nlIndex);
try {
resolve(JSON.parse(line));
@@ -123,15 +134,23 @@ function sendToDaemon(botName: string, msg: any): Promise<any> {
});
sock.on('error', (err: any) => {
reject(new Error(`Cannot connect to daemon: ${err.message}`));
if (!settled) {
settled = true;
clearTimeout(timer);
reject(new Error(`Cannot connect to daemon: ${err.message}`));
}
});
sock.on('end', () => {
if (buf.trim()) {
try {
resolve(JSON.parse(buf.trim()));
} catch {
reject(new Error('Incomplete response from daemon'));
if (!settled) {
settled = true;
clearTimeout(timer);
if (buf.trim()) {
try {
resolve(JSON.parse(buf.trim()));
} catch {
reject(new Error('Incomplete response from daemon'));
}
}
}
});
@@ -146,7 +165,7 @@ async function ensureDaemon(botName: string): Promise<void> {
if (isDaemonRunning(botName)) {
// Verify it's alive with a ping
try {
const resp = await sendToDaemon(botName, { type: 'ping' });
const resp = await sendToDaemon(botName, { type: 'ping' }, 5_000);
if (resp.ok) return;
} catch {
// Socket exists but daemon is dead, clean up
@@ -237,13 +256,15 @@ async function handleExec(botName: string, codeArg: string | undefined, flags: {
}
}
console.error(`Connecting to ${botName}...`);
await ensureDaemon(botName);
console.error(`Executing on ${botName}...`);
try {
const resp = await sendToDaemon(botName, {
type: 'exec',
code,
});
}, 120_000);
if (resp.output) {
console.log(resp.output);
@@ -260,7 +281,8 @@ async function handleState(botName: string, flags: { server: string; timeout: nu
// If daemon is running, use it for faster response
if (isDaemonRunning(botName)) {
try {
const resp = await sendToDaemon(botName, { type: 'state', json: flags.json });
console.error(`Fetching state from daemon...`);
const resp = await sendToDaemon(botName, { type: 'state', json: flags.json }, 10_000);
if (resp.ok) {
if (flags.json) {
console.log(JSON.stringify(resp.state, null, 2));
@@ -270,7 +292,8 @@ async function handleState(botName: string, flags: { server: string; timeout: nu
process.exit(0);
}
} catch {
// Daemon dead, fall through to one-shot
console.error(`Daemon unresponsive, falling back to direct connection...`);
// Daemon dead/stuck, fall through to one-shot
}
}