Viewing files at commit 2735d7f5558eCommit detailsView latest

Browse files
scripts/mount_probe.py 1557 lines · 57.3 KB · python
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 ENV_ISOLATION_VERSION
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 = os.environ.copy()
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": 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
                capture_output=True,
751
                text=True,
752
                timeout=15,
753
                check=False,
754
            )
755
            _SUPPORT_CACHE[key] = probe.returncode == 0
756
        except (OSError, subprocess.TimeoutExpired):
757
            _SUPPORT_CACHE[key] = False
758
    return _SUPPORT_CACHE[key]
759
760
761
def mount_repo(spec: dict[str, Any]) -> str:
762
    remote = spec.get("remote") or {}
763
    if not isinstance(remote, dict):
764
        remote = {}
765
    configured = str(remote.get("owner_repo") or "").strip()
766
    env_name = str(remote.get("owner_repo_env") or "OAK_BENCH_MOUNT_REPO")
767
    return configured or os.environ.get(env_name, "").strip()
768
769
770
def safe_push_enabled(spec: dict[str, Any]) -> bool:
771
    remote = spec.get("remote") or {}
772
    return bool(isinstance(remote, dict) and remote.get("safe_push"))
773
774
775
def scenario_runs(defaults: dict[str, Any]) -> range:
776
    return range(max(1, int_default(defaults, "runs", 1)))
777
778
779
def mount_path(run_root: Path, scenario_name: str, run_index: int) -> Path:
780
    safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", scenario_name).strip("-") or "mount"
781
    return run_root / f"{safe}-run-{run_index}"
782
783
784
def finish_desc_file_path(run_root: Path, scenario_name: str, run_index: int) -> Path:
785
    safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", scenario_name).strip("-") or "mount"
786
    return run_root / f"{safe}-run-{run_index}.finish-desc.md"
787
788
789
def run_timed_command(
790
    rows: list[dict[str, Any]],
791
    meta: dict[str, Any],
792
    scenario_name: str,
793
    operation: str,
794
    run_index: int,
795
    command: list[str],
796
    cwd: Optional[Path],
797
    defaults: dict[str, Any],
798
    scenario_start: float,
799
    capture_stdout: bool = True,
800
    extra: Optional[dict[str, Any]] = None,
801
) -> dict[str, Any]:
802
    result = run_process(
803
        command,
804
        cwd=cwd,
805
        timeout=int_default(defaults, "timeout_seconds", 120),
806
        retries=int_default(defaults, "retries", 0),
807
        capture_stdout=capture_stdout,
808
    )
809
    row = command_row(meta, scenario_name, operation, run_index, command, result, scenario_start, extra)
810
    rows.append(row)
811
    return row
812
813
814
def run_operation_sequence(
815
    meta: dict[str, Any],
816
    oak_bin: str,
817
    owner_repo: str,
818
    allow_push: bool,
819
    scenario: dict[str, Any],
820
    defaults: dict[str, Any],
821
    run_root: Path,
822
    run_index: int,
823
) -> Tuple[list[dict[str, Any]], bool]:
824
    rows: list[dict[str, Any]] = []
825
    cleanup_safe = True
826
    scenario_name = str(scenario.get("name", "mount"))
827
    path = mount_path(run_root, scenario_name, run_index)
828
    operations = scenario.get("operations") or []
829
    mounted = False
830
    first_useful_recorded = False
831
    scenario_start = time.perf_counter()
832
    purge_result = oakbench_cachectl.purge_fs_caches() if scenario.get("cache_state") == "cold" else None
833
834
    for operation in operations:
835
        operation = str(operation)
836
        if operation == "mount.start":
837
            command = [oak_bin, "mount", owner_repo, str(path)]
838
            row = run_timed_command(rows, meta, scenario_name, operation, run_index, command, None, defaults, scenario_start)
839
            command_return_since_start = row.get("since_scenario_start_ms")
840
            row["mount_command_returncode"] = row["returncode"]
841
            row["mount_command_elapsed_ms"] = row["elapsed_ms"]
842
            row["time_to_mount_command_return_ms"] = command_return_since_start
843
            if row["returncode"] == 0:
844
                readiness = mount_readiness_probe(path, defaults)
845
                row.update(readiness)
846
                row["since_scenario_start_ms"] = round((time.perf_counter() - scenario_start) * 1000, 3)
847
                row["time_to_mount_ready_to_read_ms"] = row["since_scenario_start_ms"]
848
                if not readiness.get("checks", {}).get("ok"):
849
                    row["returncode"] = 1
850
            else:
851
                row["mount_ready_probe_elapsed_ms"] = 0.0
852
                row["mount_ready_read_ok"] = False
853
                row["mount_ready_list_ok"] = False
854
                row["time_to_mount_ready_to_read_ms"] = None
855
                row["checks"] = {"ok": False, "ready_to_read": False, "command_returncode": row["returncode"]}
856
            mounted = row["returncode"] == 0
857
            cleanup_safe = cleanup_safe and mounted
858
            continue
859
860
        if operation == "mount.teardown":
861
            if not mounted:
862
                rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
863
                cleanup_safe = False
864
                continue
865
            command = [oak_bin, "mount", "end", str(path)]
866
            row = run_timed_command(rows, meta, scenario_name, operation, run_index, command, None, defaults, scenario_start)
867
            released = not path.exists()
868
            row["checks"] = {"ok": row["returncode"] == 0 and released, "path_released": released}
869
            mounted = False
870
            cleanup_safe = cleanup_safe and row["returncode"] == 0
871
            continue
872
873
        if operation == "oak.status":
874
            if not mounted:
875
                rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
876
                continue
877
            run_timed_command(rows, meta, scenario_name, operation, run_index, [oak_bin, "status"], path, defaults, scenario_start)
878
            continue
879
880
        if operation == "oak.diff":
881
            if not mounted:
882
                rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
883
                continue
884
            run_timed_command(rows, meta, scenario_name, operation, run_index, [oak_bin, "diff"], path, defaults, scenario_start)
885
            continue
886
887
        if operation == "oak.commit":
888
            if not mounted:
889
                rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
890
                continue
891
            run_timed_command(
892
                rows,
893
                meta,
894
                scenario_name,
895
                operation,
896
                run_index,
897
                [oak_bin, "commit", "--no-verify"],
898
                path,
899
                defaults,
900
                scenario_start,
901
            )
902
            continue
903
904
        if operation == "oak.push":
905
            if not mounted:
906
                rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
907
                continue
908
            if not allow_push:
909
                rows.append(skip_row(meta, scenario_name, operation, run_index, "safe_push_disabled"))
910
                continue
911
            run_timed_command(rows, meta, scenario_name, operation, run_index, [oak_bin, "push"], path, defaults, scenario_start)
912
            continue
913
914
        if operation == "fs.first_ls":
915
            if not mounted:
916
                rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
917
                continue
918
919
            def first_ls() -> dict[str, Any]:
920
                entries = sorted(os.listdir(path))
921
                return {"entry_count": len(entries), "sample_entries": entries[:10], "checks": {"ok": True, "exists": path.exists()}}
922
923
            rows.append(
924
                fs_row(
925
                    meta,
926
                    scenario_name,
927
                    operation,
928
                    run_index,
929
                    ["python:os.listdir", "."],
930
                    scenario_start,
931
                    first_ls,
932
                    not first_useful_recorded,
933
                )
934
            )
935
            first_useful_recorded = True
936
            continue
937
938
        if operation == "fs.targeted_read":
939
            if not mounted:
940
                rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
941
                continue
942
            target = str_default(defaults, "target_read", "README.md")
943
            read_bytes = int_default(defaults, "target_read_bytes", 4096)
944
            expected = defaults.get("target_read_contains")
945
946
            def targeted_read() -> dict[str, Any]:
947
                target_path = path / target
948
                with target_path.open("rb") as fh:
949
                    data = fh.read(read_bytes)
950
                checks = {"ok": target_path.exists(), "exists": target_path.exists(), "read_bytes": len(data)}
951
                content_fields = content_check_fields(path, target, data, defaults)
952
                checks["content_verified"] = bool(content_fields.get("content_verified"))
953
                checks["ok"] = checks["ok"] and checks["content_verified"]
954
                if expected is not None:
955
                    checks["contains_expected"] = bool(content_fields.get("content_verified"))
956
                return {"target_path": target, "read_bytes": len(data), "checks": checks, **content_fields}
957
958
            rows.append(
959
                fs_row(
960
                    meta,
961
                    scenario_name,
962
                    operation,
963
                    run_index,
964
                    ["python:read_bytes", target, str(read_bytes)],
965
                    scenario_start,
966
                    targeted_read,
967
                    not first_useful_recorded,
968
                )
969
            )
970
            first_useful_recorded = True
971
            continue
972
973
        if operation == "fs.first_edit":
974
            if not mounted:
975
                rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
976
                continue
977
            edit_path = str_default(defaults, "edit_path", ".oak-bench/mount-edit.txt")
978
979
            def first_edit() -> dict[str, Any]:
980
                target = path / edit_path
981
                target.parent.mkdir(parents=True, exist_ok=True)
982
                text = f"oak mount benchmark {scenario_name} run {run_index}\n"
983
                target.write_text(text)
984
                round_trip = target.read_text()
985
                return {
986
                    "edit_path": edit_path,
987
                    "write_bytes": len(text.encode("utf-8")),
988
                    "checks": {"ok": round_trip == text, "round_trip": round_trip == text},
989
                }
990
991
            rows.append(
992
                fs_row(
993
                    meta,
994
                    scenario_name,
995
                    operation,
996
                    run_index,
997
                    ["python:write_text", edit_path],
998
                    scenario_start,
999
                    first_edit,
1000
                    not first_useful_recorded,
1001
                )
1002
            )
1003
            first_useful_recorded = True
1004
            continue
1005
1006
        if operation == "fs.sparse_paths":
1007
            if not mounted:
1008
                rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
1009
                continue
1010
            sparse_paths = list_default(defaults, "sparse_paths")
1011
1012
            def sparse_probe() -> dict[str, Any]:
1013
                checked = []
1014
                for rel in sparse_paths:
1015
                    item = path / rel
1016
                    exists = item.exists()
1017
                    is_dir = item.is_dir() if exists else False
1018
                    checked.append({"path": rel, "exists": exists, "is_dir": is_dir})
1019
                    if exists and not is_dir:
1020
                        with item.open("rb") as fh:
1021
                            fh.read(128)
1022
                return {"sparse_paths": checked, "checks": {"ok": all(item["exists"] for item in checked)}}
1023
1024
            rows.append(
1025
                fs_row(
1026
                    meta,
1027
                    scenario_name,
1028
                    operation,
1029
                    run_index,
1030
                    ["python:sparse_paths"] + sparse_paths,
1031
                    scenario_start,
1032
                    sparse_probe,
1033
                    not first_useful_recorded,
1034
                )
1035
            )
1036
            first_useful_recorded = True
1037
            continue
1038
1039
        if operation == "fs.huge_partial_read":
1040
            if not mounted:
1041
                rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
1042
                continue
1043
            huge_path = str_default(defaults, "huge_file_path", "")
1044
            if not huge_path:
1045
                rows.append(skip_row(meta, scenario_name, operation, run_index, "huge_file_path_not_configured"))
1046
                continue
1047
            read_bytes = int_default(defaults, "huge_file_read_bytes", 4096)
1048
1049
            def huge_partial_read() -> dict[str, Any]:
1050
                target = path / huge_path
1051
                with target.open("rb") as fh:
1052
                    data = fh.read(read_bytes)
1053
                return {
1054
                    "huge_file_path": huge_path,
1055
                    "requested_bytes": read_bytes,
1056
                    "read_bytes": len(data),
1057
                    "checks": {"ok": target.exists() and len(data) > 0, "exists": target.exists()},
1058
                }
1059
1060
            rows.append(
1061
                fs_row(
1062
                    meta,
1063
                    scenario_name,
1064
                    operation,
1065
                    run_index,
1066
                    ["python:read_prefix", huge_path, str(read_bytes)],
1067
                    scenario_start,
1068
                    huge_partial_read,
1069
                    not first_useful_recorded,
1070
                )
1071
            )
1072
            first_useful_recorded = True
1073
            continue
1074
1075
        if operation == "disk.usage":
1076
            if not mounted:
1077
                rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
1078
                continue
1079
            max_entries = int_default(defaults, "max_disk_entries", 100000)
1080
            rows.append(
1081
                fs_row(
1082
                    meta,
1083
                    scenario_name,
1084
                    operation,
1085
                    run_index,
1086
                    ["python:bounded_tree_usage", str(path), str(max_entries)],
1087
                    scenario_start,
1088
                    lambda: bounded_tree_usage(path, max_entries),
1089
                    False,
1090
                )
1091
            )
1092
            continue
1093
1094
        if operation == "oak.desc":
1095
            if not mounted:
1096
                rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
1097
                continue
1098
            message = str_default(defaults, "desc_message", f"bench {scenario_name} run {run_index}")
1099
            run_timed_command(
1100
                rows, meta, scenario_name, operation, run_index, [oak_bin, "desc", message], path, defaults, scenario_start
1101
            )
1102
            continue
1103
1104
        if operation == "oak.finish":
1105
            if not mounted:
1106
                rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
1107
                continue
1108
            if not oak_supports(oak_bin, "finish"):
1109
                rows.append(
1110
                    skip_row(meta, scenario_name, operation, run_index, "finish_not_supported_by_binary")
1111
                )
1112
                continue
1113
            message = str_default(defaults, "desc_message", f"bench {scenario_name} run {run_index}")
1114
            desc_file = finish_desc_file_path(run_root, scenario_name, run_index).resolve()
1115
            desc_file.parent.mkdir(parents=True, exist_ok=True)
1116
            desc_file.write_text(message + "\n")
1117
            row = run_timed_command(
1118
                rows,
1119
                meta,
1120
                scenario_name,
1121
                operation,
1122
                run_index,
1123
                [oak_bin, "finish", "--desc-file", str(desc_file)],
1124
                path,
1125
                defaults,
1126
                scenario_start,
1127
                extra={"desc_file": str(desc_file)},
1128
            )
1129
            released = not path.exists()
1130
            row["checks"] = {"ok": row["returncode"] == 0, "path_released": released}
1131
            mounted = mounted and not released
1132
            continue
1133
1134
        if operation == "space.clean":
1135
            # space clean operates on the space (parent of the mounts); it
1136
            # should tear down this mount only if its tree is clean
1137
            # (committed + pushed), which is the lifecycle property under test.
1138
            row = run_timed_command(
1139
                rows,
1140
                meta,
1141
                scenario_name,
1142
                operation,
1143
                run_index,
1144
                [oak_bin, "space", "clean"],
1145
                path.parent,
1146
                defaults,
1147
                scenario_start,
1148
            )
1149
            released = not path.exists()
1150
            row["checks"] = {"ok": row["returncode"] == 0, "mount_released_by_clean": released}
1151
            mounted = mounted and not released
1152
            continue
1153
1154
        if operation == "loop.push_desc":
1155
            # The steady-state agent loop on a mount: edit, commit, desc,
1156
            # push, repeat. Lifecycle cost lives in the repetition, not the
1157
            # first call — per-iteration rows keep the trend, the .total row
1158
            # keeps the loop-level price.
1159
            if not mounted:
1160
                rows.append(skip_row(meta, scenario_name, operation, run_index, "mount_not_started"))
1161
                continue
1162
            iterations = int_default(defaults, "loop_iterations", 3)
1163
            per_iteration_ms: list[float] = []
1164
            loop_failures = 0
1165
            loop_start = time.perf_counter()
1166
            for iteration in range(iterations):
1167
                iteration_start = time.perf_counter()
1168
1169
                def loop_edit(iteration: int = iteration) -> dict[str, Any]:
1170
                    target = path / ".oak-bench" / "loop-edit.txt"
1171
                    target.parent.mkdir(parents=True, exist_ok=True)
1172
                    text = f"loop iteration {iteration} of {scenario_name} run {run_index}\n"
1173
                    target.write_text(text)
1174
                    return {"iteration": iteration, "checks": {"ok": target.read_text() == text}}
1175
1176
                rows.append(
1177
                    fs_row(
1178
                        meta,
1179
                        scenario_name,
1180
                        "loop.edit",
1181
                        run_index,
1182
                        ["python:write_text", ".oak-bench/loop-edit.txt"],
1183
                        scenario_start,
1184
                        loop_edit,
1185
                        False,
1186
                    )
1187
                )
1188
                commit_row = run_timed_command(
1189
                    rows,
1190
                    meta,
1191
                    scenario_name,
1192
                    "loop.commit",
1193
                    run_index,
1194
                    [oak_bin, "commit", "--no-verify"],
1195
                    path,
1196
                    defaults,
1197
                    scenario_start,
1198
                    extra={"iteration": iteration},
1199
                )
1200
                desc_row = run_timed_command(
1201
                    rows,
1202
                    meta,
1203
                    scenario_name,
1204
                    "loop.desc",
1205
                    run_index,
1206
                    [oak_bin, "desc", f"loop iteration {iteration}"],
1207
                    path,
1208
                    defaults,
1209
                    scenario_start,
1210
                    extra={"iteration": iteration},
1211
                )
1212
                loop_failures += int(commit_row["returncode"] != 0) + int(desc_row["returncode"] != 0)
1213
                if allow_push:
1214
                    push_row = run_timed_command(
1215
                        rows,
1216
                        meta,
1217
                        scenario_name,
1218
                        "loop.push",
1219
                        run_index,
1220
                        [oak_bin, "push"],
1221
                        path,
1222
                        defaults,
1223
                        scenario_start,
1224
                        extra={"iteration": iteration},
1225
                    )
1226
                    loop_failures += int(push_row["returncode"] != 0)
1227
                else:
1228
                    rows.append(
1229
                        skip_row(
1230
                            meta,
1231
                            scenario_name,
1232
                            "loop.push",
1233
                            run_index,
1234
                            "safe_push_disabled",
1235
                            extra={"iteration": iteration},
1236
                        )
1237
                    )
1238
                per_iteration_ms.append(round((time.perf_counter() - iteration_start) * 1000, 3))
1239
            rows.append(
1240
                {
1241
                    **meta,
1242
                    "scenario": scenario_name,
1243
                    "run": run_index,
1244
                    "operation": "loop.push_desc.total",
1245
                    "elapsed_ms": round((time.perf_counter() - loop_start) * 1000, 3),
1246
                    "returncode": 0 if loop_failures == 0 else 1,
1247
                    "command": ["loop:edit+commit+desc+push", str(iterations)],
1248
                    "iterations": iterations,
1249
                    "iteration_failures": loop_failures,
1250
                    "push_enabled": allow_push,
1251
                    "per_iteration_ms": per_iteration_ms,
1252
                    "iteration_ms_max": max(per_iteration_ms) if per_iteration_ms else None,
1253
                    "since_scenario_start_ms": round((time.perf_counter() - scenario_start) * 1000, 3),
1254
                    "attempts": 1,
1255
                    "retry_count": 0,
1256
                }
1257
            )
1258
            continue
1259
1260
        rows.append(skip_row(meta, scenario_name, operation, run_index, "operation_not_implemented"))
1261
1262
    if mounted:
1263
        command = [oak_bin, "mount", "end", str(path)]
1264
        row = run_timed_command(rows, meta, scenario_name, "mount.teardown.auto", run_index, command, None, defaults, scenario_start)
1265
        cleanup_safe = cleanup_safe and row["returncode"] == 0
1266
    apply_scenario_cache_state(rows, scenario, purge_result)
1267
    return rows, cleanup_safe
1268
1269
1270
def run_recovery_probe(
1271
    meta: dict[str, Any],
1272
    oak_bin: str,
1273
    owner_repo: str,
1274
    scenario: dict[str, Any],
1275
    defaults: dict[str, Any],
1276
    run_root: Path,
1277
    run_index: int,
1278
) -> Tuple[list[dict[str, Any]], bool]:
1279
    scenario_name = str(scenario.get("name", "interrupted_recovery"))
1280
    path = mount_path(run_root, scenario_name, run_index)
1281
    path.mkdir(parents=True, exist_ok=True)
1282
    (path / ".oak-bench-partial").write_text("simulated interrupted mount destination\n")
1283
    scenario_start = time.perf_counter()
1284
    rows: list[dict[str, Any]] = []
1285
    command = [oak_bin, "mount", owner_repo, str(path)]
1286
    row = run_timed_command(rows, meta, scenario_name, "recovery.existing_partial_dir", run_index, command, None, defaults, scenario_start)
1287
    if row["returncode"] != 0 and "not empty" in str(row.get("stderr", "")):
1288
        rows[-1] = skip_row(
1289
            meta,
1290
            scenario_name,
1291
            "recovery.existing_partial_dir",
1292
            run_index,
1293
            "mount_partial_destination_recovery_not_supported",
1294
            {
1295
                "attempted_command": command,
1296
                "process_returncode": row["returncode"],
1297
                "process_stderr": row.get("stderr"),
1298
                "recovery_supported": False,
1299
            },
1300
        )
1301
        shutil.rmtree(path, ignore_errors=True)
1302
        apply_scenario_cache_state(rows, scenario)
1303
        return rows, True
1304
    mounted = row["returncode"] == 0
1305
    if mounted:
1306
        end_row = run_timed_command(
1307
            rows,
1308
            meta,
1309
            scenario_name,
1310
            "mount.teardown",
1311
            run_index,
1312
            [oak_bin, "mount", "end", str(path)],
1313
            None,
1314
            defaults,
1315
            scenario_start,
1316
        )
1317
        apply_scenario_cache_state(rows, scenario)
1318
        return rows, end_row["returncode"] == 0
1319
    apply_scenario_cache_state(rows, scenario)
1320
    return rows, False
1321
1322
1323
def run_parallel_probe(
1324
    meta: dict[str, Any],
1325
    oak_bin: str,
1326
    owner_repo: str,
1327
    scenario: dict[str, Any],
1328
    defaults: dict[str, Any],
1329
    run_root: Path,
1330
    run_index: int,
1331
) -> Tuple[list[dict[str, Any]], bool]:
1332
    scenario_name = str(scenario.get("name", "parallel_mounts"))
1333
    parallelism = max(1, int_default(defaults, "parallelism", 4))
1334
    timeout = int_default(defaults, "timeout_seconds", 120)
1335
    scenario_start = time.perf_counter()
1336
1337
    def worker(worker_index: int) -> dict[str, Any]:
1338
        path = run_root / f"{scenario_name}-run-{run_index}-worker-{worker_index}"
1339
        command = [oak_bin, "mount", owner_repo, str(path)]
1340
        mount_result = run_process(command, None, timeout, int_default(defaults, "retries", 0))
1341
        first_ls_ok = False
1342
        teardown_returncode: Optional[int] = None
1343
        if mount_result["returncode"] == 0:
1344
            try:
1345
                os.listdir(path)
1346
                first_ls_ok = True
1347
            except OSError:
1348
                first_ls_ok = False
1349
            teardown = run_process([oak_bin, "mount", "end", str(path)], None, timeout, 0)
1350
            teardown_returncode = int(teardown["returncode"])
1351
        return {
1352
            "worker": worker_index,
1353
            "returncode": mount_result["returncode"],
1354
            "elapsed_ms": mount_result["elapsed_ms"],
1355
            "first_ls_ok": first_ls_ok,
1356
            "teardown_returncode": teardown_returncode,
1357
            "stderr": mount_result.get("stderr", ""),
1358
        }
1359
1360
    start = time.perf_counter()
1361
    with concurrent.futures.ThreadPoolExecutor(max_workers=parallelism) as executor:
1362
        worker_rows = list(executor.map(worker, range(parallelism)))
1363
    elapsed_ms = (time.perf_counter() - start) * 1000
1364
    failures = [
1365
        item
1366
        for item in worker_rows
1367
        if item["returncode"] != 0 or not item["first_ls_ok"] or item["teardown_returncode"] not in {0}
1368
    ]
1369
    result = {
1370
        "elapsed_ms": elapsed_ms,
1371
        "returncode": 0 if not failures else 1,
1372
        "stdout": "",
1373
        "stderr": "\n".join(tail(str(item.get("stderr", "")), 500) for item in failures),
1374
        "attempts": parallelism,
1375
        "retry_count": 0,
1376
    }
1377
    row = command_row(
1378
        meta,
1379
        scenario_name,
1380
        "parallel.mounts",
1381
        run_index,
1382
        [oak_bin, "mount", owner_repo, "<parallel-dests>"],
1383
        result,
1384
        scenario_start,
1385
        {
1386
            "parallelism": parallelism,
1387
            "workers": worker_rows,
1388
            **scenario_cache_fields(scenario),
1389
            "checks": {"ok": not failures, "successful_workers": parallelism - len(failures)},
1390
        },
1391
    )
1392
    return [row], not failures
1393
1394
1395
def scenario_skip_reason(
1396
    scenario: dict[str, Any],
1397
    mount_available: bool,
1398
    owner_repo: str,
1399
    spec: dict[str, Any],
1400
) -> Optional[str]:
1401
    if not mount_available:
1402
        return "oak_mount_unavailable"
1403
    if scenario.get("requires_remote") and not owner_repo:
1404
        return "mount_repo_not_configured"
1405
    if scenario.get("requires_safe_push") and not safe_push_enabled(spec):
1406
        return "safe_push_disabled"
1407
    if scenario.get("requires_huge_file_path"):
1408
        defaults = spec_defaults(spec)
1409
        if not str_default(defaults, "huge_file_path", ""):
1410
            return "huge_file_path_not_configured"
1411
    if scenario.get("requires_external_offline_harness"):
1412
        return "requires_external_offline_harness"
1413
    return None
1414
1415
1416
def write_results(results_arg: Path, timestamp: str, rows: list[dict[str, Any]]) -> Path:
1417
    """Persist mount rows through the shared lane-validated write path.
1418
1419
    Mount rows go through the same row-contract validation as every other
1420
    lane (fail loudly after persisting); the explicit-.jsonl-file form keeps
1421
    that guarantee with a direct write plus the same validation.
1422
    """
1423
    from oakbench.results import ResultsStore
1424
    from oakbench.rows import RowContractViolation, validate_rows
1425
1426
    if results_arg.suffix == ".jsonl":
1427
        results_arg.parent.mkdir(parents=True, exist_ok=True)
1428
        with results_arg.open("w") as fh:
1429
            for row in rows:
1430
                fh.write(json.dumps(row, sort_keys=True) + "\n")
1431
        errors = validate_rows(rows, "mount")
1432
        if errors:
1433
            preview = "\n  ".join(errors[:20])
1434
            raise RowContractViolation(
1435
                f"Row contract violation (mount lane). Rows were written to {results_arg} "
1436
                f"but the emitter has a bug:\n  {preview}"
1437
            )
1438
        return results_arg
1439
1440
    store = ResultsStore(results_arg, lane="mount", filename_suffix="mount")
1441
    raw_path, _ = store.write(timestamp, rows)
1442
    return raw_path
1443
1444
1445
def main() -> int:
1446
    args = parse_args()
1447
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
1448
    spec = load_spec(args.spec)
1449
    defaults = spec_defaults(spec)
1450
    oak_bin = resolve_oak_bin(args.oak_bin)
1451
    meta = metadata(timestamp, args.spec, spec, oak_bin)
1452
    rows: list[dict[str, Any]] = []
1453
    owner_repo = mount_repo(spec)
1454
    meta["mount_repo_configured"] = bool(owner_repo)
1455
    meta["mount_repo"] = owner_repo if owner_repo else None
1456
1457
    run_root = args.dest / timestamp
1458
    cleanup_safe = True
1459
    scenarios = spec.get("scenarios") or []
1460
    if not isinstance(scenarios, list):
1461
        scenarios = []
1462
    if oak_bin is not None:
1463
        run_root.mkdir(parents=True, exist_ok=True)
1464
1465
    with measurement_lock("mount_probe") as lock_info:
1466
        meta["measurement_lock_wait_ms"] = lock_info.wait_ms
1467
        meta["measurement_lock"] = "held" if lock_info.enabled else "disabled"
1468
1469
        if oak_bin is None:
1470
            rows.append(skip_row(meta, "capability_probe", "oak.version", 0, f"oak binary not found: {args.oak_bin}"))
1471
            rows.append(
1472
                skip_row(meta, "capability_probe", "oak.mount.help", 0, f"oak binary not found: {args.oak_bin}")
1473
            )
1474
        else:
1475
            version_result = run_process([oak_bin, "--version"], None, int_default(defaults, "timeout_seconds", 120), 0)
1476
            version_text = (version_result.get("stdout") or version_result.get("stderr") or "").strip()
1477
            meta["subject_versions"] = {"oak_mount": version_text or "unknown"}
1478
            rows.append(command_row(meta, "capability_probe", "oak.version", 0, [oak_bin, "--version"], version_result))
1479
1480
            mount_help_result = run_process(
1481
                [oak_bin, "mount", "--help"], None, int_default(defaults, "timeout_seconds", 120), 0
1482
            )
1483
            mount_available = mount_help_result["returncode"] == 0
1484
            rows.append(
1485
                command_row(
1486
                    meta,
1487
                    "capability_probe",
1488
                    "oak.mount.help",
1489
                    0,
1490
                    [oak_bin, "mount", "--help"],
1491
                    mount_help_result,
1492
                    extra={"mount_available": mount_available},
1493
                )
1494
            )
1495
1496
            for scenario in scenarios:
1497
                if not isinstance(scenario, dict):
1498
                    continue
1499
                scenario_name = str(scenario.get("name", "unnamed"))
1500
                if scenario_name == "capability_probe":
1501
                    continue
1502
                reason = scenario_skip_reason(scenario, mount_available, owner_repo, spec)
1503
                if reason:
1504
                    for run_index in scenario_runs(defaults):
1505
                        rows.append(
1506
                            skip_row(
1507
                                meta,
1508
                                scenario_name,
1509
                                "scenario.skip",
1510
                                run_index,
1511
                                reason,
1512
                                scenario_cache_fields(scenario),
1513
                            )
1514
                        )
1515
                    continue
1516
                for run_index in scenario_runs(defaults):
1517
                    operations = [str(item) for item in (scenario.get("operations") or [])]
1518
                    if operations == ["recovery.existing_partial_dir"]:
1519
                        scenario_rows, scenario_cleanup_safe = run_recovery_probe(
1520
                            meta, oak_bin, owner_repo, scenario, defaults, run_root, run_index
1521
                        )
1522
                    elif operations == ["parallel.mounts"]:
1523
                        scenario_rows, scenario_cleanup_safe = run_parallel_probe(
1524
                            meta, oak_bin, owner_repo, scenario, defaults, run_root, run_index
1525
                        )
1526
                    else:
1527
                        scenario_rows, scenario_cleanup_safe = run_operation_sequence(
1528
                            meta, oak_bin, owner_repo, safe_push_enabled(spec), scenario, defaults, run_root, run_index
1529
                        )
1530
                    rows.extend(scenario_rows)
1531
                    cleanup_safe = cleanup_safe and scenario_cleanup_safe
1532
1533
    raw_path = write_results(args.results, timestamp, rows)
1534
    print(f"[result] {raw_path}")
1535
1536
    if oak_bin is None:
1537
        return 0
1538
1539
    if not args.keep and cleanup_safe:
1540
        shutil.rmtree(run_root, ignore_errors=True)
1541
    elif not args.keep:
1542
        print(f"[keep] cleanup skipped because a mount command failed: {run_root}", file=sys.stderr)
1543
    else:
1544
        print(f"[keep] {run_root}")
1545
1546
    real_failures = [
1547
        row
1548
        for row in rows
1549
        if row.get("returncode") not in {0, SKIP_RETURNCODE}
1550
        and row.get("scenario") != "capability_probe"
1551
        and not row.get("skipped")
1552
    ]
1553
    return 1 if real_failures else 0
1554
1555
1556
if __name__ == "__main__":
1557
    raise SystemExit(main())