165 lines
9.0 KiB
Python
165 lines
9.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Show progress, throughput, and bottlenecks for the one-shot Web smoke run.
|
|
|
|
The output follows the observable-state style of show_freecad_web_scheduler.py
|
|
and includes its scheduler status anchor for correlation.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import statistics
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
MANIFEST = Path(__import__("os").environ.get("FREECAD_SMOKE_RUN_DIR", str(ROOT / "docs/current/module-smoke-once"))).expanduser().resolve() / "manifest.json"
|
|
SCHEDULER_STATUS = ROOT / "docs/current/freecad-web-scheduler-status.json"
|
|
|
|
|
|
def parse_time(value: object) -> float | None:
|
|
if not isinstance(value, str):
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def scheduler_snapshot() -> dict[str, object]:
|
|
"""Expose the same scheduler status anchor used by the scheduler viewer."""
|
|
try:
|
|
value = json.loads(SCHEDULER_STATUS.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return {"status": "unknown"}
|
|
return {key: value.get(key) for key in ("status", "cycle", "activeTask", "unresolvedFailures", "heartbeatAt")}
|
|
|
|
|
|
def snapshot() -> dict[str, object]:
|
|
if not MANIFEST.exists():
|
|
return {"status": "not_started", "completed": 0, "total": 0, "message": str(MANIFEST)}
|
|
try:
|
|
data = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
return {"status": "unreadable", "message": str(exc)}
|
|
results = data.get("results", {}) if isinstance(data.get("results", {}), dict) else {}
|
|
total = len(data.get("modules", []))
|
|
completed = len(results)
|
|
passed = sum(isinstance(v, dict) and v.get("status") == "pass" for v in results.values())
|
|
failed = sum(isinstance(v, dict) and v.get("status") != "pass" for v in results.values())
|
|
started = parse_time(data.get("startedAt"))
|
|
now = parse_time(data.get("completedAt")) or time.time()
|
|
elapsed = max(0.0, now - started) if started else 0.0
|
|
durations = [float(v["durationSeconds"]) for v in results.values() if isinstance(v, dict) and isinstance(v.get("durationSeconds"), (int, float))]
|
|
average = statistics.mean(durations) if durations else 0.0
|
|
median = statistics.median(durations) if durations else 0.0
|
|
remaining = max(0, total - completed)
|
|
current = data.get("current") if isinstance(data.get("current"), dict) else None
|
|
current_elapsed = max(0.0, time.time() - float(current["startedEpoch"])) if current and isinstance(current.get("startedEpoch"), (int, float)) else 0.0
|
|
completed_per_minute = completed / (elapsed / 60.0) if elapsed > 0 else 0.0
|
|
slowest = sorted(
|
|
((name, float(v.get("durationSeconds", 0.0)), v.get("status")) for name, v in results.items() if isinstance(v, dict)),
|
|
key=lambda item: item[1], reverse=True,
|
|
)[:5]
|
|
phase_seconds = {
|
|
"compile": round(sum(float(v.get("compileDurationSeconds", 0.0)) for v in results.values() if isinstance(v, dict)), 3),
|
|
"build": round(sum(float(v.get("buildDurationSeconds", 0.0)) for v in results.values() if isinstance(v, dict)), 3),
|
|
"smoke": round(sum(float(v.get("smokeDurationSeconds", v.get("durationSeconds", 0.0))) for v in results.values() if isinstance(v, dict) and v.get("phase") == "smoke"), 3),
|
|
}
|
|
timeout_count = sum(1 for v in results.values() if isinstance(v, dict) and v.get("timeoutSeconds"))
|
|
build_failures = sum(isinstance(v, dict) and v.get("phase") == "build" for v in results.values())
|
|
compile_failures = sum(isinstance(v, dict) and v.get("phase") == "compile" for v in results.values())
|
|
failure_classes: dict[str, int] = {}
|
|
for value in results.values():
|
|
if not isinstance(value, dict) or value.get("status") == "pass":
|
|
continue
|
|
stderr = str(value.get("stderr", ""))
|
|
if value.get("timeoutSeconds") or value.get("interrupted"):
|
|
category = "timeout_or_interrupted"
|
|
elif "require is not defined in ES module scope" in stderr:
|
|
category = "esm_cjs_worker"
|
|
elif "compiled " in stderr and "path is required" in stderr:
|
|
category = "missing_facade_argument"
|
|
elif "ERR_MODULE_NOT_FOUND" in stderr:
|
|
category = "missing_runtime_artifact"
|
|
elif "FROZEN_CACHE" in stderr or "cache" in stderr.lower() and value.get("phase") == "build":
|
|
category = "build_cache"
|
|
elif value.get("phase") == "build":
|
|
category = "build_failure"
|
|
else:
|
|
category = "runtime_or_assertion"
|
|
failure_classes[category] = failure_classes.get(category, 0) + 1
|
|
measured_total = sum(duration for _, duration, _ in slowest)
|
|
bottleneck = slowest[0] if slowest else None
|
|
return {
|
|
"status": data.get("status", "unknown"), "completed": completed, "total": total,
|
|
"passed": passed, "failed": failed, "remaining": remaining, "elapsedSeconds": round(elapsed, 3),
|
|
"completedPerMinute": round(completed_per_minute, 3), "averageDurationSeconds": round(average, 3),
|
|
"medianDurationSeconds": round(median, 3), "estimatedRemainingSeconds": round(remaining * median, 3),
|
|
"current": current, "currentElapsedSeconds": round(current_elapsed, 3), "slowest": slowest,
|
|
"phaseSeconds": phase_seconds, "timeouts": timeout_count, "buildFailures": build_failures, "compileFailures": compile_failures,
|
|
"efficiencyVerdict": (
|
|
"LOW: timeout dominates smoke time" if timeout_count and slowest else
|
|
"MIXED: build/compile failures present" if build_failures or compile_failures else
|
|
"GOOD: no timeout or preparation bottleneck"
|
|
),
|
|
"failureClasses": failure_classes,
|
|
"bottleneck": ({"module": bottleneck[0], "seconds": bottleneck[1], "shareOfTop5": round(100.0 * bottleneck[1] / measured_total, 2)} if bottleneck and measured_total else None),
|
|
"failedModules": [name for name, v in results.items() if isinstance(v, dict) and v.get("status") != "pass"],
|
|
"scheduler": scheduler_snapshot(),
|
|
}
|
|
|
|
|
|
def render(view: dict[str, object]) -> str:
|
|
if view.get("status") in {"not_started", "unreadable"}:
|
|
return json.dumps(view, ensure_ascii=False, indent=2)
|
|
total = int(view["total"])
|
|
completed = int(view["completed"])
|
|
percent = (100.0 * completed / total) if total else 0.0
|
|
lines = [
|
|
f"status={view['status']} progress={completed}/{total} ({percent:.1f}%) pass={view['passed']} fail={view['failed']} remaining={view['remaining']}",
|
|
f"elapsed={view['elapsedSeconds']}s throughput={view['completedPerMinute']}/min average/module={view['averageDurationSeconds']}s median/module={view['medianDurationSeconds']}s est_remaining={view['estimatedRemainingSeconds']}s",
|
|
f"phaseSeconds: compile={view['phaseSeconds']['compile']} build={view['phaseSeconds']['build']} smoke={view['phaseSeconds']['smoke']} timeouts={view['timeouts']} buildFailures={view['buildFailures']} compileFailures={view['compileFailures']}",
|
|
f"efficiencyVerdict={view['efficiencyVerdict']}",
|
|
]
|
|
current = view.get("current")
|
|
if isinstance(current, dict):
|
|
lines.append(f"current={current.get('module')} phase={current.get('phase')} elapsed={view['currentElapsedSeconds']}s timeout={current.get('timeoutSeconds', '-')}")
|
|
if view["slowest"]:
|
|
lines.append("slowest=" + ", ".join(f"{name}:{duration:.3f}s/{status}" for name, duration, status in view["slowest"]))
|
|
if view.get("bottleneck"):
|
|
b = view["bottleneck"]
|
|
lines.append(f"bottleneck={b['module']} {b['seconds']:.3f}s ({b['shareOfTop5']:.1f}% of top-5 measured time)")
|
|
if view.get("failureClasses"):
|
|
lines.append("failureClasses=" + ", ".join(f"{name}:{count}" for name, count in sorted(view["failureClasses"].items(), key=lambda item: (-item[1], item[0]))))
|
|
if view["failedModules"]:
|
|
lines.append("failed=" + ", ".join(view["failedModules"]))
|
|
scheduler = view.get("scheduler")
|
|
if isinstance(scheduler, dict):
|
|
lines.append(
|
|
"scheduler(reference=tools/show_freecad_web_scheduler.py): "
|
|
f"status={scheduler.get('status', '-')} cycle={scheduler.get('cycle', '-')} "
|
|
f"activeTask={scheduler.get('activeTask', '-')} unresolvedFailures={scheduler.get('unresolvedFailures', '-') }"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--watch", type=float, metavar="SECONDS", help="refresh continuously")
|
|
parser.add_argument("--json", action="store_true", help="emit a JSON snapshot")
|
|
args = parser.parse_args()
|
|
while True:
|
|
view = snapshot()
|
|
print(json.dumps(view, ensure_ascii=False) if args.json else render(view), flush=True)
|
|
if not args.watch:
|
|
return 0
|
|
time.sleep(max(0.2, args.watch))
|
|
print("\033[2J\033[H", end="")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|