omakit 0.1.9 → 0.2.0
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 +44 -217
- package/package.json +2 -2
- package/skills/omarchy-plugin-check/SKILL.md +102 -0
- package/skills/omarchy-plugin-submit/SKILL.md +4 -0
- package/skills/omarchy-plugin-weigh/SKILL.md +128 -0
- package/tools/marketplace/README.md +15 -1
- package/tools/marketplace/cli.mjs +147 -3
- package/tools/marketplace/completion.mjs +3 -3
- package/tools/marketplace/paths.mjs +13 -0
- package/tools/marketplace/report.mjs +3 -2
- package/tools/marketplace/usage.mjs +21 -0
- package/tools/weigh/audit.mjs +666 -0
- package/tools/weigh/commands.mjs +69 -0
- package/tools/weigh/config.mjs +131 -0
- package/tools/weigh/confirm.mjs +32 -0
- package/tools/weigh/contract.mjs +167 -0
- package/tools/weigh/proc.mjs +150 -0
- package/tools/weigh/report.mjs +166 -0
- package/tools/weigh/stats.mjs +63 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
// The startup A/B measurement, in two halves.
|
|
2
|
+
//
|
|
3
|
+
// `planWeigh()` reads and decides: the shell that runs, whether the session is
|
|
4
|
+
// locked, what is installed and enabled, which plugins will be measured, how
|
|
5
|
+
// many restarts that is and how long it will take. It writes nothing, so the
|
|
6
|
+
// confirmation is made from it and a refusal costs nothing.
|
|
7
|
+
//
|
|
8
|
+
// `measureWeigh()` is the half that changes the user's machine, and the only
|
|
9
|
+
// one in omakit that does. For every run: write a configuration, restart the
|
|
10
|
+
// shell, wait until every installed plugin is reported, settle, sample; then
|
|
11
|
+
// the next configuration. The backup is taken before the first write and
|
|
12
|
+
// restored in a `finally` that every exit path passes through: a completed
|
|
13
|
+
// run, a restart that did not answer, a thrown error, and an interrupt, which
|
|
14
|
+
// arrives here as an aborted signal rather than a dead process.
|
|
15
|
+
//
|
|
16
|
+
// The method is a port of the audit described in docs/WEIGH.md, and that
|
|
17
|
+
// document is the contract for what comes out; the bash it was ported from
|
|
18
|
+
// is the reference for the behaviour. What differs from it, Pss beside VmRSS
|
|
19
|
+
// and a memory trace through the window, and what was tried and measured
|
|
20
|
+
// worse (the memory sample at the settle), are named there with the figures.
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
|
23
|
+
import { hostname } from "node:os"
|
|
24
|
+
import { dirname, join, resolve } from "node:path"
|
|
25
|
+
import { run } from "./commands.mjs"
|
|
26
|
+
import { backupConfig, configPaths, md5, restoreConfig, verifyRestore, without, writeConfig } from "./config.mjs"
|
|
27
|
+
import { childTicks, cpuTicks, descendants, PROC, pssKb, rssKb } from "./proc.mjs"
|
|
28
|
+
import { median, stats, tickPercent, verdict } from "./stats.mjs"
|
|
29
|
+
import { omakitStateDir } from "../marketplace/paths.mjs"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The settle is 30 s because the shell is not settled at 8. Measured in the
|
|
33
|
+
* plugin lab on 14 September 2026 with a Pss trace twice a second over 80
|
|
34
|
+
* restarts (docs/MEASUREMENTS.md C2): after listPlugins answers (0.3 s
|
|
35
|
+
* after the restart) the shell holds a load-time high of 550 to 615 MB Pss
|
|
36
|
+
* and then releases 55 to 65 MB at a moment that varied from 9.5 to 22 s
|
|
37
|
+
* after ready. A window that opens at 8 s reads either side of that
|
|
38
|
+
* release, and the baseline spread was 70.3 MB at the settle and 34.6 MB at
|
|
39
|
+
* the end of the window. Thirty seconds puts the whole window after it.
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULTS = Object.freeze({
|
|
42
|
+
runs: 3,
|
|
43
|
+
windowSeconds: 15,
|
|
44
|
+
settleSeconds: 30,
|
|
45
|
+
readyTimeoutSeconds: 45,
|
|
46
|
+
sampleIntervalMs: 500,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Seconds from `omarchy-restart-shell` to every plugin reported, before
|
|
51
|
+
* this machine has a timing of its own. Measured in the plugin lab on
|
|
52
|
+
* 14 September 2026 over 39 restarts: 1 s each, in a VM with software
|
|
53
|
+
* rendering; a desktop with more plugins takes a few seconds. What makes a
|
|
54
|
+
* restart cost about a minute is the settle and the window that follow it,
|
|
55
|
+
* and the estimate adds those.
|
|
56
|
+
*/
|
|
57
|
+
export const RESTART_SECONDS = 5
|
|
58
|
+
|
|
59
|
+
export const METHOD = "startup A/B: the shell is restarted with the enabled set minus every measured plugin (baseline) and with that set plus one plugin; after listPlugins reports every installed plugin and a settle, a window opens in which utime+stime is read from /proc/<pid>/stat at both ends, Pss from /proc/<pid>/smaps_rollup and VmRSS from /proc/<pid>/status are traced twice a second and taken at the end of the window (the same two also recorded at the settle), descendants are sampled twice a second and the CPU of reaped children comes from cutime+cstime of the shell pid; each plugin row is the median over runs of (plus minus baseline, run by run) with the spread (max minus min); a delta whose absolute median is not above the baseline spread is within noise"
|
|
60
|
+
|
|
61
|
+
export class WeighError extends Error {
|
|
62
|
+
constructor(code, message, remedy = null) {
|
|
63
|
+
super(message)
|
|
64
|
+
this.name = "WeighError"
|
|
65
|
+
this.code = code
|
|
66
|
+
this.remedy = remedy
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function utc(date = new Date()) {
|
|
71
|
+
return date.toISOString().replace(/\.\d{3}Z$/, "Z")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A file name stamp: `2026-09-14T190225Z`, readable and safe on any filesystem. */
|
|
75
|
+
function fileStamp(date = new Date()) {
|
|
76
|
+
return utc(date).replace(/:/g, "")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Is a command reachable through PATH; a probe by name, not by running it. */
|
|
80
|
+
function onPath(command, env) {
|
|
81
|
+
return (env.PATH || "").split(":").some((dir) => dir && existsSync(join(dir, command)))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseJson(text, what) {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(text)
|
|
87
|
+
} catch {
|
|
88
|
+
throw new WeighError("shell-unreadable", `${what} did not answer with JSON`, "omarchy-restart-shell, then run it again.")
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** OMARCHY_PATH as the running session has it, the way omarchy-restart-shell reads it; the process environment is the fallback. */
|
|
93
|
+
function sessionOmarchyPath(env) {
|
|
94
|
+
const session = run("sessionEnvironment", { env })
|
|
95
|
+
const line = session.ok ? session.stdout.split("\n").filter((entry) => entry.startsWith("OMARCHY_PATH=")).at(-1) : null
|
|
96
|
+
return (line ? line.slice("OMARCHY_PATH=".length) : env.OMARCHY_PATH) || null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The per-restart timing stored by the previous run on this machine, or the lab's figure. */
|
|
100
|
+
export function restartTiming(stateDir) {
|
|
101
|
+
const timingFile = join(stateDir, "timing.json")
|
|
102
|
+
try {
|
|
103
|
+
const stored = JSON.parse(readFileSync(timingFile, "utf8"))
|
|
104
|
+
if (Number.isFinite(stored.restartSeconds) && stored.restartSeconds > 0) {
|
|
105
|
+
return { seconds: stored.restartSeconds, source: `measured over ${stored.restarts} restart(s) on this machine at ${stored.measuredAt}`, file: timingFile }
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// No timing yet: the first run on this machine.
|
|
109
|
+
}
|
|
110
|
+
return { seconds: RESTART_SECONDS, source: "before any run on this machine; the lab measured 1 s", file: timingFile }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The IPC functions of the `shell` target that a measurement relies on: two it calls, two the shell's own plugin commands call on its behalf. */
|
|
114
|
+
export const REQUIRED_IPC = Object.freeze(["listPlugins", "listShellConfig", "setPluginEnabled", "enablePlugin"])
|
|
115
|
+
|
|
116
|
+
/** The shell path a stock install has; printed only when the running shell's differs from it. */
|
|
117
|
+
export function stockShellPath(env = process.env) {
|
|
118
|
+
return join(env.HOME || "", ".local/share/omarchy")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Is this an Omarchy whose shell can be weighed. Every probe is read-only:
|
|
123
|
+
* a command on PATH, a file, `ping`, and the IPC listing (`qs ipc show`),
|
|
124
|
+
* never a call to a method that changes anything. On any failure the
|
|
125
|
+
* error is one sentence naming what is missing, and cli.mjs prints it
|
|
126
|
+
* under NOT WEIGHED before anything is confirmed.
|
|
127
|
+
*
|
|
128
|
+
* @param {NodeJS.ProcessEnv} env
|
|
129
|
+
* @returns {{ omarchyPath: string, shellVersion: string, ipc: string[] }}
|
|
130
|
+
*/
|
|
131
|
+
export function compatibility(env = process.env) {
|
|
132
|
+
if (!onPath("omarchy-shell", env)) throw new WeighError("no-omarchy-shell", "this Omarchy has no omarchy-shell on PATH, so there is no Quattro shell here to weigh a plugin on", "Weigh on an Omarchy with the Quattro shell (4.0 or newer), from a terminal in that session.")
|
|
133
|
+
if (!onPath("omarchy-restart-shell", env)) throw new WeighError("no-restart-command", "omarchy-restart-shell is not on PATH, and weigh restarts the shell only through it", "Run it from a terminal that has $OMARCHY_PATH/bin on PATH.")
|
|
134
|
+
for (const command of ["omarchy", "qs"]) {
|
|
135
|
+
if (!onPath(command, env)) throw new WeighError("command-missing", `${command} is not on PATH, and weigh reads the shell through it`, "Run it from a terminal in an Omarchy session, with $OMARCHY_PATH/bin on PATH.")
|
|
136
|
+
}
|
|
137
|
+
const omarchyPath = sessionOmarchyPath(env)
|
|
138
|
+
if (!omarchyPath || !existsSync(join(omarchyPath, "shell/shell.qml"))) {
|
|
139
|
+
throw new WeighError("omarchy-path", `OMARCHY_PATH ${omarchyPath ? `(${omarchyPath}) ` : ""}does not point at a shell: no shell/shell.qml under it`, "Log in to an Omarchy session; the shell is read from the session's OMARCHY_PATH.")
|
|
140
|
+
}
|
|
141
|
+
let shellVersion
|
|
142
|
+
try {
|
|
143
|
+
shellVersion = readFileSync(join(omarchyPath, "version"), "utf8").trim()
|
|
144
|
+
} catch {
|
|
145
|
+
shellVersion = ""
|
|
146
|
+
}
|
|
147
|
+
if (!shellVersion) throw new WeighError("no-version", `${join(omarchyPath, "version")} is not readable, and the sentence weigh ends with names the Omarchy version`, "Check the Omarchy install under that path.")
|
|
148
|
+
const shellEnv = { ...env, OMARCHY_PATH: omarchyPath }
|
|
149
|
+
if (!run("ping", { env: shellEnv }).ok) throw new WeighError("shell-not-running", "omarchy-shell shell ping does not answer, and weigh measures a running shell", "omarchy-restart-shell")
|
|
150
|
+
const listing = run("ipcShow", { env: shellEnv, extra: [join(omarchyPath, "shell"), "show"] })
|
|
151
|
+
const ipc = ipcFunctions(listing.ok ? listing.stdout : "")
|
|
152
|
+
const missing = REQUIRED_IPC.filter((name) => !ipc.includes(name))
|
|
153
|
+
if (missing.length) throw new WeighError("ipc-missing", `the shell's IPC target has no ${missing.join(", ")} (from qs ipc show), and weigh relies on ${missing.length === 1 ? "it" : "them"}`, "Weigh on an Omarchy whose shell has these; this one is older or newer than what weigh knows.")
|
|
154
|
+
return { omarchyPath, shellVersion, ipc }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The function names under `target shell` in a `qs ipc show` listing. */
|
|
158
|
+
export function ipcFunctions(listing) {
|
|
159
|
+
const out = []
|
|
160
|
+
let inShell = false
|
|
161
|
+
for (const line of String(listing).split("\n")) {
|
|
162
|
+
const target = line.match(/^target (\S+)/)
|
|
163
|
+
if (target) {
|
|
164
|
+
inShell = target[1] === "shell"
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
const fn = inShell && line.match(/^\s+function ([A-Za-z_]\w*)\(/)
|
|
168
|
+
if (fn) out.push(fn[1])
|
|
169
|
+
}
|
|
170
|
+
return out
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Everything that has to be known before a shell is restarted. Reads only.
|
|
175
|
+
*
|
|
176
|
+
* @param {{ target?: string, all?: boolean, runs?: number, windowSeconds?: number, settleSeconds?: number,
|
|
177
|
+
* out?: string, env?: NodeJS.ProcessEnv, now?: Date }} options
|
|
178
|
+
*/
|
|
179
|
+
export function planWeigh({ target, all = false, runs = DEFAULTS.runs, windowSeconds = DEFAULTS.windowSeconds, settleSeconds = DEFAULTS.settleSeconds, out, env = process.env, now = new Date() } = {}) {
|
|
180
|
+
if (!target && !all) throw new WeighError("usage", "weigh needs a plugin: `omakit weigh <plugin-id-or-dir>`, or `omakit weigh --all` for every enabled third-party plugin")
|
|
181
|
+
const { omarchyPath, shellVersion } = compatibility(env)
|
|
182
|
+
// Every Omarchy command from here on sees the session's OMARCHY_PATH, the
|
|
183
|
+
// way it would from a terminal in that session.
|
|
184
|
+
env = { ...env, OMARCHY_PATH: omarchyPath }
|
|
185
|
+
const locked = run("sessionLocked", { env })
|
|
186
|
+
if (locked.status === 0) throw new WeighError("session-locked", "the session is locked, so the shell is not restarted; this is the same check omarchy-restart-shell makes", "Unlock the session, then run it again.")
|
|
187
|
+
|
|
188
|
+
const listed = run("listPlugins", { env })
|
|
189
|
+
if (!listed.ok) throw new WeighError("shell-unreadable", "listPlugins failed", "omarchy-restart-shell, then run it again.")
|
|
190
|
+
const installed = parseJson(listed.stdout, "listPlugins")
|
|
191
|
+
const effectiveRun = run("listShellConfig", { env })
|
|
192
|
+
if (!effectiveRun.ok) throw new WeighError("shell-unreadable", "listShellConfig failed", "omarchy-restart-shell, then run it again.")
|
|
193
|
+
const effective = parseJson(effectiveRun.stdout, "listShellConfig")
|
|
194
|
+
const catalogRun = run("catalog", { env })
|
|
195
|
+
const catalog = catalogRun.ok ? parseJson(catalogRun.stdout, "omarchy-plugin-catalog") : []
|
|
196
|
+
const sourceDirOf = (id) => catalog.find((entry) => entry.id === id)?.sourceDir || null
|
|
197
|
+
|
|
198
|
+
let audited
|
|
199
|
+
if (all) {
|
|
200
|
+
audited = installed.filter((plugin) => plugin.enabled === true && plugin.firstParty === false && !(plugin.kinds || []).includes("bar"))
|
|
201
|
+
if (!audited.length) throw new WeighError("nothing-to-measure", "no enabled third-party plugin is installed; --all measures every enabled plugin that is not first-party and not a whole bar")
|
|
202
|
+
} else {
|
|
203
|
+
let id = target
|
|
204
|
+
const manifest = join(resolve(target), "manifest.json")
|
|
205
|
+
if (existsSync(manifest)) {
|
|
206
|
+
const declared = parseJson(readFileSync(manifest, "utf8"), manifest).id
|
|
207
|
+
if (!declared) throw new WeighError("plugin-unknown", `${manifest} declares no id`)
|
|
208
|
+
id = declared
|
|
209
|
+
} else if (target.includes("/") || target === "." || target === "..") {
|
|
210
|
+
throw new WeighError("plugin-unknown", `${resolve(target)} has no manifest.json, and ${target} is not an installed plugin id`, "Pass the plugin's id from `omarchy plugin list`, or the directory its manifest.json is in.")
|
|
211
|
+
}
|
|
212
|
+
const plugin = installed.find((entry) => entry.id === id)
|
|
213
|
+
if (!plugin) throw new WeighError("plugin-unknown", `${id} is not an installed plugin; weigh measures a plugin the shell can load`, "omarchy plugin list, then pass one of its ids, or install the plugin first.")
|
|
214
|
+
if ((plugin.kinds || []).includes("bar")) throw new WeighError("plugin-is-bar", `${id} is a whole bar, and replacing the bar is not a weight`)
|
|
215
|
+
if (plugin.enabled !== true) throw new WeighError("plugin-disabled", `${id} is not enabled, so there is no place in the layout to put it back into`, `omarchy plugin enable ${id}, then run it again.`)
|
|
216
|
+
audited = [plugin]
|
|
217
|
+
}
|
|
218
|
+
audited = audited.map((plugin) => ({ id: plugin.id, name: plugin.name || plugin.id, kinds: plugin.kinds || [], firstParty: plugin.firstParty === true, sourceDir: sourceDirOf(plugin.id) }))
|
|
219
|
+
|
|
220
|
+
const { file: configFile } = configPaths(env)
|
|
221
|
+
const stateDir = omakitStateDir("weigh", env)
|
|
222
|
+
const timing = restartTiming(stateDir)
|
|
223
|
+
const restarts = (1 + audited.length) * runs
|
|
224
|
+
const perRestart = timing.seconds + settleSeconds + windowSeconds
|
|
225
|
+
const estimatedMinutes = Math.ceil((restarts * perRestart) / 60)
|
|
226
|
+
const ticks = run("clockTicks", { env })
|
|
227
|
+
const clockTicksPerSecond = ticks.ok && /^\d+$/.test(ticks.stdout.trim()) ? Number(ticks.stdout.trim()) : 100
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
env,
|
|
231
|
+
omarchyPath,
|
|
232
|
+
shellVersion,
|
|
233
|
+
installed,
|
|
234
|
+
effective,
|
|
235
|
+
audited,
|
|
236
|
+
runs,
|
|
237
|
+
windowSeconds,
|
|
238
|
+
settleSeconds,
|
|
239
|
+
readyTimeoutSeconds: DEFAULTS.readyTimeoutSeconds,
|
|
240
|
+
sampleIntervalMs: DEFAULTS.sampleIntervalMs,
|
|
241
|
+
clockTicksPerSecond,
|
|
242
|
+
restarts,
|
|
243
|
+
timing,
|
|
244
|
+
perRestartSeconds: perRestart,
|
|
245
|
+
estimatedMinutes,
|
|
246
|
+
configFile,
|
|
247
|
+
stateDir,
|
|
248
|
+
out: out ? resolve(out) : join(stateDir, `${fileStamp(now)}.json`),
|
|
249
|
+
started: utc(now),
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function sleep(ms, signal) {
|
|
254
|
+
return new Promise((resolveSleep, reject) => {
|
|
255
|
+
if (signal?.aborted) {
|
|
256
|
+
reject(new WeighError("interrupted", "interrupted"))
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
const timer = setTimeout(() => {
|
|
260
|
+
signal?.removeEventListener("abort", onAbort)
|
|
261
|
+
resolveSleep()
|
|
262
|
+
}, ms)
|
|
263
|
+
function onAbort() {
|
|
264
|
+
clearTimeout(timer)
|
|
265
|
+
reject(new WeighError("interrupted", "interrupted"))
|
|
266
|
+
}
|
|
267
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function checkAbort(signal) {
|
|
272
|
+
if (signal?.aborted) throw new WeighError("interrupted", "interrupted")
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Wait until listPlugins reports every installed plugin, polling once a second; null when it does not within the timeout. */
|
|
276
|
+
async function waitReady(expectedCount, { env, timeoutSeconds, signal }) {
|
|
277
|
+
const startedAt = Date.now()
|
|
278
|
+
for (;;) {
|
|
279
|
+
checkAbort(signal)
|
|
280
|
+
const listed = run("listPlugins", { env, timeoutMs: 10_000 })
|
|
281
|
+
if (listed.ok) {
|
|
282
|
+
try {
|
|
283
|
+
if (JSON.parse(listed.stdout).length === expectedCount) return (Date.now() - startedAt) / 1000
|
|
284
|
+
} catch {
|
|
285
|
+
// Not JSON yet: the shell is still coming up.
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if ((Date.now() - startedAt) / 1000 >= timeoutSeconds) return null
|
|
289
|
+
await sleep(1000, signal)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function shellPid(omarchyPath, env) {
|
|
294
|
+
const listed = run("shellPid", { env, extra: [join(omarchyPath, "shell"), "--json"] })
|
|
295
|
+
if (!listed.ok) return null
|
|
296
|
+
try {
|
|
297
|
+
const pid = JSON.parse(listed.stdout)?.[0]?.pid
|
|
298
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null
|
|
299
|
+
} catch {
|
|
300
|
+
return null
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* One configuration, one run: write, restart, wait, settle, sample. Returns
|
|
306
|
+
* the sample, or `{ failed }` with the reason when the shell did not come
|
|
307
|
+
* back or did not report every plugin; nothing is estimated in its place.
|
|
308
|
+
*/
|
|
309
|
+
async function sampleConfig({ label, runIndex, config, plan, env, procRoot, signal, onPhase, restartTimes }) {
|
|
310
|
+
const { configFile, omarchyPath, windowSeconds, settleSeconds, readyTimeoutSeconds, sampleIntervalMs, clockTicksPerSecond: clk } = plan
|
|
311
|
+
const expectedCount = plan.installed.length
|
|
312
|
+
onPhase(`run ${runIndex} of ${plan.runs}: ${label}, restarting the shell`)
|
|
313
|
+
writeConfig(configFile, config)
|
|
314
|
+
const restartStarted = Date.now()
|
|
315
|
+
const restart = run("restartShell", { env, timeoutMs: 120_000 })
|
|
316
|
+
if (!restart.ok) return { label, run: runIndex, failed: "the shell did not answer after the restart" }
|
|
317
|
+
const readyAfterSeconds = await waitReady(expectedCount, { env, timeoutSeconds: readyTimeoutSeconds, signal })
|
|
318
|
+
if (readyAfterSeconds === null) return { label, run: runIndex, failed: `listPlugins did not reach ${expectedCount} plugins in ${readyTimeoutSeconds}s` }
|
|
319
|
+
restartTimes.push((Date.now() - restartStarted) / 1000)
|
|
320
|
+
onPhase(`run ${runIndex} of ${plan.runs}: ${label}, settling for ${settleSeconds}s`)
|
|
321
|
+
await sleep(settleSeconds * 1000, signal)
|
|
322
|
+
const pid = shellPid(omarchyPath, env)
|
|
323
|
+
if (pid === null) return { label, run: runIndex, failed: "no shell pid" }
|
|
324
|
+
|
|
325
|
+
// Memory at the settle, kept as raw data, and a trace through the window:
|
|
326
|
+
// the shell releases 55 to 65 MB at a variable moment after loading
|
|
327
|
+
// (docs/MEASUREMENTS.md C1), and the trace is how a reader sees whether
|
|
328
|
+
// this machine's settle was long enough. The headline is read at the end
|
|
329
|
+
// of the window, the latest point of the run.
|
|
330
|
+
const settled = { pssKbSettled: pssKb(procRoot, pid), rssKbSettled: rssKb(procRoot, pid) }
|
|
331
|
+
onPhase(`run ${runIndex} of ${plan.runs}: ${label}, sampling for ${windowSeconds}s`)
|
|
332
|
+
const started = utc()
|
|
333
|
+
const t0 = Date.now()
|
|
334
|
+
const cpu0 = cpuTicks(procRoot, pid) ?? 0
|
|
335
|
+
const child0 = childTicks(procRoot, pid) ?? 0
|
|
336
|
+
const rows = []
|
|
337
|
+
const trace = []
|
|
338
|
+
let elapsed = 0
|
|
339
|
+
for (;;) {
|
|
340
|
+
trace.push({ t: Number(elapsed.toFixed(3)), pssKb: pssKb(procRoot, pid), rssKb: rssKb(procRoot, pid) })
|
|
341
|
+
for (const entry of descendants(procRoot, pid)) rows.push({ t: elapsed, ...entry })
|
|
342
|
+
elapsed = (Date.now() - t0) / 1000
|
|
343
|
+
if (elapsed >= windowSeconds) break
|
|
344
|
+
await sleep(Math.min(sampleIntervalMs, Math.max(1, (windowSeconds - elapsed) * 1000)), signal)
|
|
345
|
+
elapsed = (Date.now() - t0) / 1000
|
|
346
|
+
}
|
|
347
|
+
const t1 = Date.now()
|
|
348
|
+
// Was the configuration this run started from still on disk at the end
|
|
349
|
+
// of the window? A plugin that rewrites shell.json as it starts makes the
|
|
350
|
+
// bar rebuild every widget (docs/WEIGH.md, Limits), which is one of the
|
|
351
|
+
// hypotheses for the shell's high resting level (docs/MEASUREMENTS.md C2).
|
|
352
|
+
let configRewritten = null
|
|
353
|
+
try {
|
|
354
|
+
configRewritten = md5(readFileSync(configFile)) !== md5(Buffer.from(`${JSON.stringify(config, null, 2)}\n`))
|
|
355
|
+
} catch {
|
|
356
|
+
configRewritten = null
|
|
357
|
+
}
|
|
358
|
+
const cpu1 = cpuTicks(procRoot, pid) ?? cpu0
|
|
359
|
+
const child1 = childTicks(procRoot, pid) ?? child0
|
|
360
|
+
const memory = { pssKb: pssKb(procRoot, pid), rssKb: rssKb(procRoot, pid), memoryAt: "window-end", ...settled, trace }
|
|
361
|
+
const seconds = (t1 - t0) / 1000
|
|
362
|
+
const byPid = new Map()
|
|
363
|
+
for (const row of rows) {
|
|
364
|
+
const child = byPid.get(row.pid)
|
|
365
|
+
if (!child) {
|
|
366
|
+
byPid.set(row.pid, { pid: row.pid, comm: row.comm, arg0: row.arg0, key: row.key, firstSeen: row.t, lastSeen: row.t, cpuFirst: row.cpu, cpuLast: row.cpu, rssLast: row.rss, samples: 1 })
|
|
367
|
+
} else {
|
|
368
|
+
child.lastSeen = row.t
|
|
369
|
+
child.cpuLast = row.cpu
|
|
370
|
+
child.rssLast = row.rss
|
|
371
|
+
child.samples += 1
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
label,
|
|
376
|
+
run: runIndex,
|
|
377
|
+
shellPid: pid,
|
|
378
|
+
started,
|
|
379
|
+
ended: utc(),
|
|
380
|
+
readyAfterSeconds,
|
|
381
|
+
windowSeconds: Number(seconds.toFixed(3)),
|
|
382
|
+
configRewritten,
|
|
383
|
+
shell: {
|
|
384
|
+
...memory,
|
|
385
|
+
cpuTicksStart: cpu0,
|
|
386
|
+
cpuTicksEnd: cpu1,
|
|
387
|
+
cpuSeconds: (cpu1 - cpu0) / clk,
|
|
388
|
+
cpuPercent: ((cpu1 - cpu0) / clk / seconds) * 100,
|
|
389
|
+
reapedChildTicksStart: child0,
|
|
390
|
+
reapedChildTicksEnd: child1,
|
|
391
|
+
reapedChildCpuSeconds: (child1 - child0) / clk,
|
|
392
|
+
reapedChildCpuPercent: ((child1 - child0) / clk / seconds) * 100,
|
|
393
|
+
},
|
|
394
|
+
children: [...byPid.values()].map((child) => ({ ...child, firstSeen: Number(child.firstSeen.toFixed(3)), lastSeen: Number(child.lastSeen.toFixed(3)) })),
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const KB = 1024
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The sentence for a plugin's README. It speaks about CPU and about child
|
|
402
|
+
* processes, and never about the shell's memory: until the shell's two
|
|
403
|
+
* resting levels are understood (docs/MEASUREMENTS.md C1 and C2), a memory
|
|
404
|
+
* delta is a fact about the shell's start, not the plugin's weight, and a
|
|
405
|
+
* sentence that said "under N MB" would be a claim about the wrong thing.
|
|
406
|
+
* Null when no run completed or when one run gave no floor.
|
|
407
|
+
*/
|
|
408
|
+
export function readmeSentence({ cpuVerdict, shellCpuPercent, floorCpu, childSpawns, childMb, childCpuPercent, shellVersion, date }) {
|
|
409
|
+
if (cpuVerdict === "unknown") return null
|
|
410
|
+
const floor = `(${floorCpu === null ? "?" : floorCpu.toFixed(2)}%)`
|
|
411
|
+
const spawns = Math.round(childSpawns ?? 0)
|
|
412
|
+
const tail = `on Omarchy ${shellVersion}, measured with omakit weigh on ${date}`
|
|
413
|
+
if (cpuVerdict === "within-noise" && spawns === 0) return `Weighs nothing measurable: no CPU above the floor ${floor} and no child process, ${tail}`
|
|
414
|
+
const cpu = cpuVerdict === "above-noise" ? `${shellCpuPercent.toFixed(1)}% CPU` : `no CPU above the floor ${floor}`
|
|
415
|
+
const children = spawns === 0
|
|
416
|
+
? "runs no child process"
|
|
417
|
+
: `runs ${spawns} child process${spawns === 1 ? "" : "es"} using ${(childMb ?? 0).toFixed(1)} MB and ${Math.max(0, childCpuPercent ?? 0).toFixed(1)}% CPU`
|
|
418
|
+
return `Weighs ${cpu} and ${children}, ${tail}`
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The row's one-line verdict, the same words the report prints and
|
|
423
|
+
* `verdict.summary` carries. About CPU only: the memory verdict stays in
|
|
424
|
+
* the document as `verdict.memory`, but a sentence about the plugin does
|
|
425
|
+
* not carry it (see readmeSentence).
|
|
426
|
+
*/
|
|
427
|
+
export function summaryOf(memoryVerdict, cpuVerdict, { completed = null, baselineRuns = null } = {}) {
|
|
428
|
+
if (cpuVerdict === "unknown" || memoryVerdict === "unknown") {
|
|
429
|
+
if (completed === 0) return "no completed run, so nothing is claimed"
|
|
430
|
+
if (baselineRuns !== null && baselineRuns < 2) return "one run, no spread: no floor to judge against (--runs 3 gives one)"
|
|
431
|
+
return "no completed run, so nothing is claimed"
|
|
432
|
+
}
|
|
433
|
+
return cpuVerdict === "above-noise" ? "above noise on CPU" : "no measurable CPU"
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The samples of every run, into the document docs/WEIGH.md describes. */
|
|
437
|
+
export function buildDocument(plan, samples, { started, ended, config, host = hostname(), omakitVersion }) {
|
|
438
|
+
const clk = plan.clockTicksPerSecond
|
|
439
|
+
const completed = samples.filter((sample) => !sample.failed)
|
|
440
|
+
const failed = samples.filter((sample) => sample.failed).map(({ label, run: runIndex, failed: reason }) => ({ label, run: runIndex, reason }))
|
|
441
|
+
const strip = (sample) => ({ ...sample, children: sample.children.map(({ key, ...child }) => child) })
|
|
442
|
+
const base = completed.filter((sample) => sample.label === "baseline").sort((a, b) => a.run - b.run)
|
|
443
|
+
const baseKeys = new Set(base.flatMap((sample) => sample.children.map((child) => child.key)))
|
|
444
|
+
const baseline = {
|
|
445
|
+
config: plan.baselineConfig,
|
|
446
|
+
pssMb: stats(base.map((sample) => (sample.shell.pssKb ?? 0) / KB)),
|
|
447
|
+
rssMb: stats(base.map((sample) => (sample.shell.rssKb ?? 0) / KB)),
|
|
448
|
+
pssMbSettled: stats(base.map((sample) => (sample.shell.pssKbSettled ?? 0) / KB)),
|
|
449
|
+
rssMbSettled: stats(base.map((sample) => (sample.shell.rssKbSettled ?? 0) / KB)),
|
|
450
|
+
cpuPercent: stats(base.map((sample) => sample.shell.cpuPercent)),
|
|
451
|
+
childRssMb: stats(base.map((sample) => sample.children.reduce((sum, child) => sum + child.rssLast, 0) / KB)),
|
|
452
|
+
runs: base.map(strip),
|
|
453
|
+
}
|
|
454
|
+
// One baseline run has no spread, so there is no floor: every verdict is
|
|
455
|
+
// then unknown with that reason, and no README sentence is produced.
|
|
456
|
+
// --runs 1 is the quick look the confirmation offers; it says so.
|
|
457
|
+
const floorOf = (figure) => (base.length >= 2 ? figure.spread : null)
|
|
458
|
+
const noiseFloor = {
|
|
459
|
+
pssMb: floorOf(baseline.pssMb),
|
|
460
|
+
rssMb: floorOf(baseline.rssMb),
|
|
461
|
+
pssMbSettled: floorOf(baseline.pssMbSettled),
|
|
462
|
+
rssMbSettled: floorOf(baseline.rssMbSettled),
|
|
463
|
+
cpuPercent: floorOf(baseline.cpuPercent),
|
|
464
|
+
origin: base.length >= 2
|
|
465
|
+
? `the spread (max minus min) of the ${base.length} baseline runs: Pss and VmRSS at the end of the window (the headline), the same two at the settle, and CPU percent over the window`
|
|
466
|
+
: `none: ${base.length === 1 ? "one baseline run has no spread" : "no baseline run completed"}`,
|
|
467
|
+
}
|
|
468
|
+
const date = started.slice(0, 10)
|
|
469
|
+
const plugins = plan.audited.map((plugin) => {
|
|
470
|
+
const plus = completed.filter((sample) => sample.label === plugin.id).sort((a, b) => a.run - b.run)
|
|
471
|
+
const deltas = []
|
|
472
|
+
for (const sample of plus) {
|
|
473
|
+
const pair = base.find((candidate) => candidate.run === sample.run)
|
|
474
|
+
if (!pair) continue
|
|
475
|
+
const own = sample.children.filter((child) => !baseKeys.has(child.key))
|
|
476
|
+
// A difference in count is never lost. A child whose command line the
|
|
477
|
+
// baseline also runs is not attributed, but when the with-plugin
|
|
478
|
+
// restart has more of them than the paired baseline restart, the
|
|
479
|
+
// extra ones are reported as unattributed with their commands, so a
|
|
480
|
+
// helper the shell also spawns on its own (measured on a desktop:
|
|
481
|
+
// four sidecarctl against two, a difference of two) is seen.
|
|
482
|
+
const counts = new Map()
|
|
483
|
+
for (const child of pair.children) counts.set(child.key, (counts.get(child.key) || 0) + 1)
|
|
484
|
+
const unattributed = []
|
|
485
|
+
for (const child of sample.children) {
|
|
486
|
+
if (!baseKeys.has(child.key)) continue
|
|
487
|
+
const left = counts.get(child.key) || 0
|
|
488
|
+
if (left > 0) counts.set(child.key, left - 1)
|
|
489
|
+
else unattributed.push({ comm: child.comm, arg0: child.arg0 })
|
|
490
|
+
}
|
|
491
|
+
const ownCpuSeconds = own.reduce((sum, child) => sum + (child.cpuLast - child.cpuFirst), 0) / clk
|
|
492
|
+
const reaped = sample.shell.reapedChildCpuPercent - pair.shell.reapedChildCpuPercent
|
|
493
|
+
deltas.push({
|
|
494
|
+
run: sample.run,
|
|
495
|
+
shellPssMb: ((sample.shell.pssKb ?? 0) - (pair.shell.pssKb ?? 0)) / KB,
|
|
496
|
+
shellRssMb: ((sample.shell.rssKb ?? 0) - (pair.shell.rssKb ?? 0)) / KB,
|
|
497
|
+
shellPssMbSettled: ((sample.shell.pssKbSettled ?? 0) - (pair.shell.pssKbSettled ?? 0)) / KB,
|
|
498
|
+
shellCpuPercent: sample.shell.cpuPercent - pair.shell.cpuPercent,
|
|
499
|
+
childRssMb: own.reduce((sum, child) => sum + child.rssLast, 0) / KB,
|
|
500
|
+
childCpuPercent: (ownCpuSeconds / sample.windowSeconds) * 100 + reaped,
|
|
501
|
+
reapedChildCpuPercent: reaped,
|
|
502
|
+
childSpawns: own.length,
|
|
503
|
+
children: own.map((child) => ({ comm: child.comm, arg0: child.arg0, rssKb: child.rssLast, cpuSeconds: (child.cpuLast - child.cpuFirst) / clk, firstSeen: child.firstSeen, lastSeen: child.lastSeen })),
|
|
504
|
+
unattributedChildren: unattributed,
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
const pss = stats(deltas.map((delta) => delta.shellPssMb))
|
|
508
|
+
const rss = stats(deltas.map((delta) => delta.shellRssMb))
|
|
509
|
+
const pssSettled = stats(deltas.map((delta) => delta.shellPssMbSettled))
|
|
510
|
+
const cpu = stats(deltas.map((delta) => delta.shellCpuPercent))
|
|
511
|
+
const childRss = stats(deltas.map((delta) => delta.childRssMb))
|
|
512
|
+
const childCpu = stats(deltas.map((delta) => delta.childCpuPercent))
|
|
513
|
+
const tick = tickPercent(clk, plan.windowSeconds)
|
|
514
|
+
const memoryVerdict = verdict(pss.median, noiseFloor.pssMb)
|
|
515
|
+
const cpuVerdict = verdict(cpu.median, noiseFloor.cpuPercent, tick)
|
|
516
|
+
const totalMb = pss.median === null ? null : pss.median + (childRss.median ?? 0)
|
|
517
|
+
const totalCpuPercent = cpu.median === null ? null : cpu.median + (childCpu.median ?? 0)
|
|
518
|
+
return {
|
|
519
|
+
id: plugin.id,
|
|
520
|
+
name: plugin.name,
|
|
521
|
+
kinds: plugin.kinds,
|
|
522
|
+
firstParty: plugin.firstParty,
|
|
523
|
+
sourceDir: plugin.sourceDir,
|
|
524
|
+
runsCompleted: deltas.length,
|
|
525
|
+
shellPssMb: pss,
|
|
526
|
+
shellRssMb: rss,
|
|
527
|
+
shellPssMbSettled: pssSettled,
|
|
528
|
+
shellCpuPercent: cpu,
|
|
529
|
+
childRssMb: childRss,
|
|
530
|
+
childCpuPercent: childCpu,
|
|
531
|
+
childSpawns: stats(deltas.map((delta) => delta.childSpawns)),
|
|
532
|
+
unattributedChildren: stats(deltas.map((delta) => delta.unattributedChildren.length)),
|
|
533
|
+
unattributedCommands: [...new Set(deltas.flatMap((delta) => delta.unattributedChildren.map((child) => `${child.comm} ${child.arg0}`.trim())))].sort(),
|
|
534
|
+
totalMb,
|
|
535
|
+
totalCpuPercent,
|
|
536
|
+
verdict: { memory: memoryVerdict, cpu: cpuVerdict, summary: summaryOf(memoryVerdict, cpuVerdict, { completed: deltas.length, baselineRuns: base.length }) },
|
|
537
|
+
withinNoise: {
|
|
538
|
+
pss: memoryVerdict === "unknown" ? null : memoryVerdict === "within-noise",
|
|
539
|
+
rss: rss.median === null || noiseFloor.rssMb === null ? null : verdict(rss.median, noiseFloor.rssMb) === "within-noise",
|
|
540
|
+
cpu: cpuVerdict === "unknown" ? null : cpuVerdict === "within-noise",
|
|
541
|
+
ownPss: pss.median === null ? null : verdict(pss.median, pss.spread) === "within-noise",
|
|
542
|
+
ownCpu: cpu.median === null ? null : verdict(cpu.median, cpu.spread, tick) === "within-noise",
|
|
543
|
+
baselinePssSpreadMb: noiseFloor.pssMb,
|
|
544
|
+
baselineCpuSpreadPercent: noiseFloor.cpuPercent,
|
|
545
|
+
cpuTickPercent: tick,
|
|
546
|
+
note: "pss, rss and cpu compare the median delta with the baseline spread; ownPss and ownCpu compare it with the spread of the row's own deltas; a CPU delta is above noise only when it also exceeds one clock tick over the window (cpuTickPercent)",
|
|
547
|
+
},
|
|
548
|
+
origin: `plus minus baseline per run over ${deltas.length} run(s): Pss from /proc/<shell pid>/smaps_rollup and VmRSS from /proc/<shell pid>/status at the end of a ${plan.windowSeconds}s window that opens ${plan.settleSeconds}s after listPlugins reports every plugin (the same two at the settle under Settled); utime+stime from /proc/<shell pid>/stat over that window; children from a /proc descendant walk every ${plan.sampleIntervalMs}ms, attributed by a command line absent from every baseline run, plus cutime+cstime of the shell pid`,
|
|
549
|
+
readme: readmeSentence({ cpuVerdict, shellCpuPercent: cpu.median, floorCpu: noiseFloor.cpuPercent, childSpawns: stats(deltas.map((delta) => delta.childSpawns)).median, childMb: childRss.median, childCpuPercent: childCpu.median, shellVersion: plan.shellVersion, date }),
|
|
550
|
+
deltas,
|
|
551
|
+
runs: plus.map(strip),
|
|
552
|
+
}
|
|
553
|
+
}).sort((a, b) => (b.totalMb ?? -Infinity) - (a.totalMb ?? -Infinity))
|
|
554
|
+
return {
|
|
555
|
+
omakit: omakitVersion,
|
|
556
|
+
command: "weigh",
|
|
557
|
+
method: METHOD,
|
|
558
|
+
started,
|
|
559
|
+
ended,
|
|
560
|
+
host,
|
|
561
|
+
shell: { version: plan.shellVersion, omarchyPath: plan.omarchyPath },
|
|
562
|
+
settings: {
|
|
563
|
+
runs: plan.runs,
|
|
564
|
+
windowSeconds: plan.windowSeconds,
|
|
565
|
+
settleSeconds: plan.settleSeconds,
|
|
566
|
+
readyTimeoutSeconds: plan.readyTimeoutSeconds,
|
|
567
|
+
sampleIntervalMs: plan.sampleIntervalMs,
|
|
568
|
+
clockTicksPerSecond: clk,
|
|
569
|
+
},
|
|
570
|
+
config,
|
|
571
|
+
audited: plan.audited.map((plugin) => plugin.id),
|
|
572
|
+
failedRuns: failed,
|
|
573
|
+
baseline,
|
|
574
|
+
noiseFloor,
|
|
575
|
+
plugins,
|
|
576
|
+
out: plan.out,
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* The half that changes the machine. Every exit path restores `shell.json`
|
|
582
|
+
* from the backup taken here and restarts the shell once more so it runs
|
|
583
|
+
* the user's own configuration; the document records the md5 before and
|
|
584
|
+
* after and whether they matched.
|
|
585
|
+
*
|
|
586
|
+
* @param {ReturnType<typeof planWeigh>} plan
|
|
587
|
+
* @param {{ env?: NodeJS.ProcessEnv, procRoot?: string, signal?: AbortSignal, omakitVersion?: string,
|
|
588
|
+
* onPhase?: (text: string) => void, onLine?: (line: { state: string, text: string }) => void }} [options]
|
|
589
|
+
*/
|
|
590
|
+
export async function measureWeigh(plan, { env = plan.env || process.env, procRoot = PROC, signal, omakitVersion = "unknown", onPhase = () => {}, onLine = () => {} } = {}) {
|
|
591
|
+
const stamp = fileStamp().replace(/[-T]/g, "").replace(/Z$/, "")
|
|
592
|
+
const ids = plan.audited.map((plugin) => plugin.id)
|
|
593
|
+
plan.baselineConfig = without(plan.effective, ids, plan.installed)
|
|
594
|
+
const configs = [{ label: "baseline", config: plan.baselineConfig }]
|
|
595
|
+
for (const plugin of plan.audited) {
|
|
596
|
+
configs.push({ label: plugin.id, config: without(plan.effective, ids.filter((id) => id !== plugin.id), plan.installed) })
|
|
597
|
+
}
|
|
598
|
+
mkdirSync(dirname(plan.configFile), { recursive: true })
|
|
599
|
+
const backup = backupConfig(plan.configFile, stamp)
|
|
600
|
+
onLine({ state: "info", text: backup.bytes === null
|
|
601
|
+
? `${plan.configFile} does not exist; it will be removed again afterwards`
|
|
602
|
+
: `${plan.configFile} backed up to ${backup.backupFile}, md5 ${backup.md5Before}` })
|
|
603
|
+
const samples = []
|
|
604
|
+
const restartTimes = []
|
|
605
|
+
let restore = null
|
|
606
|
+
let restoreProblem = null
|
|
607
|
+
let comeBack = null
|
|
608
|
+
try {
|
|
609
|
+
for (let runIndex = 1; runIndex <= plan.runs; runIndex += 1) {
|
|
610
|
+
for (const { label, config } of configs) {
|
|
611
|
+
checkAbort(signal)
|
|
612
|
+
const sample = await sampleConfig({ label, runIndex, config, plan, env, procRoot, signal, onPhase, restartTimes })
|
|
613
|
+
// One line per configuration, on the record: a forty-restart run
|
|
614
|
+
// in a pipe would otherwise be silent for half an hour, and the
|
|
615
|
+
// figures here are the raw samples a reader can check the medians
|
|
616
|
+
// against.
|
|
617
|
+
if (sample.failed) onLine({ state: "advisory", text: `run ${runIndex} of ${plan.runs}, ${label}: ${sample.failed}; no sample` })
|
|
618
|
+
else onLine({ state: "info", text: `run ${runIndex} of ${plan.runs}, ${label}: ${((sample.shell.pssKb ?? 0) / KB).toFixed(1)} MB Pss, ${sample.shell.cpuPercent.toFixed(2)}% CPU, ${sample.children.length} child process${sample.children.length === 1 ? "" : "es"}, ready after ${sample.readyAfterSeconds.toFixed(1)} s` })
|
|
619
|
+
samples.push(sample)
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
} finally {
|
|
623
|
+
onPhase("restoring shell.json and restarting the shell")
|
|
624
|
+
try {
|
|
625
|
+
// Bytes back, the shell restarted on them, and only then the md5: a
|
|
626
|
+
// shell that rewrote the file as it started would pass a check made
|
|
627
|
+
// before the restart and fail the one made after, and the second is
|
|
628
|
+
// the one that describes the file the user is left with.
|
|
629
|
+
restoreConfig(backup)
|
|
630
|
+
comeBack = run("restartShell", { env, timeoutMs: 120_000 })
|
|
631
|
+
if (!comeBack.ok) onLine({ state: "advisory", text: "the shell did not answer after the restore; run omarchy-restart-shell" })
|
|
632
|
+
restore = verifyRestore(backup)
|
|
633
|
+
if (restore.restored) {
|
|
634
|
+
onLine({ state: "pass", text: backup.bytes === null
|
|
635
|
+
? `${plan.configFile} removed again, as it was`
|
|
636
|
+
: `${plan.configFile} restored and verified, md5 ${restore.md5After} (before: ${backup.md5Before}); the backup is removed` })
|
|
637
|
+
} else {
|
|
638
|
+
onLine({ state: "fail", text: `${plan.configFile} differs from the backup after the restore (md5 ${restore.md5After}, before ${backup.md5Before}); the backup ${backup.backupFile} is kept` })
|
|
639
|
+
}
|
|
640
|
+
} catch (error) {
|
|
641
|
+
restoreProblem = error
|
|
642
|
+
onLine({ state: "fail", text: `the restore failed: ${error.message}; the backup is ${backup.backupFile}` })
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (restoreProblem) throw new WeighError("restore-failed", `shell.json could not be restored from ${backup.backupFile}: ${restoreProblem.message}`, `Copy ${backup.backupFile} over ${plan.configFile} yourself, then run omarchy-restart-shell.`)
|
|
646
|
+
const ended = utc()
|
|
647
|
+
const config = {
|
|
648
|
+
path: plan.configFile,
|
|
649
|
+
backup: backup.backupFile,
|
|
650
|
+
md5Before: backup.md5Before,
|
|
651
|
+
md5After: restore.md5After,
|
|
652
|
+
restored: restore.restored,
|
|
653
|
+
shellAnsweredAfterRestore: comeBack ? comeBack.ok : null,
|
|
654
|
+
}
|
|
655
|
+
const document = buildDocument(plan, samples, { started: plan.started, ended, config, omakitVersion })
|
|
656
|
+
mkdirSync(dirname(plan.out), { recursive: true })
|
|
657
|
+
const out = plan.out
|
|
658
|
+
writeFileSync(resolve(out), `${JSON.stringify(document, null, 2)}\n`)
|
|
659
|
+
if (restartTimes.length) {
|
|
660
|
+
mkdirSync(plan.stateDir, { recursive: true })
|
|
661
|
+
const timingFile = plan.timing.file
|
|
662
|
+
writeFileSync(timingFile, `${JSON.stringify({ restartSeconds: Number(median(restartTimes).toFixed(1)), restarts: restartTimes.length, measuredAt: ended }, null, 2)}\n`)
|
|
663
|
+
}
|
|
664
|
+
if (!restore.restored) throw new WeighError("restore-unverified", `shell.json differs from the backup after the restore; the backup ${backup.backupFile} is kept and the document is at ${out}`, `Compare ${backup.backupFile} with ${plan.configFile} and copy it over if the difference is not yours.`)
|
|
665
|
+
return document
|
|
666
|
+
}
|