mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 18:52:48 +00:00
Vitest's expect.poll and vi.waitFor stop after one second by default, a host-speed guess that the project's deadline policy does not see. Under hosted-CI load, kunaProjectConcurrency failed that way twice on unrelated PRs. Another 256 waits relied on the default, and 97 passed literals picked to fit some host. Waits now take as long as their test may run, less the reporting reserve that children already keep. Each Vitest project sets expect.poll's default, and the new waitFor in tests/support/testWait.ts replaces vi.waitFor. Fixture readiness waits (awaitAcknowledgement and the provider process waits) default to the same deadline, and their host-speed literal arguments are removed. One remainingTestDeadline owns the arithmetic for both children and waits. Ten waits whose short timeout was the real check now assert it directly (Kuna version probe, Cutter discovery, doctor and Inspector cancellation, apktool fixture teardown, JADX disconnect): settlement before the production deadline that would otherwise also satisfy the wait, or a deadline pinned beyond every test deadline. The rea/project-wait-deadline lint rule rejects vi.waitFor and explicit expect.poll timeouts. The testing guide and the CI tests audit skill now document the policy. Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import { access, readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
|
import {
|
|
ExecFileOutputCancelledError,
|
|
execFileOutput,
|
|
} from "../process/ExecFileOutput.js";
|
|
import { inspectLinuxHopperPythonRuntime } from "./LinuxHopperPython.js";
|
|
import { waitFor } from "../../tests/support/testWait.js";
|
|
|
|
const response = (value: unknown) => {
|
|
const stdout = JSON.stringify(value);
|
|
return {
|
|
stdout,
|
|
stderr: "",
|
|
stdoutBytes: Buffer.byteLength(stdout),
|
|
stderrBytes: 0,
|
|
};
|
|
};
|
|
const available = {
|
|
version: [3, 14],
|
|
library: { status: "loadable", path: "/usr/lib/libpython3.14.so.1.0" },
|
|
};
|
|
|
|
describe("distribution Python runtime inspection", () => {
|
|
it("preserves version and library identity without assuming Python 3.12", async () => {
|
|
const execute = vi
|
|
.fn<typeof execFileOutput>()
|
|
.mockResolvedValue(response(available));
|
|
const environment = {
|
|
PYTHONHOME: "/caller/python",
|
|
PYTHONPATH: "/caller/modules",
|
|
};
|
|
const result = await inspectLinuxHopperPythonRuntime(
|
|
environment,
|
|
undefined,
|
|
execute,
|
|
);
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
value: { ...available, interpreter: "/usr/bin/python3" },
|
|
});
|
|
expect(execute).toHaveBeenCalledWith(
|
|
"/usr/bin/python3",
|
|
expect.arrayContaining(["-I", "-S", "-B"]),
|
|
expect.objectContaining({
|
|
env: environment,
|
|
timeout: 10_000,
|
|
maxBuffer: 64 * 1024,
|
|
stopSignal: "SIGTERM",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{ version: [2, 7], library: available.library },
|
|
{ version: [3, "14"], library: available.library },
|
|
{
|
|
version: [3, 14],
|
|
library: { status: "loadable", path: "relative/libpython.so" },
|
|
},
|
|
{
|
|
version: [3, 14],
|
|
library: { status: "loadable", path: "C:\\not-a-linux-path\\python.dll" },
|
|
},
|
|
{
|
|
version: [3, 14],
|
|
library: { status: "loadable", path: "/usr/lib/python\0.so" },
|
|
},
|
|
{
|
|
version: [3, 14],
|
|
library: { status: "unavailable", path: "/lib/python.so" },
|
|
},
|
|
null,
|
|
])(
|
|
"refuses malformed facts without reporting library absence: %j",
|
|
async (value) => {
|
|
const execute = vi
|
|
.fn<typeof execFileOutput>()
|
|
.mockResolvedValue(response(value));
|
|
const result = await inspectLinuxHopperPythonRuntime(
|
|
{},
|
|
undefined,
|
|
execute,
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { reason: "invalid_response" },
|
|
});
|
|
},
|
|
);
|
|
|
|
it("retains unknown configuration and observes a later repaired library afresh", async () => {
|
|
const unknown = {
|
|
version: [3, 14],
|
|
library: { status: "unknown", reason: "No INSTSONAME" },
|
|
};
|
|
const execute = vi
|
|
.fn<typeof execFileOutput>()
|
|
.mockResolvedValueOnce(response(unknown))
|
|
.mockResolvedValueOnce(response(available));
|
|
expect(
|
|
await inspectLinuxHopperPythonRuntime({}, undefined, execute),
|
|
).toMatchObject({ ok: true, value: unknown });
|
|
expect(
|
|
await inspectLinuxHopperPythonRuntime({}, undefined, execute),
|
|
).toMatchObject({ ok: true, value: available });
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("distinguishes execution and JSON failures from an unavailable library", async () => {
|
|
const execute = vi
|
|
.fn<typeof execFileOutput>()
|
|
.mockRejectedValueOnce(new Error("fixture execution failure"))
|
|
.mockResolvedValueOnce({
|
|
stdout: "{",
|
|
stderr: "",
|
|
stdoutBytes: 1,
|
|
stderrBytes: 0,
|
|
});
|
|
expect(
|
|
await inspectLinuxHopperPythonRuntime({}, undefined, execute),
|
|
).toMatchObject({ ok: false, error: { reason: "query_failed" } });
|
|
expect(
|
|
await inspectLinuxHopperPythonRuntime({}, undefined, execute),
|
|
).toMatchObject({ ok: false, error: { reason: "invalid_response" } });
|
|
});
|
|
});
|
|
|
|
describe("distribution Python process cleanup", () => {
|
|
it("retains output failure when cancellation arrives during command cleanup", async () => {
|
|
const directory = await createTestTempDirectory("rea-python-failure-stop-");
|
|
const stopping = join(directory, "stopping");
|
|
const release = join(directory, "release");
|
|
const controller = new AbortController();
|
|
const program = `
|
|
const fs = require('node:fs');
|
|
process.on('SIGTERM', () => {
|
|
fs.writeFileSync(${JSON.stringify(stopping)}, 'stopping');
|
|
const timer = setInterval(() => {
|
|
if (fs.existsSync(${JSON.stringify(release)})) {
|
|
clearInterval(timer);
|
|
process.exit(0);
|
|
}
|
|
}, 10);
|
|
});
|
|
process.stdout.write(Buffer.alloc(128 * 1024, 120));
|
|
setInterval(() => {}, 1000);
|
|
`;
|
|
const execution = inspectLinuxHopperPythonRuntime(
|
|
process.env,
|
|
controller.signal,
|
|
(_command, _arguments, options) =>
|
|
execFileOutput(process.execPath, ["-e", program], options),
|
|
);
|
|
try {
|
|
await waitFor(() => access(stopping), {
|
|
interval: 10,
|
|
});
|
|
controller.abort({ request: "cancel during failed command cleanup" });
|
|
} finally {
|
|
await writeFile(release, "release");
|
|
await execution;
|
|
}
|
|
expect(await execution).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
reason: "query_failed",
|
|
cause: { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" },
|
|
},
|
|
});
|
|
});
|
|
|
|
it.skipIf(process.platform !== "linux" || !existsSync("/usr/bin/python3"))(
|
|
"waits for a delayed Python exit before completing cancellation",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-python-stop-");
|
|
const ready = join(directory, "ready");
|
|
const stopping = join(directory, "stopping");
|
|
const release = join(directory, "release");
|
|
const stopped = join(directory, "stopped");
|
|
const program = `from pathlib import Path
|
|
import signal, sys, time
|
|
def stop(signum, frame):
|
|
Path(${JSON.stringify(stopping)}).write_text(str(signum))
|
|
while not Path(${JSON.stringify(release)}).exists():
|
|
time.sleep(0.01)
|
|
Path(${JSON.stringify(stopped)}).write_text(str(signum))
|
|
sys.exit(0)
|
|
signal.signal(signal.SIGTERM, stop)
|
|
Path(${JSON.stringify(ready)}).write_text("ready")
|
|
while True:
|
|
time.sleep(0.1)
|
|
`;
|
|
const controller = new AbortController();
|
|
const reason = { request: "stop owned Python" };
|
|
let settled = false;
|
|
const execution = inspectLinuxHopperPythonRuntime(
|
|
process.env,
|
|
controller.signal,
|
|
(command, _arguments, options) =>
|
|
execFileOutput(command, ["-I", "-S", "-B", "-c", program], options),
|
|
).then((result) => {
|
|
settled = true;
|
|
return result;
|
|
});
|
|
try {
|
|
await waitFor(() => access(ready), { interval: 10 });
|
|
controller.abort(reason);
|
|
await waitFor(() => access(stopping), {
|
|
interval: 10,
|
|
});
|
|
// The child cannot exit until released; early settlement loses ownership.
|
|
expect(settled).toBe(false);
|
|
} finally {
|
|
try {
|
|
await writeFile(release, "release");
|
|
} finally {
|
|
controller.abort(reason);
|
|
await execution;
|
|
}
|
|
await waitFor(() => access(stopped), {
|
|
interval: 10,
|
|
});
|
|
}
|
|
expect(await readFile(stopped, "utf8")).toBe("15");
|
|
const result = await execution;
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { reason: "cancelled" },
|
|
});
|
|
if (!result.ok) expect(result.error.cause).toBe(reason);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("distribution Python isolation and cancellation", () => {
|
|
it("uses the producer's cancellation reason without requiring an aborted caller", async () => {
|
|
const reason = { producer: "interrupted command" };
|
|
const result = await inspectLinuxHopperPythonRuntime(
|
|
{},
|
|
undefined,
|
|
async () => {
|
|
throw new ExecFileOutputCancelledError(reason);
|
|
},
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { reason: "cancelled", cause: reason },
|
|
});
|
|
if (!result.ok) expect(result.error.cause).toBe(reason);
|
|
});
|
|
|
|
it("preserves cancellation and starts no interpreter for an already cancelled query", async () => {
|
|
const controller = new AbortController();
|
|
const reason = new Error("caller cancellation");
|
|
controller.abort(reason);
|
|
const execute = vi.fn<typeof execFileOutput>();
|
|
const result = await inspectLinuxHopperPythonRuntime(
|
|
{},
|
|
controller.signal,
|
|
execute,
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { reason: "cancelled", cause: reason },
|
|
});
|
|
expect(execute).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("observes cancellation that arrives during interpreter execution", async () => {
|
|
const controller = new AbortController();
|
|
const reason = new Error("cancelled during query");
|
|
const execute = vi
|
|
.fn<typeof execFileOutput>()
|
|
.mockImplementation(async () => {
|
|
controller.abort(reason);
|
|
return response(available);
|
|
});
|
|
const result = await inspectLinuxHopperPythonRuntime(
|
|
{},
|
|
controller.signal,
|
|
execute,
|
|
);
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { reason: "cancelled", cause: reason },
|
|
});
|
|
});
|
|
|
|
it.skipIf(process.platform !== "linux" || !existsSync("/usr/bin/python3"))(
|
|
"runs the real producer without loading caller Python modules or PYTHONHOME",
|
|
async () => {
|
|
const directory = await createTestTempDirectory("rea-system-python-");
|
|
for (const name of ["ctypes.py", "sysconfig.py", "sitecustomize.py"])
|
|
await writeFile(
|
|
join(directory, name),
|
|
"raise RuntimeError('caller module was loaded')\n",
|
|
);
|
|
const result = await inspectLinuxHopperPythonRuntime({
|
|
...process.env,
|
|
PYTHONPATH: directory,
|
|
PYTHONHOME: join(directory, "missing-python-home"),
|
|
});
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
interpreter: "/usr/bin/python3",
|
|
version: [3, expect.any(Number)],
|
|
},
|
|
});
|
|
},
|
|
);
|
|
});
|