mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(hopper): stop Linux demo children when the REA owner exits (#2499)
Bind the owned demo helper to REA's private stdin stream and unwind child cleanup on EOF during startup or analysis. Verify graceful and SIGKILL owner exit through real CLI/MCP workflows and preserve unrelated processes. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage)
This commit is contained in:
1 parent
d867136cec
commit
c105391db5
9 files changed
+412
-9
No files matched your search
+13
-6
@@ -317,8 +317,8 @@ The Hopper launcher action used by REA creates a document from an executable;
|
||||
its supported command-line interface does not attach to an already-open
|
||||
document. While another live REA session owns the same target and loader
|
||||
profile, a second session reports the owning run ID instead of opening a
|
||||
duplicate document. Closing the owning REA session releases this guard; Hopper
|
||||
keeps its document open. A later REA session can therefore open another
|
||||
duplicate document. On macOS, closing the owning REA session releases this guard;
|
||||
Hopper keeps its document open. A later REA session can therefore open another
|
||||
document for the same target. REA does not currently identify, focus, or reuse
|
||||
that existing GUI document, and the supported launcher exposes no attach or
|
||||
reuse action for it.
|
||||
@@ -341,8 +341,14 @@ Hopper application per user across cooperating REA processes. A competing
|
||||
CLI or MCP session receives the owning session ID before Hopper is launched,
|
||||
for both the same target and a different target. Switch targets through that
|
||||
MCP session, or close it before starting another session. Closing the owner
|
||||
releases the reservation. This does not attach to or claim ownership of
|
||||
manually opened Hopper applications.
|
||||
releases the reservation and stops REA's owned demo application and private
|
||||
display; the headless Linux demo does not retain documents after MCP shutdown.
|
||||
Its helper watches a private lifetime pipe, so an owner crash or SIGKILL also
|
||||
stops those children, without relying on a reusable PID or a process-name scan.
|
||||
A later session recovers the dead owner's stale lease after child cleanup;
|
||||
private runtime files can remain when REA itself was killed. This does not attach to
|
||||
or claim ownership of manually opened Hopper applications. An application left
|
||||
running by an older REA version must still be closed before starting a new session.
|
||||
|
||||
Stale lease recovery is serialized. If an interrupted recovery leaves its
|
||||
reservation directory, the diagnostic identifies that exact path. Stop REA
|
||||
@@ -435,8 +441,9 @@ For cursor navigation, string decoding, symbol-name limits, and regex matching,
|
||||
see [Hopper analysis behavior](native-investigation.md#hopper-analysis-behavior).
|
||||
|
||||
Closing or switching a target closes its bound Hopper document, shuts down REA's
|
||||
bridge and removes its temporary socket directory while preserving the Hopper
|
||||
application and unrelated documents. A `cleanup_incomplete`
|
||||
bridge and removes its temporary socket directory. The macOS Hopper application
|
||||
and unrelated documents remain open; the owned Linux demo application stops.
|
||||
A `cleanup_incomplete`
|
||||
result identifies resources whose cleanup could not be verified.
|
||||
REA retains unresolved cleanup ownership and any confirmed shutdown phases.
|
||||
After addressing the reported failure, retry `close_binary` on the same
|
||||
|
||||
@@ -10,6 +10,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import select
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -118,6 +119,7 @@ class XWindowAttributes(ctypes.Structure):
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--probe", action="store_true")
|
||||
parser.add_argument("--owner-stdin", action="store_true")
|
||||
parser.add_argument("--hopper")
|
||||
parser.add_argument("--socket")
|
||||
parser.add_argument(
|
||||
@@ -133,6 +135,43 @@ def parse_args() -> argparse.Namespace:
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
class OwnerLifetime:
|
||||
"""The launcher's private pipe is a lifetime capability, independent of PIDs."""
|
||||
|
||||
def __init__(self, enabled: bool) -> None:
|
||||
self._poll = select.poll() if enabled else None
|
||||
if self._poll is not None:
|
||||
# Node/libuv can implement piped stdio with a Unix socket pair.
|
||||
mode = os.fstat(0).st_mode
|
||||
if not (stat.S_ISFIFO(mode) or stat.S_ISSOCK(mode)):
|
||||
raise AdapterFailure(
|
||||
INVALID_LAUNCH_COMMAND,
|
||||
"invalid_launch_command",
|
||||
"owner lifetime requires a private stdin pipe",
|
||||
)
|
||||
self._poll.register(0, select.POLLIN | select.POLLHUP | select.POLLERR)
|
||||
|
||||
def alive(self) -> bool:
|
||||
if self._poll is None:
|
||||
return True
|
||||
for _, events in self._poll.poll(0):
|
||||
if events & (select.POLLHUP | select.POLLERR | select.POLLNVAL):
|
||||
return False
|
||||
if events & select.POLLIN and not os.read(0, 4096):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def wait_for_hopper(child: subprocess.Popen[bytes], owner: OwnerLifetime) -> int:
|
||||
while owner.alive():
|
||||
try:
|
||||
return child.wait(timeout=0.1)
|
||||
except subprocess.TimeoutExpired:
|
||||
continue
|
||||
# run_launch's finally owns cleanup of both direct, unreaped children.
|
||||
return 0
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
value = hashlib.sha256()
|
||||
with path.open("rb") as file:
|
||||
@@ -572,6 +611,7 @@ def start_xvfb(
|
||||
"-auth",
|
||||
str(authority),
|
||||
],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
@@ -627,6 +667,9 @@ def run_launch(args: argparse.Namespace) -> int:
|
||||
"invalid_launch_command",
|
||||
"missing Hopper launch coordinates",
|
||||
)
|
||||
owner = OwnerLifetime(args.owner_stdin)
|
||||
if not owner.alive():
|
||||
return 0
|
||||
hopper = Path(args.hopper)
|
||||
socket = Path(args.socket)
|
||||
try:
|
||||
@@ -654,6 +697,8 @@ def run_launch(args: argparse.Namespace) -> int:
|
||||
child: subprocess.Popen[bytes] | None = None
|
||||
display: int | None = None
|
||||
try:
|
||||
if not owner.alive():
|
||||
return 0
|
||||
hopper_environment = dict(os.environ)
|
||||
for name in ("WAYLAND_DISPLAY", "WAYLAND_SOCKET"):
|
||||
hopper_environment.pop(name, None)
|
||||
@@ -666,7 +711,10 @@ def run_launch(args: argparse.Namespace) -> int:
|
||||
"XDG_SESSION_TYPE": "x11",
|
||||
}
|
||||
)
|
||||
child = subprocess.Popen(command, env=hopper_environment)
|
||||
# Children must not consume or retain REA's lifetime pipe.
|
||||
child = subprocess.Popen(
|
||||
command, env=hopper_environment, stdin=subprocess.DEVNULL
|
||||
)
|
||||
leader = os.getpid()
|
||||
os.environ["XAUTHORITY"] = str(authority)
|
||||
|
||||
@@ -702,10 +750,12 @@ def run_launch(args: argparse.Namespace) -> int:
|
||||
observed: list[tuple[int, int, int, int, int]] = []
|
||||
dialog: tuple[int, int, int, int, int] | None = None
|
||||
while time.monotonic() < deadline:
|
||||
if not owner.alive():
|
||||
return 0
|
||||
if socket.exists():
|
||||
x11.XCloseDisplay(display)
|
||||
display = None
|
||||
return child.wait()
|
||||
return wait_for_hopper(child, owner)
|
||||
if not hopper_is_owned(leader, hopper):
|
||||
if child.poll() is None:
|
||||
time.sleep(0.1)
|
||||
@@ -749,7 +799,7 @@ def run_launch(args: argparse.Namespace) -> int:
|
||||
x11.XFlush(display)
|
||||
x11.XCloseDisplay(display)
|
||||
display = None
|
||||
return child.wait()
|
||||
return wait_for_hopper(child, owner)
|
||||
except AdapterFailure as error:
|
||||
raise error.with_stderr(capture)
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
import { verifyNoTokenOwnedProcesses } from "../../dist/process/ProcessOwnership.js";
|
||||
import {
|
||||
observeProcessStartIdentity,
|
||||
signalProcessWithStartIdentity,
|
||||
} from "../../dist/process/ProcessOwnershipObservation.js";
|
||||
import { startUnrelatedHopperSentinel } from "./unrelated-hopper-sentinel.mjs";
|
||||
import {
|
||||
requireMcpEvidenceResult,
|
||||
requireMcpLifecycleResult,
|
||||
} from "./mcp-verifier-results.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Prove actual MCP owner exit releases the demo singleton for subsequent CLI/MCP sessions. */
|
||||
export async function verifyLinuxHopperOwnerExit({
|
||||
primary,
|
||||
secondary,
|
||||
serverCommand,
|
||||
serverArgs,
|
||||
environment,
|
||||
}) {
|
||||
const sentinel = await startUnrelatedHopperSentinel();
|
||||
const outcomes = [];
|
||||
try {
|
||||
for (const mode of ["graceful", "SIGKILL"]) {
|
||||
const transport = new StdioClientTransport({
|
||||
command: serverCommand,
|
||||
args: serverArgs,
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
// Drain launcher diagnostics even on the deliberately interrupted owner.
|
||||
transport.stderr?.resume();
|
||||
const client = new Client({ name: "hopper-owner-exit", version: "1" });
|
||||
const options = { timeout: 180_000 };
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const ownerPid = transport.pid;
|
||||
assert.ok(ownerPid !== null);
|
||||
const owner = await observeProcessStartIdentity(ownerPid);
|
||||
assert.equal(owner?.state, "readable");
|
||||
requireMcpLifecycleResult(
|
||||
await client.callTool(
|
||||
{
|
||||
name: "open_binary",
|
||||
arguments: { path: primary },
|
||||
},
|
||||
options,
|
||||
),
|
||||
"open_binary",
|
||||
);
|
||||
const procedures = requireMcpEvidenceResult(
|
||||
await client.callTool(
|
||||
{ name: "list_procedures", arguments: {} },
|
||||
options,
|
||||
),
|
||||
"list_procedures",
|
||||
);
|
||||
assert.ok(procedures.length > 0);
|
||||
const status = requireMcpLifecycleResult(
|
||||
await client.callTool(
|
||||
{ name: "binary_session", arguments: {} },
|
||||
options,
|
||||
),
|
||||
"binary_session",
|
||||
);
|
||||
const runId = status.analysis_run?.run_id;
|
||||
assert.equal(typeof runId, "string");
|
||||
assert.equal(status.analysis_provider_binding?.provider.id, "hopper");
|
||||
assert.equal((await verifyNoTokenOwnedProcesses(runId)).cleaned, false);
|
||||
if (mode === "graceful") await client.close();
|
||||
else {
|
||||
assert.equal(
|
||||
await signalProcessWithStartIdentity(
|
||||
ownerPid,
|
||||
owner.identity,
|
||||
"SIGKILL",
|
||||
),
|
||||
"signaled",
|
||||
);
|
||||
}
|
||||
const deadline = Date.now() + 10_000;
|
||||
let cleanup;
|
||||
do {
|
||||
cleanup = await verifyNoTokenOwnedProcesses(runId);
|
||||
if (cleanup.cleaned) break;
|
||||
await delay(100);
|
||||
} while (Date.now() < deadline);
|
||||
assert.equal(cleanup.cleaned, true, cleanup.reason);
|
||||
// The SIGKILL case must recover the stale lease for both target identities.
|
||||
for (const target of [primary, secondary]) {
|
||||
const result = await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/rea.mjs",
|
||||
"inspect",
|
||||
target,
|
||||
"--provider",
|
||||
"hopper",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
{ env: environment, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
assert.ok(JSON.parse(result.stdout).normalized_result);
|
||||
}
|
||||
outcomes.push({
|
||||
mode,
|
||||
ownedProcessesStopped: true,
|
||||
sameTargetReopened: true,
|
||||
differentTargetReopened: true,
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
assert.equal(await sentinel.verifyAndClose(), true);
|
||||
}
|
||||
return { outcomes, unrelatedHopperSurvived: true };
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from "./lib/real-hopper-boundaries.mjs";
|
||||
import { startUnrelatedHopperSentinel } from "./lib/unrelated-hopper-sentinel.mjs";
|
||||
import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs";
|
||||
import { verifyLinuxHopperOwnerExit } from "./lib/real-hopper-owner-exit.mjs";
|
||||
const execFileAsync = promisify(execFile);
|
||||
const verifierRun = createVerifierRun();
|
||||
const timeout = 180_000;
|
||||
@@ -577,6 +578,14 @@ if (sessionsAfter.length > 0) {
|
||||
}
|
||||
if (summary === undefined)
|
||||
throw new Error("Real-Hopper verification did not produce a summary");
|
||||
if (process.platform === "linux")
|
||||
summary.ownerExit = await verifyLinuxHopperOwnerExit({
|
||||
primary: targetA,
|
||||
secondary: targetB,
|
||||
serverCommand,
|
||||
serverArgs,
|
||||
environment: serverEnvironment,
|
||||
});
|
||||
const completedVerifierRun = await completeVerifierRun(verifierRun);
|
||||
if (
|
||||
completedVerifierRun.process_lineage.status !== "verified" ||
|
||||
|
||||
@@ -413,6 +413,8 @@ const launchHopperProcess = async (input: {
|
||||
runId: input.session.runId,
|
||||
expectedCommand: ownershipCommand,
|
||||
hostEnvironment: input.options.environment,
|
||||
// The helper observes EOF when this REA process exits, including SIGKILL.
|
||||
stdin: "pipe",
|
||||
...(input.signal === undefined ? {} : { signal: input.signal }),
|
||||
});
|
||||
return spawn(input.argumentsForTarget);
|
||||
@@ -434,6 +436,7 @@ export const linuxDemoLaunch = (
|
||||
return undefined;
|
||||
const helperArguments = [
|
||||
options.demoHelperPath,
|
||||
"--owner-stdin",
|
||||
"--strategy",
|
||||
strategy ?? "direct",
|
||||
...(strategy === "user-mount-namespace" ? ["--mount-private-x11"] : []),
|
||||
|
||||
@@ -84,6 +84,7 @@ describe("Hopper bridge launcher selection", () => {
|
||||
"private",
|
||||
"/usr/bin/python3",
|
||||
"/rea/hopper-demo-x11.py",
|
||||
"--owner-stdin",
|
||||
"--strategy",
|
||||
"user-mount-namespace",
|
||||
"--mount-private-x11",
|
||||
@@ -117,6 +118,7 @@ describe("Hopper bridge launcher selection", () => {
|
||||
command: "/usr/bin/python3",
|
||||
args: [
|
||||
"/rea/hopper-demo-x11.py",
|
||||
"--owner-stdin",
|
||||
"--strategy",
|
||||
"direct",
|
||||
"--hopper",
|
||||
@@ -202,3 +204,59 @@ it("launches Hopper helpers with the caller-selected environment without restori
|
||||
await launched.value.releaseLease?.();
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "linux")(
|
||||
"gives the demo helper a live owner stream through Node's actual stdio transport",
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory("rea-hopper-owner-stdio-");
|
||||
const launcher = new HopperApplicationLauncher(
|
||||
{
|
||||
...options(process.execPath),
|
||||
launchMode: "verified_linux_demo",
|
||||
demoHelperPath: fileURLToPath(
|
||||
new URL("../../../fixtures/hopperLifetimeLaunch.py", import.meta.url),
|
||||
),
|
||||
},
|
||||
{
|
||||
selectPrivateDisplay: async () => ({
|
||||
ok: true,
|
||||
strategy: "direct",
|
||||
diagnostic: {
|
||||
component: "hopper_private_display",
|
||||
operation: "probe",
|
||||
status: "ready",
|
||||
failure_code: null,
|
||||
reason: "ready",
|
||||
socket_directory: "/tmp/.X11-unix",
|
||||
socket_directory_mode: "1777",
|
||||
mount_read_only: false,
|
||||
effective_socket_directory_mode: "1777",
|
||||
effective_mount_read_only: false,
|
||||
wsl: false,
|
||||
strategy: "direct",
|
||||
fallback_reason: null,
|
||||
xvfb_stderr_bytes: 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const launched = await launcher.launch({
|
||||
directory,
|
||||
socketPath: join(directory, "bridge.sock"),
|
||||
token: "fixture-token",
|
||||
runId: randomUUID(),
|
||||
});
|
||||
if (!launched.ok) throw launched.error;
|
||||
const supervisor = new ProviderProcessSupervisor(launched.value);
|
||||
try {
|
||||
expect(await supervisor.waitForOutputClose(5_000)).toBe(true);
|
||||
expect(supervisor.snapshot().exitCode).toBe(0);
|
||||
expect(JSON.parse(supervisor.snapshot().stdout.text)).toEqual({
|
||||
owner_alive: true,
|
||||
});
|
||||
} finally {
|
||||
await supervisor.stop();
|
||||
await launched.value.releaseLease?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -31,6 +31,9 @@ const dialogDetectionFixturePath = fileURLToPath(
|
||||
const cleanupTimeoutFixturePath = fileURLToPath(
|
||||
new URL("../../../fixtures/hopperCleanupTimeout.py", import.meta.url),
|
||||
);
|
||||
const ownerLifetimeFixturePath = fileURLToPath(
|
||||
new URL("../../../fixtures/hopperOwnerLifetime.py", import.meta.url),
|
||||
);
|
||||
|
||||
type DiagnosticOverrides = Partial<
|
||||
Omit<HopperStartupDiagnostic, "failure_code" | "status">
|
||||
@@ -107,6 +110,23 @@ describe("Hopper demo dialog detection", () => {
|
||||
});
|
||||
|
||||
describe("Hopper process cleanup", () => {
|
||||
it.skipIf(process.platform !== "linux").each(["startup", "ready"])(
|
||||
"reaps only its own children when the owner pipe closes during %s",
|
||||
(phase) => {
|
||||
const result = spawnSync(
|
||||
"/usr/bin/python3",
|
||||
[ownerLifetimeFixturePath, helperPath, phase],
|
||||
{ encoding: "utf8", timeout: 10_000 },
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
phase,
|
||||
children_cleaned: true,
|
||||
sentinel_alive: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform !== "linux")(
|
||||
"kills a child that ignores SIGTERM after the bounded timeout",
|
||||
() => {
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inspect the real Node launcher stdio with the production lifetime parser."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"hopper_demo_x11", Path(__file__).resolve().parents[2] / "scripts/hopper-demo-x11.py"
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
helper = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(helper)
|
||||
assert "--owner-stdin" in sys.argv
|
||||
print(json.dumps({"owner_alive": helper.OwnerLifetime(True).alive()}))
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the production runner with real children and a simulated X11 surface."""
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hopper_demo_x11", sys.argv[1])
|
||||
assert spec is not None and spec.loader is not None
|
||||
helper = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(helper)
|
||||
|
||||
|
||||
def child_command(receipt):
|
||||
return [
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import os,pathlib,time;"
|
||||
f"receipt=pathlib.Path({str(receipt)!r});"
|
||||
"pending=receipt.with_suffix('.pending');"
|
||||
"pending.write_text(str(os.getpid()));pending.replace(receipt);"
|
||||
"time.sleep(100)",
|
||||
]
|
||||
|
||||
|
||||
if len(sys.argv) > 3:
|
||||
phase, directory = sys.argv[2], Path(sys.argv[3])
|
||||
|
||||
def terminate_worker(*_):
|
||||
raise SystemExit(143)
|
||||
|
||||
# Failed assertions in the parent must still unwind run_launch's finally.
|
||||
signal.signal(signal.SIGTERM, terminate_worker)
|
||||
|
||||
class X11:
|
||||
def XOpenDisplay(self, *_):
|
||||
return 1
|
||||
|
||||
def XDefaultRootWindow(self, *_):
|
||||
return 1
|
||||
|
||||
def XDisplayWidth(self, *_):
|
||||
return 1280
|
||||
|
||||
def XDisplayHeight(self, *_):
|
||||
return 1024
|
||||
|
||||
def XCloseDisplay(self, *_):
|
||||
return 1
|
||||
|
||||
def start_xvfb(*_):
|
||||
child = subprocess.Popen(
|
||||
child_command(directory / "xvfb.pid"),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
return child, ":123", directory / "Xauthority", helper.CapturedStderr(child.stderr)
|
||||
|
||||
helper.digest = lambda _: next(iter(helper.SUPPORTED_HOPPER_SHA256))
|
||||
helper.start_xvfb = start_xvfb
|
||||
helper.ctypes.CDLL = lambda _: X11()
|
||||
helper.configure_x11 = lambda *_: None
|
||||
helper.windows = lambda *_: []
|
||||
socket = directory / "bridge.sock"
|
||||
if phase == "ready":
|
||||
socket.touch()
|
||||
raise SystemExit(helper.run_launch(argparse.Namespace(
|
||||
owner_stdin=True,
|
||||
hopper=sys.executable,
|
||||
socket=str(socket),
|
||||
command=child_command(directory / "hopper.pid"),
|
||||
)))
|
||||
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="rea-owner-lifetime-") as root:
|
||||
directory = Path(root)
|
||||
sentinel = subprocess.Popen([sys.executable, "-c", "import time;time.sleep(100)"])
|
||||
worker = subprocess.Popen(
|
||||
[sys.executable, __file__, sys.argv[1], sys.argv[2], root],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
receipts = [directory / "hopper.pid", directory / "xvfb.pid"]
|
||||
while not all(receipt.exists() for receipt in receipts):
|
||||
assert worker.poll() is None, worker.communicate()
|
||||
assert time.monotonic() < deadline, "owned children did not start"
|
||||
time.sleep(0.01)
|
||||
pids = [int(receipt.read_text()) for receipt in receipts]
|
||||
assert worker.poll() is None, "live owner did not retain the helper"
|
||||
assert worker.stdin is not None
|
||||
worker.stdin.close()
|
||||
assert worker.wait(timeout=5) == 0, worker.stderr.read()
|
||||
for pid in pids:
|
||||
assert not Path(f"/proc/{pid}").exists(), f"owned child {pid} survived EOF"
|
||||
assert sentinel.poll() is None, "unrelated process was stopped"
|
||||
print(json.dumps({"phase": sys.argv[2], "children_cleaned": True, "sentinel_alive": True}))
|
||||
finally:
|
||||
helper.stop_process(worker)
|
||||
helper.stop_process(sentinel)
|
||||
Reference in new issue
Block a user