File size: 4,581 Bytes
d197cf3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
/**
 * Runs startGmailWatcher and stopGmailWatcher with NO mocks for spawn or
 * killProcessTree. A fake `gog` binary is placed on PATH; it spawns a
 * credential-helper child and deliberately does NOT kill it on SIGTERM,
 * simulating the real bug. The test asserts that stopGmailWatcher removes
 * both the gog process and its descendant via the process-group signal.
 */
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { startGmailWatcher, stopGmailWatcher } from "./gmail-watcher.js";

const describePosix = process.platform === "win32" ? describe.skip : describe;

function alive(pid: number): boolean {
  try {
    process.kill(pid, 0);
    return true;
  } catch {
    return false;
  }
}

describePosix("gmail-watcher process-tree shutdown (integration)", () => {
  let tmpDir: string;
  let savedPath: string | undefined;
  let gogPid: number | undefined;
  let helperPid: number | undefined;

  beforeAll(() => {
    tmpDir = mkdtempSync(join(tmpdir(), "openclaw-gog-integration-"));

    // fake gog: handles `watch start` (exits 0) and `watch serve`
    // (spawns a credential-helper that ignores SIGTERM)
    const gogScript = join(tmpDir, "gog");
    writeFileSync(
      gogScript,
      [
        "#!/bin/bash",
        'SCRIPT_DIR="$(cd -- "$(dirname -- "$0")" && pwd)"',
        'case "$*" in',
        '  *"watch start"*) echo "[gog] watch registered"; exit 0 ;;',
        '  *"watch serve"*)',
        '    echo "[gog] serve started pid=$$"',
        // The reader polls existence, so publish only complete PID files.
        '    echo $$ > "$SCRIPT_DIR/gog.pid.tmp"',
        '    mv "$SCRIPT_DIR/gog.pid.tmp" "$SCRIPT_DIR/gog.pid"',
        `    /bin/bash -c 'trap "" TERM; while true; do sleep 1; done' &`,
        "    HELPER=$!",
        '    echo "[gog] credential-helper spawned pid=$HELPER"',
        '    echo $HELPER > "$SCRIPT_DIR/helper.pid.tmp"',
        '    mv "$SCRIPT_DIR/helper.pid.tmp" "$SCRIPT_DIR/helper.pid"',
        "    trap 'echo \"[gog] SIGTERM (child NOT killed)\"; exit 0' TERM",
        "    while true; do sleep 0.3; done ;;",
        "esac",
      ].join("\n"),
    );
    chmodSync(gogScript, 0o755);

    savedPath = process.env["PATH"];
    process.env["PATH"] = `${tmpDir}:${savedPath ?? ""}`;
  });

  afterAll(async () => {
    try {
      await stopGmailWatcher();
    } catch {
      // Force cleanup below; this test must not leave subprocesses behind on failure.
    }
    if (gogPid !== undefined) {
      try {
        process.kill(-gogPid, "SIGKILL");
      } catch {
        // The process group may already be gone.
      }
    }
    if (helperPid !== undefined) {
      try {
        process.kill(helperPid, "SIGKILL");
      } catch {
        // The helper may already be gone with its process group.
      }
    }
    if (savedPath !== undefined) {
      process.env["PATH"] = savedPath;
    }
    rmSync(tmpDir, { recursive: true, force: true });
  });

  it("stopGmailWatcher removes gog and its credential-helper descendant", async () => {
    const result = await startGmailWatcher({
      hooks: {
        enabled: true,
        token: "integration-token",
        gmail: {
          account: "integration@example.com",
          topic: "projects/integration/topics/gmail",
          pushToken: "integration-push-token",
        },
      },
    });

    expect(result.started).toBe(true);

    // Wait for both pid files
    for (let i = 0; i < 50; i++) {
      if (existsSync(join(tmpDir, "helper.pid")) && existsSync(join(tmpDir, "gog.pid"))) {
        break;
      }
      await new Promise<void>((r) => {
        setTimeout(r, 100);
      });
    }

    gogPid = Number.parseInt(readFileSync(join(tmpDir, "gog.pid"), "utf8").trim(), 10);
    helperPid = Number.parseInt(readFileSync(join(tmpDir, "helper.pid"), "utf8").trim(), 10);

    console.log(`\ngog pid=${gogPid}, credential-helper pid=${helperPid}`);
    expect(alive(gogPid)).toBe(true);
    expect(alive(helperPid)).toBe(true);

    console.log("calling stopGmailWatcher...");
    await stopGmailWatcher();

    await expect.poll(() => alive(gogPid!), { interval: 25, timeout: 1_500 }).toBe(false);
    await expect.poll(() => alive(helperPid!), { interval: 25, timeout: 1_500 }).toBe(false);

    console.log(`gog alive after stop: ${alive(gogPid)}`);
    console.log(`credential-helper alive after stop: ${alive(helperPid)}`);
  }, 15_000);
});