File size: 3,759 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
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDeferredCore } from "../shared/deferred.js";
import { prepareHostedStopExecutor } from "./hosted-stop-executor.js";

const { spawn } = vi.hoisted(() => ({ spawn: vi.fn() }));
vi.mock("node:child_process", () => ({ spawn }));
const cleanups: (() => void)[] = [];
afterEach(() => {
  for (const close of cleanups.splice(0)) {
    close();
  }
  spawn.mockReset();
});

function executorChild() {
  const child = Object.assign(new EventEmitter(), {
    pid: 123,
    exitCode: null as number | null,
    signalCode: null as string | null,
    stdin: new PassThrough(),
    stdout: new PassThrough(),
    stderr: new PassThrough(),
    // Signalling deliberately does not deliver close; the OS owns completion.
    kill: vi.fn(() => true),
  });
  spawn.mockReturnValueOnce(child);
  const close = () => {
    child.signalCode = "SIGKILL";
    child.emit("close", null, "SIGKILL");
  };
  cleanups.push(close);
  return { child, close };
}

describe("native executor cleanup ownership", () => {
  it.each(["abort", "spawn error"])("joins close after preparation %s", async (fault) => {
    const { child, close } = executorChild();
    const abort = new AbortController();
    let settled = false;
    const preparation = prepareHostedStopExecutor({
      command: ["native-stop"],
      env: {},
      signal: abort.signal,
      assertCurrent: () => {},
    });
    const failure = preparation.catch((error: unknown) => {
      settled = true;
      return error;
    });
    if (fault === "abort") {
      abort.abort();
    } else {
      child.emit("error", new Error("native spawn failed"));
    }
    await Promise.resolve();
    expect(settled).toBe(false);
    expect(child.kill).toHaveBeenCalledWith("SIGKILL");
    close();
    expect(await failure).toBeInstanceOf(Error);
  });

  it("joins both cancelled placement discovery and the original child close", async () => {
    const { child, close } = executorChild();
    const abort = new AbortController();
    const placement = createDeferredCore();
    const inspecting = createDeferredCore();
    let settled = false;
    const preparation = prepareHostedStopExecutor({
      command: ["native-stop"],
      env: {},
      signal: abort.signal,
      assertCurrent: () => {},
      verifyPlacement: () => {
        inspecting.resolve();
        return placement.promise;
      },
    });
    const failure = preparation.catch((error: unknown) => {
      settled = true;
      return error;
    });
    child.stdout.write("123\n");
    await inspecting.promise;
    abort.abort();
    close();
    await Promise.resolve();
    expect(settled).toBe(false);
    placement.resolve();
    expect(await failure).toBeInstanceOf(Error);
  });

  it("fences a committed executor immediately but does not retire it until close", async () => {
    const { child, close } = executorChild();
    const preparation = prepareHostedStopExecutor({
      command: ["native-stop"],
      env: {},
      signal: new AbortController().signal,
      assertCurrent: () => {},
    });
    child.stdout.write("123\n");
    const executor = await preparation;
    const nativeResult = executor.execute(() => {});
    let retired = false;
    const retirement = executor.dispose().then(() => {
      retired = true;
    });
    expect(() => executor.execute(() => {})).toThrow("no longer available");
    expect(child.kill).toHaveBeenCalledExactlyOnceWith("SIGKILL");
    await Promise.resolve();
    expect(retired).toBe(false);
    close();
    await retirement;
    await expect(nativeResult).resolves.toMatchObject({ disposition: "uncertain" });
  });
});