Files
lix/subprojects/nix-eval-jobs/tests/test_eval.py
T
Maximilian Bosch 4c5efd4548 nix-eval-jobs: reenable tests, rework waitpid handling
Closes #703

The problem boils down to:

* A testcase with a segfaulting worker due to a stack overflow from the
  evaluation.

* When the controller encounters that the pipes are down, the worker's
  state is checked and a potential infrec is reported if it stopped with
  SIGSEGV.

* However, in a few cases, the worker didn't exit yet, but only had closed
  pipes (which is how the error is detected). In that case, a different
  error message is printed breaking the test.

This bug handling only affects error cases, so this race doesn't happen
on normal shutdown. To mitigate the problem a bit, the controller will
wait for a second and re-check the state of the worker process then.

While this should make the error reporting for end-users a little more
reliable, it's still no guarantee for a non-racy test. Hence, the test
asserts against both cases now and it passes if either the infrec error
or the "worker still running, but pipes are closed" error is returned.

Change-Id: Ifdc7a05bc86b6aecd5d03118d3e3ffc9affe1c5e
2025-03-14 19:50:07 +01:00

288 lines
8.7 KiB
Python
Executable File

#!/usr/bin/env python3
import json
import os
import subprocess
import pytest
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, Dict, List
TEST_ROOT = Path(__file__).parent.resolve()
# subprojects/nix-eval-jobs/tests
# PROJECT_ROOT = TEST_ROOT.parent.parent.parent
# BIN = PROJECT_ROOT.joinpath("outputs", "out", "bin", "nix-eval-jobs")
BIN = "nix-eval-jobs"
def check_gc_root(gcRootDir: str, drvPath: str):
"""
Make sure the expected GC root exists in the given dir
"""
link_name = os.path.basename(drvPath)
symlink_path = os.path.join(gcRootDir, link_name)
assert os.path.islink(symlink_path) and drvPath == os.readlink(symlink_path)
def evaluate(
tempdir: TemporaryDirectory,
expected_statuscode: int = 0,
extra_args: List[str] = [],
) -> tuple[Dict[str, Dict[str, Any]], str]:
if nixpkgs_path := os.getenv("NEJ_NIXPKGS_PATH"):
if "--flake" in extra_args:
extra_args.extend(["--override-input", "nixpkgs", f"path:{nixpkgs_path}"])
else:
extra_args.extend(["--arg", "pkgs", f"import {nixpkgs_path} {{}}"])
cmd = [
str(BIN),
"--gc-roots-dir",
tempdir,
"--meta",
"--extra-experimental-features",
"flakes",
] + extra_args
res = subprocess.run(
cmd,
cwd=TEST_ROOT.joinpath("assets"),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
assert res.returncode == expected_statuscode
print(res.stdout)
print(res.stderr)
return [json.loads(r) for r in res.stdout.split("\n") if r], res.stderr
def common_test(extra_args: List[str]) -> List[Dict[str, Any]]:
with TemporaryDirectory() as tempdir:
results, _ = evaluate(tempdir, 0, extra_args)
assert len(results) == 4
built_job = results[0]
assert built_job["attr"] == "builtJob"
assert built_job["name"] == "job1"
assert built_job["outputs"]["out"].startswith("/nix/store")
assert built_job["drvPath"].endswith(".drv")
assert built_job["meta"]["broken"] is False
check_gc_root(tempdir, built_job["drvPath"])
dotted_job = results[1]
assert dotted_job["attr"] == '"dotted.attr"'
assert dotted_job["attrPath"] == ["dotted.attr"]
check_gc_root(tempdir, dotted_job["drvPath"])
recurse_drv = results[2]
assert recurse_drv["attr"] == "recurse.drvB"
assert recurse_drv["name"] == "drvB"
check_gc_root(tempdir, recurse_drv["drvPath"])
substituted_job = results[3]
assert substituted_job["attr"] == "substitutedJob"
assert substituted_job["name"].startswith("hello-")
assert substituted_job["meta"]["broken"] is False
return results
def test_flake() -> None:
results = common_test(["--flake", ".#hydraJobs"])
for result in results:
assert "isCached" not in result
def test_query_cache_status() -> None:
results = common_test(["--flake", ".#hydraJobs", "--check-cache-status"])
# FIXME in the nix sandbox we cannot query binary caches
# this would need some local one
for result in results:
assert "isCached" in result
def test_expression() -> None:
results = common_test(["ci.nix"])
for result in results:
assert "isCached" not in result
with open(TEST_ROOT.joinpath("assets/ci.nix"), "r") as ci_nix:
common_test(["-E", ci_nix.read()])
def test_eval_error() -> None:
with TemporaryDirectory() as tempdir:
results, _ = evaluate(
tempdir,
0,
["--workers", "1", "--flake", ".#legacyPackages.x86_64-linux.brokenPkgs"],
)
assert len(results) == 1
attr = results[0]
assert attr["attr"] == "brokenPackage"
assert "this is an evaluation error" in attr["error"]
@pytest.mark.infiniterecursion
def test_recursion_error() -> None:
with TemporaryDirectory() as tempdir:
results, stderr = evaluate(
tempdir,
1,
[
"--workers",
"1",
"--flake",
".#legacyPackages.x86_64-linux.infiniteRecursionPkgs",
],
)
print(stderr)
# Closing pipes and exiting is not atomic, so it is possible
# that the worker is still up when the collector notices that
# the pipe is closed already. We mitigate this condition a bit
# by waiting a second and checking the state of the worker again,
# but assuming the infrec error is still racy.
# Hence, we assert that one of the two possible outcomes actually happen.
assert (
(
"packageWithInfiniteRecursion" in stderr
and "possible infinite recursion" in stderr
)
or
(
"worker pipe got closed" in stderr
)
)
def test_constituents() -> None:
with TemporaryDirectory() as tempdir:
results, _ = evaluate(
tempdir,
0,
[
"--workers",
"1",
"--flake",
".#legacyPackages.x86_64-linux.constituents.success",
"--constituents",
],
)
assert len(results) == 4
child = results[0]
assert child["attr"] == "anotherone"
assert "constituents" not in child
assert "namedConstituents" not in child
direct = results[1]
assert direct["attr"] == "direct_aggregate"
assert "constituents" in direct
assert "namedConstituents" not in direct
indirect = results[2]
assert indirect["attr"] == "indirect_aggregate"
assert "constituents" in indirect
assert "namedConstituents" not in indirect
mixed = results[3]
assert mixed["attr"] == "mixed_aggregate"
def absent_or_empty(f: str, d: dict) -> bool:
return f not in d or len(d[f]) == 0
assert absent_or_empty("namedConstituents", direct)
assert absent_or_empty("namedConstituents", indirect)
assert absent_or_empty("namedConstituents", mixed)
assert direct["constituents"][0].endswith("-job1.drv")
assert indirect["constituents"][0] == child["drvPath"]
assert mixed["constituents"][0].endswith("-job1.drv")
assert mixed["constituents"][1] == child["drvPath"]
assert "error" not in direct
assert "error" not in indirect
assert "error" not in mixed
check_gc_root(tempdir, direct["drvPath"])
check_gc_root(tempdir, indirect["drvPath"])
check_gc_root(tempdir, mixed["drvPath"])
def test_constituents_cycle() -> None:
with TemporaryDirectory() as tempdir:
results, _ = evaluate(
tempdir,
0,
[
"--workers",
"1",
"--flake",
".#legacyPackages.x86_64-linux.constituents.cycle",
"--constituents",
],
)
assert len(results) == 2
assert list(map(lambda x: x["name"], results)) == ["aggregate0", "aggregate1"]
for i in results:
assert i["error"] == "Dependency cycle: aggregate0 <-> aggregate1"
def test_constituents_error() -> None:
with TemporaryDirectory() as tempdir:
results, _ = evaluate(
tempdir,
0,
[
"--workers",
"1",
"--flake",
".#legacyPackages.x86_64-linux.constituents.failures",
"--constituents",
],
)
assert len(results) == 2
child = results[0]
assert child["attr"] == "doesnteval"
assert "error" in child
aggregate = results[1]
assert aggregate["attr"] == "aggregate"
assert "namedConstituents" not in aggregate
assert "doesntexist: does not exist\n" in aggregate["error"]
assert "constituents" in aggregate
def test_transitivity() -> None:
with TemporaryDirectory() as tempdir:
results, _ = evaluate(
tempdir,
0,
[
"--workers",
"1",
"--flake",
".#legacyPackages.x86_64-linux.constituents.transitive",
"--constituents",
],
)
assert len(results) == 3
job = results[0]
assert job["attr"] == "constituent"
assert "constituents" not in job
aggregate1 = results[1]
assert aggregate1["attr"] == "aggregate1"
aggregate0 = results[2]
assert aggregate0["attr"] == "aggregate0"
assert aggregate1["drvPath"] == aggregate0["constituents"][0]