diff --git a/src/opendox/cli.py b/src/opendox/cli.py index cbfb034..7d11a97 100644 --- a/src/opendox/cli.py +++ b/src/opendox/cli.py @@ -598,7 +598,13 @@ def cmd_generate_and_open(args: argparse.Namespace, *, opener=webbrowser.open) - url = serve_mod.server_url(httpd, "/index.html") print(f" serving {url}") print(f" snapshot {serve_mod.server_url(httpd, '/snapshot.json')}") - print(url) # the URL is ALWAYS printed on its own line + # The URL is ALWAYS printed on its own line, AND FLUSHED (plan 034 T056). + # Where standard output is a pipe or a file, Python buffers it by block, + # and the process is about to block in `serve_forever()`. So without the + # flush, a wrapper reading this line never sees it while the server runs, + # and it cannot learn an ephemeral port or tell that the server started. + # Measured at openDox-code#59 e3ef506a: zero lines in 20 s on a pipe. + print(url, flush=True) if not args.no_open: try: @@ -610,7 +616,7 @@ def cmd_generate_and_open(args: argparse.Namespace, *, opener=webbrowser.open) - httpd.server_close() return 0 - print(" serving until interrupted (Ctrl-C to stop)") + print(" serving until interrupted (Ctrl-C to stop)", flush=True) try: httpd.serve_forever() except KeyboardInterrupt: diff --git a/src/opendox/serve.py b/src/opendox/serve.py index 5ab4700..c642091 100644 --- a/src/opendox/serve.py +++ b/src/opendox/serve.py @@ -2216,7 +2216,12 @@ def serve( checkout_root=checkout_root)) httpd = build_server(web_dir, snapshot_path, checkout_root, host=host, port=port, quiet=quiet, actor=actor, **build_kwargs) - print(f"serving ideation dashboard at {server_url(httpd, '/index.html')}") + # FLUSHED before the process blocks (plan 034 T056): where standard + # output is a pipe or a file it is block-buffered, so an unflushed line + # never reaches a wrapper while the server runs, and the wrapper cannot + # learn an ephemeral port or tell that the server started. + print(f"serving ideation dashboard at {server_url(httpd, '/index.html')}", + flush=True) try: httpd.serve_forever() except KeyboardInterrupt: diff --git a/tests/standalone_child.py b/tests/standalone_child.py new file mode 100644 index 0000000..37d1fe5 --- /dev/null +++ b/tests/standalone_child.py @@ -0,0 +1,245 @@ +"""A lone openDox, as a CHILD PROCESS: `python -m ...` with neither +sibling importable (plan 034's T056, and T058's F7.2 case after it). + +A test that means "the verb, the way a user runs it" runs the verb in a process +of its own, through `python -m`, as #1144's falsifiers are written. This module +builds that child and reads it: + +* NEITHER SIBLING IS IMPORTABLE. The child's interpreter loads a + `sitecustomize` from a directory put first on `PYTHONPATH`. It installs a + meta-path finder that refuses `openxdox`, `ideation_dashboard`, `doc_health` + and `corpus_adapter_openxfactory`, whatever is installed, and drops any of + them that a `.pth` file imported before it ran. It records every refused name + in a log (`Child.refused()`). A case asserts that the log is EMPTY: a refused + import that some `except ImportError` swallowed would otherwise pass as a + degraded run. +* ITS STANDARD OUTPUT IS A PIPE WITH PYTHON'S DEFAULT BUFFERING, as any + wrapper that reads it sees it. `PYTHONUNBUFFERED` is taken out of the + child's environment on purpose, so a line the child prints and does not + flush before it blocks is a line a case never reads. +* It is read on threads while it runs (`Child.wait_for_line`), so a server that + never exits can still be asked where it serves, and then interrupted + (`Child.interrupt`, SIGINT, as Ctrl-C sends). +* CTRL-C REACHES IT AS IT WOULD AT A TERMINAL, whatever the runner's own + disposition. The same `sitecustomize` sets SIGINT back to Python's + KeyboardInterrupt handler. A runner started as a background job + (`nohup pytest ... &`) has SIGINT ignored, and every child would inherit + that, so an interrupted server would time out and the case would report the + runner, not the server (measured at openDox-code#66 a6e953ce). A child that + ignores SIGINT ITSELF, after startup, still does, and is killed at the + deadline. + +`fresh_repository()` is #1144's preamble: a fixture copied into a FRESH git +repository and committed as the fixture's own identity. + +A helper module, not a test module: it holds no case. A CREATED FILE, with no +carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import os +import queue +import re +import shutil +import signal +import subprocess +import sys +import threading +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +#: The four packages a neutral openDox must run without (#1144's F2.1). +SIBLINGS = ("openxdox", "ideation_dashboard", "doc_health", + "corpus_adapter_openxfactory") + +#: How long a child may take to print what a case waits for. Generation over a +#: fixture takes about a second; the margin is for a loaded CI runner. +START_DEADLINE_SECONDS = 120.0 + +#: How long a child may take to stop once interrupted, or to finish a run. +STOP_DEADLINE_SECONDS = 30.0 +RUN_DEADLINE_SECONDS = 300.0 + +#: The environment variable naming the child's refused-import log. +REFUSED_LOG_ENV = "OPENDOX_STANDALONE_CHILD_REFUSED" + +#: The `sitecustomize` every child loads. +_BLOCKER = f'''\ +import os +import signal +import sys + +# Ctrl-C as at a terminal: a runner started as a background job ignores +# SIGINT, and an ignored signal is inherited across exec (tests/standalone_child.py). +signal.signal(signal.SIGINT, signal.default_int_handler) + +_SIBLINGS = {SIBLINGS!r} + +for _name in list(sys.modules): + if _name.split(".")[0] in _SIBLINGS: + del sys.modules[_name] + + +class _RefuseTheSiblings: + """Neither sibling is importable in this process (plan 034 T056).""" + + def find_spec(self, name, path=None, target=None): + if name.split(".")[0] not in _SIBLINGS: + return None + log = os.environ.get({REFUSED_LOG_ENV!r}) + if log: + with open(log, "a", encoding="utf-8") as stream: + stream.write(name + "\\n") + raise ModuleNotFoundError( + f"No module named {{name!r}} (refused: a lone openDox has no " + f"sibling)", name=name) + + +sys.meta_path.insert(0, _RefuseTheSiblings()) +''' + + +def git(root: Path, *args: str) -> None: + """`git` in `root` as #1144's fixture identity, with no inherited `GIT_*` + variable and no user or system configuration.""" + env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + env.update({ + "GIT_AUTHOR_NAME": "fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid", + "GIT_COMMITTER_NAME": "fixture", + "GIT_COMMITTER_EMAIL": "fixture@example.invalid", + "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull, + }) + subprocess.run(["git", "-C", str(root), *args], check=True, + capture_output=True, env=env) + + +def fresh_repository(fixture: Path, parent: Path, *, + edits: dict[str, str] | None = None) -> Path: + """#1144's preamble: `fixture` copied into a FRESH repository under + `parent`, named as the fixture is, with `edits` (a document's new text, by + name) applied before the one commit.""" + root = parent / fixture.name + shutil.copytree(fixture, root) + for name, text in (edits or {}).items(): + (root / name).write_text(text, encoding="utf-8") + git(root, "-c", "init.defaultBranch=main", "init", "-q") + git(root, "add", "-A") + git(root, "commit", "-qm", "fixture") + return root + + +class Child: + """One `python -m ...` child, with the siblings refused and its + standard output a buffered pipe. `workdir` holds the blocker and the log.""" + + def __init__(self, workdir: Path, module: str, *args: str) -> None: + blocker = workdir / "sibling-blocker" + blocker.mkdir(parents=True, exist_ok=True) + (blocker / "sitecustomize.py").write_text(_BLOCKER, encoding="utf-8") + self.refused_log = workdir / "refused-imports.log" + env = dict(os.environ) + env.pop("PYTHONUNBUFFERED", None) + env["PYTHONPATH"] = os.pathsep.join( + [str(blocker), *filter(None, [env.get("PYTHONPATH")])]) + env[REFUSED_LOG_ENV] = str(self.refused_log) + self.argv = [sys.executable, "-m", module, *args] + verb = args[0] if args and not args[0].startswith("-") else "" + self.label = f"python -m {module} {verb}".strip() + self.process = subprocess.Popen( + self.argv, cwd=ROOT, env=env, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, encoding="utf-8", + errors="replace") + self.lines: queue.Queue[str | None] = queue.Queue() + self.stdout: list[str] = [] + self.stderr: list[str] = [] + self._pumps = [ + threading.Thread(target=self._pump, args=(self.process.stdout, True), + daemon=True), + threading.Thread(target=self._pump, args=(self.process.stderr, False), + daemon=True), + ] + for pump in self._pumps: + pump.start() + + def _pump(self, stream, is_stdout: bool) -> None: + for line in stream: + (self.stdout if is_stdout else self.stderr).append(line) + if is_stdout: + self.lines.put(line) + if is_stdout: + self.lines.put(None) + + def wait_for_line(self, pattern: re.Pattern[str]) -> re.Match[str]: + """The first standard-output line matching `pattern`, read while the + child runs. Fails, naming what the child said, if it exits first or + the deadline passes.""" + deadline = time.monotonic() + START_DEADLINE_SECONDS + while time.monotonic() < deadline: + try: + line = self.lines.get(timeout=0.25) + except queue.Empty: + continue + if line is None: + break + match = pattern.search(line) + if match: + return match + raise AssertionError( + f"{self.label} never printed a line matching " + f"{pattern.pattern!r} on its (buffered) standard output while it " + f"ran: exit status {self.process.poll()}, standard output " + f"{''.join(self.stdout)!r}, standard error {self.stderr_text()[-2000:]!r}") + + def wait(self) -> int: + """Run the child to completion and answer its exit status.""" + try: + return self.process.wait(timeout=RUN_DEADLINE_SECONDS) + finally: + self.kill() + self._join() + + def interrupt(self) -> int: + """Send SIGINT, as Ctrl-C would, and answer the exit status. + + A child that does not stop within `STOP_DEADLINE_SECONDS` is KILLED + before its pipes are joined, and the timeout is raised (Copilot at + openDox-code#66 1597511d, r4139607689). Its readers block until the + pipes close, so joining first would hold the caller for two more + deadlines on exactly the path, an interrupt that is ignored, this + helper exists to report.""" + self.process.send_signal(signal.SIGINT) + try: + return self.process.wait(timeout=STOP_DEADLINE_SECONDS) + finally: + self.kill() + self._join() + + def kill(self) -> None: + if self.process.poll() is None: + self.process.kill() + self.process.wait(timeout=STOP_DEADLINE_SECONDS) + + def _join(self) -> None: + for pump in self._pumps: + pump.join(timeout=STOP_DEADLINE_SECONDS) + + def stdout_text(self) -> str: + return "".join(self.stdout) + + def stderr_text(self) -> str: + return "".join(self.stderr) + + def refused(self) -> list[str]: + """Every sibling name the child tried to import, in order.""" + if not self.refused_log.exists(): + return [] + return self.refused_log.read_text(encoding="utf-8").split() + + +def run_module(workdir: Path, module: str, *args: str) -> tuple[Child, int]: + """A child run to completion: `(child, exit status)`.""" + child = Child(workdir, module, *args) + return child, child.wait() diff --git a/tests/test_standalone_generate_path.py b/tests/test_standalone_generate_path.py new file mode 100644 index 0000000..0a95efc --- /dev/null +++ b/tests/test_standalone_generate_path.py @@ -0,0 +1,382 @@ +"""The standalone generate path, end to end, through the verbs themselves: +plan 034's T056 (#1144's 5.1, in part). + +T054 tests the neutral projection in process, and T055 routes the verbs +through the seams. What neither shows is the path a user runs: +`python -m opendox.cli generate` and `generate-and-open`, in a process of their +own, with neither sibling importable, until the server STARTS and answers. That +is what research R7 measured as refused, and this file holds the lifted limit: + +1. F5.3, #1144's falsifier for Group 5, run the way it is written: + `python -m opendox.cli generate` over a fresh repository copied from T050's + `tests/fixtures/plain-documents`. The snapshot is not empty, it is the + neutral kind, and none of openxFactory's declared vocabulary is in a string + value of it. +2. The verb's half of spec.md's `stage:` edge case. Over a copy of T050's + fixture in which one document declares a `stage:` value outside the six + role keys, the verb reports it, naming the document, the value and the six + keys, and the snapshot it writes reads that document as a source. T054 + tests the projection's half in process. +3. `python -m opendox.cli generate-and-open --no-open` STARTS a server, which + answers `/index.html`, `/snapshot.json`, `/capabilities` and `/source/`, + refuses `/source/.git/config`, and stops on an interrupt with status 0. +4. `python -m opendox.serve`, the server's own entry point, starts and answers + the same way. +5. The harness itself: a child that ignores the interrupt is killed at the + deadline, and the timeout is raised, so a server that will not stop is + reported rather than waited out. And a child stops on the interrupt even + when the RUNNER ignores SIGINT, as a suite started as a background job + (`nohup ... &`) does, so cases 3 and 4 test the server, not how the suite + was launched. + +HOW "NEITHER SIBLING IS IMPORTABLE" IS MADE TRUE. Each run is a real child +process, `python -m ...`, built by `tests/standalone_child.py`. The child's +interpreter refuses `openxdox`, `ideation_dashboard`, `doc_health` and +`corpus_adapter_openxfactory` at its first finder, whatever is installed, and +logs every refused name. Each case asserts that the log is EMPTY. A refused +import that some `except ImportError` swallowed would otherwise pass as a +degraded run, so the case holds both halves: the siblings cannot be imported, +and nothing on the path tries to. + +THE CHILD'S STANDARD OUTPUT IS A PIPE, with Python's default buffering, as +any wrapper that reads the URL sees it. A server that printed its URL and then +blocked in `serve_forever()` without flushing never delivered that line on a +pipe, so a caller could neither learn an ephemeral port nor tell that the +server had started (measured at openDox-code#59 `e3ef506a`: zero lines in 20 +seconds). T056 flushes it in both entry points, and cases 3 and 4 fail +without that. + +NOT HERE: F10.1's run through a plain install, with the console script and no +`--local`, arrives in phase 3 (T070, and T077 as batch H amends it). + +A CREATED FILE: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import http.client +import json +import re +import signal +import socket +import subprocess +import textwrap +import time +from pathlib import Path + +import pytest + +import standalone_child +from standalone_child import Child, fresh_repository, run_module + +ROOT = Path(__file__).resolve().parent.parent +PLAIN_DOCUMENTS = ROOT / "tests" / "fixtures" / "plain-documents" +NEUTRAL = "opendox-snapshot" + +#: The six station role keys, in the order the neutral contract lists them. +ROLE_KEYS = ("source", "grouping", "candidate", "selection", "submission", + "completion") + +#: F5.3's declared vocabulary, verbatim: the eight lifecycle `Status:` words +#: and the change/spec/delta nouns, as #1144's Group 5 falsifier spells them. +F53_WORDS = ["brainstorm", "staged", "draft", "ratified", "standard", + "superseded", "retired", "record", "openspec", "proposal.md", + "tasks.md", "design.md", "added requirements", + "modified requirements"] + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +def _fresh_repository(tmp_path: Path, *, edits: dict[str, str] | None = None) -> Path: + """F5.3's preamble over T050's fixture (`standalone_child.fresh_repository`).""" + return fresh_repository(PLAIN_DOCUMENTS, tmp_path, edits=edits) + + +def _run(tmp_path: Path, module: str, *args: str) -> tuple[Child, int]: + return run_module(tmp_path, module, *args) + + +def _get(base: tuple[str, int], path: str) -> tuple[int, str, bytes]: + connection = http.client.HTTPConnection(*base, timeout=30) + try: + connection.request("GET", path) + response = connection.getresponse() + return (response.status, response.getheader("Content-Type") or "", + response.read()) + finally: + connection.close() + + +def _string_values(value): + """Every string VALUE in a JSON document: keys are the product's own + structure, as F5.3 reads it.""" + if isinstance(value, dict): + for item in value.values(): + yield from _string_values(item) + elif isinstance(value, list): + for item in value: + yield from _string_values(item) + elif isinstance(value, str): + yield value + + +def _leaks(snapshot: dict) -> list[str]: + """F5.3's assertion, verbatim in substance: the declared words found as + whole words in a lower-cased string value.""" + pattern = re.compile(r"\b(" + "|".join(re.escape(w) for w in F53_WORDS) + r")\b") + return sorted({m.group(1) for text in _string_values(snapshot) + for m in pattern.finditer(text.lower())}) + + +def _assert_the_server_answers(base: tuple[str, int], written: Path, + repo: Path) -> None: + """The core routes a standalone server answers, from openDox's own + registry and source.""" + status, kind, body = _get(base, "/index.html") + assert status == 200 and " None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.settimeout(5) + assert probe.connect_ex(base) != 0, f"{base} still accepts connections" + + +_URL = re.compile(r"^(http://([0-9.]+):([0-9]+))/index\.html$") +_SERVING = re.compile(r"^ serving until interrupted \(Ctrl-C to stop\)$") +_SERVE_URL = re.compile(r"^serving ideation dashboard at " + r"(http://([0-9.]+):([0-9]+))/index\.html$") + + +# --------------------------------------------------------------------------- +# 1 — F5.3, through the module +# --------------------------------------------------------------------------- + +def test_F5_3_generate_through_the_module_writes_the_neutral_snapshot(tmp_path) -> None: + """#1144's F5.3, as written: `python -m opendox.cli generate` over a fresh + copy of T050's fixture, with neither sibling importable.""" + repo = _fresh_repository(tmp_path) + out = tmp_path / "snap.json" + child, status = _run(tmp_path, "opendox.cli", "generate", + "--repo-root", str(repo), "--repository", "fixture", + "--output", str(out)) + assert status == 0, child.stderr_text() + assert child.refused() == [], child.refused() + snapshot = json.loads(out.read_text(encoding="utf-8")) + assert snapshot["documents"], "snapshot is empty" + assert snapshot["kind"] == NEUTRAL + assert snapshot["repository"] == "fixture" + assert _leaks(snapshot) == [], ( + f"openxFactory's vocabulary leaked into the neutral projection: " + f"{_leaks(snapshot)}") + assert f"wrote {out}" in "".join(child.stdout) + assert "notice:" not in child.stderr_text() + + +# --------------------------------------------------------------------------- +# 2 — the verb's half of the `stage:` edge case +# --------------------------------------------------------------------------- + +def test_the_verb_reports_a_stage_outside_the_six_and_reads_it_as_a_source( + tmp_path) -> None: + """spec.md's edge case, through `python -m opendox.cli generate`: the value + is not a declaration, the verb names the document, the value and the six + keys, and the snapshot reads the document as a source.""" + document = "candidate-toolshed-rebuild.md" + original = (PLAIN_DOCUMENTS / document).read_text(encoding="utf-8") + assert original.startswith("stage: candidate\n"), original[:40] + repo = _fresh_repository( + tmp_path, edits={document: original.replace( + "stage: candidate\n", "stage: someday\n", 1)}) + out = tmp_path / "snap.json" + child, status = _run(tmp_path, "opendox.cli", "generate", + "--repo-root", str(repo), "--repository", "fixture", + "--output", str(out)) + assert status == 0, child.stderr_text() + assert child.refused() == [], child.refused() + notices = [line for line in child.stderr_text().splitlines() + if line.startswith("notice:")] + assert len(notices) == 1, child.stderr_text() + notice = notices[0] + assert document in notice + assert "'someday'" in notice + # The six keys as ONE rendered list: `candidate` is in the document's name + # and `source` in "read as a source", so a word-by-word check passed a + # list that left both out (Copilot at openDox-code#66 149d7295, + # r4147767447). + listed = "(" + ", ".join(ROLE_KEYS) + ")" + assert listed in notice, (listed, notice) + assert "read as a source" in notice + snapshot = json.loads(out.read_text(encoding="utf-8")) + [entry] = [d for d in snapshot["documents"] if d["path"] == document] + assert entry["stage"] == "source" + carried = [text for text in _string_values(snapshot) if "someday" in text] + assert carried == [], ( + f"the undeclared value reached the snapshot, inside {carried}") + assert {d["stage"] for d in snapshot["documents"]} <= set(ROLE_KEYS) + + +def test_the_unedited_fixture_declares_that_document_a_candidate(tmp_path) -> None: + """The control for the case above: the same document, as T050 ships it, + is a declared candidate and draws no notice. So the reading above is the + out-of-six value's doing.""" + repo = _fresh_repository(tmp_path) + out = tmp_path / "snap.json" + child, status = _run(tmp_path, "opendox.cli", "generate", + "--repo-root", str(repo), "--repository", "fixture", + "--output", str(out)) + assert status == 0, child.stderr_text() + snapshot = json.loads(out.read_text(encoding="utf-8")) + [entry] = [d for d in snapshot["documents"] + if d["path"] == "candidate-toolshed-rebuild.md"] + assert entry["stage"] == "candidate" + assert "notice:" not in child.stderr_text() + + +# --------------------------------------------------------------------------- +# 3 — `generate-and-open` STARTS the server (research R7's limit, lifted) +# --------------------------------------------------------------------------- + +def test_generate_and_open_starts_a_server_that_answers_with_no_sibling(tmp_path) -> None: + """`python -m opendox.cli generate-and-open --no-open`, with no + `--no-serve`: the server starts, says where on a buffered pipe, answers + the core routes, and stops on an interrupt with status 0. + + Both lines it prints before blocking in `serve_forever()`, the URL and + "serving until interrupted", are read WHILE IT RUNS, before the + interrupt: after it, Python's exit flush would deliver an unflushed line + anyway, and the case would not tell (Copilot at openDox-code#66 + e3574774, r4146289331).""" + repo = _fresh_repository(tmp_path) + run_dir = tmp_path / "run" + child = Child(tmp_path, "opendox.cli", "generate-and-open", + "--repo-root", str(repo), "--repository", "fixture", + "--no-open", "--port", "0", "--run-dir", str(run_dir)) + try: + match = child.wait_for_line(_URL) + base = (match.group(2), int(match.group(3))) + assert child.process.poll() is None, "the server exited after printing its URL" + child.wait_for_line(_SERVING) + assert child.process.poll() is None, "the server exited after saying it serves" + _assert_the_server_answers(base, run_dir / "snapshot.json", repo) + assert child.interrupt() == 0, child.stderr_text() + finally: + child.kill() + _assert_the_port_is_closed(base) + assert child.refused() == [], child.refused() + + +# --------------------------------------------------------------------------- +# 4 — the server's own entry point starts the same way +# --------------------------------------------------------------------------- + +def test_serve_main_starts_a_server_that_answers_with_no_sibling(tmp_path) -> None: + """`python -m opendox.serve` over a snapshot `generate` wrote: it starts, + announces its URL on a buffered pipe, answers, and stops on an interrupt.""" + repo = _fresh_repository(tmp_path) + out = tmp_path / "out" / "snapshot.json" + generated, status = _run(tmp_path, "opendox.cli", "generate", + "--repo-root", str(repo), "--repository", "fixture", + "--output", str(out), "--no-validate") + assert status == 0, generated.stderr_text() + child = Child(tmp_path, "opendox.serve", "--snapshot", str(out), + "--checkout-root", str(repo), "--port", "0") + try: + match = child.wait_for_line(_SERVE_URL) + base = (match.group(2), int(match.group(3))) + _assert_the_server_answers(base, out, repo) + assert child.interrupt() == 0, child.stderr_text() + finally: + child.kill() + _assert_the_port_is_closed(base) + assert child.refused() == [], child.refused() + + +# --------------------------------------------------------------------------- +# 5 — the harness itself: an ignored interrupt is reported, not waited out +# --------------------------------------------------------------------------- + +def test_a_child_that_ignores_the_interrupt_is_killed_at_the_deadline( + tmp_path, monkeypatch) -> None: + """The regression path cases 3 and 4 guard: a server that does not stop + on Ctrl-C. `Child.interrupt()` raises at its deadline with the child + already killed, so the caller is not held while its pipes drain.""" + monkeypatch.setattr(standalone_child, "STOP_DEADLINE_SECONDS", 1.0) + blocker = tmp_path / "sibling-blocker" + blocker.mkdir() + (blocker / "t056_ignores_sigint.py").write_text(textwrap.dedent(""" + import signal, time + signal.signal(signal.SIGINT, signal.SIG_IGN) + print("ready", flush=True) + time.sleep(600) + """), encoding="utf-8") + child = Child(tmp_path, "t056_ignores_sigint") + try: + child.wait_for_line(re.compile(r"^ready$")) + started = time.monotonic() + with pytest.raises(subprocess.TimeoutExpired): + child.interrupt() + assert child.process.poll() is not None, "the child outlived its deadline" + assert time.monotonic() - started < 10 + finally: + child.kill() + + +def test_a_child_stops_on_the_interrupt_even_when_the_runner_ignores_it( + tmp_path, monkeypatch) -> None: + """A runner started as a background job ignores SIGINT: POSIX starts an + asynchronous command with SIGINT and SIGQUIT ignored when job control is + off, and `nohup ... &` from a script is such a command. An ignored signal + stays ignored across `exec`, and Python installs its KeyboardInterrupt + handler only where SIGINT was not ignored. So every child such a runner + starts would ignore the interrupt, and cases 3 and 4 would time out + having tested nothing about the server. Measured at openDox-code#66 + a6e953ce: both failed that way under `nohup pytest ... &`, and passed + run in the foreground. + + The case builds that runner in process. SIGINT is ignored while the child + is started, and restored at once. The child, a module that sleeps until + interrupted, must still stop on the interrupt and exit 0.""" + monkeypatch.setattr(standalone_child, "STOP_DEADLINE_SECONDS", 10.0) + blocker = tmp_path / "sibling-blocker" + blocker.mkdir() + (blocker / "t056_waits_for_sigint.py").write_text(textwrap.dedent(""" + import time + print("ready", flush=True) + try: + time.sleep(600) + except KeyboardInterrupt: + print("interrupted", flush=True) + raise SystemExit(0) + """), encoding="utf-8") + previous = signal.signal(signal.SIGINT, signal.SIG_IGN) + try: + child = Child(tmp_path, "t056_waits_for_sigint") + finally: + if previous is not None: + signal.signal(signal.SIGINT, previous) + try: + child.wait_for_line(re.compile(r"^ready$")) + assert child.interrupt() == 0, child.stderr_text() + finally: + child.kill() + assert child.stdout_text().splitlines() == ["ready", "interrupted"] + assert signal.getsignal(signal.SIGINT) is previous