|
| 1 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 2 | +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; |
| 3 | +import { tmpdir } from "node:os"; |
| 4 | +import { join } from "node:path"; |
| 5 | +import { runChantRaw, isScheduledRead, resolveChant } from "./chant.ts"; |
| 6 | +import { createApp } from "./server.ts"; |
| 7 | +import { invalidateReadGeneration, withReadSignal } from "./read-scheduler.ts"; |
| 8 | + |
| 9 | +// Real subprocesses, no cluster. The fixture writes its instrumentation outside |
| 10 | +// the member so observing it cannot accidentally change the source stamp. |
| 11 | +let scratch: string, project: string, log: string; |
| 12 | +beforeEach(() => { |
| 13 | + scratch = mkdtempSync(join(tmpdir(), "behold-read-test-")); |
| 14 | + project = join(scratch, "project"); log = join(scratch, "events"); |
| 15 | + const pkg = join(project, "node_modules/@intentius/chant"); |
| 16 | + mkdirSync(pkg, { recursive: true }); |
| 17 | + writeFileSync(join(project, "source.ts"), "// fixture source\n"); |
| 18 | + writeFileSync(join(pkg, "package.json"), JSON.stringify({ name: "@intentius/chant", version: "0.54.0", main: "chant.cjs", bin: { chant: "chant.cjs" } })); |
| 19 | + writeFileSync(join(pkg, "chant.cjs"), `#!/usr/bin/env node |
| 20 | +const fs = require('node:fs'); |
| 21 | +const log = ${JSON.stringify(log)}; |
| 22 | +const event = (value) => fs.appendFileSync(log, JSON.stringify(value) + '\\n'); |
| 23 | +event({ event: 'start', pid: process.pid, args: process.argv.slice(2) }); |
| 24 | +if (process.argv.includes('--tree')) { |
| 25 | + const child = require('node:child_process').spawn(process.execPath, ['-e', |
| 26 | + "process.on('SIGTERM', () => {}); require('node:fs').appendFileSync(" + JSON.stringify(log) + ", JSON.stringify({event:'child',pid:process.pid})+'\\\\n'); setInterval(() => {}, 1000);" |
| 27 | + ], { stdio: 'ignore' }); |
| 28 | + process.on('SIGTERM', () => process.exit(0)); |
| 29 | + setInterval(() => {}, 1000); |
| 30 | +} else { |
| 31 | + setTimeout(() => { |
| 32 | + event({ event: 'finish', pid: process.pid }); |
| 33 | + process.stdout.write(JSON.stringify({ pid: process.pid, variant: process.env.READ_TEST_VARIANT })); |
| 34 | + }, process.argv.includes('--slow') ? 1000 : 150); |
| 35 | +} |
| 36 | +`, { mode: 0o755 }); |
| 37 | + expect(resolveChant(project).source).toBe("project"); |
| 38 | + vi.stubEnv("BEHOLD_ESTATE_CONCURRENCY", "2"); |
| 39 | +}); |
| 40 | +afterEach(() => { vi.unstubAllEnvs(); rmSync(scratch, { recursive: true, force: true }); }); |
| 41 | +const events = (): { event: string; pid: number; args?: string[] }[] => { |
| 42 | + try { return readFileSync(log, "utf8").trim().split("\n").map((s) => JSON.parse(s)); } catch { return []; } |
| 43 | +}; |
| 44 | + |
| 45 | +describe("real Chant read scheduling", () => { |
| 46 | + it("matching HTTP and background reads share a process, then the next read is fresh", async () => { |
| 47 | + const args = ["graph", "source.ts", "--live", "--namespace", "app"]; |
| 48 | + const first = withReadSignal(new AbortController().signal, () => runChantRaw(args, project)); |
| 49 | + const second = runChantRaw(args, project); |
| 50 | + const [a, b] = await Promise.all([first, second]); |
| 51 | + expect(a.stdout).toBe(b.stdout); |
| 52 | + expect(events().filter((e) => e.event === "start")).toHaveLength(1); |
| 53 | + a.stdout = "caller mutation"; |
| 54 | + expect(b.stdout).not.toBe(a.stdout); |
| 55 | + const fresh = await runChantRaw(args, project); |
| 56 | + expect(fresh.stdout).not.toBe(b.stdout); |
| 57 | + }); |
| 58 | + |
| 59 | + it("the HTTP middleware isolates cancellation between matching requests", async () => { |
| 60 | + const app = createApp({ projectDir: project, port: 0 }); |
| 61 | + app.onError((error, c) => c.json({ error: error.message }, 500)); |
| 62 | + const caller = new AbortController(); |
| 63 | + const first = app.request(new Request("http://localhost/api/diff?env=home", { signal: caller.signal })); |
| 64 | + const second = app.request("/api/diff?env=home"); |
| 65 | + await vi.waitFor(() => expect(events().filter((e) => e.event === "start")).toHaveLength(1)); |
| 66 | + caller.abort(new Error("client disconnected")); |
| 67 | + expect((await first).status).toBe(500); |
| 68 | + expect((await second).status).toBe(200); |
| 69 | + expect(events().filter((e) => e.event === "start")).toHaveLength(1); |
| 70 | + }); |
| 71 | + |
| 72 | + it("namespace, effective environment, source edits, and watcher invalidation isolate reads", async () => { |
| 73 | + const args = ["graph", "source.ts", "--live"]; |
| 74 | + const reads = [runChantRaw(args, project)]; |
| 75 | + reads.push(runChantRaw([...args, "--namespace", "other"], project)); |
| 76 | + reads.push(runChantRaw(args, project, { READ_TEST_VARIANT: "other" })); |
| 77 | + writeFileSync(join(project, "source.ts"), "// changed while reading\n"); |
| 78 | + reads.push(runChantRaw(args, project)); |
| 79 | + invalidateReadGeneration(); |
| 80 | + reads.push(runChantRaw(args, project)); |
| 81 | + const results = await Promise.all(reads); |
| 82 | + expect(new Set(results.map((r) => JSON.parse(r.stdout).pid)).size).toBe(5); |
| 83 | + let active = 0, peak = 0; |
| 84 | + for (const e of events()) { active += e.event === "start" ? 1 : -1; peak = Math.max(peak, active); } |
| 85 | + expect(peak).toBe(2); |
| 86 | + }); |
| 87 | + |
| 88 | + it("a timed-out process releases the shared budget", async () => { |
| 89 | + vi.stubEnv("BEHOLD_READ_TIMEOUT_MS", "50"); |
| 90 | + await expect(runChantRaw(["graph", "source.ts", "--slow"], project)).rejects.toThrow("exceeded 50ms"); |
| 91 | + vi.stubEnv("BEHOLD_READ_TIMEOUT_MS", "3000"); |
| 92 | + expect((await runChantRaw(["graph", "source.ts"], project)).code).toBe(0); |
| 93 | + }); |
| 94 | + |
| 95 | + it.skipIf(process.platform === "win32")("disconnect kills the whole worker group, including a descendant ignoring TERM", async () => { |
| 96 | + const caller = new AbortController(); |
| 97 | + const read = withReadSignal(caller.signal, () => runChantRaw(["graph", "source.ts", "--tree"], project)); |
| 98 | + // Handle rejection immediately; disconnect rejects the subscriber before |
| 99 | + // the worker group has necessarily finished closing its pipes. |
| 100 | + const rejected = read.catch((error) => error); |
| 101 | + let child = 0; |
| 102 | + try { |
| 103 | + await vi.waitFor(() => { child = events().find((e) => e.event === "child")?.pid ?? 0; expect(child).toBeGreaterThan(0); }); |
| 104 | + caller.abort(new Error("client left")); |
| 105 | + expect((await rejected).message).toBe("client left"); |
| 106 | + await vi.waitFor(() => expect(() => process.kill(child, 0)).toThrow(), { timeout: 3000 }); |
| 107 | + } finally { |
| 108 | + caller.abort(new Error("client left")); |
| 109 | + for (const e of events()) { try { process.kill(e.pid, "SIGKILL"); } catch {} } |
| 110 | + } |
| 111 | + }); |
| 112 | + |
| 113 | + it("delegated mutations are never scheduled or deduplicated as reads", async () => { |
| 114 | + for (const args of [["approve", "op", "gate"], ["run", "apply"], ["emulator", "up"], ["carve", "emit"], ["build"]]) { |
| 115 | + expect(isScheduledRead(args)).toBe(false); |
| 116 | + } |
| 117 | + const results = await Promise.all([runChantRaw(["approve", "op", "gate"], project), runChantRaw(["approve", "op", "gate"], project)]); |
| 118 | + expect(results[0].stdout).not.toBe(results[1].stdout); |
| 119 | + }); |
| 120 | +}); |
0 commit comments