215 lines
9.7 KiB
Python
215 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Preflight one Emscripten Web probe link rule before spending link time."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import shlex
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
CACHE_PATH = ROOT / "docs/current/web-link-preflight-cache.json"
|
|
|
|
|
|
def link_options(flags: str, libraries: str) -> dict:
|
|
tokens = shlex.split(flags)
|
|
settings = {}
|
|
for index, token in enumerate(tokens):
|
|
setting = tokens[index + 1] if token == "-s" and index + 1 < len(tokens) else token[2:] if token.startswith("-s") else ""
|
|
key, separator, value = setting.partition("=")
|
|
if separator:
|
|
settings[key] = value
|
|
concurrent = any(Path(token).name in {"libQt5Concurrent.a", "libQt6Concurrent.a"}
|
|
or token in {"-lQt5Concurrent", "-lQt6Concurrent"} for token in shlex.split(libraries))
|
|
return {"settings": settings, "hasPthreadFlag": "-pthread" in tokens,
|
|
"needsPthread": concurrent or "-pthread" in tokens or settings.get("USE_PTHREADS") == "1" or "PTHREAD_POOL_SIZE" in settings}
|
|
|
|
|
|
def target_rule(build_dir: Path, target: str) -> tuple[str, str, str] | None:
|
|
ninja = build_dir / "build.ninja"
|
|
try:
|
|
lines = ninja.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
except OSError:
|
|
return None
|
|
target = target.removesuffix(".js")
|
|
marker = f"build bin/{target}.js:"
|
|
for index, line in enumerate(lines):
|
|
if not line.startswith(marker):
|
|
continue
|
|
flags = ""
|
|
libraries = ""
|
|
for candidate in lines[index + 1 : index + 8]:
|
|
if candidate.startswith(" LINK_FLAGS = "):
|
|
flags = candidate.removeprefix(" LINK_FLAGS = ")
|
|
elif candidate.startswith(" LINK_LIBRARIES = "):
|
|
libraries = candidate.removeprefix(" LINK_LIBRARIES = ")
|
|
return line, flags, libraries
|
|
return None
|
|
|
|
|
|
def resolve_target_alias(build_dir: Path, target: str) -> str:
|
|
"""Resolve the common generic CoreProbe name to the active module probe."""
|
|
normalized = target.removesuffix(".js")
|
|
if normalized != "FreeCADCoreProbe":
|
|
return normalized
|
|
aliases = {
|
|
"Part": "FreeCADWebPartBoxProbe",
|
|
"PartDesign": "FreeCADWebPartDesignRuntimeProbe",
|
|
"Sketcher": "FreeCADWebSketcherProbe",
|
|
"TechDraw": "FreeCADWebTechDrawRuntimeProbe",
|
|
"Measure": "FreeCADWebMeasureKernelProbe",
|
|
"Material": "FreeCADWebMaterialRuntimeProbe",
|
|
"Mesh": "FreeCADWebMeshRuntimeProbe",
|
|
"Assembly": "FreeCADWebAssemblyProbe",
|
|
}
|
|
try:
|
|
batch = json.loads((ROOT / "docs/current/module-batch-manifest.json").read_text(encoding="utf-8"))
|
|
module = (batch.get("selected") or {}).get("module") if isinstance(batch, dict) else None
|
|
except (OSError, json.JSONDecodeError):
|
|
module = None
|
|
candidate = aliases.get(module)
|
|
if candidate and target_rule(build_dir, candidate) is not None:
|
|
return candidate
|
|
return normalized
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("build_dir", type=Path)
|
|
parser.add_argument("target")
|
|
parser.add_argument("--json", action="store_true")
|
|
args = parser.parse_args()
|
|
build_dir = args.build_dir if args.build_dir.is_absolute() else ROOT / args.build_dir
|
|
requested_build_dir = build_dir
|
|
if build_dir.name == "bin" and not (build_dir / "build.ninja").is_file() and (build_dir.parent / "build.ninja").is_file():
|
|
build_dir = build_dir.parent
|
|
requested_target = args.target.removesuffix(".js")
|
|
args.target = resolve_target_alias(build_dir, requested_target)
|
|
errors: list[str] = []
|
|
warnings: list[str] = []
|
|
missing_archives: list[str] = []
|
|
# The link rule is static until CMake rewrites build.ninja. Cache only the
|
|
# rule text and its file stamp; all archive and artifact checks below still
|
|
# run on every invocation so newly installed libraries are noticed.
|
|
rule = None
|
|
cache_hit = False
|
|
ninja_path = build_dir / "build.ninja"
|
|
cache_key = f"{build_dir.resolve()}::{args.target.removesuffix('.js')}"
|
|
try:
|
|
ninja_stat = ninja_path.stat()
|
|
except OSError:
|
|
ninja_stat = None
|
|
try:
|
|
cache_payload = json.loads(CACHE_PATH.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
cache_payload = {}
|
|
cache_entries = cache_payload.get("entries", {}) if isinstance(cache_payload, dict) else {}
|
|
cached = cache_entries.get(cache_key) if isinstance(cache_entries, dict) else None
|
|
if (
|
|
ninja_stat is not None
|
|
and isinstance(cached, dict)
|
|
and cached.get("size") == ninja_stat.st_size
|
|
and cached.get("mtime_ns") == ninja_stat.st_mtime_ns
|
|
and cached.get("inode") == ninja_stat.st_ino
|
|
and isinstance(cached.get("rule"), list)
|
|
and len(cached["rule"]) == 3
|
|
):
|
|
rule = tuple(str(value) for value in cached["rule"])
|
|
cache_hit = True
|
|
if rule is None:
|
|
rule = target_rule(build_dir, args.target)
|
|
if rule is not None and ninja_stat is not None:
|
|
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
updated_entries = dict(cache_entries) if isinstance(cache_entries, dict) else {}
|
|
updated_entries[cache_key] = {
|
|
"size": ninja_stat.st_size,
|
|
"mtime_ns": ninja_stat.st_mtime_ns,
|
|
"inode": ninja_stat.st_ino,
|
|
"rule": list(rule),
|
|
}
|
|
temporary = CACHE_PATH.with_suffix(".tmp")
|
|
temporary.write_text(
|
|
json.dumps({"schema": "freecad-web-link-preflight-cache/v1", "entries": updated_entries}, ensure_ascii=False, separators=(",", ":")) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
temporary.replace(CACHE_PATH)
|
|
if rule is None:
|
|
errors.append(f"link rule not found for {args.target}")
|
|
flags = libraries = ""
|
|
else:
|
|
_, flags, libraries = rule
|
|
options = link_options(flags, libraries)
|
|
if options["settings"].get("WASM") != "1" or options["settings"].get("MODULARIZE") != "1":
|
|
errors.append("target is not an Emscripten modular WASM executable")
|
|
# Do not match library directory names such as `python-pthread-install`
|
|
# or `libboost_thread.a`: those archives can be linked in a
|
|
# single-threaded probe. Require an explicit Emscripten/Qt concurrent
|
|
# feature that actually needs the pthread link mode.
|
|
needs_pthread = options["needsPthread"]
|
|
if needs_pthread and not options["hasPthreadFlag"]:
|
|
errors.append("pthread-dependent closure has no -pthread link flag")
|
|
if needs_pthread and "PTHREAD_POOL_SIZE" not in options["settings"]:
|
|
errors.append("pthread-dependent closure has no explicit PTHREAD_POOL_SIZE link setting")
|
|
try:
|
|
cache_text = (build_dir / "CMakeCache.txt").read_text(encoding="utf-8", errors="ignore")
|
|
except OSError:
|
|
cache_text = ""
|
|
# CMake can emit a bare -lTK* when an optional OCCT component is
|
|
# listed in the package metadata but absent from the selected Web
|
|
# install. Detect that dangling archive before invoking wasm-ld.
|
|
occt_roots: list[Path] = []
|
|
cache_match = re.search(r"^FREECAD_WEB_OCCT_ROOT:PATH=([^\n]+)", cache_text, re.MULTILINE)
|
|
if cache_match:
|
|
occt_roots.append(Path(cache_match.group(1)).expanduser() / "lib")
|
|
for token in libraries.split():
|
|
match = re.fullmatch(r"-l(TK[A-Za-z0-9_]+)", token)
|
|
if not match:
|
|
continue
|
|
archive = f"lib{match.group(1)}.a"
|
|
if not any((root / archive).is_file() for root in occt_roots):
|
|
missing_archives.append(match.group(1))
|
|
if missing_archives:
|
|
errors.append("missing OCCT archives: " + ", ".join(sorted(set(missing_archives))))
|
|
if "FREECAD_WEB_FAST_LINK:BOOL=ON" in cache_text:
|
|
profile = "fast-dev"
|
|
else:
|
|
profile = "release"
|
|
output = build_dir / "bin" / f"{args.target.removesuffix('.js')}.js"
|
|
wasm = build_dir / "bin" / f"{args.target.removesuffix('.js')}.wasm"
|
|
if output.exists() != wasm.exists():
|
|
warnings.append("runtime and WASM output presence are inconsistent")
|
|
result = {
|
|
"schema": "freecad-web-link-preflight/v1",
|
|
"buildDirectory": str(build_dir.relative_to(ROOT)) if build_dir.is_relative_to(ROOT) else str(build_dir),
|
|
"requestedBuildDirectory": str(requested_build_dir.relative_to(ROOT)) if requested_build_dir.is_relative_to(ROOT) else str(requested_build_dir),
|
|
"target": args.target.removesuffix(".js"),
|
|
"requestedTarget": requested_target,
|
|
"resolvedTarget": args.target.removesuffix(".js"),
|
|
"profile": profile,
|
|
"needsPthread": needs_pthread,
|
|
"hasPthreadFlag": options["hasPthreadFlag"],
|
|
"missingArchives": sorted(set(missing_archives)),
|
|
"runtimePresent": output.exists(),
|
|
"wasmPresent": wasm.exists(),
|
|
"errors": errors,
|
|
"warnings": warnings,
|
|
"cacheHit": cache_hit,
|
|
}
|
|
print(json.dumps(result, ensure_ascii=False) if args.json else json.dumps(result, ensure_ascii=False, indent=2))
|
|
return 1 if errors else 0
|
|
result = {
|
|
"schema": "freecad-web-link-preflight/v1",
|
|
"buildDirectory": str(build_dir),
|
|
"target": args.target.removesuffix(".js"),
|
|
"errors": errors,
|
|
"warnings": warnings,
|
|
}
|
|
print(json.dumps(result, ensure_ascii=False) if args.json else json.dumps(result, ensure_ascii=False, indent=2))
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|