| 1 | """Robustness oracles: fault-fired accounting, torn worktrees, actionability, disk fill, row validation."""
|
| 2 |
|
| 3 | from __future__ import annotations
|
| 4 |
|
| 5 | import json
|
| 6 | import os
|
| 7 | import platform
|
| 8 | import shutil
|
| 9 | import subprocess
|
| 10 | import sys
|
| 11 | import tempfile
|
| 12 | import unittest
|
| 13 | from pathlib import Path
|
| 14 |
|
| 15 | ROOT = Path(__file__).resolve().parents[1]
|
| 16 | sys.path.insert(0, str(ROOT / "suites" / "robustness"))
|
| 17 | sys.path.insert(0, str(ROOT / "scripts"))
|
| 18 |
|
| 19 | import run_robustness # noqa: E402
|
| 20 | from robustness import FAULT_NOT_FIRED, HARNESS_ERROR, HELD, VIOLATED, crashpoints, known_bugs # noqa: E402
|
| 21 | from robustness.concurrency import reported_pushed_head # noqa: E402
|
| 22 | from robustness.crash import run_time_point, run_trial, time_fault # noqa: E402
|
| 23 | from robustness.engine import actionable, classify, torn_state, torn_verdict, torn_worktree # noqa: E402
|
| 24 | from robustness.fixtures import build_git # noqa: E402
|
| 25 | from robustness.procs import Result, run_killed_after # noqa: E402
|
| 26 | from robustness.readers import worktree_manifest # noqa: E402
|
| 27 |
|
| 28 | from oakbench.rows import SKIP_RETURNCODE, validate_rows # noqa: E402
|
| 29 | from oakbench.subjects import which_measured_git # noqa: E402
|
| 30 |
|
| 31 | GIT = which_measured_git()
|
| 32 |
|
| 33 |
|
| 34 | def result(rc, stdout="", stderr=""):
|
| 35 | return Result(["x"], rc, stdout, stderr, 1.0)
|
| 36 |
|
| 37 |
|
| 38 | def f(tag):
|
| 39 | return {"kind": "file", "size": len(tag), "sha256": tag, "executable": False}
|
| 40 |
|
| 41 |
|
| 42 | class FaultFiredTests(unittest.TestCase):
|
| 43 | def test_not_fired_is_never_held(self):
|
| 44 | self.assertEqual(classify({"a": True}, fault_fired=False), FAULT_NOT_FIRED)
|
| 45 | self.assertEqual(classify({"a": False}, fault_fired=False), FAULT_NOT_FIRED)
|
| 46 | self.assertEqual(classify({"a": True}, fault_fired=True), HELD)
|
| 47 | self.assertEqual(classify({"a": True}, fault_fired=None), HELD)
|
| 48 | self.assertEqual(classify({"a": True}, harness_error="x", fault_fired=False), HARNESS_ERROR)
|
| 49 |
|
| 50 | def test_reader_exception_is_harness_error_not_violation(self):
|
| 51 | self.assertEqual(classify({"a": None}, reader_errors=["reader error: boom"]), HARNESS_ERROR)
|
| 52 | # an independent subject failure still wins
|
| 53 | self.assertEqual(classify({"repo_opens": False, "a": None}, reader_errors=["x"]), VIOLATED)
|
| 54 |
|
| 55 | def test_process_that_exits_before_the_kill_is_not_fired(self):
|
| 56 | with tempfile.TemporaryDirectory() as temp:
|
| 57 | res = run_killed_after(["/bin/sh", "-c", "exit 0"], Path(temp), dict(os.environ), 2.0)
|
| 58 | self.assertEqual(res.returncode, 0)
|
| 59 |
|
| 60 | class Stub:
|
| 61 | def op_argv(self):
|
| 62 | return ["/bin/sh", "-c", "exit 0"]
|
| 63 |
|
| 64 | def op_cwd(self):
|
| 65 | return Path(temp)
|
| 66 |
|
| 67 | class adapter: # noqa: N801
|
| 68 | @staticmethod
|
| 69 | def env():
|
| 70 | return dict(os.environ)
|
| 71 |
|
| 72 | _, fired, point = time_fault(1.0)(Stub())
|
| 73 | self.assertFalse(fired)
|
| 74 | self.assertEqual(point, "t=1000.0ms")
|
| 75 |
|
| 76 | def test_process_killed_mid_run_is_fired(self):
|
| 77 | with tempfile.TemporaryDirectory() as temp:
|
| 78 | res = run_killed_after(["/bin/sleep", "5"], Path(temp), dict(os.environ), 0.05)
|
| 79 | self.assertEqual(res.returncode, -9)
|
| 80 | self.assertTrue(res.killed_by_harness)
|
| 81 |
|
| 82 |
|
| 83 | @unittest.skipUnless(GIT and shutil.which(GIT), "real git required")
|
| 84 | class NotFiredEndToEndTests(unittest.TestCase):
|
| 85 | def test_git_commit_that_finishes_before_the_kill_is_fault_not_fired(self):
|
| 86 | with tempfile.TemporaryDirectory() as temp:
|
| 87 | temp = Path(temp)
|
| 88 | fx = build_git(GIT, "commit", temp / "fx")
|
| 89 | from robustness.crash import calibrate
|
| 90 |
|
| 91 | calib = calibrate(fx, GIT, temp / "trials", None)
|
| 92 | self.assertTrue(calib.ok, calib.detail)
|
| 93 | row = run_trial(fx, GIT, temp / "trials", "late", calib, time_fault(60.0),
|
| 94 | lambda trial, c: shutil.rmtree(trial.dir, ignore_errors=True))
|
| 95 | self.assertIs(row["fault_fired"], False)
|
| 96 | self.assertEqual(row["classification"], FAULT_NOT_FIRED)
|
| 97 | self.assertIn("did not fire", row["skip_reason"])
|
| 98 | args = run_robustness.parse_args(["--subjects", "git", "--results", str(temp / "r"), "--workdir",
|
| 99 | str(temp / "w"), "--bench-id", "20260929T000000Z", "--git-bin", GIT])
|
| 100 | runner = run_robustness.Runner(args)
|
| 101 | runner.versions = {"git": "git test"}
|
| 102 | emitted = runner.emit("git", "crash_time_v1", "commit", "crash_time", row)
|
| 103 | self.assertEqual(emitted["returncode"], SKIP_RETURNCODE)
|
| 104 | self.assertTrue(emitted["skipped"])
|
| 105 | self.assertEqual(validate_rows([emitted], "robustness"), [])
|
| 106 |
|
| 107 | def test_time_point_re_aims_from_observed_duration(self):
|
| 108 | with tempfile.TemporaryDirectory() as temp:
|
| 109 | temp = Path(temp)
|
| 110 | fx = build_git(GIT, "commit", temp / "fx")
|
| 111 | from robustness.crash import calibrate
|
| 112 |
|
| 113 | calib = calibrate(fx, GIT, temp / "trials", None)
|
| 114 | calib.elapsed_ms = 120000.0 # a stale calibration: the first kill lands long after the op
|
| 115 | row = run_time_point(fx, GIT, temp / "trials", "reaim", calib, 0.5,
|
| 116 | lambda trial, c: shutil.rmtree(trial.dir, ignore_errors=True))
|
| 117 | attempts = row["observations"]["time_attempts"]
|
| 118 | self.assertIs(attempts[0]["fired"], False)
|
| 119 | self.assertGreater(len(attempts), 1)
|
| 120 | self.assertLess(attempts[1]["delay_ms"], attempts[0]["delay_ms"])
|
| 121 | if row["fault_fired"] is False:
|
| 122 | self.assertEqual(row["classification"], FAULT_NOT_FIRED)
|
| 123 | else:
|
| 124 | self.assertIn(row["classification"], (HELD, VIOLATED))
|
| 125 |
|
| 126 |
|
| 127 | class TornWorktreeTests(unittest.TestCase):
|
| 128 | PRE = {"a": f("a0"), "b": f("b0"), "c": f("c0")}
|
| 129 | POST = {"a": f("a1"), "b": f("b1"), "c": f("c0"), "n": f("n1")}
|
| 130 |
|
| 131 | def test_split(self):
|
| 132 | wt = {"a": f("a1"), "b": f("b0"), "c": f("c0"), "a.tmp-1": f("x")}
|
| 133 | split = torn_worktree(wt, self.PRE, self.POST)
|
| 134 | self.assertEqual(split["op_output"], ["a"])
|
| 135 | self.assertEqual(split["pre_side"], ["b", "n"])
|
| 136 | self.assertEqual(split["stray"], ["a.tmp-1"])
|
| 137 |
|
| 138 | def test_states(self):
|
| 139 | pre_split = torn_worktree(dict(self.PRE), self.PRE, self.POST)
|
| 140 | post_split = torn_worktree(dict(self.POST), self.PRE, self.POST)
|
| 141 | self.assertFalse(torn_state(pre_split, "pre"))
|
| 142 | self.assertFalse(torn_state(post_split, "post"))
|
| 143 | # every file already on the target side while the ref still names the old side
|
| 144 | self.assertTrue(torn_state(post_split, "pre"))
|
| 145 | # ref moved but the worktree lags
|
| 146 | self.assertTrue(torn_state(pre_split, "post"))
|
| 147 | mixed = torn_worktree({"a": f("a1"), "b": f("b0"), "c": f("c0")}, self.PRE, self.POST)
|
| 148 | self.assertTrue(torn_state(mixed, "pre"))
|
| 149 | self.assertTrue(torn_state(mixed, None))
|
| 150 | # user edits to files the op does not touch are not the op's output
|
| 151 | user = torn_worktree({**self.PRE, "c": f("mine")}, self.PRE, self.POST)
|
| 152 | self.assertFalse(torn_state(user, "pre"))
|
| 153 |
|
| 154 | def _verdict(self, changes, in_progress, head="old"):
|
| 155 | class Adapter:
|
| 156 | def head(self, repo):
|
| 157 | return head
|
| 158 |
|
| 159 | def status_state(self, repo):
|
| 160 | return changes, in_progress, result(0 if changes is not None else 4)
|
| 161 |
|
| 162 | class T:
|
| 163 | adapter = Adapter()
|
| 164 |
|
| 165 | from unittest import mock
|
| 166 | with mock.patch("robustness.readers.worktree_manifest",
|
| 167 | return_value={"a": f("a1"), "b": f("b0"), "c": f("c0")}):
|
| 168 | return torn_verdict(T(), Path("."), self.PRE, self.POST, "old")
|
| 169 |
|
| 170 | def test_partial_output_presented_as_user_changes_fails(self):
|
| 171 | ok, detail = self._verdict(True, False)
|
| 172 | self.assertIs(ok, False)
|
| 173 | self.assertEqual(detail["torn"], "PARTIAL_OP_OUTPUT_AS_USER_CHANGES")
|
| 174 | self.assertEqual(detail["ref_side"], "pre")
|
| 175 |
|
| 176 | def test_flagged_in_progress_or_refused_passes(self):
|
| 177 | self.assertIs(self._verdict(True, True)[0], True)
|
| 178 | self.assertIs(self._verdict(None, False)[0], True)
|
| 179 |
|
| 180 |
|
| 181 | @unittest.skipUnless(GIT and shutil.which(GIT), "real git required")
|
| 182 | class TornWorktreeRealGitTests(unittest.TestCase):
|
| 183 | def test_half_switched_git_worktree_is_torn_but_merge_head_is_not(self):
|
| 184 | with tempfile.TemporaryDirectory() as temp:
|
| 185 | temp = Path(temp)
|
| 186 | fx = build_git(GIT, "switch", temp / "fx")
|
| 187 | client = fx.pristine / "client"
|
| 188 | pre = worktree_manifest(client)
|
| 189 | from robustness.engine import Trial
|
| 190 |
|
| 191 | trial = Trial(fx, GIT, temp / "trials", "torn")
|
| 192 | trial.adapter.must([GIT, "switch", "-q", fx.meta["target"]], trial.client)
|
| 193 | post = worktree_manifest(trial.client)
|
| 194 | trial.adapter.must([GIT, "switch", "-q", fx.branch], trial.client)
|
| 195 | # simulate a crash mid-checkout: some files already hold the target side, HEAD is old
|
| 196 | for name in sorted(p for p in post if pre.get(p) != post.get(p))[:5]:
|
| 197 | src = subprocess.run([GIT, "show", f"{fx.meta['target']}:{name}"], cwd=trial.client,
|
| 198 | capture_output=True, check=True).stdout
|
| 199 | (trial.client / name).parent.mkdir(parents=True, exist_ok=True)
|
| 200 | (trial.client / name).write_bytes(src)
|
| 201 | ok, detail = torn_verdict(trial, trial.client, pre, post, fx.meta["pre_head"])
|
| 202 | self.assertIs(ok, False, detail)
|
| 203 | (trial.client / ".git" / "MERGE_HEAD").write_text(fx.meta["pre_head"] + "\n")
|
| 204 | ok, detail = torn_verdict(trial, trial.client, pre, post, fx.meta["pre_head"])
|
| 205 | self.assertIs(ok, True, detail)
|
| 206 | trial.cleanup()
|
| 207 |
|
| 208 |
|
| 209 | @unittest.skipUnless(GIT, "git required")
|
| 210 | class GitStaleLockTests(unittest.TestCase):
|
| 211 | """M1: git locks that make the next commit refuse count as 'in progress', like index.lock.
|
| 212 |
|
| 213 | Synthetic, interposer-free: a real repo with a lock file planted, and real
|
| 214 | `git commit` confirming which locks actually block."""
|
| 215 |
|
| 216 | def repo(self, temp: Path) -> Path:
|
| 217 | repo = temp / "r"
|
| 218 | env = {**os.environ, "GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_NOSYSTEM": "1"}
|
| 219 | for argv in (["init", "-q", "-b", "main", str(repo)],):
|
| 220 | subprocess.run([GIT, *argv], check=True, env=env)
|
| 221 | for argv in (["config", "user.email", "a@b"], ["config", "user.name", "a"]):
|
| 222 | subprocess.run([GIT, *argv], cwd=repo, check=True, env=env)
|
| 223 | (repo / "f").write_text("a\n")
|
| 224 | subprocess.run([GIT, "add", "f"], cwd=repo, check=True, env=env)
|
| 225 | subprocess.run([GIT, "commit", "-qm", "a"], cwd=repo, check=True, env=env)
|
| 226 | subprocess.run([GIT, "branch", "other"], cwd=repo, check=True, env=env)
|
| 227 | subprocess.run([GIT, "tag", "t1"], cwd=repo, check=True, env=env)
|
| 228 | self.env = env
|
| 229 | return repo
|
| 230 |
|
| 231 | def test_blocking_locks_match_what_git_commit_refuses(self):
|
| 232 | from robustness.adapters import GitAdapter
|
| 233 |
|
| 234 | blocking = ("index.lock", "HEAD.lock", "refs/heads/main.lock")
|
| 235 | harmless = ("config.lock", "refs/heads/other.lock", "refs/tags/t1.lock", "ORIG_HEAD.lock")
|
| 236 | with tempfile.TemporaryDirectory() as temp:
|
| 237 | repo = self.repo(Path(temp))
|
| 238 | adapter = GitAdapter(GIT, Path(temp) / "home")
|
| 239 | for n, lock in enumerate(blocking + harmless):
|
| 240 | path = repo / ".git" / lock
|
| 241 | path.parent.mkdir(parents=True, exist_ok=True)
|
| 242 | path.write_text("")
|
| 243 | (repo / "f").write_text(f"change {n}\n") # a pending change status lists
|
| 244 | changes, in_progress, _ = adapter.status_state(repo)
|
| 245 | commit = subprocess.run([GIT, "commit", "-qam", f"c{n}"], cwd=repo, capture_output=True,
|
| 246 | text=True, env=self.env)
|
| 247 | path.unlink()
|
| 248 | if lock in blocking:
|
| 249 | self.assertTrue(in_progress, lock)
|
| 250 | self.assertEqual(adapter.blocking_locks(repo), [], lock)
|
| 251 | self.assertNotEqual(commit.returncode, 0, lock)
|
| 252 | self.assertIn("remove the file manually", commit.stderr, lock)
|
| 253 | self.assertTrue(actionable(result(commit.returncode, stderr=commit.stderr)), lock)
|
| 254 | else:
|
| 255 | self.assertTrue(changes, lock)
|
| 256 | self.assertFalse(in_progress, lock)
|
| 257 | self.assertEqual(commit.returncode, 0, (lock, commit.stderr))
|
| 258 |
|
| 259 | def test_half_switched_worktree_with_head_lock_is_flagged_not_torn(self):
|
| 260 | # `git crash_syscall switch before rename#2`: worktree/index on the target, HEAD
|
| 261 | # still names the old branch, HEAD.lock left behind.
|
| 262 | with tempfile.TemporaryDirectory() as temp:
|
| 263 | temp = Path(temp)
|
| 264 | fx = build_git(GIT, "switch", temp / "fx")
|
| 265 | client = fx.pristine / "client"
|
| 266 | pre = worktree_manifest(client)
|
| 267 | from robustness.engine import Trial
|
| 268 |
|
| 269 | trial = Trial(fx, GIT, temp / "trials", "torn")
|
| 270 | trial.adapter.must([GIT, "switch", "-q", fx.meta["target"]], trial.client)
|
| 271 | post = worktree_manifest(trial.client)
|
| 272 | (trial.client / ".git" / "HEAD").write_text(f"ref: refs/heads/{fx.branch}\n")
|
| 273 | ok, detail = torn_verdict(trial, trial.client, pre, post, fx.meta["pre_head"])
|
| 274 | self.assertIs(ok, False, detail) # without the lock: presented as user changes
|
| 275 | (trial.client / ".git" / "HEAD.lock").write_text(f"ref: refs/heads/{fx.meta['target']}\n")
|
| 276 | ok, detail = torn_verdict(trial, trial.client, pre, post, fx.meta["pre_head"])
|
| 277 | self.assertIs(ok, True, detail)
|
| 278 | self.assertEqual(detail["torn"], "flagged in progress")
|
| 279 | (trial.client / ".git" / "HEAD.lock").unlink()
|
| 280 | (trial.client / ".git" / "config.lock").write_text("") # does not block a commit
|
| 281 | ok, detail = torn_verdict(trial, trial.client, pre, post, fx.meta["pre_head"])
|
| 282 | self.assertIs(ok, False, detail)
|
| 283 | trial.cleanup()
|
| 284 |
|
| 285 |
|
| 286 | class ActionableTests(unittest.TestCase):
|
| 287 | def test_corruption_internal_and_panic_are_not_actionable(self):
|
| 288 | for text in ("error: failed to open oak database: file is not a database",
|
| 289 | "error: internal error in oak core",
|
| 290 | "error: push rejected: corrupted object",
|
| 291 | "fatal: not a git repository (or any of the parent directories): .git",
|
| 292 | "error: Database error: database disk image is malformed",
|
| 293 | "thread 'main' panicked at src/lib.rs:9:5",
|
| 294 | "error: internal error; please try again"):
|
| 295 | self.assertFalse(actionable(result(1, stderr=text)), text)
|
| 296 | internal = {"error": {"code": "internal", "message": "store malformed"}}
|
| 297 | self.assertFalse(actionable(result(1, stdout=json.dumps(internal))))
|
| 298 | generic = {"error": {"code": "error", "message": "something failed", "recommended_next_commands": []}}
|
| 299 | self.assertFalse(actionable(result(1, stdout=json.dumps(generic))))
|
| 300 |
|
| 301 | def test_recovery_commands_and_typed_codes_are_actionable(self):
|
| 302 | for text in ("error: working tree has uncommitted changes — run `oak commit` to keep them",
|
| 303 | "Remove this directory and re-run `oak clone`.",
|
| 304 | "The remote branch has commits this clone doesn't have.\nRun 'oak pull' to bring them in",
|
| 305 | "remove the file manually to continue.",
|
| 306 | "hint: Updates were rejected ... ! [rejected] main -> main (fetch first)",
|
| 307 | "error: repository locked by another process; try again"):
|
| 308 | self.assertTrue(actionable(result(4, stderr=text)), text)
|
| 309 | rec = {"error": {"code": "error", "message": "x", "recommended_next_commands": ["oak status --json"]}}
|
| 310 | self.assertTrue(actionable(result(6, stdout=json.dumps(rec))))
|
| 311 | self.assertTrue(actionable(result(5, stdout=json.dumps({"error": {"code": "merge_in_progress"}}))))
|
| 312 | self.assertFalse(actionable(result(0, stderr="run `oak pull`")))
|
| 313 |
|
| 314 | CONFLICT = ("Auto-merging f.txt\nCONFLICT (content): Merge conflict in f.txt\n"
|
| 315 | "Automatic merge failed; fix conflicts and then commit the result.\n")
|
| 316 | FF = "fatal: Not possible to fast-forward, aborting.\n"
|
| 317 | PULL_FF = "From /tmp/srv/remote.git\n * branch main -> FETCH_HEAD\n" + FF
|
| 318 |
|
| 319 | def test_git_conflict_and_fast_forward_outcomes_are_actionable(self):
|
| 320 | def git(rc, **kw):
|
| 321 | return Result(["/usr/bin/git", "merge"], rc, kw.get("stdout", ""), kw.get("stderr", ""), 1.0)
|
| 322 | ff_hints = ("hint: Diverging branches can't be fast-forwarded, you need to either:\nhint:\n"
|
| 323 | "hint: \tgit merge --no-ff\nhint:\nhint: or:\nhint:\nhint: \tgit rebase\nhint:\n"
|
| 324 | "hint: Disable this message with \"git config set advice.diverging false\"\n" + self.FF)
|
| 325 | self.assertTrue(actionable(git(1, stdout=self.CONFLICT)))
|
| 326 | for text in (self.FF, self.PULL_FF, ff_hints):
|
| 327 | self.assertTrue(actionable(git(128, stderr=text)), text)
|
| 328 |
|
| 329 | def test_git_outcome_hints_are_git_only_and_allowlisted(self):
|
| 330 | def git(text, rc=1):
|
| 331 | return Result(["git", "pull"], rc, "", text, 1.0)
|
| 332 | # the same sentences from another subject are not git's hints
|
| 333 | for text in (self.CONFLICT, self.FF):
|
| 334 | self.assertFalse(actionable(Result(["oak", "pull"], 1, "", text, 1.0)), text)
|
| 335 | self.assertFalse(actionable(result(1, stderr=text)), text)
|
| 336 | broken_lines = ("fatal: not a git repository (or any of the parent directories): .git",
|
| 337 | "error: object file .git/objects/ab/cd is empty",
|
| 338 | "fatal: loose object abcd is corrupt",
|
| 339 | "error: RPC failed; HTTP 500 curl 22 The requested URL returned error: 500",
|
| 340 | "fatal: internal error in merge-ort",
|
| 341 | "thread 'main' panicked at src/merge.rs:1:1",
|
| 342 | "error: Database error: database disk image is malformed",
|
| 343 | "fatal: bad object HEAD",
|
| 344 | "error: unable to read sha1 file of f.txt (abcdef)",
|
| 345 | "fatal: invalid object 1234567 for 'f.txt'",
|
| 346 | "fatal: early EOF",
|
| 347 | "Segmentation fault: 11",
|
| 348 | "fatal: unable to read tree 0123456789abcdef",
|
| 349 | "error: object file .git/objects/12/34 is empty",
|
| 350 | "fatal: unable to write new index file",
|
| 351 | "error: Database error: unable to open database file")
|
| 352 | for broken in broken_lines:
|
| 353 | for hint in (self.CONFLICT, self.FF, self.PULL_FF):
|
| 354 | for text in (broken + "\n" + hint, hint + broken + "\n"):
|
| 355 | self.assertFalse(actionable(git(text)), text)
|
| 356 | # the phrase must be git's own sentence, not any mention of a failed merge
|
| 357 | self.assertFalse(actionable(git("error: automatic merge failed")))
|
| 358 | self.assertFalse(actionable(git("error: fast-forward not possible")))
|
| 359 |
|
| 360 |
|
| 361 | @unittest.skipUnless(platform.system() == "Darwin" and shutil.which("hdiutil"), "macOS RAM disk required")
|
| 362 | class DiskFillTests(unittest.TestCase):
|
| 363 | def test_fill_leaves_less_than_one_block_and_records_measured_free(self):
|
| 364 | from robustness.envfaults import RamDisk, fill, free_bytes
|
| 365 |
|
| 366 | with tempfile.TemporaryDirectory() as temp:
|
| 367 | mount = Path(temp) / "mnt"
|
| 368 | with RamDisk(mount, mib=8):
|
| 369 | _, free = fill(mount, 0)
|
| 370 | self.assertLess(free, 4096)
|
| 371 | self.assertEqual(free, free_bytes(mount))
|
| 372 | with self.assertRaises(OSError):
|
| 373 | with (mount / "more.bin").open("wb") as fh:
|
| 374 | fh.write(b"x" * 8192)
|
| 375 | fh.flush()
|
| 376 | os.fsync(fh.fileno())
|
| 377 | (mount / "more.bin").unlink(missing_ok=True)
|
| 378 | (mount / "p4-filler.bin").unlink()
|
| 379 | _, free = fill(mount, 64)
|
| 380 | self.assertGreaterEqual(free, 60 * 1024)
|
| 381 | self.assertLess(free, 72 * 1024)
|
| 382 |
|
| 383 |
|
| 384 | class EnvFaultFiredTests(unittest.TestCase):
|
| 385 | def test_disk_full_judges_every_failure_and_skips_only_a_roomy_success(self):
|
| 386 | from robustness.envfaults import disk_full_fired
|
| 387 |
|
| 388 | # only a successful op that had room is not fired
|
| 389 | fired, evidence = disk_full_fired(1 << 20, 4096, result(0))
|
| 390 | self.assertIs(fired, False)
|
| 391 | self.assertIn("succeeded", evidence)
|
| 392 | for text in ("error: IO error: No space left on device (os error 28)",
|
| 393 | "fatal: unable to write new index file: ENOSPC",
|
| 394 | "Error: database or disk is full", "sqlite: SQLITE_FULL"):
|
| 395 | fired, evidence = disk_full_fired(1 << 20, 4096, result(1, stderr=text))
|
| 396 | self.assertTrue(fired, text)
|
| 397 | self.assertIn("ENOSPC", evidence)
|
| 398 | self.assertTrue(disk_full_fired(0, 4096, result(0))[0]) # measured short: fired even if it succeeded
|
| 399 | # a failure with room and no ENOSPC text is judged, never skipped
|
| 400 | fb548 = "error: Database error: unable to open database file"
|
| 401 | panic = "thread 'main' panicked at src/store.rs:9:5:\nnote: run with `RUST_BACKTRACE=1`"
|
| 402 | hang = Result(["oak", "pull"], None, "", "", 90000.0, killed_by_harness=True, timed_out=True)
|
| 403 | for res in (result(1, stderr=fb548), result(101, stderr=panic), result(1, stderr="error: something else"),
|
| 404 | hang):
|
| 405 | for free in (0, 1 << 20):
|
| 406 | fired, evidence = disk_full_fired(free, 4096, res)
|
| 407 | self.assertIs(fired, True, (res, free))
|
| 408 |
|
| 409 | def test_opaque_failures_on_a_full_disk_are_violations(self):
|
| 410 | from robustness.envfaults import disk_full_fired, env_actionable
|
| 411 |
|
| 412 | fb548 = "error: Database error: unable to open database file"
|
| 413 | panic = "thread 'main' panicked at src/store.rs:9:5:\nnote: run with `RUST_BACKTRACE=1`"
|
| 414 | for res in (result(1, stderr=fb548), result(101, stderr=panic)):
|
| 415 | fired, _ = disk_full_fired(1 << 20, 4096, res)
|
| 416 | inv = {"op_no_hang": True, "op_failure_actionable": env_actionable(res)}
|
| 417 | self.assertEqual(classify(inv, fault_fired=fired), VIOLATED, res.stderr)
|
| 418 | hang = Result(["oak", "pull"], None, "", "", 90000.0, killed_by_harness=True, timed_out=True)
|
| 419 | fired, _ = disk_full_fired(1 << 20, 4096, hang)
|
| 420 | self.assertEqual(classify({"op_no_hang": False}, fault_fired=fired), VIOLATED)
|
| 421 | # the cause named plainly stays actionable
|
| 422 | named = result(1, stderr="error: IO error: No space left on device (os error 28)")
|
| 423 | self.assertTrue(env_actionable(named))
|
| 424 |
|
| 425 | def test_failed_calibration_is_a_harness_error_not_zero_need(self):
|
| 426 | from robustness.envfaults import disk_full_trial
|
| 427 |
|
| 428 | row = disk_full_trial(None, "oak", Path("/nonexistent"), "x", None, 0, None)
|
| 429 | self.assertEqual(row["classification"], HARNESS_ERROR)
|
| 430 | self.assertIn("unmeasured", row["harness_error"])
|
| 431 |
|
| 432 | def test_readonly_judges_failures_and_skips_only_unexercised_success(self):
|
| 433 | from robustness.envfaults import readonly_fired
|
| 434 |
|
| 435 | hang = Result(["oak", "pull"], None, "", "", 90000.0, killed_by_harness=True, timed_out=True)
|
| 436 | for res in (result(1, stderr="Permission denied"), result(1, stderr="error: unable to open database file"),
|
| 437 | result(101, stderr="thread 'main' panicked at x"), hang):
|
| 438 | for writable, exercised in ((True, False), (False, False), (False, None), (False, True)):
|
| 439 | self.assertIs(readonly_fired(writable, exercised, res)[0], True, (res, writable, exercised))
|
| 440 | self.assertTrue(readonly_fired(False, False, result(0, stderr="warning: permission denied on d3"))[0])
|
| 441 | self.assertTrue(readonly_fired(False, True, result(0))[0])
|
| 442 | fired, evidence = readonly_fired(False, False, result(0))
|
| 443 | self.assertIs(fired, False)
|
| 444 | self.assertTrue(evidence.startswith("not exercised"))
|
| 445 | self.assertIs(readonly_fired(True, True, result(0))[0], False) # root: fault never applied
|
| 446 | self.assertIsNone(readonly_fired(False, None, result(0))[0])
|
| 447 |
|
| 448 | def test_write_snapshot_sees_writes_under_the_locked_path(self):
|
| 449 | from robustness.envfaults import write_snapshot
|
| 450 |
|
| 451 | with tempfile.TemporaryDirectory() as temp:
|
| 452 | d = Path(temp) / "d3"
|
| 453 | (d / "sub").mkdir(parents=True)
|
| 454 | (d / "a").write_text("a")
|
| 455 | before = write_snapshot(d, "subdir")
|
| 456 | (d / "sub" / "deep").write_text("x") # not blocked by a read-only d3 itself
|
| 457 | (d / "a").write_text("changed in place") # in-place write: allowed
|
| 458 | self.assertEqual(write_snapshot(d, "subdir"), before)
|
| 459 | (d / "b").write_text("new entry")
|
| 460 | self.assertNotEqual(write_snapshot(d, "subdir"), before)
|
| 461 | store = write_snapshot(d, "store")
|
| 462 | os.utime(d / "sub" / "deep", ns=(1, 1))
|
| 463 | self.assertNotEqual(write_snapshot(d, "store"), store)
|
| 464 | self.assertIsNone(write_snapshot(d / "missing", "store"))
|
| 465 |
|
| 466 | @unittest.skipUnless(GIT and os.geteuid() != 0, "git and a non-root user required")
|
| 467 | def test_git_readonly_rows_record_fired_or_not_exercised(self):
|
| 468 | from robustness.crash import calibrate
|
| 469 | from robustness.envfaults import readonly_exercised, readonly_trial
|
| 470 |
|
| 471 | with tempfile.TemporaryDirectory() as temp:
|
| 472 | temp = Path(temp)
|
| 473 | fx = build_git(GIT, "commit", temp / "fx")
|
| 474 | calib = calibrate(fx, GIT, temp / "trials", None)
|
| 475 | exercised, detail = readonly_exercised(fx, GIT, temp / "trials", "probe", "store")
|
| 476 | self.assertTrue(exercised, detail)
|
| 477 | row = readonly_trial(fx, GIT, temp / "trials", "ro", calib, "store", exercised, detail)
|
| 478 | self.assertIs(row["fault_fired"], True, row)
|
| 479 | # a lock-down no write reaches is recorded as not exercised, never held
|
| 480 | row = readonly_trial(fx, GIT, temp / "trials", "ro2", calib, "store", False, {})
|
| 481 | if row["fault_fired"] is False:
|
| 482 | self.assertEqual(row["classification"], FAULT_NOT_FIRED)
|
| 483 | self.assertTrue(row["skip_reason"].startswith("not exercised"))
|
| 484 | else: # git failed on the read-only store: judged, never skipped
|
| 485 | self.assertNotEqual(row["command"], [])
|
| 486 | self.assertIn(row["classification"], (HELD, VIOLATED))
|
| 487 | self.assertRegex(row["observations"]["fault_fired_evidence"], "judged|permission")
|
| 488 |
|
| 489 |
|
| 490 | class RowValidationTests(unittest.TestCase):
|
| 491 | def _row(self, **kw):
|
| 492 | row = {"bench_id": "20260929T000000Z", "scenario": "crash_time_v1", "operation": "commit", "run": 0,
|
| 493 | "subject": "git", "subject_kind": "git", "elapsed_ms": 1.0, "returncode": 0, "classification": "held",
|
| 494 | "fault_family": "crash_time", "fault_point": "t=1ms", "fault_fired": True, "violations": []}
|
| 495 | row.update(kw)
|
| 496 | return row
|
| 497 |
|
| 498 | def test_rejects_unknown_and_inconsistent_classifications(self):
|
| 499 | self.assertEqual(validate_rows([self._row()], "robustness"), [])
|
| 500 | self.assertTrue(validate_rows([self._row(classification="bogus")], "robustness"))
|
| 501 | self.assertTrue(validate_rows([self._row(returncode=1)], "robustness"))
|
| 502 | self.assertTrue(validate_rows([self._row(classification="violated", returncode=0)], "robustness"))
|
| 503 | self.assertTrue(validate_rows([self._row(fault_fired=False)], "robustness"))
|
| 504 | self.assertTrue(validate_rows([self._row(violations=["x"])], "robustness"))
|
| 505 | not_fired = self._row(classification="fault_not_fired", returncode=77, fault_fired=False, skipped=True,
|
| 506 | skip_reason="op exited before the kill")
|
| 507 | self.assertEqual(validate_rows([not_fired], "robustness"), [])
|
| 508 | self.assertTrue(validate_rows([{**not_fired, "returncode": 0, "skipped": False}], "robustness"))
|
| 509 |
|
| 510 |
|
| 511 | class MiscTests(unittest.TestCase):
|
| 512 | def test_known_bug_tags(self):
|
| 513 | self.assertEqual(known_bugs("oak", "crash_time_v1", "pull_ff", ["worktree_not_torn"]), ["fb-535"])
|
| 514 | self.assertEqual(known_bugs("oak", "crash_syscall_v1", "switch", ["worktree_not_torn", "x"]), ["fb-535"])
|
| 515 | self.assertEqual(known_bugs("oak", "conc_same_branch_v1", "concurrent.push",
|
| 516 | ["no_lost_acknowledged_write", "linearizable"]), ["fb-534"])
|
| 517 | self.assertEqual(known_bugs("git", "crash_time_v1", "switch", ["worktree_not_torn"]), [])
|
| 518 | self.assertEqual(known_bugs("oak", "crash_time_v1", "commit", ["repo_opens"]), [])
|
| 519 |
|
| 520 | def test_no_boundary_reasons_are_subject_specific(self):
|
| 521 | self.assertNotIn("git", crashpoints.NO_BOUNDARY_REASON["oak"])
|
| 522 | self.assertIn("git", crashpoints.NO_BOUNDARY_REASON["git"])
|
| 523 | inst = crashpoints.CrashInstrument(Path("x"), None, Path("g"), None, {}, {"oak": "codesign failed"})
|
| 524 | self.assertEqual(crashpoints.unavailable_reason(inst, "", "oak"), "codesign failed")
|
| 525 | self.assertEqual(crashpoints.unavailable_reason(inst, "", "git"), "")
|
| 526 | self.assertEqual(crashpoints.unavailable_reason(None, "no clang", "git"), "no clang")
|
| 527 |
|
| 528 | def test_reported_pushed_head(self):
|
| 529 | self.assertEqual(reported_pushed_head(result(0, stdout=json.dumps({"pushed": True, "pushed_head": "abc"}))),
|
| 530 | "abc")
|
| 531 | self.assertIsNone(reported_pushed_head(result(0, stdout="plain text")))
|
| 532 |
|
| 533 |
|
| 534 | if __name__ == "__main__":
|
| 535 | unittest.main()
|