The JIT that runs makepad apps from source: lexer, front end, type checker, lazy borrow checker (Gandalf; `--check-first` opts in), codegen, session and warm/cold caches keyed by compiler stamp, provided crates bound late, startup images, live patching. User-facing name is `makepad run` (binary `makepad`, MAKEPAD_RUN_* env, ~/.makepad-run); libs/relax stays the internal library name. `makepad check` reports rustc-style diagnostics with rustc error codes and checks a whole workspace (`--workspace`). Runs on macOS, Linux, Windows and Android. makepad-remote: one tool schema for apps and the WM (app/1): grabs, captures, traces, frame profiles and tweaks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
313 lines
12 KiB
Python
313 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Hot-reload cases for `makepad live` (local/plans/hotreload.md section 8).
|
|
|
|
usage: run_cases.py --makepad <makepad binary> [--only name[,name..]] [--verbose] [--keep DIR]
|
|
|
|
A case is a directory under tests/live/ (the files directly in tests/live/ are the case "body_edit"):
|
|
v0.rs, v1.rs, .. single-file crate: each edit replaces the crate root
|
|
v0/root.rs + v0/*.rs, v1/.. multi-file crate: v0/ is the crate (root.rs is its root), each later vK/
|
|
holds only the files that edit K changes (an overlay); the step is passed
|
|
to `makepad live` as the overlay directory
|
|
expect.txt one line per edit (# starts a comment line): `vK <rung>[|<rung>..] [out=oracle|prev|file] [note=<text>]`
|
|
rung: patch | reinst | restart | refuse | rollback
|
|
- patch, reinst: the edit applied; the running program prints what a fresh
|
|
run of vK prints (out=oracle, the default), or expect-vK.out (out=file)
|
|
- refuse, restart, rollback: the running program is unchanged; a run after it
|
|
prints what the previous segment printed (out=prev, the default)
|
|
note=<text>: the edit's output must contain <text> (rest of the line)
|
|
expect-v0.out (optional) what the v0 second run prints, if not what a fresh run of v0 prints
|
|
env (optional) KEY=VALUE lines for both the live session and the oracle runs
|
|
MAKEPAD_RUN_LIVE_STREAM=1 feeds single-file edits through apply_stream, 17 ms apart.
|
|
xfail (optional) the case is expected to FAIL today; its first line names the gap (G1..G10)
|
|
forbid (optional) substrings (one per line) that must not appear in the live session's output from the
|
|
first edit on
|
|
|
|
Program output that is compared: the lines starting with "out " or "checksum ".
|
|
The oracle for vK is `makepad test` on vK's sources run fresh (one run).
|
|
|
|
The live driver's edit lines say how an edit was applied: `rung=<name>` when the driver prints it;
|
|
otherwise (the driver before the patch transaction) it is inferred: "patch REFUSED" or an edit line
|
|
without "changed fn(s)" = refuse; "rolled back patch" = rollback; "patch compile error"/"patch link
|
|
error" = half (a half-applied patch, never acceptable); else patch.
|
|
|
|
Result per case: PASS, FAIL, XFAIL (fails and is marked xfail), XPASS (passes but is marked xfail:
|
|
remove the marker). Exit status 1 on any FAIL or XPASS.
|
|
"""
|
|
import os, re, shutil, subprocess, sys, tempfile
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
OUT_RE = re.compile(r"^(out |checksum )")
|
|
|
|
|
|
def parse_args(argv):
|
|
a = {"relax": None, "only": None, "verbose": False, "keep": None, "timeout": 180}
|
|
i = 0
|
|
while i < len(argv):
|
|
k = argv[i]
|
|
if k == "--makepad":
|
|
a["relax"] = argv[i + 1]; i += 2
|
|
elif k == "--only":
|
|
a["only"] = set(argv[i + 1].split(",")); i += 2
|
|
elif k == "--keep":
|
|
a["keep"] = argv[i + 1]; i += 2
|
|
elif k == "--timeout":
|
|
a["timeout"] = int(argv[i + 1]); i += 2
|
|
elif k in ("-v", "--verbose"):
|
|
a["verbose"] = True; i += 1
|
|
else:
|
|
sys.exit("unknown argument " + k)
|
|
if not a["relax"]:
|
|
sys.exit(__doc__)
|
|
return a
|
|
|
|
|
|
def cases():
|
|
out = []
|
|
if os.path.exists(os.path.join(HERE, "v0.rs")):
|
|
out.append(("body_edit", HERE))
|
|
for n in sorted(os.listdir(HERE)):
|
|
d = os.path.join(HERE, n)
|
|
if os.path.isdir(d) and (os.path.exists(os.path.join(d, "v0.rs")) or os.path.isdir(os.path.join(d, "v0"))):
|
|
out.append((n, d))
|
|
return out
|
|
|
|
|
|
def versions(d):
|
|
"""[(name, path)] in order: files vK.rs or overlay dirs vK/."""
|
|
vs = []
|
|
k = 0
|
|
while True:
|
|
f = os.path.join(d, "v%d.rs" % k)
|
|
g = os.path.join(d, "v%d" % k)
|
|
if os.path.isfile(f):
|
|
vs.append(("v%d" % k, f))
|
|
elif os.path.isdir(g):
|
|
vs.append(("v%d" % k, g))
|
|
else:
|
|
break
|
|
k += 1
|
|
return vs
|
|
|
|
|
|
def read_env(d):
|
|
env = dict(os.environ)
|
|
env.setdefault("MAKEPAD_RUN_CACHE", "0")
|
|
p = os.path.join(d, "env")
|
|
if os.path.exists(p):
|
|
for line in open(p):
|
|
line = line.strip()
|
|
if line and not line.startswith("#") and "=" in line:
|
|
k, v = line.split("=", 1)
|
|
env[k] = v
|
|
return env
|
|
|
|
|
|
def read_expect(d, nedits):
|
|
p = os.path.join(d, "expect.txt")
|
|
ex = {}
|
|
if os.path.exists(p):
|
|
for line in open(p):
|
|
line = line.rstrip("\n")
|
|
if not line.strip() or line.lstrip().startswith("#"):
|
|
continue
|
|
parts = line.split()
|
|
v, rungs = parts[0], parts[1].split("|")
|
|
e = {"rungs": rungs, "out": None, "note": None}
|
|
rest = line.split(None, 2)[2] if len(parts) > 2 else ""
|
|
m = re.search(r"note=(.*)$", rest)
|
|
if m:
|
|
e["note"] = m.group(1).strip()
|
|
rest = rest[: m.start()]
|
|
m = re.search(r"out=(\S+)", rest)
|
|
if m:
|
|
e["out"] = m.group(1)
|
|
ex[v] = e
|
|
return ex
|
|
|
|
|
|
def run(cmd, env, cwd, timeout):
|
|
try:
|
|
p = subprocess.run(cmd, env=env, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
|
|
return p.returncode, p.stdout.decode("utf-8", "replace")
|
|
except subprocess.TimeoutExpired as e:
|
|
return "timeout", (e.stdout or b"").decode("utf-8", "replace")
|
|
|
|
|
|
def outs(text):
|
|
return [l for l in text.splitlines() if OUT_RE.match(l)]
|
|
|
|
|
|
def materialize(vs, k, tmp):
|
|
"""The source tree of version k of a multi-file case: v0 plus overlays 1..k. Returns its root.rs."""
|
|
t = os.path.join(tmp, "tree-v%d" % k)
|
|
shutil.copytree(vs[0][1], t)
|
|
for j in range(1, k + 1):
|
|
src = vs[j][1]
|
|
for base, _, files in os.walk(src):
|
|
rel = os.path.relpath(base, src)
|
|
os.makedirs(os.path.join(t, rel), exist_ok=True)
|
|
for f in files:
|
|
shutil.copy(os.path.join(base, f), os.path.join(t, rel, f))
|
|
return os.path.join(t, "root.rs")
|
|
|
|
|
|
def segments(text):
|
|
"""[(edit line, [lines])] after the v0 second run; segment 0 is that run (edit line '')."""
|
|
lines = text.splitlines()
|
|
try:
|
|
i = next(j for j, l in enumerate(lines) if l.startswith("v0: second run"))
|
|
except StopIteration:
|
|
return None
|
|
segs = [("", [])]
|
|
for l in lines[i + 1:]:
|
|
if l.startswith("edit "):
|
|
segs.append((l, []))
|
|
else:
|
|
segs[-1][1].append(l)
|
|
return segs
|
|
|
|
|
|
def rung_of(edit_line, body):
|
|
text = "\n".join(body)
|
|
if "rolled back patch" in text:
|
|
return "rollback"
|
|
m = re.search(r"\brung=(\w+)", edit_line)
|
|
if m:
|
|
return m.group(1)
|
|
if re.search(r"patch (compile|link) error", text):
|
|
return "half"
|
|
if "rolled back patch" in text:
|
|
return "rollback"
|
|
if "patch REFUSED" in edit_line or "changed fn(s)" not in edit_line:
|
|
return "refuse"
|
|
return "patch"
|
|
|
|
|
|
def final_run(body):
|
|
"""The program output of the last run in a segment (after a rollback's rerun if any)."""
|
|
k = 0
|
|
for j, l in enumerate(body):
|
|
if "rolled back patch" in l:
|
|
k = j + 1
|
|
return outs("\n".join(body[k:]))
|
|
|
|
|
|
def check_case(name, d, a, tmp):
|
|
vs = versions(d)
|
|
env = read_env(d)
|
|
ex = read_expect(d, len(vs) - 1)
|
|
multi = os.path.isdir(vs[0][1])
|
|
msgs = []
|
|
# the live session
|
|
if multi:
|
|
root = os.path.join(tmp, "live")
|
|
shutil.copytree(vs[0][1], root)
|
|
cmd = [a["relax"], "live", os.path.join(root, "root.rs")] + [p for _, p in vs[1:]]
|
|
else:
|
|
cmd = [a["relax"], "live"] + [p for _, p in vs]
|
|
code, text = run(cmd, env, tmp, a["timeout"])
|
|
if a["verbose"]:
|
|
print("---- %s live (exit %s)\n%s----" % (name, code, text))
|
|
if code != 0:
|
|
msgs.append("live session exit %s%s" % (code, " (crash)" if code not in (1, 2) else ""))
|
|
fp = os.path.join(d, "forbid")
|
|
if os.path.exists(fp):
|
|
for bad in [l.strip() for l in open(fp) if l.strip()]:
|
|
# from the first edit on (warm-up before it is the program's own first run)
|
|
k = text.find("\nedit ")
|
|
if k >= 0 and bad in text[k:]:
|
|
msgs.append("forbidden output: %r" % bad)
|
|
segs = segments(text)
|
|
if segs is None:
|
|
msgs.append("no v0 second run in the output")
|
|
return msgs, text
|
|
if len(segs) != len(vs):
|
|
msgs.append("%d edit segment(s) for %d edit(s)" % (len(segs) - 1, len(vs) - 1))
|
|
# oracles
|
|
oracle_cache = {}
|
|
|
|
def oracle(k):
|
|
if k not in oracle_cache:
|
|
src = materialize(vs, k, tmp) if multi else vs[k][1]
|
|
c, t = run([a["relax"], "test", src], env, tmp, a["timeout"])
|
|
if c != 0:
|
|
msgs.append("oracle v%d: makepad test exit %s" % (k, c))
|
|
oracle_cache[k] = outs(t)
|
|
return oracle_cache[k]
|
|
|
|
def expected_file(v):
|
|
p = os.path.join(d, "expect-%s.out" % v)
|
|
return [l for l in open(p).read().splitlines() if l.strip()] if os.path.exists(p) else None
|
|
|
|
prev = expected_file("v0")
|
|
if prev is None:
|
|
prev = oracle(0)
|
|
got0 = final_run(segs[0][1])
|
|
if got0 != prev:
|
|
msgs.append("v0: second run prints %s, expected %s" % (got0, prev))
|
|
prev = got0
|
|
for k in range(1, len(vs)):
|
|
v = vs[k][0]
|
|
if k >= len(segs):
|
|
break
|
|
edit_line, body = segs[k]
|
|
e = ex.get(v, {"rungs": ["patch"], "out": None, "note": None})
|
|
r = rung_of(edit_line, body)
|
|
if r not in e["rungs"]:
|
|
msgs.append("%s: rung %s, expected %s (%s)" % (v, r, "|".join(e["rungs"]), edit_line.strip()[:200]))
|
|
if e["note"] and e["note"] not in edit_line + "\n" + "\n".join(body):
|
|
msgs.append("%s: missing note %r" % (v, e["note"]))
|
|
got = final_run(body)
|
|
applied = r in ("patch", "reinst")
|
|
mode = e["out"] or ("oracle" if applied else "prev")
|
|
if mode == "oracle":
|
|
want = oracle(k)
|
|
elif mode == "prev":
|
|
want = prev
|
|
else:
|
|
want = expected_file(v)
|
|
if want is None:
|
|
msgs.append("%s: expect-%s.out missing" % (v, v))
|
|
want = []
|
|
if not got and not applied and r != "half":
|
|
# a refused edit may print no run after it: nothing to compare
|
|
pass
|
|
elif got != want:
|
|
msgs.append("%s: prints %s, expected (%s) %s" % (v, got, mode, want))
|
|
if got:
|
|
prev = got
|
|
return msgs, text
|
|
|
|
|
|
def main():
|
|
a = parse_args(sys.argv[1:])
|
|
a["relax"] = os.path.abspath(a["relax"])
|
|
counts = {"PASS": 0, "FAIL": 0, "XFAIL": 0, "XPASS": 0}
|
|
for name, d in cases():
|
|
if a["only"] and name not in a["only"]:
|
|
continue
|
|
tmp = tempfile.mkdtemp(prefix="live-%s-" % name, dir=a["keep"])
|
|
msgs, text = check_case(name, d, a, tmp)
|
|
xf = os.path.join(d, "xfail")
|
|
marked = os.path.exists(xf)
|
|
gap = open(xf).readline().strip() if marked else ""
|
|
if msgs:
|
|
res = "XFAIL" if marked else "FAIL"
|
|
else:
|
|
res = "XPASS" if marked else "PASS"
|
|
counts[res] += 1
|
|
print("%-6s %-20s %s" % (res, name, gap))
|
|
if res in ("FAIL", "XFAIL") or a["verbose"]:
|
|
for m in msgs:
|
|
print(" " + m)
|
|
if res == "FAIL" and not a["verbose"]:
|
|
for l in text.splitlines()[-12:]:
|
|
print(" | " + l)
|
|
if not a["keep"]:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
print("live cases: %d pass, %d fail, %d xfail, %d xpass" % (counts["PASS"], counts["FAIL"], counts["XFAIL"], counts["XPASS"]))
|
|
sys.exit(1 if counts["FAIL"] or counts["XPASS"] else 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|