• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 23326817002

20 Mar 2026 02:42AM UTC coverage: 89.826% (-0.05%) from 89.876%
23326817002

push

github

BillChirico
test: raise coverage threshold for PR 332

6338 of 7450 branches covered (85.07%)

Branch coverage included in aggregate %.

10711 of 11530 relevant lines covered (92.9%)

225.51 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.19
/src/modules/cli-process.js
1
/**
2
 * CLIProcess — Claude CLI subprocess manager with dual-mode support.
3
 *
4
 * Spawns the `claude` binary directly in headless
5
 * mode.  Supports two lifecycle modes controlled by the `streaming` option:
6
 *
7
 * - **Short-lived** (default, `streaming: false`):  Each `send()` spawns a
8
 *   fresh `claude -p <prompt>` process that exits after returning its result.
9
 *   No token accumulation, clean abort via process kill.
10
 *
11
 * - **Long-lived** (`streaming: true`):  A single subprocess is kept alive
12
 *   across multiple `send()` calls using NDJSON stream-json I/O.  Tokens are
13
 *   tracked and the process is transparently recycled when a configurable
14
 *   threshold is exceeded.
15
 */
16

17
import { spawn } from 'node:child_process';
18
import { existsSync } from 'node:fs';
19
import { dirname, resolve } from 'node:path';
20
import { createInterface } from 'node:readline';
21
import { fileURLToPath } from 'node:url';
22
import { debug, info, error as logError, warn } from '../logger.js';
23
import { CLIProcessError } from '../utils/errors.js';
24

25
// Resolve the `claude` binary path from node_modules/.bin (may not be in PATH in Docker).
26
const __dirname = dirname(fileURLToPath(import.meta.url));
25✔
27
const LOCAL_BIN = resolve(__dirname, '..', '..', 'node_modules', '.bin', 'claude');
25✔
28
const CLAUDE_BIN = existsSync(LOCAL_BIN) ? LOCAL_BIN : 'claude';
25✔
29

30
export { CLIProcessError };
31

32
// ── AsyncQueue ───────────────────────────────────────────────────────────────
33

34
/**
35
 * Push-based async iterable for buffering stdin writes in long-lived mode.
36
 */
37
export class AsyncQueue {
38
  /** @type {Array<*>} */
39
  #queue = [];
5✔
40
  /** @type {Array<Function>} */
41
  #waiters = [];
5✔
42
  #closed = false;
5✔
43

44
  push(value) {
45
    if (this.#closed) return;
5✔
46
    if (this.#waiters.length > 0) {
4✔
47
      const resolve = this.#waiters.shift();
2✔
48
      resolve({ value, done: false });
2✔
49
    } else {
50
      this.#queue.push(value);
2✔
51
    }
52
  }
53

54
  close() {
55
    this.#closed = true;
5✔
56
    for (const resolve of this.#waiters) {
5✔
57
      resolve({ value: undefined, done: true });
1✔
58
    }
59
    this.#waiters.length = 0;
5✔
60
  }
61

62
  [Symbol.asyncIterator]() {
63
    return {
5✔
64
      next: () => {
65
        if (this.#queue.length > 0) {
9✔
66
          return Promise.resolve({ value: this.#queue.shift(), done: false });
2✔
67
        }
68
        if (this.#closed) {
7✔
69
          return Promise.resolve({ value: undefined, done: true });
4✔
70
        }
71
        return new Promise((resolve) => {
3✔
72
          this.#waiters.push(resolve);
3✔
73
        });
74
      },
75
    };
76
  }
77
}
78

79
// ── Helpers ──────────────────────────────────────────────────────────────────
80

81
const MAX_STDERR_LINES = 20;
25✔
82

83
/**
84
 * Build CLI argument array from a flags object.
85
 * @param {Object} flags
86
 * @param {boolean} longLived  Whether to include stream-json input flags.
87
 * @returns {string[]}
88
 */
89
function buildArgs(flags, longLived) {
90
  const args = ['-p'];
44✔
91

92
  // Always output NDJSON and enable verbose diagnostics
93
  args.push('--output-format', 'stream-json');
44✔
94
  args.push('--verbose');
44✔
95

96
  if (longLived) {
44✔
97
    args.push('--input-format', 'stream-json');
19✔
98
  }
99

100
  if (flags.model) {
44✔
101
    args.push('--model', flags.model);
3✔
102
  }
103

104
  if (flags.systemPromptFile) {
44✔
105
    args.push('--system-prompt-file', flags.systemPromptFile);
1✔
106
  }
107

108
  if (flags.systemPrompt) {
44!
109
    args.push('--system-prompt', flags.systemPrompt);
×
110
  }
111

112
  if (flags.appendSystemPrompt) {
44!
113
    args.push('--append-system-prompt', flags.appendSystemPrompt);
×
114
  }
115

116
  if (flags.tools !== undefined) {
44!
117
    args.push('--tools', flags.tools);
×
118
  }
119

120
  if (flags.allowedTools) {
44✔
121
    const toolList = Array.isArray(flags.allowedTools) ? flags.allowedTools : [flags.allowedTools];
1!
122
    for (const tool of toolList) {
1✔
123
      args.push('--allowedTools', tool);
2✔
124
    }
125
  }
126

127
  if (flags.permissionMode) {
44!
128
    args.push('--permission-mode', flags.permissionMode);
×
129
  } else {
130
    args.push('--permission-mode', 'bypassPermissions');
44✔
131
  }
132

133
  // SAFETY: --dangerously-skip-permissions is required for non-interactive
134
  // (headless) use. Without it, the CLI blocks waiting for a TTY-based
135
  // permission prompt that can never be answered in a subprocess context.
136
  // This is safe here because the bot controls what prompts and tools are
137
  // passed — user input is never forwarded raw to the CLI. The bot's own
138
  // permission model (Discord permissions + config.json) gates access.
139
  args.push('--dangerously-skip-permissions');
44✔
140

141
  args.push('--no-session-persistence');
44✔
142

143
  if (flags.maxBudgetUsd != null) {
44✔
144
    args.push('--max-budget-usd', String(flags.maxBudgetUsd));
1✔
145
  }
146

147
  return args;
44✔
148
}
149

150
/**
151
 * Build the subprocess environment with thinking token configuration.
152
 * @param {Object} flags
153
 * @param {string} [flags.baseUrl]  Override ANTHROPIC_BASE_URL (e.g. for claude-code-router proxy)
154
 * @param {string} [flags.apiKey]   Override ANTHROPIC_API_KEY (e.g. for provider-specific key)
155
 * @returns {Object}
156
 */
157
function buildEnv(flags) {
158
  // Security: pass only what the Claude CLI subprocess actually needs.
159
  // Never spread process.env — that would leak DISCORD_TOKEN, DATABASE_URL,
160
  // BOT_API_SECRET, SESSION_SECRET, REDIS_URL, etc. to the child process.
161
  // See: https://github.com/VolvoxLLC/volvox-bot/issues/155
162
  const env = {
44✔
163
    PATH: process.env.PATH,
164
    HOME: process.env.HOME,
165
    ...(process.env.NODE_ENV && { NODE_ENV: process.env.NODE_ENV }),
88✔
166
    ...(process.env.DISABLE_PROMPT_CACHING && {
44!
167
      DISABLE_PROMPT_CACHING: process.env.DISABLE_PROMPT_CACHING,
168
    }),
169
    MAX_THINKING_TOKENS: String(flags.thinkingTokens ?? 4096),
87✔
170
  };
171

172
  // Auth priority: explicit apiKey flag > ANTHROPIC_API_KEY env > CLAUDE_CODE_OAUTH_TOKEN env.
173
  // When flags.apiKey is provided we intentionally omit CLAUDE_CODE_OAUTH_TOKEN
174
  // to avoid conflicting auth headers in the subprocess.
175
  if (flags.apiKey) {
44✔
176
    env.ANTHROPIC_API_KEY = flags.apiKey;
3✔
177
  } else if (process.env.ANTHROPIC_API_KEY) {
41✔
178
    env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
1✔
179
  } else if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
40✔
180
    env.CLAUDE_CODE_OAUTH_TOKEN = process.env.CLAUDE_CODE_OAUTH_TOKEN;
1✔
181
  }
182

183
  // baseUrl is set from admin config (triage.classifyBaseUrl / respondBaseUrl),
184
  // never from user input. Validate URL format as defense-in-depth.
185
  if (flags.baseUrl) {
44✔
186
    try {
1✔
187
      const parsed = new URL(flags.baseUrl);
1✔
188
      if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
1!
189
        env.ANTHROPIC_BASE_URL = flags.baseUrl;
1✔
190
      }
191
    } catch {
192
      warn('Ignoring malformed baseUrl — falling back to default Anthropic endpoint', {
×
193
        baseUrl: flags.baseUrl,
194
      });
195
    }
196
  }
197

198
  return env;
44✔
199
}
200

201
// ── CLIProcess ───────────────────────────────────────────────────────────────
202

203
export class CLIProcess {
204
  #name;
205
  #flags;
206
  #streaming;
207
  #tokenLimit;
208
  #timeout;
209

210
  // Long-lived state
211
  #proc = null;
84✔
212
  #sessionId = null;
84✔
213
  #alive = false;
84✔
214
  #accumulatedTokens = 0;
84✔
215
  #stderrBuffer = [];
84✔
216

217
  // Long-lived consume-loop bookkeeping
218
  #pendingResolve = null;
84✔
219
  #pendingReject = null;
84✔
220
  /** @type {string[]} Accumulated text blocks for the current long-lived turn */
221
  #longLivedTextParts = [];
84✔
222

223
  // Short-lived: reference to the in-flight process for abort
224
  #inflightProc = null;
84✔
225

226
  // Mutex state — serialises concurrent send() calls.
227
  #mutexPromise = Promise.resolve();
84✔
228

229
  /**
230
   * @param {string} name  Human-readable label ('classifier' | 'responder' | 'ai-chat')
231
   * @param {Object} flags  CLI flag configuration
232
   * @param {string} [flags.model]  Model name (e.g. 'claude-sonnet-4-6')
233
   * @param {string} [flags.systemPromptFile]  Path to system prompt .md file
234
   * @param {string} [flags.systemPrompt]  System prompt as a string
235
   * @param {string} [flags.appendSystemPrompt]  Text appended to system prompt
236
   * @param {string} [flags.tools]  Tools flag ('' to disable all)
237
   * @param {string|string[]} [flags.allowedTools]  Allowed tool names
238
   * @param {string} [flags.permissionMode]  Permission mode (default: 'bypassPermissions')
239
   * @param {number} [flags.maxBudgetUsd]  Budget cap per process lifetime
240
   * @param {number} [flags.thinkingTokens]  MAX_THINKING_TOKENS env (default: 4096)
241
   * @param {string} [flags.baseUrl]  Override ANTHROPIC_BASE_URL (e.g. 'http://router:3456' for CCR proxy)
242
   * @param {string} [flags.apiKey]  Override ANTHROPIC_API_KEY (e.g. provider-specific key for routed requests)
243
   * @param {Object} [meta]
244
   * @param {number} [meta.tokenLimit=20000]  Token threshold before auto-recycle (long-lived only)
245
   * @param {boolean} [meta.streaming=false]  true for long-lived mode
246
   * @param {number} [meta.timeout=120000]  Per-send timeout in milliseconds
247
   */
248
  constructor(name, flags = {}, { tokenLimit = 20000, streaming = false, timeout = 120_000 } = {}) {
420✔
249
    this.#name = name;
84✔
250
    this.#flags = flags;
84✔
251
    this.#streaming = streaming;
84✔
252
    this.#tokenLimit = tokenLimit;
84✔
253
    this.#timeout = timeout;
84✔
254
  }
255

256
  // ── Lifecycle ────────────────────────────────────────────────────────────
257

258
  async start() {
259
    if (this.#streaming) {
87✔
260
      await this.#startLongLived();
19✔
261
    } else {
262
      this.#alive = true;
68✔
263
      this.#accumulatedTokens = 0;
68✔
264
    }
265
  }
266

267
  async #startLongLived() {
268
    this.#accumulatedTokens = 0;
19✔
269
    this.#stderrBuffer = [];
19✔
270
    this.#sessionId = null;
19✔
271

272
    const args = buildArgs(this.#flags, true);
19✔
273
    const env = buildEnv(this.#flags);
19✔
274

275
    this.#proc = spawn(CLAUDE_BIN, args, {
19✔
276
      stdio: ['pipe', 'pipe', 'pipe'],
277
      env,
278
    });
279

280
    // EPIPE protection: if the child dies between the alive check and stdin.write,
281
    // catch the error instead of crashing the host process.
282
    this.#proc.stdin.on('error', (err) => {
19✔
283
      warn(`${this.#name}: stdin error (child may have exited)`, { error: err.message });
1✔
284
      this.#alive = false;
1✔
285
    });
286

287
    // Capture stderr for diagnostics
288
    this.#proc.stderr.on('data', (chunk) => {
19✔
289
      const lines = chunk.toString().split('\n').filter(Boolean);
25✔
290
      this.#stderrBuffer.push(...lines);
25✔
291
      if (this.#stderrBuffer.length > MAX_STDERR_LINES) {
25✔
292
        this.#stderrBuffer = this.#stderrBuffer.slice(-MAX_STDERR_LINES);
5✔
293
      }
294
    });
295

296
    // Handle unexpected exit
297
    this.#proc.on('exit', (code, signal) => {
19✔
298
      if (this.#alive) {
1!
299
        warn(`${this.#name}: long-lived process exited`, { code, signal });
1✔
300
        this.#alive = false;
1✔
301
        if (this.#pendingReject) {
1!
302
          this.#pendingReject(
1✔
303
            new CLIProcessError(
304
              `${this.#name}: process exited unexpectedly (code=${code}, signal=${signal})`,
305
              'exit',
306
              { code, signal },
307
            ),
308
          );
309
          this.#pendingReject = null;
1✔
310
          this.#pendingResolve = null;
1✔
311
        }
312
      }
313
    });
314

315
    // Start the background consume loop
316
    this.#runConsumeLoop();
19✔
317
    this.#alive = true;
19✔
318
    info(`${this.#name}: long-lived process started`, { pid: this.#proc.pid });
19✔
319
  }
320

321
  #runConsumeLoop() {
322
    const rl = createInterface({ input: this.#proc.stdout, crlfDelay: Infinity });
19✔
323

324
    rl.on('line', (line) => {
19✔
325
      if (!line.trim()) return;
5!
326
      let msg;
327
      try {
5✔
328
        msg = JSON.parse(line);
5✔
329
      } catch {
330
        warn(`${this.#name}: non-JSON stdout line`, { line: line.slice(0, 200) });
×
331
        return;
×
332
      }
333

334
      // Capture session_id from init message
335
      if (msg.type === 'system' && msg.subtype === 'init') {
5✔
336
        this.#sessionId = msg.session_id;
1✔
337
        return;
1✔
338
      }
339

340
      // Accumulate text from assistant messages (long-lived mode)
341
      if (msg.type === 'assistant' && msg.message?.content) {
4!
342
        for (const block of msg.message.content) {
×
343
          if (block.type === 'text' && block.text) {
×
344
            this.#longLivedTextParts.push(block.text);
×
345
          }
346
        }
347
      }
348

349
      if (msg.type === 'result') {
4!
350
        // Reconstruct result text from accumulated assistant blocks
351
        if (msg.result === undefined && this.#longLivedTextParts.length > 0) {
4!
352
          msg.result = this.#longLivedTextParts.join('');
×
353
        }
354
        this.#longLivedTextParts = [];
4✔
355
        this.#trackTokens(msg);
4✔
356
        this.#pendingResolve?.(msg);
4✔
357
        this.#pendingResolve = null;
4✔
358
        this.#pendingReject = null;
4✔
359
      }
360
    });
361

362
    rl.on('close', () => {
19✔
363
      if (this.#alive) {
1!
364
        this.#alive = false;
1✔
365
        this.#pendingReject?.(
1✔
366
          new CLIProcessError(`${this.#name}: stdout closed unexpectedly`, 'exit'),
367
        );
368
        this.#pendingReject = null;
1✔
369
        this.#pendingResolve = null;
1✔
370
      }
371
    });
372
  }
373

374
  // ── send() ───────────────────────────────────────────────────────────────
375

376
  /**
377
   * Send a prompt and await the result.
378
   * Concurrent calls are serialised via an internal mutex.
379
   *
380
   * @param {string} prompt  The user-turn prompt text.
381
   * @param {Object} [overrides]  Per-call flag overrides (short-lived mode only).
382
   * @param {string} [overrides.systemPrompt]  Override system prompt string.
383
   * @param {string} [overrides.appendSystemPrompt]  Override append-system-prompt.
384
   * @param {string} [overrides.systemPromptFile]  Override system prompt file path.
385
   * @param {Object} [options]  Additional options.
386
   * @param {Function} [options.onEvent]  Callback for intermediate NDJSON messages (short-lived only).
387
   * @returns {Promise<Object>} The result message from the CLI.
388
   */
389
  async send(prompt, overrides = {}, { onEvent } = {}) {
66✔
390
    const release = await this.#acquireMutex();
33✔
391
    try {
33✔
392
      const result = this.#streaming
33✔
393
        ? await this.#sendLongLived(prompt)
394
        : await this.#sendShortLived(prompt, overrides, onEvent);
395

396
      // Token recycling — non-blocking so the caller gets the result now.
397
      if (this.#streaming && this.#accumulatedTokens >= this.#tokenLimit) {
19✔
398
        info(`Recycling ${this.#name} process`, {
1✔
399
          accumulatedTokens: this.#accumulatedTokens,
400
          tokenLimit: this.#tokenLimit,
401
        });
402
        this.recycle().catch((err) =>
1✔
403
          logError(`Failed to recycle ${this.#name}`, { error: err.message }),
404
        );
405
      }
406

407
      return result;
23✔
408
    } finally {
409
      release();
33✔
410
    }
411
  }
412

413
  async #sendShortLived(prompt, overrides = {}, onEvent = null) {
50✔
414
    const mergedFlags = { ...this.#flags, ...overrides };
25✔
415
    const args = buildArgs(mergedFlags, false);
25✔
416

417
    // In short-lived mode, the prompt is a positional argument after -p
418
    args.push(prompt);
25✔
419

420
    const env = buildEnv(mergedFlags);
25✔
421
    const stderrLines = [];
25✔
422

423
    return new Promise((resolve, reject) => {
25✔
424
      const proc = spawn(CLAUDE_BIN, args, {
25✔
425
        stdio: ['ignore', 'pipe', 'pipe'],
426
        env,
427
      });
428

429
      this.#inflightProc = proc;
25✔
430

431
      // Timeout handling
432
      const timer = setTimeout(() => {
25✔
433
        proc.kill('SIGKILL');
1✔
434
        reject(
1✔
435
          new CLIProcessError(
436
            `${this.#name}: send() timed out after ${this.#timeout}ms`,
437
            'timeout',
438
          ),
439
        );
440
      }, this.#timeout);
441

442
      let result = null;
25✔
443
      const textParts = [];
25✔
444

445
      // Capture stderr
446
      proc.stderr.on('data', (chunk) => {
25✔
447
        const lines = chunk.toString().split('\n').filter(Boolean);
1✔
448
        stderrLines.push(...lines);
1✔
449
        if (stderrLines.length > MAX_STDERR_LINES) {
1!
450
          stderrLines.splice(0, stderrLines.length - MAX_STDERR_LINES);
×
451
        }
452
      });
453

454
      const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
25✔
455

456
      rl.on('line', (line) => {
25✔
457
        if (!line.trim()) return;
24✔
458
        let msg;
459
        try {
22✔
460
          msg = JSON.parse(line);
22✔
461
        } catch {
462
          debug(`${this.#name}: non-JSON stdout line (short-lived)`, { line: line.slice(0, 200) });
1✔
463
          return;
1✔
464
        }
465
        if (msg.type === 'result') {
21✔
466
          // The result message no longer carries a `result` field in newer
467
          // claude-code versions. Reconstruct it from the accumulated
468
          // assistant text blocks collected during the stream.
469
          if (msg.result === undefined && textParts.length > 0) {
20!
470
            msg.result = textParts.join('');
×
471
          }
472
          result = msg;
20✔
473
        } else {
474
          // Accumulate text from assistant messages so we can attach it
475
          // to the result message (claude-code >=2.1.77 moved text out
476
          // of the result envelope into streamed assistant messages).
477
          if (msg.type === 'assistant' && msg.message?.content) {
1!
478
            for (const block of msg.message.content) {
×
479
              if (block.type === 'text' && block.text) {
×
480
                textParts.push(block.text);
×
481
              }
482
            }
483
          }
484
          if (onEvent) {
1!
485
            onEvent(msg);
1✔
486
          }
487
        }
488
      });
489

490
      proc.on('exit', (code, signal) => {
25✔
491
        clearTimeout(timer);
23✔
492
        this.#inflightProc = null;
23✔
493

494
        if (result) {
23✔
495
          try {
20✔
496
            resolve(this.#extractResult(result));
20✔
497
          } catch (err) {
498
            reject(err);
1✔
499
          }
500
        } else {
501
          const stderr = stderrLines.join('\n');
3✔
502
          reject(
3✔
503
            new CLIProcessError(
504
              `${this.#name}: process exited without result (code=${code}, signal=${signal})${stderr ? `\nstderr: ${stderr}` : ''}`,
3✔
505
              'exit',
506
              { code, signal },
507
            ),
508
          );
509
        }
510
      });
511

512
      proc.on('error', (err) => {
25✔
513
        clearTimeout(timer);
1✔
514
        this.#inflightProc = null;
1✔
515
        reject(
1✔
516
          new CLIProcessError(`${this.#name}: failed to spawn process — ${err.message}`, 'exit'),
517
        );
518
      });
519
    });
520
  }
521

522
  async #sendLongLived(prompt) {
523
    if (!this.#alive) {
8✔
524
      throw new CLIProcessError(`${this.#name}: process is not alive`, 'exit');
1✔
525
    }
526

527
    // Reset text accumulator for new turn
528
    this.#longLivedTextParts = [];
7✔
529

530
    return new Promise((resolve, reject) => {
7✔
531
      this.#pendingResolve = (msg) => {
7✔
532
        clearTimeout(timer);
4✔
533
        try {
4✔
534
          resolve(this.#extractResult(msg));
4✔
535
        } catch (err) {
536
          reject(err);
×
537
        }
538
      };
539
      this.#pendingReject = (err) => {
7✔
540
        clearTimeout(timer);
2✔
541
        reject(err);
2✔
542
      };
543

544
      // Timeout handling
545
      const timer = setTimeout(() => {
7✔
546
        this.#pendingResolve = null;
1✔
547
        this.#pendingReject = null;
1✔
548
        // Kill and restart the long-lived process
549
        this.#proc?.kill('SIGKILL');
1✔
550
        reject(
1✔
551
          new CLIProcessError(
552
            `${this.#name}: send() timed out after ${this.#timeout}ms`,
553
            'timeout',
554
          ),
555
        );
556
      }, this.#timeout);
557

558
      // Write NDJSON user-turn message to stdin
559
      const message = JSON.stringify({
7✔
560
        type: 'user',
561
        message: { role: 'user', content: prompt },
562
        session_id: this.#sessionId ?? '',
13✔
563
        parent_tool_use_id: null,
564
      });
565

566
      this.#proc.stdin.write(`${message}\n`);
7✔
567
    });
568
  }
569

570
  // ── Result extraction ────────────────────────────────────────────────────
571

572
  #extractResult(message) {
573
    if (message.is_error) {
24✔
574
      const errMsg = message.errors?.map((e) => e.message || e).join('; ') || 'Unknown CLI error';
1!
575
      logError(`${this.#name}: CLI error`, {
1✔
576
        error: errMsg,
577
        errorCount: message.errors?.length ?? 0,
1!
578
        resultSnippet: JSON.stringify(message).slice(0, 500),
579
      });
580
      throw new CLIProcessError(`${this.#name}: CLI error — ${errMsg}`, 'exit');
1✔
581
    }
582
    return message;
23✔
583
  }
584

585
  #trackTokens(message) {
586
    const usage = message.usage;
4✔
587
    if (usage) {
4✔
588
      const inp = usage.inputTokens ?? usage.input_tokens ?? 0;
3!
589
      const out = usage.outputTokens ?? usage.output_tokens ?? 0;
3!
590
      this.#accumulatedTokens += inp + out;
3✔
591
    }
592
  }
593

594
  // ── Recycle / restart ────────────────────────────────────────────────────
595

596
  async recycle() {
597
    this.close();
4✔
598
    await this.start();
4✔
599
  }
600

601
  async restart(attempt = 0) {
2✔
602
    const baseDelay = Math.min(1000 * 2 ** attempt, 30_000);
2✔
603
    const jitter = Math.floor(Math.random() * 1000);
2✔
604
    const delay = baseDelay + jitter;
2✔
605
    warn(`Restarting ${this.#name} process`, { attempt, delayMs: delay });
2✔
606
    await new Promise((r) => setTimeout(r, delay));
2✔
607
    try {
2✔
608
      await this.recycle();
2✔
609
    } catch (err) {
610
      logError(`${this.#name} restart failed`, { error: err.message, attempt });
×
611
      if (attempt < 5) {
×
612
        await this.restart(attempt + 1);
×
613
      } else {
614
        throw err;
×
615
      }
616
    }
617
  }
618

619
  close() {
620
    this.#killProc(this.#proc);
15✔
621
    this.#proc = null;
15✔
622

623
    this.#killProc(this.#inflightProc);
15✔
624
    this.#inflightProc = null;
15✔
625

626
    this.#alive = false;
15✔
627
    this.#sessionId = null;
15✔
628

629
    if (this.#pendingReject) {
15!
630
      this.#pendingReject(new CLIProcessError(`${this.#name}: process closed`, 'killed'));
×
631
      this.#pendingReject = null;
×
632
      this.#pendingResolve = null;
×
633
    }
634
  }
635

636
  /**
637
   * Send SIGTERM to a child process, then escalate to SIGKILL after 2 seconds
638
   * if it hasn't exited. Prevents zombie processes from stuck CLI subprocesses.
639
   * @param {import('node:child_process').ChildProcess|null} proc
640
   */
641
  #killProc(proc) {
642
    if (!proc) return;
30✔
643
    try {
6✔
644
      proc.kill('SIGTERM');
6✔
645
    } catch {
646
      return; // Already exited
×
647
    }
648
    const sigkillTimer = setTimeout(() => {
6✔
649
      try {
2✔
650
        proc.kill('SIGKILL');
2✔
651
      } catch {
652
        // Already exited between SIGTERM and SIGKILL — expected
653
      }
654
    }, 2000);
655
    // Don't keep the event loop alive just for the SIGKILL escalation
656
    sigkillTimer.unref();
6✔
657
  }
658

659
  // ── Mutex ────────────────────────────────────────────────────────────────
660

661
  #acquireMutex() {
662
    let release;
663
    const next = new Promise((resolve) => {
33✔
664
      release = resolve;
33✔
665
    });
666
    const prev = this.#mutexPromise;
33✔
667
    this.#mutexPromise = prev.then(() => next);
33✔
668
    return prev.then(() => release);
33✔
669
  }
670

671
  // ── Accessors ────────────────────────────────────────────────────────────
672

673
  get alive() {
674
    return this.#alive;
11✔
675
  }
676

677
  get tokenCount() {
678
    return this.#accumulatedTokens;
4✔
679
  }
680

681
  get name() {
682
    return this.#name;
2✔
683
  }
684

685
  get stderrDiagnostics() {
686
    return this.#stderrBuffer.join('\n');
2✔
687
  }
688
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc