返回 DeepSeek-Reasonix
desktop-shell-metrics.sh
根目录 / scripts / desktop-shell-metrics.sh
1 #!/usr/bin/env bash
2 # Measure one desktop shell build: launch with a disposable data home, wait for
3 # the Go lifecycle diagnostics to reach "healthy", then sample the full process
4 # tree (shell, renderer/GPU helpers and the Go service) at fixed offsets.
5 # Output is one JSON document; compare runs of this script only, never against
6 # a differently sampled figure. The retired Wails shell's baseline runs live in
7 # docs/desktop-migration/baseline/.
8 #
9 # Usage: scripts/desktop-shell-metrics.sh <executable> <label> <out.json> [idle_seconds]
10 # executable: the packaged app's main executable
11 # label: free text recorded in the output (e.g. electron-darwin-arm64)
12 set -euo pipefail
13
14 exe="${1:?usage: desktop-shell-metrics.sh <executable> <label> <out.json> [idle_seconds]}"
15 label="${2:?label}"
16 out="${3:?out.json}"
17 idle="${4:-30}"
18
19 home="$(mktemp -d "${TMPDIR:-/tmp}/reasonix-shell-metrics.XXXXXX")"
20 cleanup() {
21 if [ -n "${pid:-}" ] && kill -0 "$pid" 2>/dev/null; then
22 kill -TERM "$pid" 2>/dev/null || true
23 for _ in $(seq 1 50); do
24 kill -0 "$pid" 2>/dev/null || break
25 sleep 0.1
26 done
27 kill -KILL "$pid" 2>/dev/null || true
28 fi
29 rm -rf "$home"
30 }
31 trap cleanup EXIT
32
33 now_ms() { python3 -c 'import time; print(int(time.time()*1000))'; }
34
35 # Process-tree RSS in KiB: the launched pid, its descendants, and helper
36 # processes that started after launch and belong to the same shell family
37 # (WebKit XPC helpers on macOS re-parent to launchd; Electron helpers do not).
38 tree_rss() {
39 python3 - "$pid" "$launch_epoch" <<'PY'
40 import subprocess, sys, time
41 root = int(sys.argv[1]); launched = float(sys.argv[2])
42 rows = subprocess.run(["ps", "-axo", "pid=,ppid=,rss=,lstart=,comm="], capture_output=True, text=True).stdout.splitlines()
43 procs = {}
44 for row in rows:
45 parts = row.split(None, 3)
46 if len(parts) < 4:
47 continue
48 pid, ppid, rss = int(parts[0]), int(parts[1]), int(parts[2])
49 rest = parts[3]
50 # lstart is 5 whitespace-separated fields; the remainder is comm.
51 fields = rest.split(None, 5)
52 if len(fields) < 6:
53 continue
54 started = " ".join(fields[:5]); comm = fields[5]
55 try:
56 epoch = time.mktime(time.strptime(started, "%a %b %d %H:%M:%S %Y"))
57 except ValueError:
58 epoch = 0
59 procs[pid] = (ppid, rss, epoch, comm)
60 family = set()
61 def descend(p):
62 for q, (pp, _, _, _) in procs.items():
63 if pp == p and q not in family:
64 family.add(q); descend(q)
65 if root in procs:
66 family.add(root); descend(root)
67 helpers = ("WebKit", "Reasonix Helper", "reasonix-desktop", "Electron Helper", "reasonix", "chrome_crashpad")
68 for p, (pp, rss, epoch, comm) in procs.items():
69 if p in family:
70 continue
71 if epoch >= launched - 1 and any(h in comm for h in helpers):
72 family.add(p)
73 total = sum(procs[p][1] for p in family)
74 print(total, len(family), ";".join(sorted({procs[p][3].rsplit('/',1)[-1] for p in family})))
75 PY
76 }
77
78 lifecycle_phase() {
79 python3 - "$home" "$pid" <<'PY'
80 import glob, json, os, sys
81 home, pid = sys.argv[1], sys.argv[2]
82 # The Go process writes the file under its own pid; a fresh home has one.
83 paths = glob.glob(os.path.join(home, "**", "diagnostics", "lifecycle", "*.json"), recursive=True)
84 for path in sorted(paths, key=os.path.getmtime, reverse=True):
85 try:
86 with open(path) as fh:
87 state = json.load(fh)
88 print(state.get("phase", ""), state.get("startedAt", ""), state.get("updatedAt", ""))
89 break
90 except (OSError, ValueError):
91 pass
92 PY
93 }
94
95 export REASONIX_HOME="$home" REASONIX_STATE_HOME="$home" REASONIX_CACHE_HOME="$home/cache" REASONIX_DEV=1
96 launch_epoch="$(python3 -c 'import time; print(time.time())')"
97 t0="$(now_ms)"
98 "$exe" >"$home/stdout.log" 2>"$home/stderr.log" &
99 pid=$!
100
101 phase=""; healthy_ms=""; ready_ms=""
102 for _ in $(seq 1 600); do
103 sleep 0.1
104 kill -0 "$pid" 2>/dev/null || { echo "shell exited before becoming healthy" >&2; cat "$home/stderr.log" >&2; exit 1; }
105 read -r phase _ _ < <(lifecycle_phase) || true
106 case "$phase" in
107 ready) [ -n "$ready_ms" ] || ready_ms=$(( $(now_ms) - t0 )) ;;
108 healthy) [ -n "$ready_ms" ] || ready_ms=$(( $(now_ms) - t0 )); healthy_ms=$(( $(now_ms) - t0 )); break ;;
109 esac
110 done
111 [ -n "$healthy_ms" ] || { echo "shell never reported healthy (last phase: '$phase')" >&2; exit 1; }
112
113 samples="["
114 for offset in 2 10 "$idle"; do
115 sleep "$offset"
116 read -r rss_kib count names < <(tree_rss)
117 samples+="{\"afterHealthySeconds\":$offset,\"rssKiB\":$rss_kib,\"processCount\":$count,\"processes\":\"$names\"},"
118 done
119 samples="${samples%,}]"
120
121 kill -TERM "$pid"
122 exit_t0="$(now_ms)"
123 for _ in $(seq 1 100); do
124 kill -0 "$pid" 2>/dev/null || break
125 sleep 0.1
126 done
127 exit_ms=$(( $(now_ms) - exit_t0 ))
128 kill -0 "$pid" 2>/dev/null && exit_clean=false || exit_clean=true
129
130 python3 - "$out" "$label" "$exe" "$ready_ms" "$healthy_ms" "$samples" "$exit_ms" "$exit_clean" <<'PY'
131 import json, platform, sys, datetime
132 out, label, exe, ready, healthy, samples, exit_ms, exit_clean = sys.argv[1:]
133 doc = {
134 "label": label, "executable": exe,
135 "measuredAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
136 "machine": {"platform": platform.platform(), "machine": platform.machine()},
137 "startup": {"readyMs": int(ready), "healthyMs": int(healthy)},
138 "samples": json.loads(samples),
139 "exit": {"terminatedWithinMs": int(exit_ms), "clean": exit_clean == "true"},
140 }
141 with open(out, "w") as fh:
142 json.dump(doc, fh, indent=2); fh.write("\n")
143 print(json.dumps(doc, indent=2))
144 PY
145
145 lines BASH