Log in
scripts/mount_probe.py 1558 lines · 57.4 KB · python Blame
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())