process-guard.test.js 6.24 KB
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';

const { ProcessGuard, FRESH_DEFAULTS, RESUME_DEFAULTS } = require('../../engine/process-guard');

/** 构造一个假的 ChildProcess */
function createMockProc() {
  const proc = new EventEmitter();
  proc.killed = false;
  proc.kill = vi.fn((signal) => {
    if (signal === 'SIGKILL') proc.killed = true;
  });
  proc.pid = 12345;
  return proc;
}

describe('ProcessGuard', () => {
  beforeEach(() => vi.useFakeTimers());
  afterEach(() => vi.useRealTimers());

  describe('Overall Timeout', () => {
    it('should kill process when overall timeout is reached', async () => {
      const proc = createMockProc();
      const guard = new ProcessGuard(proc, { overallTimeoutMs: 5000 });

      const timeoutPromise = new Promise((resolve) => {
        guard.on('timeout', resolve);
      });

      await vi.advanceTimersByTimeAsync(5000);
      const event = await timeoutPromise;

      expect(event.reason).toBe('overall-timeout');
      expect(guard.wasKilled).toBe(true);
      expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
    });

    it('should not trigger if cancelled before timeout', async () => {
      const proc = createMockProc();
      const guard = new ProcessGuard(proc, { overallTimeoutMs: 5000 });

      await vi.advanceTimersByTimeAsync(3000);
      guard.cancel();
      await vi.advanceTimersByTimeAsync(5000);

      expect(guard.wasKilled).toBe(false);
      expect(proc.kill).not.toHaveBeenCalled();
    });
  });

  describe('Watchdog (No-Output) Timeout', () => {
    it('should kill process when no output for watchdog duration', async () => {
      const proc = createMockProc();
      const guard = new ProcessGuard(proc, {
        overallTimeoutMs: 600_000,
        noOutputTimeoutMs: 3000,
      });

      const timeoutPromise = new Promise((resolve) => {
        guard.on('timeout', resolve);
      });

      await vi.advanceTimersByTimeAsync(3000);
      const event = await timeoutPromise;

      expect(event.reason).toBe('no-output-timeout');
      expect(guard.wasKilled).toBe(true);
    });

    it('should reset watchdog on touch()', async () => {
      const proc = createMockProc();
      const guard = new ProcessGuard(proc, {
        overallTimeoutMs: 600_000,
        noOutputTimeoutMs: 3000,
      });

      // 2s 后 touch → 从这里重新倒计 3s
      await vi.advanceTimersByTimeAsync(2000);
      guard.touch();

      // 再过 2s(总共 4s)→ 还没超时
      await vi.advanceTimersByTimeAsync(2000);
      expect(guard.wasKilled).toBe(false);

      // 再过 1.5s(总共 5.5s, touch 后 3.5s)→ 超时
      const timeoutPromise = new Promise((resolve) => {
        guard.on('timeout', resolve);
      });
      await vi.advanceTimersByTimeAsync(1500);
      const event = await timeoutPromise;

      expect(event.reason).toBe('no-output-timeout');
    });

    it('should not trigger watchdog if continuously touched', async () => {
      const proc = createMockProc();
      const guard = new ProcessGuard(proc, {
        overallTimeoutMs: 600_000,
        noOutputTimeoutMs: 3000,
      });

      // 模拟每秒收到输出
      for (let i = 0; i < 10; i++) {
        await vi.advanceTimersByTimeAsync(1000);
        guard.touch();
      }

      expect(guard.wasKilled).toBe(false);
      guard.cancel();
    });
  });

  describe('Watchdog Profile Computation', () => {
    it('should use FRESH_DEFAULTS for new sessions', () => {
      const proc = createMockProc();
      const guard = new ProcessGuard(proc, {
        overallTimeoutMs: 600_000,
        isResume: false,
      });

      // 600k * 0.8 = 480k, clamped by max 600k → 480k
      expect(guard.noOutputTimeoutMs).toBe(480_000);
      guard.cancel();
    });

    it('should use RESUME_DEFAULTS for resume sessions', () => {
      const proc = createMockProc();
      const guard = new ProcessGuard(proc, {
        overallTimeoutMs: 600_000,
        isResume: true,
      });

      // 600k * 0.3 = 180k, clamped [60k, 180k] → 180k
      expect(guard.noOutputTimeoutMs).toBe(180_000);
      guard.cancel();
    });

    it('should clamp to minMs for short overall timeout', () => {
      const proc = createMockProc();
      const guard = new ProcessGuard(proc, {
        overallTimeoutMs: 60_000,
        isResume: false,
      });

      // 60k * 0.8 = 48k, below minMs 180k → 180k
      expect(guard.noOutputTimeoutMs).toBe(180_000);
      guard.cancel();
    });

    it('should allow explicit noOutputTimeoutMs override', () => {
      const proc = createMockProc();
      const guard = new ProcessGuard(proc, {
        overallTimeoutMs: 600_000,
        noOutputTimeoutMs: 10_000,
      });

      expect(guard.noOutputTimeoutMs).toBe(10_000);
      guard.cancel();
    });
  });

  describe('Process Kill Sequence', () => {
    it('should SIGTERM first, then SIGKILL after 2s', async () => {
      const proc = createMockProc();
      const guard = new ProcessGuard(proc, {
        overallTimeoutMs: 1000,
      });

      await vi.advanceTimersByTimeAsync(1000);

      // SIGTERM 已发送
      expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
      expect(proc.kill).not.toHaveBeenCalledWith('SIGKILL');

      // 2s 后 SIGKILL
      await vi.advanceTimersByTimeAsync(2000);
      expect(proc.kill).toHaveBeenCalledWith('SIGKILL');
    });
  });

  describe('Cleanup on Process Exit', () => {
    it('should clear all timers when process closes', async () => {
      const proc = createMockProc();
      const guard = new ProcessGuard(proc, {
        overallTimeoutMs: 600_000,
        noOutputTimeoutMs: 3000,
      });

      // 进程提前退出
      proc.emit('close', 0);

      // 推进超过所有超时 → 不应触发任何 kill
      await vi.advanceTimersByTimeAsync(700_000);
      expect(guard.wasKilled).toBe(false);
      expect(proc.kill).not.toHaveBeenCalled();
    });
  });

  describe('Manual Terminate', () => {
    it('should allow manual termination', () => {
      const proc = createMockProc();
      const guard = new ProcessGuard(proc, { overallTimeoutMs: 600_000 });

      guard.terminate();

      expect(guard.wasKilled).toBe(true);
      expect(guard.terminationReason).toBe('manual-cancel');
      expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
    });
  });
});