omakit 0.5.0 → 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 +39 -32
- 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 +7 -4
- 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 +37 -6
- package/tools/inspect/functions.mjs +236 -12
- package/tools/inspect/helpers.mjs +217 -0
- package/tools/inspect/inspect.mjs +71 -5
- package/tools/inspect/measure-functions.mjs +12 -3
- package/tools/inspect/patterns.mjs +35 -18
- package/tools/inspect/processes.mjs +38 -5
- package/tools/inspect/report.mjs +33 -6
- 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,522 @@
|
|
|
1
|
+
# omakit block: run 0.2.1
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
# Copyright (c) 2026 Maarten Tolhuijs
|
|
4
|
+
# Source: omakit blocks/run/run-supervisor.py, commit unstamped
|
|
5
|
+
# Body sha256: d872a5fc91f648ba4f9b9654d5cb19bba17a8afa4abb618502a04f47f66a6be1
|
|
6
|
+
# end of omakit block header
|
|
7
|
+
#
|
|
8
|
+
# The supervisor behind Run.qml. Started by Run.qml as
|
|
9
|
+
# /usr/bin/python3 -I -S -B <this file> [options] -- /absolute/program args...
|
|
10
|
+
# and never by hand. It reads a one-line token from stdin, forks the
|
|
11
|
+
# program into a new session behind a gate, announces the leader, releases
|
|
12
|
+
# the gate on Run.qml's acknowledgement, watches the leader through a pidfd,
|
|
13
|
+
# reads stdout and stderr under byte and line caps, keeps an absolute
|
|
14
|
+
# deadline, then TERM to the whole group, a grace, KILL to the group, waits
|
|
15
|
+
# until the group is empty, and reaps the leader last, so the group number
|
|
16
|
+
# cannot be reused while it is signalled. Every protocol line on its own
|
|
17
|
+
# stdout starts with the token, which the program never sees (not argv, not
|
|
18
|
+
# the environment, consumed from stdin before the fork), so a line the
|
|
19
|
+
# program writes into this pipe through /proc carries no token and is
|
|
20
|
+
# dropped by Run.qml. docs/BLOCKS.md is the contract.
|
|
21
|
+
#
|
|
22
|
+
# --kill-group PGID --grace-ms N the detached reaper Run.qml starts from
|
|
23
|
+
# Component.onDestruction and after a
|
|
24
|
+
# supervisor that died without a result:
|
|
25
|
+
# TERM, grace, KILL, nothing printed
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import select
|
|
30
|
+
import signal
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
|
|
34
|
+
# C0 except tab and newline, DEL, C1, and the bidirectional controls that
|
|
35
|
+
# reorder displayed text: removed from the result's text, which is meant
|
|
36
|
+
# for Text.PlainText and nothing else.
|
|
37
|
+
CONTROL = re.compile("[\\x00-\\x08\\x0b-\\x1f\\x7f-\\x9f\\u061c\\u200e\\u200f\\u202a-\\u202e\\u2066-\\u2069]")
|
|
38
|
+
|
|
39
|
+
# A shell or an interpreter whose string flag turns the next argument into a
|
|
40
|
+
# program the argv does not show: the flag's letters, its long forms, and
|
|
41
|
+
# the options that consume the next argument (so `-o pipefail -c` is seen
|
|
42
|
+
# through and `-m json.tool` is not a string); and the wrappers that hand
|
|
43
|
+
# argv on to another command, with the options that take a value and the
|
|
44
|
+
# positionals before the command. Best effort over the forms listed here,
|
|
45
|
+
# not a sandbox: refused unless allowShellString. The wrappers are the
|
|
46
|
+
# neutral ones: a privileged wrapper is not on the list, because Run
|
|
47
|
+
# decides nothing about privilege and a plugin that runs one is in review
|
|
48
|
+
# for that on its own (0.2.1).
|
|
49
|
+
SHELLS = frozenset(["sh", "bash", "dash", "zsh", "ksh", "fish", "rbash", "ash", "mksh", "busybox"])
|
|
50
|
+
SHELL_VALUE_OPTIONS = frozenset(["-o", "+o", "-O", "+O", "--rcfile", "--init-file", "-C", "-d", "--debug-level"])
|
|
51
|
+
INTERPRETERS = {
|
|
52
|
+
"python": ("c", frozenset(["-W", "-X", "--check-hash-based-pycs"])),
|
|
53
|
+
"perl": ("eE", frozenset(["-I", "-M", "-m", "-F", "-C", "-x"])),
|
|
54
|
+
"ruby": ("e", frozenset(["-r", "-I", "-E", "-C", "-F", "-x"])),
|
|
55
|
+
"php": ("r", frozenset(["-c", "-d", "-f", "-z", "-B", "-R", "-F"])),
|
|
56
|
+
"lua": ("e", frozenset(["-l"])),
|
|
57
|
+
"node": ("ep", frozenset(["-r", "--require", "--import", "--loader", "--experimental-loader", "-C", "--conditions"])),
|
|
58
|
+
}
|
|
59
|
+
LONG_STRING_FLAGS = frozenset(["--eval", "--print", "--run"])
|
|
60
|
+
WRAPPERS = frozenset(["env", "nice", "timeout", "setsid", "flock", "xargs", "nohup", "stdbuf", "ionice", "chrt", "unbuffer", "busybox"])
|
|
61
|
+
WRAPPER_VALUE_OPTIONS = frozenset(["-u", "-C", "-S", "-n", "-s", "-k", "-w", "-E", "-L", "-P", "-a", "-d", "-I", "-i", "-o", "-e", "-p", "-g", "-D", "-h", "-r", "-t", "-U", "-T", "--adjustment", "--signal", "--kill-after", "--timeout", "--conflict-exit-code", "--input", "--output", "--error", "--max-args", "--max-lines", "--max-procs", "--delimiter", "--arg-file", "--replace", "--user", "--group", "--chdir", "--split-string", "--unset", "--class", "--classdata", "--pid"])
|
|
62
|
+
POSITIONAL_BEFORE_COMMAND = {"timeout": 1, "flock": 1}
|
|
63
|
+
|
|
64
|
+
DEFAULTS = {"deadline_ms": 10000, "grace_ms": 1000, "max_bytes": 1048576, "max_lines": 10000,
|
|
65
|
+
"keep_bytes": 65536, "kill_group": 0, "allow_shell_string": 0}
|
|
66
|
+
TOKEN = re.compile(r"^[0-9a-f]{32}$")
|
|
67
|
+
token = ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def emit(obj):
|
|
71
|
+
sys.stdout.write(token + " " + json.dumps(obj, ensure_ascii=True) + "\n")
|
|
72
|
+
sys.stdout.flush()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def plain(data, keep):
|
|
76
|
+
"""The first `keep` bytes as text with every control character removed."""
|
|
77
|
+
return CONTROL.sub("", bytes(data[:keep]).decode("utf-8", "replace"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_token():
|
|
81
|
+
"""The per-run token Run.qml writes as the first line of stdin, read a byte at a time so the acknowledgement behind it stays in the pipe; the program never sees it."""
|
|
82
|
+
line = b""
|
|
83
|
+
while not line.endswith(b"\n") and len(line) < 64:
|
|
84
|
+
byte = os.read(0, 1)
|
|
85
|
+
if not byte:
|
|
86
|
+
break
|
|
87
|
+
line += byte
|
|
88
|
+
text = line.decode("ascii", "replace").strip()
|
|
89
|
+
if not TOKEN.match(text):
|
|
90
|
+
raise SystemExit("run-supervisor: no token on stdin; started by Run.qml only")
|
|
91
|
+
return text
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def interpreter_name(path):
|
|
95
|
+
return re.sub(r"[\d.]+$", "", os.path.basename(path))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def string_flags(name):
|
|
99
|
+
"""(letters, value-taking options) of an interpreter, or None when the name is no interpreter."""
|
|
100
|
+
if name in SHELLS:
|
|
101
|
+
return "c", SHELL_VALUE_OPTIONS
|
|
102
|
+
return INTERPRETERS.get(name)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def flock_command(args):
|
|
106
|
+
"""`flock [options] file -c string`: the string flag comes after the lock file, so it is looked for anywhere before `--`."""
|
|
107
|
+
for arg in args:
|
|
108
|
+
if arg == "--":
|
|
109
|
+
return False
|
|
110
|
+
if arg == "--command" or (arg.startswith("-") and not arg.startswith("--") and "c" in arg[1:]):
|
|
111
|
+
return True
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def has_string_flag(args, letters, value_options):
|
|
116
|
+
"""A string flag among the leading options: clustered (-lc), alone, or a long form; a value-taking option's value is skipped."""
|
|
117
|
+
skip = False
|
|
118
|
+
for arg in args:
|
|
119
|
+
if skip:
|
|
120
|
+
skip = False
|
|
121
|
+
continue
|
|
122
|
+
if arg == "--" or not arg.startswith(("-", "+")) or arg in ("-", "+"):
|
|
123
|
+
return False
|
|
124
|
+
if arg in LONG_STRING_FLAGS or (not arg.startswith("--") and any(letter in arg[1:] for letter in letters)):
|
|
125
|
+
return True
|
|
126
|
+
skip = arg in value_options
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def unwrap(cmd):
|
|
131
|
+
"""The command a wrapper hands on: past the wrapper's options, NAME=value words and its leading positionals."""
|
|
132
|
+
name = interpreter_name(cmd[0])
|
|
133
|
+
rest = cmd[1:]
|
|
134
|
+
positionals = POSITIONAL_BEFORE_COMMAND.get(name, 0)
|
|
135
|
+
while rest and (rest[0].startswith("-") or (name == "env" and "=" in rest[0])):
|
|
136
|
+
skip = 2 if rest[0] in WRAPPER_VALUE_OPTIONS else 1
|
|
137
|
+
rest = rest[skip:]
|
|
138
|
+
return rest[positionals:]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def is_shell_string(cmd):
|
|
142
|
+
"""`bash -c`, `sh -o pipefail -c`, `perl -e`, `node --eval`, `env bash -c`, `flock -c`: an interpreter and its string flag among the leading options, through up to eight wrappers."""
|
|
143
|
+
for _depth in range(8):
|
|
144
|
+
if not cmd:
|
|
145
|
+
return False
|
|
146
|
+
name = interpreter_name(cmd[0])
|
|
147
|
+
flags = string_flags(name)
|
|
148
|
+
if flags is not None and has_string_flag(cmd[1:], flags[0], flags[1]):
|
|
149
|
+
return True
|
|
150
|
+
if name == "flock" and flock_command(cmd[1:]):
|
|
151
|
+
return True
|
|
152
|
+
if name not in WRAPPERS:
|
|
153
|
+
return False
|
|
154
|
+
cmd = unwrap(cmd)
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def refusal(cmd, allow_shell_string):
|
|
159
|
+
"""Why the command is not started, or None."""
|
|
160
|
+
if not cmd:
|
|
161
|
+
return "command is empty"
|
|
162
|
+
if not os.path.isabs(cmd[0]):
|
|
163
|
+
return "command[0] is not an absolute path: %s" % cmd[0]
|
|
164
|
+
if not allow_shell_string and is_shell_string(cmd):
|
|
165
|
+
return "command is a shell string (an interpreter with its string flag, %s); pass argv, or set allowShellString: true" % interpreter_name(cmd[0])
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def parse(argv):
|
|
170
|
+
"""The options before `--` as a dict of integers, and the command after it."""
|
|
171
|
+
opts = dict(DEFAULTS)
|
|
172
|
+
index = 0
|
|
173
|
+
while index < len(argv):
|
|
174
|
+
arg = argv[index]
|
|
175
|
+
if arg == "--":
|
|
176
|
+
return opts, argv[index + 1:]
|
|
177
|
+
key = arg[2:].replace("-", "_")
|
|
178
|
+
if not arg.startswith("--") or key not in opts:
|
|
179
|
+
raise SystemExit("run-supervisor: unknown option %s" % arg)
|
|
180
|
+
if key == "allow_shell_string":
|
|
181
|
+
opts[key] = 1
|
|
182
|
+
index += 1
|
|
183
|
+
continue
|
|
184
|
+
if index + 1 >= len(argv) or not argv[index + 1].isdigit():
|
|
185
|
+
raise SystemExit("run-supervisor: %s needs an integer" % arg)
|
|
186
|
+
opts[key] = int(argv[index + 1])
|
|
187
|
+
index += 2
|
|
188
|
+
return opts, []
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def pgrp_and_state(pid):
|
|
192
|
+
"""(pgrp, state) of a process from /proc, or None when it is gone."""
|
|
193
|
+
try:
|
|
194
|
+
with open("/proc/%d/stat" % pid, "rb") as handle:
|
|
195
|
+
stat = handle.read()
|
|
196
|
+
except OSError:
|
|
197
|
+
return None
|
|
198
|
+
rest = stat[stat.rfind(b")") + 2:].split()
|
|
199
|
+
if len(rest) < 3:
|
|
200
|
+
return None
|
|
201
|
+
return int(rest[2]), rest[0]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def group_members(pgid, leader):
|
|
205
|
+
"""The live pids in the group other than the leader; a zombie is not live."""
|
|
206
|
+
live = []
|
|
207
|
+
for name in os.listdir("/proc"):
|
|
208
|
+
if not name.isdigit() or int(name) == leader:
|
|
209
|
+
continue
|
|
210
|
+
found = pgrp_and_state(int(name))
|
|
211
|
+
if found and found[0] == pgid and found[1] != b"Z":
|
|
212
|
+
live.append(int(name))
|
|
213
|
+
return live
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def killpg(pgid, sig):
|
|
217
|
+
"""Signal the group; True when the group no longer exists."""
|
|
218
|
+
try:
|
|
219
|
+
os.killpg(pgid, sig)
|
|
220
|
+
return False
|
|
221
|
+
except ProcessLookupError:
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def wait_empty(pgid, leader, budget_s):
|
|
226
|
+
"""Poll until the group has no live member or the budget is spent; the live members left."""
|
|
227
|
+
end = time.monotonic() + budget_s
|
|
228
|
+
while True:
|
|
229
|
+
live = group_members(pgid, leader)
|
|
230
|
+
if not live or time.monotonic() >= end:
|
|
231
|
+
return live
|
|
232
|
+
time.sleep(0.02)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def reap_group(pgid, grace_ms):
|
|
236
|
+
"""The detached reaper: TERM, grace while polling, KILL, one more wait."""
|
|
237
|
+
if killpg(pgid, signal.SIGTERM):
|
|
238
|
+
return 0
|
|
239
|
+
if not wait_empty(pgid, 0, grace_ms / 1000.0):
|
|
240
|
+
return 0
|
|
241
|
+
killpg(pgid, signal.SIGKILL)
|
|
242
|
+
wait_empty(pgid, 0, 1.0)
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def close_all_but(keep):
|
|
247
|
+
"""Every descriptor from 3 up except the ones kept."""
|
|
248
|
+
low = 3
|
|
249
|
+
for fd in sorted(keep):
|
|
250
|
+
os.closerange(low, fd)
|
|
251
|
+
low = fd + 1
|
|
252
|
+
os.closerange(low, os.sysconf("SC_OPEN_MAX"))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def child_exec(cmd, fds):
|
|
256
|
+
"""In the child: a new session, the three descriptors, nothing else open, the gate, then exec."""
|
|
257
|
+
try:
|
|
258
|
+
os.setsid()
|
|
259
|
+
os.dup2(fds["devnull"], 0)
|
|
260
|
+
os.dup2(fds["w_out"], 1)
|
|
261
|
+
os.dup2(fds["w_err"], 2)
|
|
262
|
+
close_all_but([fds["e_w"], fds["g_r"]])
|
|
263
|
+
if os.read(fds["g_r"], 1) != b"1":
|
|
264
|
+
os._exit(0) # the gate closed unreleased: Run.qml went away before it knew this pid
|
|
265
|
+
os.close(fds["g_r"])
|
|
266
|
+
os.execv(cmd[0], cmd)
|
|
267
|
+
except OSError as error:
|
|
268
|
+
os.write(fds["e_w"], str(error.errno or 0).encode())
|
|
269
|
+
os._exit(127)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def spawn(cmd):
|
|
273
|
+
"""Fork behind a gate; (pid, stdout fd, stderr fd, errno fd, gate fd). Exec waits for release()."""
|
|
274
|
+
r_out, w_out = os.pipe()
|
|
275
|
+
r_err, w_err = os.pipe()
|
|
276
|
+
e_r, e_w = os.pipe() # close-on-exec: a successful exec closes it unwritten
|
|
277
|
+
g_r, g_w = os.pipe() # the gate: one byte releases the exec; EOF ends the child unrun
|
|
278
|
+
devnull = os.open(os.devnull, os.O_RDONLY)
|
|
279
|
+
pid = os.fork()
|
|
280
|
+
if pid == 0:
|
|
281
|
+
child_exec(cmd, {"devnull": devnull, "w_out": w_out, "w_err": w_err, "e_w": e_w, "g_r": g_r})
|
|
282
|
+
for fd in (w_out, w_err, e_w, g_r, devnull):
|
|
283
|
+
os.close(fd)
|
|
284
|
+
for fd in (r_out, r_err, e_r):
|
|
285
|
+
os.set_blocking(fd, False)
|
|
286
|
+
return pid, r_out, r_err, e_r, g_w
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class Stream:
|
|
290
|
+
"""One pipe: counted in full, kept up to the cap."""
|
|
291
|
+
|
|
292
|
+
def __init__(self, fd, keep):
|
|
293
|
+
self.fd = fd
|
|
294
|
+
self.keep = keep
|
|
295
|
+
self.open = True
|
|
296
|
+
self.bytes = 0
|
|
297
|
+
self.lines = 0
|
|
298
|
+
self.kept = bytearray()
|
|
299
|
+
|
|
300
|
+
def read(self):
|
|
301
|
+
"""One read; False at EOF or when nothing is there."""
|
|
302
|
+
try:
|
|
303
|
+
data = os.read(self.fd, 65536)
|
|
304
|
+
except BlockingIOError:
|
|
305
|
+
return False
|
|
306
|
+
except OSError:
|
|
307
|
+
data = b""
|
|
308
|
+
if not data:
|
|
309
|
+
self.open = False
|
|
310
|
+
return False
|
|
311
|
+
self.bytes += len(data)
|
|
312
|
+
self.lines += data.count(b"\n")
|
|
313
|
+
room = self.keep - len(self.kept)
|
|
314
|
+
if room > 0:
|
|
315
|
+
self.kept += data[:room]
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
def over(self, max_bytes, max_lines):
|
|
319
|
+
return self.bytes > max_bytes or self.lines > max_lines
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class Supervisor:
|
|
323
|
+
def __init__(self, opts, cmd):
|
|
324
|
+
self.opts = opts
|
|
325
|
+
self.cmd = cmd
|
|
326
|
+
self.t0 = time.monotonic()
|
|
327
|
+
self.deadline = self.t0 + opts["deadline_ms"] / 1000.0
|
|
328
|
+
self.state = "running"
|
|
329
|
+
self.reason = None
|
|
330
|
+
self.exit_code = None
|
|
331
|
+
self.term_signal = None
|
|
332
|
+
self.leader_exited = False
|
|
333
|
+
self.next_kill = None
|
|
334
|
+
self.kill_sent = False
|
|
335
|
+
self.signals = []
|
|
336
|
+
self.pid = 0
|
|
337
|
+
self.pidfd = -1
|
|
338
|
+
self.errno_fd = -1
|
|
339
|
+
self.gate_fd = -1
|
|
340
|
+
self.spawn_error = None
|
|
341
|
+
self.streams = {}
|
|
342
|
+
self.wake_r, self.wake_w = os.pipe()
|
|
343
|
+
os.set_blocking(self.wake_w, False)
|
|
344
|
+
signal.set_wakeup_fd(self.wake_w, warn_on_full_buffer=False)
|
|
345
|
+
signal.signal(signal.SIGTERM, lambda *_: None)
|
|
346
|
+
signal.signal(signal.SIGINT, lambda *_: None)
|
|
347
|
+
|
|
348
|
+
def now_ms(self):
|
|
349
|
+
return int((time.monotonic() - self.t0) * 1000)
|
|
350
|
+
|
|
351
|
+
def start(self):
|
|
352
|
+
self.pid, r_out, r_err, self.errno_fd, self.gate_fd = spawn(self.cmd)
|
|
353
|
+
self.pidfd = os.pidfd_open(self.pid)
|
|
354
|
+
self.streams = {r_out: Stream(r_out, self.opts["keep_bytes"]), r_err: Stream(r_err, self.opts["keep_bytes"])}
|
|
355
|
+
self.out, self.err = self.streams[r_out], self.streams[r_err]
|
|
356
|
+
emit({"ev": "leader", "pid": self.pid, "pgid": self.pid, "atMs": self.now_ms()})
|
|
357
|
+
|
|
358
|
+
def release(self, run):
|
|
359
|
+
"""Open or close the gate: the child execs on a byte, exits unrun on EOF."""
|
|
360
|
+
if self.gate_fd < 0:
|
|
361
|
+
return
|
|
362
|
+
if run:
|
|
363
|
+
os.write(self.gate_fd, b"1")
|
|
364
|
+
os.close(self.gate_fd)
|
|
365
|
+
self.gate_fd = -1
|
|
366
|
+
|
|
367
|
+
def watch(self):
|
|
368
|
+
fds = [fd for fd, stream in self.streams.items() if stream.open]
|
|
369
|
+
fds.append(self.wake_r)
|
|
370
|
+
if not self.leader_exited:
|
|
371
|
+
fds.append(self.pidfd)
|
|
372
|
+
if self.errno_fd >= 0:
|
|
373
|
+
fds.append(self.errno_fd)
|
|
374
|
+
if self.gate_fd >= 0:
|
|
375
|
+
fds.append(0)
|
|
376
|
+
return fds
|
|
377
|
+
|
|
378
|
+
def timeout(self):
|
|
379
|
+
now = time.monotonic()
|
|
380
|
+
if self.state == "running":
|
|
381
|
+
return max(0.0, self.deadline - now)
|
|
382
|
+
if self.next_kill is not None and not self.kill_sent:
|
|
383
|
+
return max(0.0, self.next_kill - now)
|
|
384
|
+
return 0.02
|
|
385
|
+
|
|
386
|
+
def send(self, sig, name):
|
|
387
|
+
gone = killpg(self.pid, sig)
|
|
388
|
+
self.signals.append({"sig": name, "atMs": self.now_ms(), "esrch": gone})
|
|
389
|
+
emit({"ev": "signal", "sig": name, "atMs": self.now_ms(), "esrch": gone})
|
|
390
|
+
if name == "TERM":
|
|
391
|
+
self.next_kill = time.monotonic() + self.opts["grace_ms"] / 1000.0
|
|
392
|
+
else:
|
|
393
|
+
self.kill_sent = True
|
|
394
|
+
|
|
395
|
+
def begin_end(self, reason):
|
|
396
|
+
"""The run is over: close the gate if it is still shut, TERM the group unless nothing is left to signal."""
|
|
397
|
+
if self.state != "running":
|
|
398
|
+
return
|
|
399
|
+
self.state, self.reason = "ending", reason
|
|
400
|
+
self.release(False)
|
|
401
|
+
if self.leader_exited and not group_members(self.pid, self.pid):
|
|
402
|
+
return
|
|
403
|
+
self.send(signal.SIGTERM, "TERM")
|
|
404
|
+
|
|
405
|
+
def leader_exit(self):
|
|
406
|
+
"""The leader is gone; its status is read without reaping it."""
|
|
407
|
+
info = os.waitid(os.P_PIDFD, self.pidfd, os.WEXITED | os.WNOWAIT)
|
|
408
|
+
self.leader_exited = True
|
|
409
|
+
if info.si_code == os.CLD_EXITED:
|
|
410
|
+
self.exit_code = info.si_status
|
|
411
|
+
else:
|
|
412
|
+
self.term_signal = info.si_status
|
|
413
|
+
emit({"ev": "leader-exited", "code": self.exit_code, "signal": self.term_signal, "atMs": self.now_ms()})
|
|
414
|
+
self.begin_end("ok" if self.exit_code == 0 else "exit")
|
|
415
|
+
|
|
416
|
+
def exec_outcome(self):
|
|
417
|
+
"""The errno pipe: bytes mean the exec failed; EOF means it succeeded."""
|
|
418
|
+
try:
|
|
419
|
+
data = os.read(self.errno_fd, 32)
|
|
420
|
+
except BlockingIOError:
|
|
421
|
+
return
|
|
422
|
+
os.close(self.errno_fd)
|
|
423
|
+
self.errno_fd = -1
|
|
424
|
+
if data:
|
|
425
|
+
self.spawn_error = os.strerror(int(data)) if data.isdigit() and int(data) else "exec failed"
|
|
426
|
+
self.state, self.reason = "ending", "spawn-failed" # the child exited 127 on its own; nothing to signal
|
|
427
|
+
|
|
428
|
+
def acknowledged(self):
|
|
429
|
+
"""Run.qml's `go` on stdin releases the gate; EOF before it means Run.qml is gone."""
|
|
430
|
+
line = os.read(0, 16)
|
|
431
|
+
if line.strip() == b"go":
|
|
432
|
+
self.release(True)
|
|
433
|
+
else:
|
|
434
|
+
self.begin_end("cancelled")
|
|
435
|
+
|
|
436
|
+
def handle(self, fd):
|
|
437
|
+
if fd == self.pidfd:
|
|
438
|
+
self.leader_exit()
|
|
439
|
+
elif fd == self.wake_r:
|
|
440
|
+
os.read(self.wake_r, 4096)
|
|
441
|
+
self.begin_end("cancelled")
|
|
442
|
+
elif fd == self.errno_fd:
|
|
443
|
+
self.exec_outcome()
|
|
444
|
+
elif fd == 0:
|
|
445
|
+
self.acknowledged()
|
|
446
|
+
elif self.streams[fd].read() and self.streams[fd].over(self.opts["max_bytes"], self.opts["max_lines"]):
|
|
447
|
+
self.begin_end("overflow")
|
|
448
|
+
|
|
449
|
+
def clock(self):
|
|
450
|
+
"""The deadline and the grace, checked after every select."""
|
|
451
|
+
now = time.monotonic()
|
|
452
|
+
if self.state == "running" and now >= self.deadline:
|
|
453
|
+
self.begin_end("timeout")
|
|
454
|
+
if self.state != "ending" or self.next_kill is None or self.kill_sent or now < self.next_kill:
|
|
455
|
+
return
|
|
456
|
+
if group_members(self.pid, self.pid) or not self.leader_exited:
|
|
457
|
+
self.send(signal.SIGKILL, "KILL")
|
|
458
|
+
else:
|
|
459
|
+
self.kill_sent = True # the group emptied during the grace; no KILL needed
|
|
460
|
+
|
|
461
|
+
def done(self):
|
|
462
|
+
if self.state != "ending" or not self.leader_exited:
|
|
463
|
+
return False
|
|
464
|
+
if not group_members(self.pid, self.pid):
|
|
465
|
+
return True
|
|
466
|
+
return self.kill_sent and self.now_ms() > self.opts["deadline_ms"] + self.opts["grace_ms"] + 2000
|
|
467
|
+
|
|
468
|
+
def step(self):
|
|
469
|
+
fds = self.watch()
|
|
470
|
+
ready = select.select(fds, [], [], self.timeout())[0]
|
|
471
|
+
for fd in ready:
|
|
472
|
+
self.handle(fd)
|
|
473
|
+
self.clock()
|
|
474
|
+
|
|
475
|
+
def drain(self):
|
|
476
|
+
"""What is still buffered in the pipes after the group is gone, without blocking."""
|
|
477
|
+
for stream in self.streams.values():
|
|
478
|
+
while stream.open and stream.read():
|
|
479
|
+
pass
|
|
480
|
+
|
|
481
|
+
def finish(self):
|
|
482
|
+
"""The group is empty or given up on: drain, count, reap the leader last, report."""
|
|
483
|
+
self.drain()
|
|
484
|
+
survivors = group_members(self.pid, self.pid)
|
|
485
|
+
os.waitpid(self.pid, 0)
|
|
486
|
+
os.close(self.pidfd)
|
|
487
|
+
emit(self.result(survivors))
|
|
488
|
+
|
|
489
|
+
def result(self, survivors):
|
|
490
|
+
spawn_failed = self.reason == "spawn-failed"
|
|
491
|
+
return {"ev": "result", "state": self.reason, "exitCode": None if spawn_failed else self.exit_code, "termSignal": self.term_signal,
|
|
492
|
+
"reason": "%s: %s" % (self.cmd[0], self.spawn_error) if spawn_failed else None,
|
|
493
|
+
"ms": self.now_ms(), "pgid": self.pid, "survivors": len(survivors),
|
|
494
|
+
"outBytes": self.out.bytes, "outLines": self.out.lines, "errBytes": self.err.bytes, "errLines": self.err.lines,
|
|
495
|
+
"stdout": plain(self.out.kept, self.opts["keep_bytes"]), "stderr": plain(self.err.kept, self.opts["keep_bytes"]),
|
|
496
|
+
"signals": self.signals}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def supervise(opts, cmd):
|
|
500
|
+
why = refusal(cmd, opts["allow_shell_string"])
|
|
501
|
+
if why:
|
|
502
|
+
emit({"ev": "result", "state": "spawn-failed", "reason": why, "ms": 0})
|
|
503
|
+
return 0
|
|
504
|
+
run = Supervisor(opts, cmd)
|
|
505
|
+
run.start()
|
|
506
|
+
while not run.done():
|
|
507
|
+
run.step()
|
|
508
|
+
run.finish()
|
|
509
|
+
return 0
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def main():
|
|
513
|
+
global token
|
|
514
|
+
opts, cmd = parse(sys.argv[1:])
|
|
515
|
+
if opts["kill_group"]:
|
|
516
|
+
return reap_group(opts["kill_group"], opts["grace_ms"])
|
|
517
|
+
token = read_token()
|
|
518
|
+
return supervise(opts, cmd)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
if __name__ == "__main__":
|
|
522
|
+
sys.exit(main())
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
omakit blocks in this directory
|
|
2
|
+
|
|
3
|
+
Each file below was copied from omakit (https://github.com/mtolhuys/omakit),
|
|
4
|
+
MIT licence, Copyright (c) 2026 Maarten Tolhuijs, by `omakit add`. The body
|
|
5
|
+
sha256 is over everything after the file's header line
|
|
6
|
+
`end of omakit block header`; `omakit inspect` reports a file whose body differs as
|
|
7
|
+
modified, and `omakit add --update` refuses to overwrite one. Modifications
|
|
8
|
+
are to be listed here by the plugin's author.
|
|
9
|
+
|
|
10
|
+
block store 0.2.0, from omakit commit unstamped
|
|
11
|
+
Store.qml sha256 6fafd2ec98b4d4fe18ead2ae1f1221386e9714eb9a33ef0f1b9cf48520f2c743
|
|
12
|
+
store-helper.py sha256 2dd4d6ad308bbed473936c29844793411d8849415812b96ca5e7a6688097e37c
|