| 1 | #!/usr/bin/env python3
|
| 2 | """Probe Oak mount startup and lazy-hydration behavior.
|
| 3 |
|
| 4 | The default run is intentionally safe: if `oak mount` is unavailable or no
|
| 5 | mount repo is configured, the script writes capability and skip rows instead of
|
| 6 | failing. Real mount work starts only when the spec or environment provides a
|
| 7 | throwaway OWNER/REPO.
|
| 8 | """
|
| 9 |
|
| 10 | from __future__ import annotations
|
| 11 |
|
| 12 | import argparse
|
| 13 | import concurrent.futures
|
| 14 | import hashlib
|
| 15 | import json
|
| 16 | import os
|
| 17 | import platform
|
| 18 | import re
|
| 19 | import shutil
|
| 20 | import subprocess
|
| 21 | import sys
|
| 22 | import tempfile
|
| 23 | import time
|
| 24 | from datetime import datetime, timezone
|
| 25 | from pathlib import Path
|
| 26 | from typing import Any, Callable, Optional, Tuple
|
| 27 |
|
| 28 | from oakbench import cachectl as oakbench_cachectl
|
| 29 | from oakbench import diskprobe as oakbench_diskprobe
|
| 30 | from oakbench.environment import HOST_HOME_ENV_ISOLATION_VERSION, host_home_environment
|
| 31 | from oakbench.integrity import parse_manifest
|
| 32 | from oakbench.runlock import measurement_lock
|
| 33 |
|
| 34 | ROOT = Path(__file__).resolve().parents[1]
|
| 35 | DEFAULT_SPEC = ROOT / "scenarios" / "mount.yaml"
|
| 36 | DEFAULT_DEST = Path(tempfile.gettempdir()) / "oak-mount-probe"
|
| 37 | SKIP_RETURNCODE = 77
|
| 38 | MANIFEST_TARGET = "MANIFEST.sha256"
|
| 39 | OAK_CACHE_STATE_NOT_PURGED = "oak_global_cache_not_purged"
|
| 40 | UNKNOWN_CACHE_STATE = "unknown"
|
| 41 | UNSET_CACHE_STATE = "unset"
|
| 42 | CACHE_STATE_UNSET = "cache_state_unset"
|
| 43 | EXPLICIT_CACHE_STATE = "cache_state_declared"
|
| 44 |
|
| 45 |
|
| 46 | class SpecError(ValueError):
|
| 47 | pass
|
| 48 |
|
| 49 |
|
| 50 | def parse_args() -> argparse.Namespace:
|
| 51 | parser = argparse.ArgumentParser(description=__doc__)
|
| 52 | parser.add_argument("--spec", type=Path, default=DEFAULT_SPEC)
|
| 53 | parser.add_argument("--dest", type=Path, default=DEFAULT_DEST)
|
| 54 | parser.add_argument("--oak-bin", default="oak")
|
| 55 | parser.add_argument("--results", type=Path, default=ROOT / "results")
|
| 56 | parser.add_argument("--keep", action="store_true", help="Keep probe workdirs and mounts for inspection")
|
| 57 | return parser.parse_args()
|
| 58 |
|
| 59 |
|
| 60 | def strip_comment(line: str) -> str:
|
| 61 | quote: Optional[str] = None
|
| 62 | escaped = False
|
| 63 | for index, char in enumerate(line):
|
| 64 | if escaped:
|
| 65 | escaped = False
|
| 66 | continue
|
| 67 | if char == "\\":
|
| 68 | escaped = True
|
| 69 | continue
|
| 70 | if quote:
|
| 71 | if char == quote:
|
| 72 | quote = None
|
| 73 | continue
|
| 74 | if char in {"'", '"'}:
|
| 75 | quote = char
|
| 76 | continue
|
| 77 | if char == "#" and (index == 0 or line[index - 1].isspace()):
|
| 78 | return line[:index]
|
| 79 | return line
|
| 80 |
|
| 81 |
|
| 82 | def parse_scalar(value: str) -> Any:
|
| 83 | raw = value.strip()
|
| 84 | if raw in {"null", "Null", "NULL", "~"}:
|
| 85 | return None
|
| 86 | if raw in {"true", "True", "TRUE"}:
|
| 87 | return True
|
| 88 | if raw in {"false", "False", "FALSE"}:
|
| 89 | return False
|
| 90 | if raw.startswith('"') and raw.endswith('"'):
|
| 91 | try:
|
| 92 | return json.loads(raw)
|
| 93 | except json.JSONDecodeError:
|
| 94 | return raw[1:-1]
|
| 95 | if raw.startswith("'") and raw.endswith("'"):
|
| 96 | return raw[1:-1]
|
| 97 | if re.fullmatch(r"-?\d+", raw):
|
| 98 | return int(raw)
|
| 99 | if re.fullmatch(r"-?\d+\.\d+", raw):
|
| 100 | return float(raw)
|
| 101 | return raw
|
| 102 |
|
| 103 |
|
| 104 | def split_key_value(text: str) -> Tuple[str, str]:
|
| 105 | if ":" not in text:
|
| 106 | raise SpecError(f"expected key: value line, got {text!r}")
|
| 107 | key, value = text.split(":", 1)
|
| 108 | key = key.strip()
|
| 109 | if not key:
|
| 110 | raise SpecError(f"empty key in line {text!r}")
|
| 111 | return key, value.strip()
|
| 112 |
|
| 113 |
|
| 114 | def looks_like_inline_mapping(text: str) -> bool:
|
| 115 | if ":" not in text:
|
| 116 | return False
|
| 117 | key = text.split(":", 1)[0].strip()
|
| 118 | return bool(re.fullmatch(r"[A-Za-z0-9_.-]+", key))
|
| 119 |
|
| 120 |
|
| 121 | BLOCK_SCALAR_MARKERS = {">", ">-", ">+", "|", "|-", "|+"}
|
| 122 |
|
| 123 |
|
| 124 | def _block_scalar_line(text: str) -> tuple[str, str] | None:
|
| 125 | prefix = ""
|
| 126 | body = text
|
| 127 | if body.startswith("- "):
|
| 128 | prefix = "- "
|
| 129 | body = body[2:].strip()
|
| 130 | if ":" not in body:
|
| 131 | return None
|
| 132 | key, value = body.split(":", 1)
|
| 133 | marker = value.strip()
|
| 134 | if marker not in BLOCK_SCALAR_MARKERS:
|
| 135 | return None
|
| 136 | return prefix + key.strip(), marker
|
| 137 |
|
| 138 |
|
| 139 | def yaml_lines(text: str) -> list[Tuple[int, str]]:
|
| 140 | rows: list[Tuple[int, str]] = []
|
| 141 | raw_lines = text.splitlines()
|
| 142 | index = 0
|
| 143 | while index < len(raw_lines):
|
| 144 | line_no = index + 1
|
| 145 | raw = raw_lines[index]
|
| 146 | without_comment = strip_comment(raw).rstrip()
|
| 147 | if not without_comment.strip():
|
| 148 | index += 1
|
| 149 | continue
|
| 150 | indent_text = without_comment[: len(without_comment) - len(without_comment.lstrip(" "))]
|
| 151 | if "\t" in indent_text:
|
| 152 | raise SpecError(f"tabs are not supported for indentation on line {line_no}")
|
| 153 | indent = len(indent_text)
|
| 154 | stripped = without_comment.strip()
|
| 155 | block = _block_scalar_line(stripped)
|
| 156 | if block is not None:
|
| 157 | key_text, marker = block
|
| 158 | index += 1
|
| 159 | block_lines: list[tuple[int, str]] = []
|
| 160 | while index < len(raw_lines):
|
| 161 | child_raw = raw_lines[index]
|
| 162 | child_without_comment = child_raw.rstrip()
|
| 163 | if not child_without_comment.strip():
|
| 164 | block_lines.append((indent + 1, ""))
|
| 165 | index += 1
|
| 166 | continue
|
| 167 | child_indent_text = child_without_comment[
|
| 168 | : len(child_without_comment) - len(child_without_comment.lstrip(" "))
|
| 169 | ]
|
| 170 | if "\t" in child_indent_text:
|
| 171 | raise SpecError(f"tabs are not supported for indentation on line {index + 1}")
|
| 172 | child_indent = len(child_indent_text)
|
| 173 | if child_indent <= indent:
|
| 174 | break
|
| 175 | block_lines.append((child_indent, child_without_comment))
|
| 176 | index += 1
|
| 177 | content_indent = min((child_indent for child_indent, line in block_lines if line.strip()), default=indent + 1)
|
| 178 | normalized = [
|
| 179 | line[content_indent:] if len(line) >= content_indent else ""
|
| 180 | for _, line in block_lines
|
| 181 | ]
|
| 182 | if marker.startswith("|"):
|
| 183 | block_value = "\n".join(normalized)
|
| 184 | else:
|
| 185 | block_value = " ".join(line.strip() for line in normalized if line.strip())
|
| 186 | if marker.endswith("-"):
|
| 187 | block_value = block_value.rstrip("\n")
|
| 188 | elif marker.endswith("+"):
|
| 189 | pass
|
| 190 | elif marker.startswith("|") and block_lines:
|
| 191 | block_value += "\n"
|
| 192 | rows.append((indent, f"{key_text}: {json.dumps(block_value)}"))
|
| 193 | continue
|
| 194 | rows.append((indent, stripped))
|
| 195 | index += 1
|
| 196 | return rows
|
| 197 |
|
| 198 |
|
| 199 | def parse_block(rows: list[Tuple[int, str]], index: int, indent: int) -> Tuple[Any, int]:
|
| 200 | if index >= len(rows):
|
| 201 | return {}, index
|
| 202 | current_indent, current_text = rows[index]
|
| 203 | if current_indent < indent:
|
| 204 | return {}, index
|
| 205 | if current_text.startswith("- "):
|
| 206 | return parse_list(rows, index, current_indent)
|
| 207 | return parse_map(rows, index, current_indent)
|
| 208 |
|
| 209 |
|
| 210 | def parse_map(rows: list[Tuple[int, str]], index: int, indent: int) -> Tuple[dict[str, Any], int]:
|
| 211 | result: dict[str, Any] = {}
|
| 212 | while index < len(rows):
|
| 213 | current_indent, text = rows[index]
|
| 214 | if current_indent < indent:
|
| 215 | break
|
| 216 | if current_indent > indent:
|
| 217 | raise SpecError(f"unexpected indentation before {text!r}")
|
| 218 | if text.startswith("- "):
|
| 219 | break
|
| 220 | key, value = split_key_value(text)
|
| 221 | index += 1
|
| 222 | if value:
|
| 223 | result[key] = parse_scalar(value)
|
| 224 | continue
|
| 225 | if index < len(rows) and rows[index][0] > current_indent:
|
| 226 | child, index = parse_block(rows, index, rows[index][0])
|
| 227 | result[key] = child
|
| 228 | else:
|
| 229 | result[key] = {}
|
| 230 | return result, index
|
| 231 |
|
| 232 |
|
| 233 | def parse_list(rows: list[Tuple[int, str]], index: int, indent: int) -> Tuple[list[Any], int]:
|
| 234 | result: list[Any] = []
|
| 235 | while index < len(rows):
|
| 236 | current_indent, text = rows[index]
|
| 237 | if current_indent < indent:
|
| 238 | break
|
| 239 | if current_indent > indent:
|
| 240 | raise SpecError(f"unexpected indentation before {text!r}")
|
| 241 | if not text.startswith("- "):
|
| 242 | break
|
| 243 | item_text = text[2:].strip()
|
| 244 | index += 1
|
| 245 | if not item_text:
|
| 246 | if index < len(rows) and rows[index][0] > current_indent:
|
| 247 | item, index = parse_block(rows, index, rows[index][0])
|
| 248 | else:
|
| 249 | item = None
|
| 250 | result.append(item)
|
| 251 | continue
|
| 252 | if looks_like_inline_mapping(item_text):
|
| 253 | key, value = split_key_value(item_text)
|
| 254 | item_dict: dict[str, Any] = {}
|
| 255 | if value:
|
| 256 | item_dict[key] = parse_scalar(value)
|
| 257 | elif index < len(rows) and rows[index][0] > current_indent:
|
| 258 | child, index = parse_block(rows, index, rows[index][0])
|
| 259 | item_dict[key] = child
|
| 260 | else:
|
| 261 | item_dict[key] = {}
|
| 262 | if index < len(rows) and rows[index][0] > current_indent:
|
| 263 | extra, index = parse_block(rows, index, rows[index][0])
|
| 264 | if isinstance(extra, dict):
|
| 265 | item_dict.update(extra)
|
| 266 | else:
|
| 267 | item_dict["_items"] = extra
|
| 268 | result.append(item_dict)
|
| 269 | else:
|
| 270 | result.append(parse_scalar(item_text))
|
| 271 | return result, index
|
| 272 |
|
| 273 |
|
| 274 | def parse_yaml_subset(text: str) -> dict[str, Any]:
|
| 275 | stripped = text.lstrip()
|
| 276 | if stripped.startswith("{"):
|
| 277 | data = json.loads(text)
|
| 278 | if not isinstance(data, dict):
|
| 279 | raise SpecError("JSON spec root must be an object")
|
| 280 | return data
|
| 281 | rows = yaml_lines(text)
|
| 282 | if not rows:
|
| 283 | return {}
|
| 284 | parsed, index = parse_block(rows, 0, rows[0][0])
|
| 285 | if index != len(rows):
|
| 286 | raise SpecError(f"unparsed content starting at {rows[index][1]!r}")
|
| 287 | if not isinstance(parsed, dict):
|
| 288 | raise SpecError("spec root must be a mapping")
|
| 289 | return parsed
|
| 290 |
|
| 291 |
|
| 292 | def default_spec(reason: str) -> dict[str, Any]:
|
| 293 | return {
|
| 294 | "version": 1,
|
| 295 | "suite": "oak_mount_lazy_hydration",
|
| 296 | "spec_missing": reason,
|
| 297 | "remote": {"owner_repo": "", "owner_repo_env": "OAK_BENCH_MOUNT_REPO", "safe_push": False},
|
| 298 | "defaults": {
|
| 299 | "runs": 1,
|
| 300 | "timeout_seconds": 120,
|
| 301 | "retries": 0,
|
| 302 | "target_read": "README.md",
|
| 303 | "target_read_bytes": 4096,
|
| 304 | "target_read_contains": "# oak-benchmarks-tmp",
|
| 305 | "content_manifest": MANIFEST_TARGET,
|
| 306 | "require_content_manifest": False,
|
| 307 | "edit_path": ".oak-bench/mount-edit.txt",
|
| 308 | "huge_file_path": "",
|
| 309 | "huge_file_read_bytes": 4096,
|
| 310 | "parallelism": 4,
|
| 311 | "max_disk_entries": 100000,
|
| 312 | "sparse_paths": ["README.md"],
|
| 313 | },
|
| 314 | "scenarios": [
|
| 315 | {"name": "capability_probe", "requires_remote": False, "operations": ["oak.version", "oak.mount.help"]},
|
| 316 | {"name": "cold_mount_startup", "requires_remote": True, "operations": ["mount.start"]},
|
| 317 | ],
|
| 318 | }
|
| 319 |
|
| 320 |
|
| 321 | def load_spec(path: Path) -> dict[str, Any]:
|
| 322 | if not path.exists():
|
| 323 | return default_spec(f"missing spec: {path}")
|
| 324 | try:
|
| 325 | return parse_yaml_subset(path.read_text())
|
| 326 | except Exception as exc: # noqa: BLE001 - preserve graceful benchmark behavior
|
| 327 | return default_spec(f"could not parse spec {path}: {exc}")
|
| 328 |
|
| 329 |
|
| 330 | def resolve_oak_bin(value: str) -> Optional[str]:
|
| 331 | expanded = os.path.expanduser(value)
|
| 332 | if os.sep in expanded or expanded.startswith("."):
|
| 333 | path = Path(expanded)
|
| 334 | return str(path) if path.exists() else None
|
| 335 | found = shutil.which(expanded)
|
| 336 | return found
|
| 337 |
|
| 338 |
|
| 339 | def base_env() -> dict[str, str]:
|
| 340 | env = host_home_environment()
|
| 341 | env.update(
|
| 342 | {
|
| 343 | "OAK_AUTHOR": "oakbench",
|
| 344 | "NO_COLOR": "1",
|
| 345 | "CLICOLOR": "0",
|
| 346 | }
|
| 347 | )
|
| 348 | return env
|
| 349 |
|
| 350 |
|
| 351 | def run_process(
|
| 352 | command: list[str],
|
| 353 | cwd: Optional[Path],
|
| 354 | timeout: int,
|
| 355 | retries: int,
|
| 356 | capture_stdout: bool = True,
|
| 357 | ) -> dict[str, Any]:
|
| 358 | attempts = 0
|
| 359 | total_elapsed_ms = 0.0
|
| 360 | last: dict[str, Any] = {}
|
| 361 | while True:
|
| 362 | attempts += 1
|
| 363 | start = time.perf_counter()
|
| 364 | try:
|
| 365 | proc = subprocess.run(
|
| 366 | command,
|
| 367 | cwd=str(cwd) if cwd else None,
|
| 368 | env=base_env(),
|
| 369 | stdout=subprocess.PIPE if capture_stdout else subprocess.DEVNULL,
|
| 370 | stderr=subprocess.PIPE,
|
| 371 | text=True,
|
| 372 | timeout=timeout,
|
| 373 | check=False,
|
| 374 | )
|
| 375 | elapsed_ms = (time.perf_counter() - start) * 1000
|
| 376 | stdout = proc.stdout or ""
|
| 377 | stderr = proc.stderr or ""
|
| 378 | last = {
|
| 379 | "elapsed_ms": elapsed_ms,
|
| 380 | "returncode": proc.returncode,
|
| 381 | "stdout": stdout,
|
| 382 | "stderr": stderr,
|
| 383 | }
|
| 384 | except FileNotFoundError as exc:
|
| 385 | elapsed_ms = (time.perf_counter() - start) * 1000
|
| 386 | last = {"elapsed_ms": elapsed_ms, "returncode": 127, "stdout": "", "stderr": str(exc)}
|
| 387 | except subprocess.TimeoutExpired as exc:
|
| 388 | elapsed_ms = (time.perf_counter() - start) * 1000
|
| 389 | stdout = exc.stdout if isinstance(exc.stdout, str) else ""
|
| 390 | stderr = exc.stderr if isinstance(exc.stderr, str) else ""
|
| 391 | last = {
|
| 392 | "elapsed_ms": elapsed_ms,
|
| 393 | "returncode": 124,
|
| 394 | "stdout": stdout,
|
| 395 | "stderr": stderr or f"timed out after {timeout}s",
|
| 396 | }
|
| 397 | total_elapsed_ms += float(last["elapsed_ms"])
|
| 398 | if last["returncode"] == 0 or attempts >= retries + 1:
|
| 399 | break
|
| 400 | last["elapsed_ms"] = total_elapsed_ms
|
| 401 | last["attempts"] = attempts
|
| 402 | last["retry_count"] = attempts - 1
|
| 403 | return last
|
| 404 |
|
| 405 |
|
| 406 | def parse_observed_bytes(text: str) -> dict[str, int]:
|
| 407 | found: dict[str, int] = {}
|
| 408 | for label in ("hydrated", "downloaded"):
|
| 409 | pattern = rf"{label}[^\d]([0-9][0-9_,]*)\s*(bytes|b|kb|kib|mb|mib|gb|gib)?"
|
| 410 | match = re.search(pattern, text, flags=re.IGNORECASE)
|
| 411 | if not match:
|
| 412 | continue
|
| 413 | value = int(match.group(1).replace("_", "").replace(",", ""))
|
| 414 | unit = (match.group(2) or "bytes").lower()
|
| 415 | scale = {
|
| 416 | "bytes": 1,
|
| 417 | "b": 1,
|
| 418 | "kb": 1000,
|
| 419 | "mb": 1000 * 1000,
|
| 420 | "gb": 1000 * 1000 * 1000,
|
| 421 | "kib": 1024,
|
| 422 | "mib": 1024 * 1024,
|
| 423 | "gib": 1024 * 1024 * 1024,
|
| 424 | }[unit]
|
| 425 | found[f"bytes_{label}"] = value * scale
|
| 426 | return found
|
| 427 |
|
| 428 |
|
| 429 | def tail(text: str, limit: int = 4000) -> str:
|
| 430 | return text[-limit:] if len(text) > limit else text
|
| 431 |
|
| 432 |
|
| 433 | def metadata(timestamp: str, spec_path: Path, spec: dict[str, Any], oak_bin: Optional[str]) -> dict[str, Any]:
|
| 434 | return {
|
| 435 | "bench_id": timestamp,
|
| 436 | "profile": "mount",
|
| 437 | "timestamp_utc": timestamp,
|
| 438 | "host": platform.node(),
|
| 439 | "platform": platform.platform(),
|
| 440 | "machine": platform.machine(),
|
| 441 | "python": platform.python_version(),
|
| 442 | "env_isolation_version": HOST_HOME_ENV_ISOLATION_VERSION,
|
| 443 | "subject": "oak_mount",
|
| 444 | "subject_kind": "oak",
|
| 445 | "subject_label": "Oak mount",
|
| 446 | "spec": {
|
| 447 | "path": str(spec_path),
|
| 448 | "suite": spec.get("suite"),
|
| 449 | "version": spec.get("version"),
|
| 450 | "spec_missing": spec.get("spec_missing"),
|
| 451 | },
|
| 452 | "oak_bin": oak_bin,
|
| 453 | }
|
| 454 |
|
| 455 |
|
| 456 | def command_row(
|
| 457 | meta: dict[str, Any],
|
| 458 | scenario: str,
|
| 459 | operation: str,
|
| 460 | run_index: int,
|
| 461 | command: list[str],
|
| 462 | result: dict[str, Any],
|
| 463 | scenario_start: Optional[float] = None,
|
| 464 | extra: Optional[dict[str, Any]] = None,
|
| 465 | ) -> dict[str, Any]:
|
| 466 | stdout = result.get("stdout", "")
|
| 467 | stderr = result.get("stderr", "")
|
| 468 | row = {
|
| 469 | **meta,
|
| 470 | "scenario": scenario,
|
| 471 | "run": run_index,
|
| 472 | "operation": operation,
|
| 473 | "elapsed_ms": round(float(result.get("elapsed_ms", 0.0)), 3),
|
| 474 | "returncode": int(result.get("returncode", 0)),
|
| 475 | "command": command,
|
| 476 | "attempts": int(result.get("attempts", 1)),
|
| 477 | "retry_count": int(result.get("retry_count", 0)),
|
| 478 | "stdout_bytes": len(stdout.encode("utf-8", "replace")),
|
| 479 | "stderr_bytes": len(stderr.encode("utf-8", "replace")),
|
| 480 | }
|
| 481 | if scenario_start is not None:
|
| 482 | row["since_scenario_start_ms"] = round((time.perf_counter() - scenario_start) * 1000, 3)
|
| 483 | if stderr and row["returncode"] != 0:
|
| 484 | row["stderr"] = tail(stderr)
|
| 485 | if stdout and operation in {"oak.version", "oak.mount.help"}:
|
| 486 | row["stdout"] = tail(stdout, 1200)
|
| 487 | row.update(parse_observed_bytes(stdout + "\n" + stderr))
|
| 488 | if extra:
|
| 489 | row.update(extra)
|
| 490 | return row
|
| 491 |
|
| 492 |
|
| 493 | def skip_row(
|
| 494 | meta: dict[str, Any],
|
| 495 | scenario: str,
|
| 496 | operation: str,
|
| 497 | run_index: int,
|
| 498 | reason: str,
|
| 499 | extra: Optional[dict[str, Any]] = None,
|
| 500 | ) -> dict[str, Any]:
|
| 501 | row = {
|
| 502 | **meta,
|
| 503 | "scenario": scenario,
|
| 504 | "run": run_index,
|
| 505 | "operation": operation,
|
| 506 | "elapsed_ms": 0.0,
|
| 507 | "returncode": SKIP_RETURNCODE,
|
| 508 | "command": [],
|
| 509 | "skipped": True,
|
| 510 | "skip_reason": reason,
|
| 511 | "attempts": 0,
|
| 512 | "retry_count": 0,
|
| 513 | }
|
| 514 | if extra:
|
| 515 | row.update(extra)
|
| 516 | return row
|
| 517 |
|
| 518 |
|
| 519 | def fs_row(
|
| 520 | meta: dict[str, Any],
|
| 521 | scenario: str,
|
| 522 | operation: str,
|
| 523 | run_index: int,
|
| 524 | command: list[str],
|
| 525 | scenario_start: float,
|
| 526 | func: Callable[[], dict[str, Any]],
|
| 527 | first_useful: bool,
|
| 528 | ) -> dict[str, Any]:
|
| 529 | start = time.perf_counter()
|
| 530 | try:
|
| 531 | extra = func()
|
| 532 | returncode = 0 if extra.get("checks", {}).get("ok", True) else 1
|
| 533 | stderr = ""
|
| 534 | except Exception as exc: # noqa: BLE001 - turn filesystem failures into rows
|
| 535 | extra = {"checks": {"ok": False, "error": str(exc)}}
|
| 536 | returncode = 1
|
| 537 | stderr = str(exc)
|
| 538 | elapsed_ms = (time.perf_counter() - start) * 1000
|
| 539 | result = {
|
| 540 | "elapsed_ms": elapsed_ms,
|
| 541 | "returncode": returncode,
|
| 542 | "stdout": "",
|
| 543 | "stderr": stderr,
|
| 544 | "attempts": 1,
|
| 545 | "retry_count": 0,
|
| 546 | }
|
| 547 | row = command_row(meta, scenario, operation, run_index, command, result, scenario_start, extra)
|
| 548 | if first_useful:
|
| 549 | row["time_to_first_useful_work_ms"] = row.get("since_scenario_start_ms", row["elapsed_ms"])
|
| 550 | return row
|
| 551 |
|
| 552 |
|
| 553 | def bounded_tree_usage(root: Path, max_entries: int) -> dict[str, Any]:
|
| 554 | # Shared measurement policy (oakbench.diskprobe); re-exported here so
|
| 555 | # existing callers and tests keep their entry point.
|
| 556 | return oakbench_diskprobe.bounded_tree_usage(root, max_entries)
|
| 557 |
|
| 558 |
|
| 559 | def spec_defaults(spec: dict[str, Any]) -> dict[str, Any]:
|
| 560 | defaults = spec.get("defaults") or {}
|
| 561 | if not isinstance(defaults, dict):
|
| 562 | return {}
|
| 563 | return defaults
|
| 564 |
|
| 565 |
|
| 566 | def int_default(defaults: dict[str, Any], key: str, fallback: int) -> int:
|
| 567 | value = defaults.get(key, fallback)
|
| 568 | try:
|
| 569 | return int(value)
|
| 570 | except (TypeError, ValueError):
|
| 571 | return fallback
|
| 572 |
|
| 573 |
|
| 574 | def str_default(defaults: dict[str, Any], key: str, fallback: str = "") -> str:
|
| 575 | value = defaults.get(key, fallback)
|
| 576 | return fallback if value is None else str(value)
|
| 577 |
|
| 578 |
|
| 579 | def list_default(defaults: dict[str, Any], key: str) -> list[str]:
|
| 580 | value = defaults.get(key, [])
|
| 581 | if isinstance(value, list):
|
| 582 | return [str(item) for item in value]
|
| 583 | if isinstance(value, str) and value:
|
| 584 | return [value]
|
| 585 | return []
|
| 586 |
|
| 587 |
|
| 588 | def bool_default(defaults: dict[str, Any], key: str, fallback: bool = False) -> bool:
|
| 589 | value = defaults.get(key, fallback)
|
| 590 | if isinstance(value, str):
|
| 591 | return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 592 | return bool(value)
|
| 593 |
|
| 594 |
|
| 595 | def manifest_expected_hash(root: Path, manifest_rel: str, target_rel: str) -> tuple[str | None, str | None]:
|
| 596 | manifest_path = root / manifest_rel
|
| 597 | try:
|
| 598 | manifest = parse_manifest(manifest_path.read_text())
|
| 599 | except OSError as exc:
|
| 600 | return None, str(exc)
|
| 601 | expected = manifest.get(target_rel)
|
| 602 | if not expected:
|
| 603 | return None, f"{target_rel} missing from {manifest_rel}"
|
| 604 | return expected, None
|
| 605 |
|
| 606 |
|
| 607 | def content_check_fields(
|
| 608 | root: Path,
|
| 609 | target_rel: str,
|
| 610 | data: bytes,
|
| 611 | defaults: dict[str, Any],
|
| 612 | ) -> dict[str, Any]:
|
| 613 | expected_contains = defaults.get("target_read_contains")
|
| 614 | expected_sha = str_default(defaults, "target_read_sha256", "")
|
| 615 | manifest_rel = str_default(defaults, "content_manifest", MANIFEST_TARGET)
|
| 616 | require_manifest = bool_default(defaults, "require_content_manifest", False)
|
| 617 |
|
| 618 | if expected_contains is not None:
|
| 619 | needle = str(expected_contains).encode("utf-8")
|
| 620 | ok = needle in data
|
| 621 | return {
|
| 622 | "content_check_method": "contains",
|
| 623 | "content_check_status": "passed" if ok else "failed",
|
| 624 | "content_verified": ok,
|
| 625 | "content_expected_contains": str(expected_contains),
|
| 626 | "content_actual_sha256": hashlib.sha256(data).hexdigest(),
|
| 627 | }
|
| 628 |
|
| 629 | if expected_sha:
|
| 630 | actual = hashlib.sha256((root / target_rel).read_bytes()).hexdigest()
|
| 631 | ok = actual == expected_sha.lower()
|
| 632 | return {
|
| 633 | "content_check_method": "sha256",
|
| 634 | "content_check_status": "passed" if ok else "failed",
|
| 635 | "content_verified": ok,
|
| 636 | "content_expected_sha256": expected_sha.lower(),
|
| 637 | "content_actual_sha256": actual,
|
| 638 | }
|
| 639 |
|
| 640 | expected_from_manifest, manifest_error = manifest_expected_hash(root, manifest_rel, target_rel)
|
| 641 | if expected_from_manifest:
|
| 642 | actual = hashlib.sha256((root / target_rel).read_bytes()).hexdigest()
|
| 643 | ok = actual == expected_from_manifest
|
| 644 | return {
|
| 645 | "content_check_method": "manifest_sha256",
|
| 646 | "content_check_status": "passed" if ok else "failed",
|
| 647 | "content_verified": ok,
|
| 648 | "content_manifest": manifest_rel,
|
| 649 | "content_expected_sha256": expected_from_manifest,
|
| 650 | "content_actual_sha256": actual,
|
| 651 | }
|
| 652 | if require_manifest:
|
| 653 | return {
|
| 654 | "content_check_method": "manifest_sha256",
|
| 655 | "content_check_status": "failed",
|
| 656 | "content_verified": False,
|
| 657 | "content_manifest": manifest_rel,
|
| 658 | "content_error": manifest_error or "manifest_missing_or_unparseable",
|
| 659 | }
|
| 660 | return {
|
| 661 | "content_check_method": "none",
|
| 662 | "content_check_status": "skipped_unconfigured",
|
| 663 | "content_verified": False,
|
| 664 | }
|
| 665 |
|
| 666 |
|
| 667 | def mount_readiness_probe(path: Path, defaults: dict[str, Any]) -> dict[str, Any]:
|
| 668 | start = time.perf_counter()
|
| 669 | target = str_default(defaults, "target_read", "README.md")
|
| 670 | read_bytes = int_default(defaults, "target_read_bytes", 4096)
|
| 671 | fields: dict[str, Any] = {
|
| 672 | "mount_ready_path": str(path),
|
| 673 | "mount_ready_target": target,
|
| 674 | }
|
| 675 | try:
|
| 676 | entries = sorted(os.listdir(path))
|
| 677 | fields["mount_ready_list_ok"] = True
|
| 678 | fields["mount_ready_entry_count"] = len(entries)
|
| 679 | except OSError as exc:
|
| 680 | fields["mount_ready_list_ok"] = False
|
| 681 | fields["mount_ready_error"] = str(exc)
|
| 682 | fields["mount_ready_probe_elapsed_ms"] = round((time.perf_counter() - start) * 1000, 3)
|
| 683 | fields["checks"] = {"ok": False, "ready_to_read": False, "error": str(exc)}
|
| 684 | return fields
|
| 685 |
|
| 686 | try:
|
| 687 | with (path / target).open("rb") as fh:
|
| 688 | data = fh.read(read_bytes)
|
| 689 | fields["mount_ready_read_ok"] = True
|
| 690 | fields["mount_ready_read_bytes"] = len(data)
|
| 691 | except OSError as exc:
|
| 692 | fields["mount_ready_read_ok"] = False
|
| 693 | fields["mount_ready_error"] = str(exc)
|
| 694 | data = b""
|
| 695 |
|
| 696 | ready = bool(fields.get("mount_ready_list_ok") and fields.get("mount_ready_read_ok"))
|
| 697 | fields["mount_ready_probe_elapsed_ms"] = round((time.perf_counter() - start) * 1000, 3)
|
| 698 | fields["checks"] = {"ok": ready, "ready_to_read": ready}
|
| 699 | return fields
|
| 700 |
|
| 701 |
|
| 702 | def scenario_cache_fields(
|
| 703 | scenario: dict[str, Any],
|
| 704 | purge_result: dict[str, Any] | None = None,
|
| 705 | ) -> dict[str, Any]:
|
| 706 | requested = scenario.get("cache_state")
|
| 707 | if requested == "cold":
|
| 708 | return {
|
| 709 | "requested_cache_state": "cold",
|
| 710 | **oakbench_cachectl.cache_fields("cold", purge_result),
|
| 711 | }
|
| 712 | if not requested:
|
| 713 | return {
|
| 714 | "requested_cache_state": UNSET_CACHE_STATE,
|
| 715 | "cache_state": UNKNOWN_CACHE_STATE,
|
| 716 | "cache_state_reason": scenario.get("cache_state_reason") or CACHE_STATE_UNSET,
|
| 717 | }
|
| 718 | return {
|
| 719 | "requested_cache_state": requested,
|
| 720 | "cache_state": requested,
|
| 721 | "cache_state_reason": scenario.get("cache_state_reason") or EXPLICIT_CACHE_STATE,
|
| 722 | }
|
| 723 |
|
| 724 |
|
| 725 | def apply_scenario_cache_state(
|
| 726 | rows: list[dict[str, Any]],
|
| 727 | scenario: dict[str, Any],
|
| 728 | purge_result: dict[str, Any] | None = None,
|
| 729 | ) -> None:
|
| 730 | fields = scenario_cache_fields(scenario, purge_result)
|
| 731 | for row in rows:
|
| 732 | row.update(fields)
|
| 733 |
|
| 734 |
|
| 735 | _SUPPORT_CACHE: dict[Tuple[str, str], bool] = {}
|
| 736 |
|
| 737 |
|
| 738 | def oak_supports(oak_bin: str, subcommand: str) -> bool:
|
| 739 | """Untimed capability probe: does this binary know the subcommand?
|
| 740 |
|
| 741 | Keeps planned lifecycle operations (e.g. `oak finish`) as explicit skip
|
| 742 | rows on older binaries instead of guaranteed-failure rows, and makes them
|
| 743 | light up automatically once the binary ships the command.
|
| 744 | """
|
| 745 | key = (oak_bin, subcommand)
|
| 746 | if key not in _SUPPORT_CACHE:
|
| 747 | try:
|
| 748 | probe = subprocess.run(
|
| 749 | [oak_bin, subcommand, "--help"],
|
| 750 | env=base_env(),
|
| 751 | capture_output=True,
|
| 752 | text=True,
|
| 753 | timeout=15,
|
| 754 | check=False,
|
| 755 | )
|
| 756 | _SUPPORT_CACHE[key] = probe.returncode == 0
|
| 757 | except (OSError, subprocess.TimeoutExpired):
|
| 758 | _SUPPORT_CACHE[key] = False
|
| 759 | return _SUPPORT_CACHE[key]
|
| 760 |
|
| 761 |
|
| 762 | def mount_repo(spec: dict[str, Any]) -> str:
|
| 763 | remote = spec.get("remote") or {}
|
| 764 | if not isinstance(remote, dict):
|
| 765 | remote = {}
|
| 766 | configured = str(remote.get("owner_repo") or "").strip()
|
| 767 | env_name = str(remote.get("owner_repo_env") or "OAK_BENCH_MOUNT_REPO")
|
| 768 | return configured or os.environ.get(env_name, "").strip()
|
| 769 |
|
| 770 |
|
| 771 | def safe_push_enabled(spec: dict[str, Any]) -> bool:
|
| 772 | remote = spec.get("remote") or {}
|
| 773 | return bool(isinstance(remote, dict) and remote.get("safe_push"))
|
| 774 |
|
| 775 |
|
| 776 | def scenario_runs(defaults: dict[str, Any]) -> range:
|
| 777 | return range(max(1, int_default(defaults, "runs", 1)))
|
| 778 |
|
| 779 |
|
| 780 | def mount_path(run_root: Path, scenario_name: str, run_index: int) -> Path:
|
| 781 | safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", scenario_name).strip("-") or "mount"
|
| 782 | return run_root / f"{safe}-run-{run_index}"
|
| 783 |
|
| 784 |
|
| 785 | def finish_desc_file_path(run_root: Path, scenario_name: str, run_index: int) -> Path:
|
| 786 | safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", scenario_name).strip("-") or "mount"
|
| 787 | return run_root / f"{safe}-run-{run_index}.finish-desc.md"
|
| 788 |
|
| 789 |
|
| 790 | def run_timed_command(
|
| 791 | rows: list[dict[str, Any]],
|
| 792 | meta: dict[str, Any],
|
| 793 | scenario_name: str,
|
| 794 | operation: str,
|
| 795 | run_index: int,
|
| 796 | command: list[str],
|
| 797 | cwd: Optional[Path],
|
| 798 | defaults: dict[str, Any],
|
| 799 | scenario_start: float,
|
| 800 | capture_stdout: bool = True,
|
| 801 | extra: Optional[dict[str, Any]] = None,
|
| 802 | ) -> dict[str, Any]:
|
| 803 | result = run_process(
|
| 804 | command,
|
| 805 | cwd=cwd,
|
| 806 | timeout=int_default(defaults, "timeout_seconds", 120),
|
| 807 | retries=int_default(defaults, "retries", 0),
|
| 808 | capture_stdout=capture_stdout,
|
| 809 | )
|
| 810 | row = command_row(meta, scenario_name, operation, run_index, command, result, scenario_start, extra)
|
| 811 | rows.append(row)
|
| 812 | return row
|
| 813 |
|
| 814 |
|
| 815 | def run_operation_sequence(
|
| 816 | meta: dict[str, Any],
|
| 817 | oak_bin: str,
|
| 818 | owner_repo: str,
|
| 819 | allow_push: bool,
|
| 820 | scenario: dict[str, Any],
|
| 821 | defaults: dict[str, Any],
|
| 822 | run_root: Path,
|
| 823 | run_index: int,
|
| 824 | ) -> Tuple[list[dict[str, Any]], bool]:
|
| 825 | rows: list[dict[str, Any]] = []
|
| 826 | cleanup_safe = True
|
| 827 | scenario_name = str(scenario.get("name", "mount"))
|
| 828 | path = mount_path(run_root, scenario_name, run_index)
|
| 829 | operations = scenario.get("operations") or []
|
| 830 | mounted = False
|
| 831 | first_useful_recorded = False
|
| 832 | scenario_start = time.perf_counter()
|
| 833 | purge_result = oakbench_cachectl.purge_fs_caches() if scenario.get("cache_state") == "cold" else None
|
| 834 |
|
| 835 | for operation in operations:
|
| 836 | operation = str(operation)
|
| 837 | if operation == "mount.start":
|
| 838 | command = [oak_bin, "mount", owner_repo, str(path)]
|
| 839 | row = run_timed_command(rows, meta, scenario_name, operation, run_index, command, None, defaults, scenario_start)
|
| 840 | command_return_since_start = row.get("since_scenario_start_ms")
|
| 841 | row["mount_command_returncode"] = row["returncode"]
|
| 842 | row["mount_command_elapsed_ms"] = row["elapsed_ms"]
|
| 843 | row["time_to_mount_command_return_ms"] = command_return_since_start
|
| 844 | if row["returncode"] == 0:
|
| 845 | readiness = mount_readiness_probe(path, defaults)
|
| 846 | row.update(readiness)
|
| 847 | row["since_scenario_start_ms"] = round((time.perf_counter() - scenario_start) * 1000, 3)
|
| 848 | row["time_to_mount_ready_to_read_ms"] = row["since_scenario_start_ms"]
|
| 849 | if not readiness.get("checks", {}).get("ok"):
|
| 850 | row["returncode"] = 1
|
| 851 | else:
|
| 852 | row["mount_ready_probe_elapsed_ms"] = 0.0
|
| 853 | row["mount_ready_read_ok"] = False
|
| 854 | row["mount_ready_list_ok"] = False
|
| 855 | row["time_to_mount_ready_to_read_ms"] = None
|
| 856 | row["checks"] = {"ok": False, "ready_to_read": False, "command_returncode": row["returncode"]}
|
| 857 | mounted = row["returncode"] == 0
|
| 858 | cleanup_safe = cleanup_safe and mounted
|
| 859 | continue
|
| 860 |
|
| 861 | if operation == "mount.teardown":
|
| 862 | if not mounted:
|
| 863 | rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
|
| 864 | cleanup_safe = False
|
| 865 | continue
|
| 866 | command = [oak_bin, "mount", "end", str(path)]
|
| 867 | row = run_timed_command(rows, meta, scenario_name, operation, run_index, command, None, defaults, scenario_start)
|
| 868 | released = not path.exists()
|
| 869 | row["checks"] = {"ok": row["returncode"] == 0 and released, "path_released": released}
|
| 870 | mounted = False
|
| 871 | cleanup_safe = cleanup_safe and row["returncode"] == 0
|
| 872 | continue
|
| 873 |
|
| 874 | if operation == "oak.status":
|
| 875 | if not mounted:
|
| 876 | rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
|
| 877 | continue
|
| 878 | run_timed_command(rows, meta, scenario_name, operation, run_index, [oak_bin, "status"], path, defaults, scenario_start)
|
| 879 | continue
|
| 880 |
|
| 881 | if operation == "oak.diff":
|
| 882 | if not mounted:
|
| 883 | rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
|
| 884 | continue
|
| 885 | run_timed_command(rows, meta, scenario_name, operation, run_index, [oak_bin, "diff"], path, defaults, scenario_start)
|
| 886 | continue
|
| 887 |
|
| 888 | if operation == "oak.commit":
|
| 889 | if not mounted:
|
| 890 | rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
|
| 891 | continue
|
| 892 | run_timed_command(
|
| 893 | rows,
|
| 894 | meta,
|
| 895 | scenario_name,
|
| 896 | operation,
|
| 897 | run_index,
|
| 898 | [oak_bin, "commit", "--no-verify"],
|
| 899 | path,
|
| 900 | defaults,
|
| 901 | scenario_start,
|
| 902 | )
|
| 903 | continue
|
| 904 |
|
| 905 | if operation == "oak.push":
|
| 906 | if not mounted:
|
| 907 | rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
|
| 908 | continue
|
| 909 | if not allow_push:
|
| 910 | rows.append(skip_row(meta, scenario_name, operation, run_index, "safe_push_disabled"))
|
| 911 | continue
|
| 912 | run_timed_command(rows, meta, scenario_name, operation, run_index, [oak_bin, "push"], path, defaults, scenario_start)
|
| 913 | continue
|
| 914 |
|
| 915 | if operation == "fs.first_ls":
|
| 916 | if not mounted:
|
| 917 | rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
|
| 918 | continue
|
| 919 |
|
| 920 | def first_ls() -> dict[str, Any]:
|
| 921 | entries = sorted(os.listdir(path))
|
| 922 | return {"entry_count": len(entries), "sample_entries": entries[:10], "checks": {"ok": True, "exists": path.exists()}}
|
| 923 |
|
| 924 | rows.append(
|
| 925 | fs_row(
|
| 926 | meta,
|
| 927 | scenario_name,
|
| 928 | operation,
|
| 929 | run_index,
|
| 930 | ["python:os.listdir", "."],
|
| 931 | scenario_start,
|
| 932 | first_ls,
|
| 933 | not first_useful_recorded,
|
| 934 | )
|
| 935 | )
|
| 936 | first_useful_recorded = True
|
| 937 | continue
|
| 938 |
|
| 939 | if operation == "fs.targeted_read":
|
| 940 | if not mounted:
|
| 941 | rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
|
| 942 | continue
|
| 943 | target = str_default(defaults, "target_read", "README.md")
|
| 944 | read_bytes = int_default(defaults, "target_read_bytes", 4096)
|
| 945 | expected = defaults.get("target_read_contains")
|
| 946 |
|
| 947 | def targeted_read() -> dict[str, Any]:
|
| 948 | target_path = path / target
|
| 949 | with target_path.open("rb") as fh:
|
| 950 | data = fh.read(read_bytes)
|
| 951 | checks = {"ok": target_path.exists(), "exists": target_path.exists(), "read_bytes": len(data)}
|
| 952 | content_fields = content_check_fields(path, target, data, defaults)
|
| 953 | checks["content_verified"] = bool(content_fields.get("content_verified"))
|
| 954 | checks["ok"] = checks["ok"] and checks["content_verified"]
|
| 955 | if expected is not None:
|
| 956 | checks["contains_expected"] = bool(content_fields.get("content_verified"))
|
| 957 | return {"target_path": target, "read_bytes": len(data), "checks": checks, **content_fields}
|
| 958 |
|
| 959 | rows.append(
|
| 960 | fs_row(
|
| 961 | meta,
|
| 962 | scenario_name,
|
| 963 | operation,
|
| 964 | run_index,
|
| 965 | ["python:read_bytes", target, str(read_bytes)],
|
| 966 | scenario_start,
|
| 967 | targeted_read,
|
| 968 | not first_useful_recorded,
|
| 969 | )
|
| 970 | )
|
| 971 | first_useful_recorded = True
|
| 972 | continue
|
| 973 |
|
| 974 | if operation == "fs.first_edit":
|
| 975 | if not mounted:
|
| 976 | rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
|
| 977 | continue
|
| 978 | edit_path = str_default(defaults, "edit_path", ".oak-bench/mount-edit.txt")
|
| 979 |
|
| 980 | def first_edit() -> dict[str, Any]:
|
| 981 | target = path / edit_path
|
| 982 | target.parent.mkdir(parents=True, exist_ok=True)
|
| 983 | text = f"oak mount benchmark {scenario_name} run {run_index}\n"
|
| 984 | target.write_text(text)
|
| 985 | round_trip = target.read_text()
|
| 986 | return {
|
| 987 | "edit_path": edit_path,
|
| 988 | "write_bytes": len(text.encode("utf-8")),
|
| 989 | "checks": {"ok": round_trip == text, "round_trip": round_trip == text},
|
| 990 | }
|
| 991 |
|
| 992 | rows.append(
|
| 993 | fs_row(
|
| 994 | meta,
|
| 995 | scenario_name,
|
| 996 | operation,
|
| 997 | run_index,
|
| 998 | ["python:write_text", edit_path],
|
| 999 | scenario_start,
|
| 1000 | first_edit,
|
| 1001 | not first_useful_recorded,
|
| 1002 | )
|
| 1003 | )
|
| 1004 | first_useful_recorded = True
|
| 1005 | continue
|
| 1006 |
|
| 1007 | if operation == "fs.sparse_paths":
|
| 1008 | if not mounted:
|
| 1009 | rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
|
| 1010 | continue
|
| 1011 | sparse_paths = list_default(defaults, "sparse_paths")
|
| 1012 |
|
| 1013 | def sparse_probe() -> dict[str, Any]:
|
| 1014 | checked = []
|
| 1015 | for rel in sparse_paths:
|
| 1016 | item = path / rel
|
| 1017 | exists = item.exists()
|
| 1018 | is_dir = item.is_dir() if exists else False
|
| 1019 | checked.append({"path": rel, "exists": exists, "is_dir": is_dir})
|
| 1020 | if exists and not is_dir:
|
| 1021 | with item.open("rb") as fh:
|
| 1022 | fh.read(128)
|
| 1023 | return {"sparse_paths": checked, "checks": {"ok": all(item["exists"] for item in checked)}}
|
| 1024 |
|
| 1025 | rows.append(
|
| 1026 | fs_row(
|
| 1027 | meta,
|
| 1028 | scenario_name,
|
| 1029 | operation,
|
| 1030 | run_index,
|
| 1031 | ["python:sparse_paths"] + sparse_paths,
|
| 1032 | scenario_start,
|
| 1033 | sparse_probe,
|
| 1034 | not first_useful_recorded,
|
| 1035 | )
|
| 1036 | )
|
| 1037 | first_useful_recorded = True
|
| 1038 | continue
|
| 1039 |
|
| 1040 | if operation == "fs.huge_partial_read":
|
| 1041 | if not mounted:
|
| 1042 | rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
|
| 1043 | continue
|
| 1044 | huge_path = str_default(defaults, "huge_file_path", "")
|
| 1045 | if not huge_path:
|
| 1046 | rows.append(skip_row(meta, scenario_name, operation, run_index, "huge_file_path_not_configured"))
|
| 1047 | continue
|
| 1048 | read_bytes = int_default(defaults, "huge_file_read_bytes", 4096)
|
| 1049 |
|
| 1050 | def huge_partial_read() -> dict[str, Any]:
|
| 1051 | target = path / huge_path
|
| 1052 | with target.open("rb") as fh:
|
| 1053 | data = fh.read(read_bytes)
|
| 1054 | return {
|
| 1055 | "huge_file_path": huge_path,
|
| 1056 | "requested_bytes": read_bytes,
|
| 1057 | "read_bytes": len(data),
|
| 1058 | "checks": {"ok": target.exists() and len(data) > 0, "exists": target.exists()},
|
| 1059 | }
|
| 1060 |
|
| 1061 | rows.append(
|
| 1062 | fs_row(
|
| 1063 | meta,
|
| 1064 | scenario_name,
|
| 1065 | operation,
|
| 1066 | run_index,
|
| 1067 | ["python:read_prefix", huge_path, str(read_bytes)],
|
| 1068 | scenario_start,
|
| 1069 | huge_partial_read,
|
| 1070 | not first_useful_recorded,
|
| 1071 | )
|
| 1072 | )
|
| 1073 | first_useful_recorded = True
|
| 1074 | continue
|
| 1075 |
|
| 1076 | if operation == "disk.usage":
|
| 1077 | if not mounted:
|
| 1078 | rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
|
| 1079 | continue
|
| 1080 | max_entries = int_default(defaults, "max_disk_entries", 100000)
|
| 1081 | rows.append(
|
| 1082 | fs_row(
|
| 1083 | meta,
|
| 1084 | scenario_name,
|
| 1085 | operation,
|
| 1086 | run_index,
|
| 1087 | ["python:bounded_tree_usage", str(path), str(max_entries)],
|
| 1088 | scenario_start,
|
| 1089 | lambda: bounded_tree_usage(path, max_entries),
|
| 1090 | False,
|
| 1091 | )
|
| 1092 | )
|
| 1093 | continue
|
| 1094 |
|
| 1095 | if operation == "oak.desc":
|
| 1096 | if not mounted:
|
| 1097 | rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
|
| 1098 | continue
|
| 1099 | message = str_default(defaults, "desc_message", f"bench {scenario_name} run {run_index}")
|
| 1100 | run_timed_command(
|
| 1101 | rows, meta, scenario_name, operation, run_index, [oak_bin, "desc", message], path, defaults, scenario_start
|
| 1102 | )
|
| 1103 | continue
|
| 1104 |
|
| 1105 | if operation == "oak.finish":
|
| 1106 | if not mounted:
|
| 1107 | rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
|
| 1108 | continue
|
| 1109 | if not oak_supports(oak_bin, "finish"):
|
| 1110 | rows.append(
|
| 1111 | skip_row(meta, scenario_name, operation, run_index, "finish_not_supported_by_binary")
|
| 1112 | )
|
| 1113 | continue
|
| 1114 | message = str_default(defaults, "desc_message", f"bench {scenario_name} run {run_index}")
|
| 1115 | desc_file = finish_desc_file_path(run_root, scenario_name, run_index).resolve()
|
| 1116 | desc_file.parent.mkdir(parents=True, exist_ok=True)
|
| 1117 | desc_file.write_text(message + "\n")
|
| 1118 | row = run_timed_command(
|
| 1119 | rows,
|
| 1120 | meta,
|
| 1121 | scenario_name,
|
| 1122 | operation,
|
| 1123 | run_index,
|
| 1124 | [oak_bin, "finish", "--desc-file", str(desc_file)],
|
| 1125 | path,
|
| 1126 | defaults,
|
| 1127 | scenario_start,
|
| 1128 | extra={"desc_file": str(desc_file)},
|
| 1129 | )
|
| 1130 | released = not path.exists()
|
| 1131 | row["checks"] = {"ok": row["returncode"] == 0, "path_released": released}
|
| 1132 | mounted = mounted and not released
|
| 1133 | continue
|
| 1134 |
|
| 1135 | if operation == "space.clean":
|
| 1136 | # space clean operates on the space (parent of the mounts); it
|
| 1137 | # should tear down this mount only if its tree is clean
|
| 1138 | # (committed + pushed), which is the lifecycle property under test.
|
| 1139 | row = run_timed_command(
|
| 1140 | rows,
|
| 1141 | meta,
|
| 1142 | scenario_name,
|
| 1143 | operation,
|
| 1144 | run_index,
|
| 1145 | [oak_bin, "space", "clean"],
|
| 1146 | path.parent,
|
| 1147 | defaults,
|
| 1148 | scenario_start,
|
| 1149 | )
|
| 1150 | released = not path.exists()
|
| 1151 | row["checks"] = {"ok": row["returncode"] == 0, "mount_released_by_clean": released}
|
| 1152 | mounted = mounted and not released
|
| 1153 | continue
|
| 1154 |
|
| 1155 | if operation == "loop.push_desc":
|
| 1156 | # The steady-state agent loop on a mount: edit, commit, desc,
|
| 1157 | # push, repeat. Lifecycle cost lives in the repetition, not the
|
| 1158 | # first call β per-iteration rows keep the trend, the .total row
|
| 1159 | # keeps the loop-level price.
|
| 1160 | if not mounted:
|
| 1161 | rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
|
| 1162 | continue
|
| 1163 | iterations = int_default(defaults, "loop_iterations", 3)
|
| 1164 | per_iteration_ms: list[float] = []
|
| 1165 | loop_failures = 0
|
| 1166 | loop_start = time.perf_counter()
|
| 1167 | for iteration in range(iterations):
|
| 1168 | iteration_start = time.perf_counter()
|
| 1169 |
|
| 1170 | def loop_edit(iteration: int = iteration) -> dict[str, Any]:
|
| 1171 | target = path / ".oak-bench" / "loop-edit.txt"
|
| 1172 | target.parent.mkdir(parents=True, exist_ok=True)
|
| 1173 | text = f"loop iteration {iteration} of {scenario_name} run {run_index}\n"
|
| 1174 | target.write_text(text)
|
| 1175 | return {"iteration": iteration, "checks": {"ok": target.read_text() == text}}
|
| 1176 |
|
| 1177 | rows.append(
|
| 1178 | fs_row(
|
| 1179 | meta,
|
| 1180 | scenario_name,
|
| 1181 | "loop.edit",
|
| 1182 | run_index,
|
| 1183 | ["python:write_text", ".oak-bench/loop-edit.txt"],
|
| 1184 | scenario_start,
|
| 1185 | loop_edit,
|
| 1186 | False,
|
| 1187 | )
|
| 1188 | )
|
| 1189 | commit_row = run_timed_command(
|
| 1190 | rows,
|
| 1191 | meta,
|
| 1192 | scenario_name,
|
| 1193 | "loop.commit",
|
| 1194 | run_index,
|
| 1195 | [oak_bin, "commit", "--no-verify"],
|
| 1196 | path,
|
| 1197 | defaults,
|
| 1198 | scenario_start,
|
| 1199 | extra={"iteration": iteration},
|
| 1200 | )
|
| 1201 | desc_row = run_timed_command(
|
| 1202 | rows,
|
| 1203 | meta,
|
| 1204 | scenario_name,
|
| 1205 | "loop.desc",
|
| 1206 | run_index,
|
| 1207 | [oak_bin, "desc", f"loop iteration {iteration}"],
|
| 1208 | path,
|
| 1209 | defaults,
|
| 1210 | scenario_start,
|
| 1211 | extra={"iteration": iteration},
|
| 1212 | )
|
| 1213 | loop_failures += int(commit_row["returncode"] != 0) + int(desc_row["returncode"] != 0)
|
| 1214 | if allow_push:
|
| 1215 | push_row = run_timed_command(
|
| 1216 | rows,
|
| 1217 | meta,
|
| 1218 | scenario_name,
|
| 1219 | "loop.push",
|
| 1220 | run_index,
|
| 1221 | [oak_bin, "push"],
|
| 1222 | path,
|
| 1223 | defaults,
|
| 1224 | scenario_start,
|
| 1225 | extra={"iteration": iteration},
|
| 1226 | )
|
| 1227 | loop_failures += int(push_row["returncode"] != 0)
|
| 1228 | else:
|
| 1229 | rows.append(
|
| 1230 | skip_row(
|
| 1231 | meta,
|
| 1232 | scenario_name,
|
| 1233 | "loop.push",
|
| 1234 | run_index,
|
| 1235 | "safe_push_disabled",
|
| 1236 | extra={"iteration": iteration},
|
| 1237 | )
|
| 1238 | )
|
| 1239 | per_iteration_ms.append(round((time.perf_counter() - iteration_start) * 1000, 3))
|
| 1240 | rows.append(
|
| 1241 | {
|
| 1242 | **meta,
|
| 1243 | "scenario": scenario_name,
|
| 1244 | "run": run_index,
|
| 1245 | "operation": "loop.push_desc.total",
|
| 1246 | "elapsed_ms": round((time.perf_counter() - loop_start) * 1000, 3),
|
| 1247 | "returncode": 0 if loop_failures == 0 else 1,
|
| 1248 | "command": ["loop:edit+commit+desc+push", str(iterations)],
|
| 1249 | "iterations": iterations,
|
| 1250 | "iteration_failures": loop_failures,
|
| 1251 | "push_enabled": allow_push,
|
| 1252 | "per_iteration_ms": per_iteration_ms,
|
| 1253 | "iteration_ms_max": max(per_iteration_ms) if per_iteration_ms else None,
|
| 1254 | "since_scenario_start_ms": round((time.perf_counter() - scenario_start) * 1000, 3),
|
| 1255 | "attempts": 1,
|
| 1256 | "retry_count": 0,
|
| 1257 | }
|
| 1258 | )
|
| 1259 | continue
|
| 1260 |
|
| 1261 | rows.append(skip_row(meta, scenario_name, operation, run_index, "operation_not_implemented"))
|
| 1262 |
|
| 1263 | if mounted:
|
| 1264 | command = [oak_bin, "mount", "end", str(path)]
|
| 1265 | row = run_timed_command(rows, meta, scenario_name, "mount.teardown.auto", run_index, command, None, defaults, scenario_start)
|
| 1266 | cleanup_safe = cleanup_safe and row["returncode"] == 0
|
| 1267 | apply_scenario_cache_state(rows, scenario, purge_result)
|
| 1268 | return rows, cleanup_safe
|
| 1269 |
|
| 1270 |
|
| 1271 | def run_recovery_probe(
|
| 1272 | meta: dict[str, Any],
|
| 1273 | oak_bin: str,
|
| 1274 | owner_repo: str,
|
| 1275 | scenario: dict[str, Any],
|
| 1276 | defaults: dict[str, Any],
|
| 1277 | run_root: Path,
|
| 1278 | run_index: int,
|
| 1279 | ) -> Tuple[list[dict[str, Any]], bool]:
|
| 1280 | scenario_name = str(scenario.get("name", "interrupted_recovery"))
|
| 1281 | path = mount_path(run_root, scenario_name, run_index)
|
| 1282 | path.mkdir(parents=True, exist_ok=True)
|
| 1283 | (path / ".oak-bench-partial").write_text("simulated interrupted mount destination\n")
|
| 1284 | scenario_start = time.perf_counter()
|
| 1285 | rows: list[dict[str, Any]] = []
|
| 1286 | command = [oak_bin, "mount", owner_repo, str(path)]
|
| 1287 | row = run_timed_command(rows, meta, scenario_name, "recovery.existing_partial_dir", run_index, command, None, defaults, scenario_start)
|
| 1288 | if row["returncode"] != 0 and "not empty" in str(row.get("stderr", "")):
|
| 1289 | rows[-1] = skip_row(
|
| 1290 | meta,
|
| 1291 | scenario_name,
|
| 1292 | "recovery.existing_partial_dir",
|
| 1293 | run_index,
|
| 1294 | "mount_partial_destination_recovery_not_supported",
|
| 1295 | {
|
| 1296 | "attempted_command": command,
|
| 1297 | "process_returncode": row["returncode"],
|
| 1298 | "process_stderr": row.get("stderr"),
|
| 1299 | "recovery_supported": False,
|
| 1300 | },
|
| 1301 | )
|
| 1302 | shutil.rmtree(path, ignore_errors=True)
|
| 1303 | apply_scenario_cache_state(rows, scenario)
|
| 1304 | return rows, True
|
| 1305 | mounted = row["returncode"] == 0
|
| 1306 | if mounted:
|
| 1307 | end_row = run_timed_command(
|
| 1308 | rows,
|
| 1309 | meta,
|
| 1310 | scenario_name,
|
| 1311 | "mount.teardown",
|
| 1312 | run_index,
|
| 1313 | [oak_bin, "mount", "end", str(path)],
|
| 1314 | None,
|
| 1315 | defaults,
|
| 1316 | scenario_start,
|
| 1317 | )
|
| 1318 | apply_scenario_cache_state(rows, scenario)
|
| 1319 | return rows, end_row["returncode"] == 0
|
| 1320 | apply_scenario_cache_state(rows, scenario)
|
| 1321 | return rows, False
|
| 1322 |
|
| 1323 |
|
| 1324 | def run_parallel_probe(
|
| 1325 | meta: dict[str, Any],
|
| 1326 | oak_bin: str,
|
| 1327 | owner_repo: str,
|
| 1328 | scenario: dict[str, Any],
|
| 1329 | defaults: dict[str, Any],
|
| 1330 | run_root: Path,
|
| 1331 | run_index: int,
|
| 1332 | ) -> Tuple[list[dict[str, Any]], bool]:
|
| 1333 | scenario_name = str(scenario.get("name", "parallel_mounts"))
|
| 1334 | parallelism = max(1, int_default(defaults, "parallelism", 4))
|
| 1335 | timeout = int_default(defaults, "timeout_seconds", 120)
|
| 1336 | scenario_start = time.perf_counter()
|
| 1337 |
|
| 1338 | def worker(worker_index: int) -> dict[str, Any]:
|
| 1339 | path = run_root / f"{scenario_name}-run-{run_index}-worker-{worker_index}"
|
| 1340 | command = [oak_bin, "mount", owner_repo, str(path)]
|
| 1341 | mount_result = run_process(command, None, timeout, int_default(defaults, "retries", 0))
|
| 1342 | first_ls_ok = False
|
| 1343 | teardown_returncode: Optional[int] = None
|
| 1344 | if mount_result["returncode"] == 0:
|
| 1345 | try:
|
| 1346 | os.listdir(path)
|
| 1347 | first_ls_ok = True
|
| 1348 | except OSError:
|
| 1349 | first_ls_ok = False
|
| 1350 | teardown = run_process([oak_bin, "mount", "end", str(path)], None, timeout, 0)
|
| 1351 | teardown_returncode = int(teardown["returncode"])
|
| 1352 | return {
|
| 1353 | "worker": worker_index,
|
| 1354 | "returncode": mount_result["returncode"],
|
| 1355 | "elapsed_ms": mount_result["elapsed_ms"],
|
| 1356 | "first_ls_ok": first_ls_ok,
|
| 1357 | "teardown_returncode": teardown_returncode,
|
| 1358 | "stderr": mount_result.get("stderr", ""),
|
| 1359 | }
|
| 1360 |
|
| 1361 | start = time.perf_counter()
|
| 1362 | with concurrent.futures.ThreadPoolExecutor(max_workers=parallelism) as executor:
|
| 1363 | worker_rows = list(executor.map(worker, range(parallelism)))
|
| 1364 | elapsed_ms = (time.perf_counter() - start) * 1000
|
| 1365 | failures = [
|
| 1366 | item
|
| 1367 | for item in worker_rows
|
| 1368 | if item["returncode"] != 0 or not item["first_ls_ok"] or item["teardown_returncode"] not in {0}
|
| 1369 | ]
|
| 1370 | result = {
|
| 1371 | "elapsed_ms": elapsed_ms,
|
| 1372 | "returncode": 0 if not failures else 1,
|
| 1373 | "stdout": "",
|
| 1374 | "stderr": "\n".join(tail(str(item.get("stderr", "")), 500) for item in failures),
|
| 1375 | "attempts": parallelism,
|
| 1376 | "retry_count": 0,
|
| 1377 | }
|
| 1378 | row = command_row(
|
| 1379 | meta,
|
| 1380 | scenario_name,
|
| 1381 | "parallel.mounts",
|
| 1382 | run_index,
|
| 1383 | [oak_bin, "mount", owner_repo, "<parallel-dests>"],
|
| 1384 | result,
|
| 1385 | scenario_start,
|
| 1386 | {
|
| 1387 | "parallelism": parallelism,
|
| 1388 | "workers": worker_rows,
|
| 1389 | **scenario_cache_fields(scenario),
|
| 1390 | "checks": {"ok": not failures, "successful_workers": parallelism - len(failures)},
|
| 1391 | },
|
| 1392 | )
|
| 1393 | return [row], not failures
|
| 1394 |
|
| 1395 |
|
| 1396 | def scenario_skip_reason(
|
| 1397 | scenario: dict[str, Any],
|
| 1398 | mount_available: bool,
|
| 1399 | owner_repo: str,
|
| 1400 | spec: dict[str, Any],
|
| 1401 | ) -> Optional[str]:
|
| 1402 | if not mount_available:
|
| 1403 | return "oak_mount_unavailable"
|
| 1404 | if scenario.get("requires_remote") and not owner_repo:
|
| 1405 | return "mount_repo_not_configured"
|
| 1406 | if scenario.get("requires_safe_push") and not safe_push_enabled(spec):
|
| 1407 | return "safe_push_disabled"
|
| 1408 | if scenario.get("requires_huge_file_path"):
|
| 1409 | defaults = spec_defaults(spec)
|
| 1410 | if not str_default(defaults, "huge_file_path", ""):
|
| 1411 | return "huge_file_path_not_configured"
|
| 1412 | if scenario.get("requires_external_offline_harness"):
|
| 1413 | return "requires_external_offline_harness"
|
| 1414 | return None
|
| 1415 |
|
| 1416 |
|
| 1417 | def write_results(results_arg: Path, timestamp: str, rows: list[dict[str, Any]]) -> Path:
|
| 1418 | """Persist mount rows through the shared lane-validated write path.
|
| 1419 |
|
| 1420 | Mount rows go through the same row-contract validation as every other
|
| 1421 | lane (fail loudly after persisting); the explicit-.jsonl-file form keeps
|
| 1422 | that guarantee with a direct write plus the same validation.
|
| 1423 | """
|
| 1424 | from oakbench.results import ResultsStore
|
| 1425 | from oakbench.rows import RowContractViolation, validate_rows
|
| 1426 |
|
| 1427 | if results_arg.suffix == ".jsonl":
|
| 1428 | results_arg.parent.mkdir(parents=True, exist_ok=True)
|
| 1429 | with results_arg.open("w") as fh:
|
| 1430 | for row in rows:
|
| 1431 | fh.write(json.dumps(row, sort_keys=True) + "\n")
|
| 1432 | errors = validate_rows(rows, "mount")
|
| 1433 | if errors:
|
| 1434 | preview = "\n ".join(errors[:20])
|
| 1435 | raise RowContractViolation(
|
| 1436 | f"Row contract violation (mount lane). Rows were written to {results_arg} "
|
| 1437 | f"but the emitter has a bug:\n {preview}"
|
| 1438 | )
|
| 1439 | return results_arg
|
| 1440 |
|
| 1441 | store = ResultsStore(results_arg, lane="mount", filename_suffix="mount")
|
| 1442 | raw_path, _ = store.write(timestamp, rows)
|
| 1443 | return raw_path
|
| 1444 |
|
| 1445 |
|
| 1446 | def main() -> int:
|
| 1447 | args = parse_args()
|
| 1448 | timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
| 1449 | spec = load_spec(args.spec)
|
| 1450 | defaults = spec_defaults(spec)
|
| 1451 | oak_bin = resolve_oak_bin(args.oak_bin)
|
| 1452 | meta = metadata(timestamp, args.spec, spec, oak_bin)
|
| 1453 | rows: list[dict[str, Any]] = []
|
| 1454 | owner_repo = mount_repo(spec)
|
| 1455 | meta["mount_repo_configured"] = bool(owner_repo)
|
| 1456 | meta["mount_repo"] = owner_repo if owner_repo else None
|
| 1457 |
|
| 1458 | run_root = args.dest / timestamp
|
| 1459 | cleanup_safe = True
|
| 1460 | scenarios = spec.get("scenarios") or []
|
| 1461 | if not isinstance(scenarios, list):
|
| 1462 | scenarios = []
|
| 1463 | if oak_bin is not None:
|
| 1464 | run_root.mkdir(parents=True, exist_ok=True)
|
| 1465 |
|
| 1466 | with measurement_lock("mount_probe") as lock_info:
|
| 1467 | meta["measurement_lock_wait_ms"] = lock_info.wait_ms
|
| 1468 | meta["measurement_lock"] = "held" if lock_info.enabled else "disabled"
|
| 1469 |
|
| 1470 | if oak_bin is None:
|
| 1471 | rows.append(skip_row(meta, "capability_probe", "oak.version", 0, f"oak binary not found: {args.oak_bin}"))
|
| 1472 | rows.append(
|
| 1473 | skip_row(meta, "capability_probe", "oak.mount.help", 0, f"oak binary not found: {args.oak_bin}")
|
| 1474 | )
|
| 1475 | else:
|
| 1476 | version_result = run_process([oak_bin, "--version"], None, int_default(defaults, "timeout_seconds", 120), 0)
|
| 1477 | version_text = (version_result.get("stdout") or version_result.get("stderr") or "").strip()
|
| 1478 | meta["subject_versions"] = {"oak_mount": version_text or "unknown"}
|
| 1479 | rows.append(command_row(meta, "capability_probe", "oak.version", 0, [oak_bin, "--version"], version_result))
|
| 1480 |
|
| 1481 | mount_help_result = run_process(
|
| 1482 | [oak_bin, "mount", "--help"], None, int_default(defaults, "timeout_seconds", 120), 0
|
| 1483 | )
|
| 1484 | mount_available = mount_help_result["returncode"] == 0
|
| 1485 | rows.append(
|
| 1486 | command_row(
|
| 1487 | meta,
|
| 1488 | "capability_probe",
|
| 1489 | "oak.mount.help",
|
| 1490 | 0,
|
| 1491 | [oak_bin, "mount", "--help"],
|
| 1492 | mount_help_result,
|
| 1493 | extra={"mount_available": mount_available},
|
| 1494 | )
|
| 1495 | )
|
| 1496 |
|
| 1497 | for scenario in scenarios:
|
| 1498 | if not isinstance(scenario, dict):
|
| 1499 | continue
|
| 1500 | scenario_name = str(scenario.get("name", "unnamed"))
|
| 1501 | if scenario_name == "capability_probe":
|
| 1502 | continue
|
| 1503 | reason = scenario_skip_reason(scenario, mount_available, owner_repo, spec)
|
| 1504 | if reason:
|
| 1505 | for run_index in scenario_runs(defaults):
|
| 1506 | rows.append(
|
| 1507 | skip_row(
|
| 1508 | meta,
|
| 1509 | scenario_name,
|
| 1510 | "scenario.skip",
|
| 1511 | run_index,
|
| 1512 | reason,
|
| 1513 | scenario_cache_fields(scenario),
|
| 1514 | )
|
| 1515 | )
|
| 1516 | continue
|
| 1517 | for run_index in scenario_runs(defaults):
|
| 1518 | operations = [str(item) for item in (scenario.get("operations") or [])]
|
| 1519 | if operations == ["recovery.existing_partial_dir"]:
|
| 1520 | scenario_rows, scenario_cleanup_safe = run_recovery_probe(
|
| 1521 | meta, oak_bin, owner_repo, scenario, defaults, run_root, run_index
|
| 1522 | )
|
| 1523 | elif operations == ["parallel.mounts"]:
|
| 1524 | scenario_rows, scenario_cleanup_safe = run_parallel_probe(
|
| 1525 | meta, oak_bin, owner_repo, scenario, defaults, run_root, run_index
|
| 1526 | )
|
| 1527 | else:
|
| 1528 | scenario_rows, scenario_cleanup_safe = run_operation_sequence(
|
| 1529 | meta, oak_bin, owner_repo, safe_push_enabled(spec), scenario, defaults, run_root, run_index
|
| 1530 | )
|
| 1531 | rows.extend(scenario_rows)
|
| 1532 | cleanup_safe = cleanup_safe and scenario_cleanup_safe
|
| 1533 |
|
| 1534 | raw_path = write_results(args.results, timestamp, rows)
|
| 1535 | print(f"[result] {raw_path}")
|
| 1536 |
|
| 1537 | if oak_bin is None:
|
| 1538 | return 0
|
| 1539 |
|
| 1540 | if not args.keep and cleanup_safe:
|
| 1541 | shutil.rmtree(run_root, ignore_errors=True)
|
| 1542 | elif not args.keep:
|
| 1543 | print(f"[keep] cleanup skipped because a mount command failed: {run_root}", file=sys.stderr)
|
| 1544 | else:
|
| 1545 | print(f"[keep] {run_root}")
|
| 1546 |
|
| 1547 | real_failures = [
|
| 1548 | row
|
| 1549 | for row in rows
|
| 1550 | if row.get("returncode") not in {0, SKIP_RETURNCODE}
|
| 1551 | and row.get("scenario") != "capability_probe"
|
| 1552 | and not row.get("skipped")
|
| 1553 | ]
|
| 1554 | return 1 if real_failures else 0
|
| 1555 |
|
| 1556 |
|
| 1557 | if __name__ == "__main__":
|
| 1558 | raise SystemExit(main())
|