omakit 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -45
- package/blocks/history.json +68 -0
- package/blocks/run/NOTICE +12 -0
- package/blocks/run/Run.qml +242 -0
- package/blocks/run/run-supervisor.py +522 -0
- package/blocks/store/NOTICE +12 -0
- package/blocks/store/Store.qml +157 -0
- package/blocks/store/store-helper.py +431 -0
- package/package.json +12 -5
- package/skills/omarchy-plugin-audit/SKILL.md +11 -5
- package/skills/omarchy-plugin-build/SKILL.md +164 -0
- package/skills/omarchy-plugin-check/SKILL.md +6 -3
- package/skills/omarchy-plugin-submit/SKILL.md +4 -1
- package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
- package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
- package/tests/fixtures/weigh/clean/Widget.qml +19 -0
- package/tests/fixtures/weigh/clean/manifest.json +9 -0
- package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
- package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
- package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
- package/tests/fixtures/weigh/poller/Service.qml +50 -0
- package/tests/fixtures/weigh/poller/manifest.json +9 -0
- package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
- package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
- package/tests/lab/run/harness/scenarios/controls.sh +6 -0
- package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
- package/tests/lab/run/harness/scenarios/forge.sh +11 -0
- package/tests/lab/run/harness/scenarios/holder.sh +5 -0
- package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
- package/tests/lab/run/harness/scenarios/stall.sh +5 -0
- package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
- package/tests/lab/run/harness/scenarios/tree.sh +7 -0
- package/tests/lab/run/harness/shell.qml +84 -0
- package/tests/lab/run/report.py +217 -0
- package/tests/lab/run/suite.sh +106 -0
- package/tests/lab/store/harness/shell.qml +73 -0
- package/tests/lab/store/report.py +133 -0
- package/tests/lab/store/suite.sh +109 -0
- package/tests/parity/corpus.mjs +8 -3
- package/tests/parity/run.mjs +4 -4
- package/tools/audit/audit.mjs +17 -6
- package/tools/audit/git.mjs +3 -3
- package/tools/audit/report.mjs +31 -5
- package/tools/blocks/add.mjs +138 -0
- package/tools/blocks/commit.json +5 -0
- package/tools/blocks/record-commit.mjs +77 -0
- package/tools/blocks/registry.mjs +191 -0
- package/tools/blocks/stamp.mjs +61 -0
- package/tools/inspect/contract.mjs +36 -5
- package/tools/inspect/helpers.mjs +217 -0
- package/tools/inspect/inspect.mjs +68 -3
- package/tools/inspect/patterns.mjs +18 -3
- package/tools/inspect/processes.mjs +38 -5
- package/tools/inspect/report.mjs +18 -2
- package/tools/inspect/writes.mjs +22 -4
- package/tools/lab/guest.mjs +155 -0
- package/tools/lab/harness.sh +119 -0
- package/tools/lab/host.mjs +177 -0
- package/tools/lab/inspect.mjs +240 -0
- package/tools/lab/omarchy.gpg +13 -0
- package/tools/lab/patches/omarchy-iso-test.patch +351 -0
- package/tools/lab/paths.mjs +173 -0
- package/tools/lab/pin.json +42 -0
- package/tools/lab/pin.mjs +64 -0
- package/tools/lab/prune.mjs +68 -0
- package/tools/lab/qemu.mjs +153 -0
- package/tools/lab/qmp-cli.mjs +21 -0
- package/tools/lab/report.mjs +183 -0
- package/tools/lab/run.mjs +344 -0
- package/tools/lab/setup.mjs +430 -0
- package/tools/lab/suites/run.sh +35 -0
- package/tools/lab/suites/store.sh +41 -0
- package/tools/lab/suites/weigh.sh +196 -0
- package/tools/lab/suites.mjs +142 -0
- package/tools/lab/verify.mjs +134 -0
- package/tools/marketplace/README.md +38 -1
- package/tools/marketplace/banner.mjs +23 -2
- package/tools/marketplace/cli.mjs +449 -146
- package/tools/marketplace/completion-check.mjs +27 -1
- package/tools/marketplace/completion.mjs +32 -4
- package/tools/marketplace/doctor.mjs +47 -9
- package/tools/marketplace/github.mjs +52 -6
- package/tools/marketplace/local-transport.mjs +1 -1
- package/tools/marketplace/options.mjs +16 -5
- package/tools/marketplace/outcome.mjs +244 -0
- package/tools/marketplace/pin.mjs +178 -33
- package/tools/marketplace/setup.mjs +16 -15
- package/tools/marketplace/tree.mjs +1 -1
- package/tools/marketplace/upgrade.mjs +5 -5
- package/tools/marketplace/usage.mjs +116 -72
- package/tools/subject/resolve.mjs +19 -6
- package/tools/weigh/audit.mjs +47 -16
- package/tools/weigh/config.mjs +105 -24
- package/tools/weigh/list.mjs +10 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# The Run lab suite's reader: every run under <out>/runs into one document,
|
|
2
|
+
# <out>/runlab.json, and the gate, asserted. Every figure says how it was
|
|
3
|
+
# read: time to end is the harness's own clock from start() to the result
|
|
4
|
+
# (to the destroy event for destroy, to the last of ten results for
|
|
5
|
+
# concurrency); Pss is /proc/<quickshell pid>/smaps_rollup sampled every
|
|
6
|
+
# 100 ms by suite.sh, before = the last sample before the start event, peak
|
|
7
|
+
# = the maximum until the end, after = the last sample at least 1.5 s after
|
|
8
|
+
# the end; survivors are counted two seconds after the end by pgid and by
|
|
9
|
+
# the scope's cgroup; a signal to an empty group is the supervisor's
|
|
10
|
+
# os.killpg raising ProcessLookupError; shadow hits are the lines the
|
|
11
|
+
# hostile scenario's shadow tools wrote.
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import re
|
|
17
|
+
import statistics
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
# What each scenario must show. deadline and grace are the harness's values,
|
|
22
|
+
# so the bound "within deadline plus grace" is checked from them; 500 ms of
|
|
23
|
+
# slack covers the harness's own clock and one supervisor start.
|
|
24
|
+
EXPECT = {
|
|
25
|
+
"producer": {"deadline": 60000, "grace": 1000, "state": "overflow"},
|
|
26
|
+
"producer-stream": {"deadline": 120000, "grace": 1000, "state": "ok", "outBytes": 1073741824},
|
|
27
|
+
"holder": {"deadline": 10000, "grace": 1000, "state": "ok"},
|
|
28
|
+
"stubborn": {"deadline": 2000, "grace": 1000, "state": "timeout", "kill": True},
|
|
29
|
+
"hostile": {"deadline": 5000, "grace": 1000, "state": "ok", "stdout": ["PATH=/usr/bin\n", "BASH_ENV=unset\n", "PYTHONPATH=unset\n"]},
|
|
30
|
+
"destroy": {"deadline": 10000, "grace": 1000, "state": None},
|
|
31
|
+
"cancel": {"deadline": 10000, "grace": 1000, "state": "cancelled"},
|
|
32
|
+
"supersede": {"deadline": 10000, "grace": 1000, "states": ["cancelled", "ok"]},
|
|
33
|
+
"shell-string": {"deadline": 5000, "grace": 1000, "state": "spawn-failed", "reason": "shell string"},
|
|
34
|
+
"relative": {"deadline": 5000, "grace": 1000, "state": "spawn-failed", "reason": "not an absolute path"},
|
|
35
|
+
"missing": {"deadline": 5000, "grace": 1000, "state": "spawn-failed", "reason": "No such file"},
|
|
36
|
+
"controls": {"deadline": 5000, "grace": 1000, "state": "ok", "stdout": ["a[31mbcde\tf\ng\n"], "stderr": ["stderr]0;titleline\n"]},
|
|
37
|
+
"concurrency": {"deadline": 30000, "grace": 1000, "state": "ok", "count": 10, "outBytes": 1048576},
|
|
38
|
+
# 0.2.0: the review of 2026-09-18 (docs/evidence/blocks/2026-09-18-review.json)
|
|
39
|
+
"forge": {"deadline": 5000, "grace": 1000, "state": "supervisor-lost", "reason": "detached reaper", "notStdout": ["forged"], "notPgid": 4194303},
|
|
40
|
+
"orphan": {"deadline": 5000, "grace": 1000, "state": "supervisor-lost", "reason": "detached reaper"},
|
|
41
|
+
"stalled-supersede": {"deadline": 2000, "grace": 500, "states": ["supervisor-lost", "ok"], "bound": 2000 + 500 + 3000 + 6000},
|
|
42
|
+
"destroy-early": {"deadline": 10000, "grace": 1000, "state": None},
|
|
43
|
+
"shell-string-option": {"deadline": 5000, "grace": 1000, "state": "spawn-failed", "reason": "shell string"},
|
|
44
|
+
"shell-string-wrapper": {"deadline": 5000, "grace": 1000, "state": "spawn-failed", "reason": "shell string"},
|
|
45
|
+
}
|
|
46
|
+
CONTROL = re.compile("[\\x00-\\x08\\x0b-\\x1f\\x7f-\\x9f\\u061c\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]")
|
|
47
|
+
SLACK_MS = 500
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def events(log):
|
|
51
|
+
found = []
|
|
52
|
+
for line in open(log, errors="replace"):
|
|
53
|
+
match = re.search(r"RUNLAB (\{.*\})", line)
|
|
54
|
+
if match:
|
|
55
|
+
try:
|
|
56
|
+
found.append(json.loads(match.group(1)))
|
|
57
|
+
except ValueError:
|
|
58
|
+
pass
|
|
59
|
+
return found
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def meta_of(path):
|
|
63
|
+
lines = [line.rstrip("\n") for line in open(path)]
|
|
64
|
+
pairs = dict(line.split("=", 1) for line in lines if "=" in line and not line.startswith("survivor"))
|
|
65
|
+
survivors = [line for line in lines if line.startswith("survivor")]
|
|
66
|
+
return pairs, survivors
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def pss_of(path, start, end):
|
|
70
|
+
samples = [tuple(map(int, line.split())) for line in open(path) if line.strip()]
|
|
71
|
+
before = [p for t, p in samples if t < start]
|
|
72
|
+
during = [p for t, p in samples if start <= t <= end + 200]
|
|
73
|
+
after = [p for t, p in samples if t >= end + 1500]
|
|
74
|
+
return {"before": before[-1] if before else None, "peak": max(during) if during else None, "after": after[-1] if after else None}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def read_run(runs_dir, scen, index):
|
|
78
|
+
base = os.path.join(runs_dir, "%s-%d" % (scen, index))
|
|
79
|
+
evs = events(base + ".log")
|
|
80
|
+
start = next((e["t"] for e in evs if e.get("ev") == "start"), None)
|
|
81
|
+
results = [e for e in evs if e.get("ev") == "result"]
|
|
82
|
+
destroy = next((e for e in evs if e.get("ev") == "destroy"), None)
|
|
83
|
+
done = next((e for e in evs if e.get("ev") == "all-finished"), None)
|
|
84
|
+
pairs, survivors = meta_of(base + ".meta")
|
|
85
|
+
end_wall = int(pairs.get("end_wall", start or 0))
|
|
86
|
+
pss = pss_of(base + ".pss", start or 0, end_wall)
|
|
87
|
+
if scen == "concurrency" and done and start:
|
|
88
|
+
ms = done["t"] - start
|
|
89
|
+
elif scen in ("destroy", "destroy-early") and destroy and start:
|
|
90
|
+
ms = destroy["t"] - start
|
|
91
|
+
else:
|
|
92
|
+
ms = results[-1]["ms"] if results else None
|
|
93
|
+
signals = [s for r in results for s in r.get("signals", [])]
|
|
94
|
+
return {"scenario": scen, "run": index, "start": start, "ms": ms,
|
|
95
|
+
"states": [r.get("state") for r in results], "results": results,
|
|
96
|
+
"pssBefore": pss["before"], "pssPeak": pss["peak"], "pssAfter": pss["after"],
|
|
97
|
+
"survivors": len(survivors), "survivorLines": survivors,
|
|
98
|
+
"signals": ["%s@%s" % (s["sig"], s["atMs"]) for s in signals],
|
|
99
|
+
"toEmptyGroup": sum(1 for s in signals if s.get("esrch")),
|
|
100
|
+
"shadowHits": int(pairs.get("shadow_hits", 0)), "destroyed": destroy is not None}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def check_states(row, want, problems):
|
|
104
|
+
if "states" in want:
|
|
105
|
+
if row["states"] != want["states"]:
|
|
106
|
+
problems.append("states %s, expected %s" % (row["states"], want["states"]))
|
|
107
|
+
return
|
|
108
|
+
count = want.get("count", 1)
|
|
109
|
+
if want["state"] is None:
|
|
110
|
+
if row["states"]:
|
|
111
|
+
problems.append("a result after destruction: %s" % row["states"])
|
|
112
|
+
return
|
|
113
|
+
if row["states"] != [want["state"]] * count:
|
|
114
|
+
problems.append("states %s, expected %s x%d" % (row["states"], want["state"], count))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def check_results(row, want, problems):
|
|
118
|
+
for result in row["results"]:
|
|
119
|
+
if "outBytes" in want and result.get("outBytes") != want["outBytes"]:
|
|
120
|
+
problems.append("outBytes %s, expected %s" % (result.get("outBytes"), want["outBytes"]))
|
|
121
|
+
if "reason" in want and want["reason"] not in str(result.get("reason", "")):
|
|
122
|
+
problems.append("reason %r does not say %r" % (result.get("reason"), want["reason"]))
|
|
123
|
+
for piece in want.get("notStdout", []):
|
|
124
|
+
if piece in str(result.get("stdout", "")):
|
|
125
|
+
problems.append("stdout carries the forged %r" % piece)
|
|
126
|
+
if "notPgid" in want and result.get("pgid") == want["notPgid"]:
|
|
127
|
+
problems.append("pgid is the forged %d" % want["notPgid"])
|
|
128
|
+
for key in ("stdout", "stderr"):
|
|
129
|
+
text = result.get(key, "")
|
|
130
|
+
if CONTROL.search(text):
|
|
131
|
+
problems.append("%s carries a control character" % key)
|
|
132
|
+
for piece in want.get(key, []):
|
|
133
|
+
if piece not in text:
|
|
134
|
+
problems.append("%s lacks %r" % (key, piece))
|
|
135
|
+
if result.get("survivors", 0):
|
|
136
|
+
problems.append("the supervisor reported %d survivors" % result["survivors"])
|
|
137
|
+
if want.get("kill") and not any(s.startswith("KILL@") for s in row["signals"]):
|
|
138
|
+
problems.append("no KILL was sent")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def check(row):
|
|
142
|
+
want = EXPECT[row["scenario"]]
|
|
143
|
+
problems = []
|
|
144
|
+
if row["start"] is None:
|
|
145
|
+
return ["no start event"]
|
|
146
|
+
check_states(row, want, problems)
|
|
147
|
+
check_results(row, want, problems)
|
|
148
|
+
bound = want.get("bound", want["deadline"] + want["grace"]) + SLACK_MS
|
|
149
|
+
if row["ms"] is None:
|
|
150
|
+
problems.append("no end")
|
|
151
|
+
elif row["ms"] > bound:
|
|
152
|
+
problems.append("ended at %d ms, over deadline plus grace (%d)" % (row["ms"], bound))
|
|
153
|
+
if row["survivors"]:
|
|
154
|
+
problems.append("%d survivor(s): %s" % (row["survivors"], "; ".join(row["survivorLines"])))
|
|
155
|
+
if row["toEmptyGroup"]:
|
|
156
|
+
problems.append("%d signal(s) to an empty group" % row["toEmptyGroup"])
|
|
157
|
+
if row["shadowHits"]:
|
|
158
|
+
problems.append("%d shadow hit(s)" % row["shadowHits"])
|
|
159
|
+
if row["scenario"] in ("destroy", "destroy-early") and not row["destroyed"]:
|
|
160
|
+
problems.append("no destroy event")
|
|
161
|
+
return problems
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def rng(values):
|
|
165
|
+
values = [v for v in values if v is not None]
|
|
166
|
+
if not values:
|
|
167
|
+
return None
|
|
168
|
+
return {"median": statistics.median(values), "min": min(values), "max": max(values), "n": len(values)}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def summarise(rows):
|
|
172
|
+
out = {}
|
|
173
|
+
for scen in EXPECT:
|
|
174
|
+
mine = [r for r in rows if r["scenario"] == scen]
|
|
175
|
+
if not mine:
|
|
176
|
+
continue
|
|
177
|
+
out[scen] = {"runs": len(mine), "ms": rng([r["ms"] for r in mine]),
|
|
178
|
+
"pssPeakMinusBeforeKb": rng([r["pssPeak"] - r["pssBefore"] for r in mine if r["pssPeak"] and r["pssBefore"]]),
|
|
179
|
+
"pssAfterMinusBeforeKb": rng([r["pssAfter"] - r["pssBefore"] for r in mine if r["pssAfter"] and r["pssBefore"]]),
|
|
180
|
+
"survivors": sum(r["survivors"] for r in mine), "toEmptyGroup": sum(r["toEmptyGroup"] for r in mine),
|
|
181
|
+
"shadowHits": sum(r["shadowHits"] for r in mine), "problems": [p for r in mine for p in r["problems"]]}
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def sha256(path):
|
|
186
|
+
return hashlib.sha256(open(path, "rb").read()).hexdigest()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def main(out, blocks):
|
|
190
|
+
runs_dir = os.path.join(out, "runs")
|
|
191
|
+
names = sorted(f[:-4] for f in os.listdir(runs_dir) if f.endswith(".log"))
|
|
192
|
+
rows = []
|
|
193
|
+
for name in names:
|
|
194
|
+
scen, index = name.rsplit("-", 1)
|
|
195
|
+
row = read_run(runs_dir, scen, int(index))
|
|
196
|
+
row["problems"] = check(row)
|
|
197
|
+
rows.append(row)
|
|
198
|
+
summary = summarise(rows)
|
|
199
|
+
failed = [scen for scen, entry in summary.items() if entry["problems"]]
|
|
200
|
+
document = {"id": "runlab", "measured": time.strftime("%Y-%m-%d"), "host": platform.node(), "kernel": platform.release(),
|
|
201
|
+
"blocks": {name: sha256(os.path.join(blocks, name)) for name in ("Run.qml", "run-supervisor.py")},
|
|
202
|
+
"method": open(__file__).read().split("import hashlib")[0].replace("# ", "").replace("#", "").strip(),
|
|
203
|
+
"runs": [{k: v for k, v in r.items() if k != "results"} for r in rows], "summary": summary, "ok": not failed}
|
|
204
|
+
with open(os.path.join(out, "runlab.json"), "w") as handle:
|
|
205
|
+
json.dump(document, handle, indent=1)
|
|
206
|
+
for scen, entry in summary.items():
|
|
207
|
+
ms = entry["ms"] or {}
|
|
208
|
+
print("%s - %s: %d run(s), end %s ms (%s-%s), Pss after-before %s kB, survivors %d, to empty group %d, shadow hits %d%s" % (
|
|
209
|
+
"not ok" if entry["problems"] else "ok", scen, entry["runs"], ms.get("median"), ms.get("min"), ms.get("max"),
|
|
210
|
+
(entry["pssAfterMinusBeforeKb"] or {}).get("median"), entry["survivors"], entry["toEmptyGroup"], entry["shadowHits"],
|
|
211
|
+
"; " + "; ".join(entry["problems"]) if entry["problems"] else ""))
|
|
212
|
+
print("%s - Run lab suite: %d scenarios, %d runs, document at %s" % ("not ok" if failed else "ok", len(summary), len(rows), os.path.join(out, "runlab.json")))
|
|
213
|
+
return 1 if failed else 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
sys.exit(main(sys.argv[1], sys.argv[2]))
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/bash
|
|
2
|
+
# The lab suite for the Run block: the spike's scenarios made repeatable,
|
|
3
|
+
# on whatever machine runs it (the desktop, or the 4.0.3 guest through
|
|
4
|
+
# `omakit lab prove run`). Never touches the running omarchy-shell: every
|
|
5
|
+
# scenario starts its own `quickshell -p <harness>` in a transient user
|
|
6
|
+
# scope (systemd-run --user --scope -p MemoryMax=768M; setsid when the
|
|
7
|
+
# user manager is not reachable), samples that instance's Pss from
|
|
8
|
+
# /proc/<pid>/smaps_rollup every 100 ms, waits for the harness's result
|
|
9
|
+
# lines, counts survivors two seconds later by pgid and by scope, and stops
|
|
10
|
+
# the scope. tests/lab/run/report.py turns the runs into one document and
|
|
11
|
+
# asserts the gate: every run ends within deadline plus grace, no survivor,
|
|
12
|
+
# no signal to an empty group, no shadow hit, the state the scenario expects.
|
|
13
|
+
#
|
|
14
|
+
# bash tests/lab/run/suite.sh [out-dir] [runs] default: /tmp/omakit-runlab, 1 run per scenario
|
|
15
|
+
# RUNLAB_BLOCKS=<dir> the block files to test; default blocks/run of this checkout
|
|
16
|
+
set -u
|
|
17
|
+
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
18
|
+
repo="$(cd -- "$here/../../.." && pwd)"
|
|
19
|
+
blocks="${RUNLAB_BLOCKS:-$repo/blocks/run}"
|
|
20
|
+
out="${1:-/tmp/omakit-runlab}"
|
|
21
|
+
runs="${2:-1}"
|
|
22
|
+
scenarios=(producer producer-stream holder stubborn hostile destroy cancel supersede shell-string relative missing controls concurrency forge orphan stalled-supersede destroy-early shell-string-option shell-string-wrapper)
|
|
23
|
+
|
|
24
|
+
for tool in /usr/bin/python3 /usr/bin/quickshell /usr/bin/setsid /usr/bin/kill; do
|
|
25
|
+
[[ -x $tool ]] || { echo "not ok - $tool is missing" >&2; exit 1; }
|
|
26
|
+
done
|
|
27
|
+
[[ -n ${WAYLAND_DISPLAY:-} ]] || { echo "not ok - no WAYLAND_DISPLAY; quickshell needs the session" >&2; exit 1; }
|
|
28
|
+
[[ -f $blocks/Run.qml && -f $blocks/run-supervisor.py ]] || { echo "not ok - no block files at $blocks" >&2; exit 1; }
|
|
29
|
+
[[ $runs =~ ^[1-9][0-9]*$ ]] || { echo "not ok - runs must be a positive integer" >&2; exit 1; }
|
|
30
|
+
|
|
31
|
+
rm -rf "$out" && mkdir -p "$out/runs" "$out/harness/omakit" "$out/shadow/pypath" || exit 1
|
|
32
|
+
cp "$here/harness/shell.qml" "$out/harness/" && cp -r "$here/harness/scenarios" "$out/harness/" || exit 1
|
|
33
|
+
cp "$blocks/Run.qml" "$blocks/run-supervisor.py" "$out/harness/omakit/" || exit 1
|
|
34
|
+
# The shadow directory for the hostile scenario: copies first in PATH that
|
|
35
|
+
# log a hit and exec the real tool; BASH_ENV and a json.py in PYTHONPATH.
|
|
36
|
+
for b in kill setsid python3 bash sh head sleep env sort sed wc; do
|
|
37
|
+
printf '#!/usr/bin/bash\nprintf "shadow %%s ppid=%%s\\n" "%s" "$PPID" >> %s/shadow/hits.log\nexec /usr/bin/%s "$@"\n' "$b" "$out" "$b" > "$out/shadow/$b"
|
|
38
|
+
chmod +x "$out/shadow/$b"
|
|
39
|
+
done
|
|
40
|
+
printf 'printf "BASH_ENV sourced by pid=%%s\\n" "$$" >> %s/shadow/hits.log\n' "$out" > "$out/shadow/bash_env.sh"
|
|
41
|
+
printf 'open("%s/shadow/hits.log","a").write("PYTHONPATH json.py imported\\n")\nraise SystemExit(99)\n' "$out" > "$out/shadow/pypath/json.py"
|
|
42
|
+
|
|
43
|
+
scope_ok=0
|
|
44
|
+
systemd-run --user --scope --quiet -p MemoryMax=768M -- /usr/bin/true >/dev/null 2>&1 && scope_ok=1
|
|
45
|
+
echo "# scope: $([[ $scope_ok == 1 ]] && echo systemd-run || echo setsid)"
|
|
46
|
+
|
|
47
|
+
ev() { grep -o 'RUNLAB {.*}' "$log" | sed 's/^RUNLAB //' | grep "\"ev\":\"$1\"" | head -1; }
|
|
48
|
+
count_ev() { grep -o 'RUNLAB {.*}' "$log" | grep -c "\"ev\":\"$1\""; }
|
|
49
|
+
waitev() { local n=0; while [[ $(count_ev "$1") -lt $2 ]]; do sleep 0.05; n=$((n+1)); [[ $n -gt $(( $3 * 20 )) ]] && return 1; done; return 0; }
|
|
50
|
+
|
|
51
|
+
one() {
|
|
52
|
+
local scen=$1 index=$2 tag=$1-$2
|
|
53
|
+
log=$out/runs/$tag.log; local pss=$out/runs/$tag.pss meta=$out/runs/$tag.meta
|
|
54
|
+
: > "$out/shadow/hits.log"; : > "$log"; : > "$pss"; : > "$meta"
|
|
55
|
+
local -a env=(RUNLAB_SCENARIO="$scen" RUNLAB_BASE="$out/harness" RUNLAB_KEEP="${RUNLAB_KEEP:-65536}")
|
|
56
|
+
[[ $scen == hostile ]] && env+=(PATH="$out/shadow:$PATH" BASH_ENV="$out/shadow/bash_env.sh" PYTHONPATH="$out/shadow/pypath")
|
|
57
|
+
local unit=runlab-$tag-$$
|
|
58
|
+
if [[ $scope_ok == 1 ]]; then
|
|
59
|
+
systemd-run --user --scope --quiet -p MemoryMax=768M --unit="$unit" -- /usr/bin/env "${env[@]}" /usr/bin/quickshell -p "$out/harness" >"$log" 2>&1 &
|
|
60
|
+
else
|
|
61
|
+
/usr/bin/setsid /usr/bin/env "${env[@]}" /usr/bin/quickshell -p "$out/harness" >"$log" 2>&1 < /dev/null &
|
|
62
|
+
fi
|
|
63
|
+
local runner=$!
|
|
64
|
+
waitev ready 1 30 || { echo "no ready line" >>"$meta"; kill -KILL -- -"$runner" 2>/dev/null; systemctl --user stop "$unit.scope" 2>/dev/null; return 1; }
|
|
65
|
+
local qspid; qspid=$(ev ready | /usr/bin/python3 -c 'import json,sys; print(json.load(sys.stdin)["pid"])')
|
|
66
|
+
local cg; cg=$(cut -d: -f3 /proc/$qspid/cgroup 2>/dev/null)
|
|
67
|
+
printf 'qspid=%s\nunit=%s\ncgroup=%s\n' "$qspid" "$unit" "$cg" >>"$meta"
|
|
68
|
+
( while [[ -r /proc/$qspid/smaps_rollup ]]; do
|
|
69
|
+
p=$(awk '/^Pss:/{print $2}' /proc/$qspid/smaps_rollup 2>/dev/null) || break
|
|
70
|
+
printf '%s %s\n' "$(date +%s%3N)" "$p" >>"$pss"; sleep 0.1; done ) &
|
|
71
|
+
local sampler=$!
|
|
72
|
+
local want=1 limit=200
|
|
73
|
+
[[ $scen == concurrency ]] && want=10
|
|
74
|
+
[[ $scen == supersede || $scen == stalled-supersede ]] && want=2
|
|
75
|
+
if [[ $scen == destroy || $scen == destroy-early ]]; then waitev destroy 1 30 || echo "no destroy line" >>"$meta"; else waitev result "$want" "$limit" || echo "no result within $limit s" >>"$meta"; fi
|
|
76
|
+
echo "end_wall=$(date +%s%3N)" >>"$meta"
|
|
77
|
+
sleep 2
|
|
78
|
+
local pgids; pgids=$(grep -o 'RUNLAB {.*}' "$log" | sed 's/^RUNLAB //' | /usr/bin/python3 -c '
|
|
79
|
+
import json,sys
|
|
80
|
+
seen=set()
|
|
81
|
+
for line in sys.stdin:
|
|
82
|
+
try: o=json.loads(line)
|
|
83
|
+
except Exception: continue
|
|
84
|
+
if o.get("pgid"): seen.add(o["pgid"])
|
|
85
|
+
print(" ".join(str(p) for p in sorted(seen)))')
|
|
86
|
+
echo "pgids=$pgids" >>"$meta"
|
|
87
|
+
for g in $pgids; do ps -eo pid=,pgid=,stat=,comm= | awk -v g="$g" '$2==g' | sed 's/^/survivor-by-pgid: /' >>"$meta"; done
|
|
88
|
+
if [[ -n $cg && -r /sys/fs/cgroup$cg/cgroup.procs ]]; then
|
|
89
|
+
for p in $(cat /sys/fs/cgroup$cg/cgroup.procs); do [[ $p == "$qspid" ]] && continue; echo "survivor-in-scope: $p $(cat /proc/$p/comm 2>/dev/null)" >>"$meta"; done
|
|
90
|
+
fi
|
|
91
|
+
echo "shadow_hits=$(grep -c . "$out/shadow/hits.log")" >>"$meta"
|
|
92
|
+
cp "$out/shadow/hits.log" "$out/runs/$tag.hits"
|
|
93
|
+
kill "$sampler" 2>/dev/null; wait "$sampler" 2>/dev/null
|
|
94
|
+
if [[ $scope_ok == 1 ]]; then systemctl --user stop "$unit.scope" 2>/dev/null; else kill -KILL -- -"$runner" 2>/dev/null; fi
|
|
95
|
+
wait "$runner" 2>/dev/null
|
|
96
|
+
echo "stopped=$(date +%s%3N)" >>"$meta"
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for index in $(seq 1 "$runs"); do
|
|
100
|
+
for scen in "${scenarios[@]}"; do
|
|
101
|
+
one "$scen" "$index" || { echo "not ok - $scen run $index did not produce a ready line" >&2; }
|
|
102
|
+
echo "# done $scen $index $(grep -o '"state":"[a-z-]*"' "$out/runs/$scen-$index.log" | head -3 | tr '\n' ' ')"
|
|
103
|
+
done
|
|
104
|
+
done
|
|
105
|
+
|
|
106
|
+
/usr/bin/python3 "$here/report.py" "$out" "$blocks"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The lab harness for the Store block: a throwaway Quickshell configuration
|
|
2
|
+
// that loads Store (and Run, which it uses) from ./omakit, copied there by
|
|
3
|
+
// suite.sh from blocks/, and runs one scenario named by STORELAB_SCENARIO
|
|
4
|
+
// against the HOME the driver prepared, logging JSON events with a STORELAB
|
|
5
|
+
// prefix. Never loaded into omarchy-shell.
|
|
6
|
+
import QtQuick
|
|
7
|
+
import Quickshell
|
|
8
|
+
import "omakit"
|
|
9
|
+
|
|
10
|
+
ShellRoot {
|
|
11
|
+
id: root
|
|
12
|
+
property string scenario: Quickshell.env("STORELAB_SCENARIO")
|
|
13
|
+
property string pluginId: "lab.store.fixture"
|
|
14
|
+
property var results: []
|
|
15
|
+
property int expected: 0
|
|
16
|
+
readonly property var schema: ({ type: "object", required: ["version", "themes"], properties: { version: { type: "integer", minimum: 1 }, themes: { type: "object", maxProperties: 64 } }, additionalProperties: false })
|
|
17
|
+
|
|
18
|
+
function log(o) { console.log("STORELAB " + JSON.stringify(o)) }
|
|
19
|
+
function collect(result) {
|
|
20
|
+
root.results = root.results.concat([result])
|
|
21
|
+
root.log(Object.assign({ ev: "result", t: Date.now() }, result))
|
|
22
|
+
if (root.results.length === root.expected) root.log({ ev: "all-done", t: Date.now() })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Store { id: state; pluginId: root.pluginId; name: "memory.json"; schema: root.schema; onFinished: function(result) { root.collect(result) } }
|
|
26
|
+
Store { id: cache; pluginId: root.pluginId; name: "catalog.json"; kind: "cache"; maxBytes: 1048576; onFinished: function(result) { root.collect(result) } }
|
|
27
|
+
Store { id: loose; pluginId: root.pluginId; name: "memory.json"; onFinished: function(result) { root.collect(result) } }
|
|
28
|
+
|
|
29
|
+
property Component writerComponent: Component { Store { pluginId: root.pluginId; name: "memory.json"; schema: root.schema; onFinished: function(result) { root.collect(result) } } }
|
|
30
|
+
|
|
31
|
+
property Timer starter: Timer {
|
|
32
|
+
interval: 1500; running: true
|
|
33
|
+
onTriggered: {
|
|
34
|
+
root.log({ ev: "start", scenario: root.scenario, t: Date.now() })
|
|
35
|
+
const value = { version: 1, themes: { a: { wallpaper: "/x" } } }
|
|
36
|
+
switch (root.scenario) {
|
|
37
|
+
case "plain":
|
|
38
|
+
root.expected = 4; state.write(value); state.read(); state.remove(); state.read(); break
|
|
39
|
+
case "symlink-directory":
|
|
40
|
+
case "symlink-parent":
|
|
41
|
+
case "outside-home":
|
|
42
|
+
case "group-writable":
|
|
43
|
+
case "foreign-owner":
|
|
44
|
+
root.expected = 2; state.read(); state.write(value); break
|
|
45
|
+
case "symlink-file":
|
|
46
|
+
root.expected = 3; state.read(); state.write(value); state.read(); break
|
|
47
|
+
case "swap":
|
|
48
|
+
root.expected = 40
|
|
49
|
+
for (let i = 0; i < 20; i++) { state.read(); state.write({ version: i + 1, themes: {} }) }
|
|
50
|
+
break
|
|
51
|
+
case "oversized":
|
|
52
|
+
root.expected = 3; cache.read(); state.write({ version: 1, themes: { big: "x".repeat(70000) } }); state.read(); break
|
|
53
|
+
case "invalid":
|
|
54
|
+
root.expected = 2; state.read(); loose.read(); break
|
|
55
|
+
case "crash":
|
|
56
|
+
root.expected = 2; state.read(); state.write(value); break
|
|
57
|
+
case "fifo":
|
|
58
|
+
root.expected = 3; state.read(); state.write(value); state.read(); break
|
|
59
|
+
case "short-write":
|
|
60
|
+
root.expected = 3; state.read(); state.write({ version: 2, themes: { big: "x".repeat(9000) } }); state.read(); break
|
|
61
|
+
case "non-ascii":
|
|
62
|
+
root.expected = 1; cache.read(); break
|
|
63
|
+
case "concurrent":
|
|
64
|
+
root.expected = 10
|
|
65
|
+
for (let i = 0; i < 10; i++) root.writerComponent.createObject(root).write({ version: i + 1, themes: {} })
|
|
66
|
+
break
|
|
67
|
+
default:
|
|
68
|
+
root.log({ ev: "unknown-scenario" })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
Component.onCompleted: log({ ev: "ready", pid: Quickshell.processId, scenario: scenario, home: Quickshell.env("HOME") })
|
|
73
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# The Store lab suite's reader: every scenario's results under <out>/runs
|
|
2
|
+
# into <out>/storelab.json, and the gate, asserted. A result is the state
|
|
3
|
+
# the block reported for one operation; the facts after the run come from
|
|
4
|
+
# suite.sh's stat of the throwaway HOME (the victim file's content, the
|
|
5
|
+
# directory and file modes, the staging files left). Every expectation
|
|
6
|
+
# below names the contract line it holds (docs/BLOCKS.md, Store).
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
MIB = 1048576
|
|
15
|
+
|
|
16
|
+
# Per scenario: the states expected in order (None: any of a set), and
|
|
17
|
+
# the facts. `victim` untouched means the planted link led nowhere.
|
|
18
|
+
EXPECT = {
|
|
19
|
+
"plain": {"states": ["ok", "ok", "ok", "missing"], "file_mode": None, "dir_mode": "700", "written_mode": "600"},
|
|
20
|
+
"symlink-directory": {"states": ["refused", "refused"], "reason": "symbolic link", "victim": "untouched", "victim_dir_unchanged": True},
|
|
21
|
+
"symlink-parent": {"states": ["refused", "refused"], "reason": "symbolic link", "victim": "untouched"},
|
|
22
|
+
"symlink-file": {"states": ["refused", "ok", "ok"], "reason": "symbolic link", "victim": "untouched", "file_type": "regular file"},
|
|
23
|
+
"swap": {"any_of": {"ok", "missing", "refused", "invalid"}, "count": 40, "victim": "untouched"},
|
|
24
|
+
"oversized": {"multiset": ["overflow", "overflow", "missing"], "reason": "over"},
|
|
25
|
+
"group-writable": {"states": ["refused", "ok"], "reason": "writable by the group", "written_mode": "600"},
|
|
26
|
+
"foreign-owner": {"states": ["refused", "ok"], "reason": "not owned", "simulated_only": True, "written_mode": "600"},
|
|
27
|
+
"invalid": {"multiset": ["invalid", "ok"], "reason": "is not an integer"},
|
|
28
|
+
"crash": {"states": ["ok", "ok"], "staging_left": 1, "file_content": '{"themes":{"a":{"wallpaper":"/x"}},"version":1}'},
|
|
29
|
+
"concurrent": {"any_of": {"ok"}, "count": 10, "staging_left": 0, "content_one_of": ['{"themes":{},"version":%d}' % i for i in range(1, 11)]},
|
|
30
|
+
"outside-home": {"states": ["refused", "refused"], "reason": "not inside HOME", "victim_dir_unchanged": True},
|
|
31
|
+
# 0.2.0: the review of 2026-09-18 (docs/evidence/blocks/2026-09-18-review.json)
|
|
32
|
+
"fifo": {"states": ["refused", "ok", "ok"], "reason": "not a regular file", "file_type": "regular file"},
|
|
33
|
+
"short-write": {"states": ["ok", "failed", "ok"], "reason": "File too large", "staging_left": 0, "file_content": '{"version":1,"themes":{}}'},
|
|
34
|
+
"non-ascii": {"states": ["ok"], "value_bytes_over": 700000},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def events(log):
|
|
39
|
+
found = []
|
|
40
|
+
for line in open(log, errors="replace"):
|
|
41
|
+
match = re.search(r"STORELAB (\{.*\})", line)
|
|
42
|
+
if match:
|
|
43
|
+
try:
|
|
44
|
+
found.append(json.loads(match.group(1)))
|
|
45
|
+
except ValueError:
|
|
46
|
+
pass
|
|
47
|
+
return found
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def meta_of(path):
|
|
51
|
+
return dict(line.rstrip("\n").split("=", 1) for line in open(path) if "=" in line)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_states(results, want, problems):
|
|
55
|
+
states = [r.get("state") for r in results]
|
|
56
|
+
if "states" in want and states != want["states"]:
|
|
57
|
+
problems.append("states %s, expected %s" % (states, want["states"]))
|
|
58
|
+
if "multiset" in want and sorted(states) != sorted(want["multiset"]):
|
|
59
|
+
problems.append("states %s, expected %s in any order (two queues)" % (states, want["multiset"]))
|
|
60
|
+
if "any_of" in want:
|
|
61
|
+
if len(states) != want["count"]:
|
|
62
|
+
problems.append("%d results, expected %d" % (len(states), want["count"]))
|
|
63
|
+
bad = [s for s in states if s not in want["any_of"]]
|
|
64
|
+
if bad:
|
|
65
|
+
problems.append("states outside %s: %s" % (sorted(want["any_of"]), bad))
|
|
66
|
+
if "reason" in want:
|
|
67
|
+
reasons = [r.get("reason", "") for r in results if r.get("state") not in ("ok", "missing")]
|
|
68
|
+
if not reasons or not all(want["reason"] in reason for reason in reasons):
|
|
69
|
+
problems.append("reasons %s do not all say %r" % (reasons, want["reason"]))
|
|
70
|
+
if "value_bytes_over" in want and not any(r.get("bytes", 0) > want["value_bytes_over"] and isinstance(r.get("value"), dict) for r in results):
|
|
71
|
+
problems.append("no ok read carried a value of over %d bytes" % want["value_bytes_over"])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check_facts(meta, want, problems):
|
|
75
|
+
for key in ("victim", "dir_mode", "file_type"):
|
|
76
|
+
if key in want and meta.get(key) != want[key]:
|
|
77
|
+
problems.append("%s is %r, expected %r" % (key, meta.get(key), want[key]))
|
|
78
|
+
if want.get("victim_dir_unchanged") and meta.get("victim_dir_before") != meta.get("victim_dir_after"):
|
|
79
|
+
problems.append("the victim directory went from %s to %s entries: something landed there" % (meta.get("victim_dir_before"), meta.get("victim_dir_after")))
|
|
80
|
+
if "written_mode" in want and meta.get("file_mode") not in (None, want["written_mode"]):
|
|
81
|
+
problems.append("the written file's mode is %s, expected %s" % (meta.get("file_mode"), want["written_mode"]))
|
|
82
|
+
if "staging_left" in want and int(meta.get("staging_left", -1)) != want["staging_left"]:
|
|
83
|
+
problems.append("%s staging files left, expected %d" % (meta.get("staging_left"), want["staging_left"]))
|
|
84
|
+
if "file_content" in want and meta.get("file_content") != want["file_content"]:
|
|
85
|
+
problems.append("file content %r, expected %r" % (meta.get("file_content"), want["file_content"]))
|
|
86
|
+
if "content_one_of" in want and meta.get("file_content") not in want["content_one_of"]:
|
|
87
|
+
problems.append("file content %r is not one whole write" % meta.get("file_content"))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def check(scen, results, meta):
|
|
91
|
+
want = EXPECT[scen]
|
|
92
|
+
if want.get("simulated_only") and meta.get("foreign_owner") == "not-simulated":
|
|
93
|
+
return [], "not simulated here: no sudo for a foreign owner; the mode check stands in (group-writable)"
|
|
94
|
+
problems = []
|
|
95
|
+
check_states(results, want, problems)
|
|
96
|
+
check_facts(meta, want, problems)
|
|
97
|
+
return problems, None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main(out, blocks):
|
|
101
|
+
runs_dir = os.path.join(out, "runs")
|
|
102
|
+
rows = []
|
|
103
|
+
for name in sorted(EXPECT):
|
|
104
|
+
log = os.path.join(runs_dir, name + ".log")
|
|
105
|
+
if not os.path.exists(log):
|
|
106
|
+
rows.append({"scenario": name, "problems": ["no log"], "results": [], "meta": {}, "msToLast": None, "skipped": None})
|
|
107
|
+
continue
|
|
108
|
+
evs = events(log)
|
|
109
|
+
results = [e for e in evs if e.get("ev") == "result"]
|
|
110
|
+
start = next((e["t"] for e in evs if e.get("ev") == "start"), None)
|
|
111
|
+
meta = meta_of(os.path.join(runs_dir, name + ".meta"))
|
|
112
|
+
problems, skipped = check(name, results, meta)
|
|
113
|
+
# ms: the harness's clock from the scenario's start to each result.
|
|
114
|
+
timed = [{k: v for k, v in r.items() if k not in ("value", "t")} | {"ms": (r["t"] - start) if start and "t" in r else None} for r in results]
|
|
115
|
+
rows.append({"scenario": name, "results": timed, "msToLast": (results[-1]["t"] - start) if results and start and "t" in results[-1] else None, "meta": meta, "problems": problems, "skipped": skipped})
|
|
116
|
+
failed = [r["scenario"] for r in rows if r["problems"]]
|
|
117
|
+
document = {"id": "storelab", "measured": time.strftime("%Y-%m-%d"), "kernel": os.uname().release,
|
|
118
|
+
"blocks": {name: hashlib.sha256(open(os.path.join(blocks, name), "rb").read()).hexdigest() for name in ("store/Store.qml", "store/store-helper.py", "run/Run.qml", "run/run-supervisor.py")},
|
|
119
|
+
"method": open(__file__).read().split("import hashlib")[0].replace("# ", "").replace("#", "").strip(),
|
|
120
|
+
"scenarios": rows, "ok": not failed}
|
|
121
|
+
with open(os.path.join(out, "storelab.json"), "w") as handle:
|
|
122
|
+
json.dump(document, handle, indent=1)
|
|
123
|
+
for row in rows:
|
|
124
|
+
states = [r.get("state") for r in row["results"]]
|
|
125
|
+
summary = ", ".join("%s x%d" % (s, states.count(s)) for s in dict.fromkeys(states)) if states else "no result"
|
|
126
|
+
note = row.get("skipped") or ("; ".join(row["problems"]) if row["problems"] else "")
|
|
127
|
+
print("%s - %s: %s, %s ms to the last result%s" % ("not ok" if row["problems"] else "ok", row["scenario"], summary, row["msToLast"], " (" + note + ")" if note else ""))
|
|
128
|
+
print("%s - Store lab suite: %d scenarios, document at %s" % ("not ok" if failed else "ok", len(rows), os.path.join(out, "storelab.json")))
|
|
129
|
+
return 1 if failed else 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
sys.exit(main(sys.argv[1], sys.argv[2]))
|