johns-harness 2026.9.28 → 2026.9.29
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/CHANGELOG.md +4 -0
- package/README.md +2 -2
- package/dist/launchd-lifecycle.js +18 -0
- package/docs/PATCHES.md +12 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026.9.29
|
|
4
|
+
|
|
5
|
+
- Family 76.1: the registration-safe reload worker waits for `launchctl bootout` teardown to finish before bootstrapping the staged plist. A gateway that shuts down gracefully on SIGTERM stays registered for a moment after `bootout`; 2026.9.28 mistook that lingering registration for the replacement, skipped `bootstrap`, and reported `Service removed while waiting for health` with the job left unloaded (observed on the first live fleet reload). The wait is bounded at 30 seconds and a timeout is a retryable worker error, not an advanced checkpoint. Ordinary `kickstart -k` restarts were unaffected. The real macOS self-restart fixture now exits gracefully so the reload cases reproduce the race; two unit cases cover the wait and the timeout.
|
|
6
|
+
|
|
3
7
|
## 2026.9.28
|
|
4
8
|
|
|
5
9
|
- Family 76: `johnness gateway restart` from inside a gateway's own launchd process group no longer unregisters its supervisor. The old `bootout -> wait -> bootstrap` sequence killed the caller before it could bootstrap, which caused a multi-hour outage when a cron issued the restart. All three service implementations, both internal restart bundles and both updater bundles now share `dist/launchd-lifecycle.js`: a loaded service restarts with `kickstart -k` only (no bootout, unload or bootstrap fallback), a missing service may bootstrap its explicitly selected plist, and real plist replacement (`gateway install --force`) stages validated bytes and hands off to an independently registered, durably armed completer with an fsynced phase journal, retry under launchd, and prior-plist restore after five failed bootstraps. Explicit stop/uninstall cancels any pending completer. Updaters snapshot the helper before npm replaces the installed tree. Success requires a changed launchd-owned PID and, when a port is declared, `/healthz` plus listener ownership by that PID; receipts live in `<stateDir>/lifecycle/<uuid>/`. Ten browser timeout messages now suggest a non-browser alternative instead of a whole-gateway restart. Covered by lifecycle unit, fault-injection, replay and packed-artifact tests plus five real disposable LaunchAgent self-restart/reload cases (`JOHNNESS_TEST_LAUNCHD=1`); verifier check 76.1.
|
package/README.md
CHANGED
|
@@ -85,7 +85,7 @@ node johnness.mjs --help
|
|
|
85
85
|
### Verify
|
|
86
86
|
|
|
87
87
|
```bash
|
|
88
|
-
npm test #
|
|
88
|
+
npm test # 992 tests against the installed dependency tree
|
|
89
89
|
bash scripts/verify-patches.sh # 468 checks across every patch family
|
|
90
90
|
```
|
|
91
91
|
|
|
@@ -199,7 +199,7 @@ The log is a [`RULES.md`](RULES.md) workspace convention enforced through the ac
|
|
|
199
199
|
|
|
200
200
|
## Patch families
|
|
201
201
|
|
|
202
|
-
The runtime carries 76 patch families plus the 65.1 safe-update amendment. Each is a production fix applied at the source with a regression test, a durable patch marker, and an explicit recovery path. A verifier runs 468 checks against the package tree and installed dependencies, and
|
|
202
|
+
The runtime carries 76 patch families plus the 65.1 safe-update amendment. Each is a production fix applied at the source with a regression test, a durable patch marker, and an explicit recovery path. A verifier runs 468 checks against the package tree and installed dependencies, and 992 tests run against the installed dependency tree.
|
|
203
203
|
|
|
204
204
|
Every family follows the same six steps: reproduce the failure, trace the exact runtime path, make the smallest source-level change that restores the invariant, add a regression test and a patch marker, run the verifier, and retain rollback artifacts. The full index: [`docs/PATCHES.md`](docs/PATCHES.md).
|
|
205
205
|
|
|
@@ -169,6 +169,17 @@ async function healthy(p, current, deps) {
|
|
|
169
169
|
if (!pids.length || !pids.every((pid) => descendant(pid, current.pid))) return false;
|
|
170
170
|
try { return (await fetch(`http://127.0.0.1:${p.port}/healthz`, { signal: AbortSignal.timeout(2000) })).ok; } catch { return false; }
|
|
171
171
|
}
|
|
172
|
+
/** After bootout, launchd finishes SIGTERM teardown asynchronously; only the previous pid can
|
|
173
|
+
* still be registered here. A different registered pid means the plist was already re-bootstrapped. */
|
|
174
|
+
async function unloaded(run, p, deps) {
|
|
175
|
+
const deadline = Date.now() + (deps.unloadTimeoutMs ?? 30000);
|
|
176
|
+
for (;;) {
|
|
177
|
+
const current = runtime(run, p.target);
|
|
178
|
+
if (!current.loaded || (current.pid && p.previousPid && current.pid !== p.previousPid)) return current;
|
|
179
|
+
if (Date.now() >= deadline) throw new Error("Previous process still registered after bootout; independent worker will retry");
|
|
180
|
+
await (deps.pause || pause)(200);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
172
183
|
/** Restartable state machine. Checkpoints precede effects; real supervisor state
|
|
173
184
|
* reconciles a missing effect acknowledgement after SIGKILL. Exported for fault tests. */
|
|
174
185
|
export async function executeLaunchdOperation(p, receiptFile, deps = {}) {
|
|
@@ -195,6 +206,11 @@ export async function executeLaunchdOperation(p, receiptFile, deps = {}) {
|
|
|
195
206
|
const r = effect(["bootout", p.target]);
|
|
196
207
|
if (r.code && !missing(r)) throw new Error(`launchctl bootout failed (${r.code})`);
|
|
197
208
|
}
|
|
209
|
+
// bootout is asynchronous for a job that handles SIGTERM: launchd keeps it registered (old pid)
|
|
210
|
+
// until graceful shutdown completes. Wait for the teardown before recording the intent to
|
|
211
|
+
// bootstrap, or the still-registered job would be mistaken for the replacement and later
|
|
212
|
+
// vanish, producing "Service removed while waiting for health" with no gateway loaded.
|
|
213
|
+
await unloaded(run, p, deps);
|
|
198
214
|
save("bootstrap-intent"); receipt = read(receiptFile);
|
|
199
215
|
current = runtime(run, p.target);
|
|
200
216
|
}
|
|
@@ -220,6 +236,8 @@ export async function executeLaunchdOperation(p, receiptFile, deps = {}) {
|
|
|
220
236
|
save("health-pending", { rolledBack: true, healthStartedAt: Date.now() }); receipt = read(receiptFile);
|
|
221
237
|
}
|
|
222
238
|
if (receipt.status === "bootstrap-intent") {
|
|
239
|
+
// Resume after a crash between bootout and this checkpoint: the old job may still be tearing down.
|
|
240
|
+
if (p.mode === "reload") await unloaded(run, p, deps);
|
|
223
241
|
current = runtime(run, p.target);
|
|
224
242
|
if (!current.loaded) {
|
|
225
243
|
const r = effect(["bootstrap", p.domain, p.plistPath]);
|
package/docs/PATCHES.md
CHANGED
|
@@ -2741,6 +2741,18 @@ self-restart can disappear; the independent receipt is the source of truth.
|
|
|
2741
2741
|
Ten browser error copies now report a browser blocker and suggest a non-browser
|
|
2742
2742
|
alternative, never a whole-gateway restart. Exec process-group behavior is unchanged.
|
|
2743
2743
|
|
|
2744
|
+
**76.1 (2026.9.29), graceful unload before bootstrap.** The first live reload of a real
|
|
2745
|
+
gateway (Trisha, `gateway install --force` semantics) failed with `Service removed while
|
|
2746
|
+
waiting for health` and left the job unloaded. `launchctl bootout` returns before a job
|
|
2747
|
+
that handles SIGTERM has exited; the worker's next `print` still saw the old registered
|
|
2748
|
+
PID, treated the job as loaded, skipped `bootstrap`, and then watched launchd finish the
|
|
2749
|
+
teardown. The worker now waits after `bootout` (and again on resume at `bootstrap-intent`)
|
|
2750
|
+
until the target is unregistered or a different PID is registered, with a 30 s bound that
|
|
2751
|
+
surfaces as a retryable worker error instead of an advanced checkpoint. The real macOS
|
|
2752
|
+
fixture now performs a 700 ms graceful SIGTERM shutdown so the reload cases exercise the
|
|
2753
|
+
race; two unit cases cover the wait and the timeout. `kickstart -k` restarts were never
|
|
2754
|
+
affected.
|
|
2755
|
+
|
|
2744
2756
|
**Verification:** `tests/launchd-safe-restart.test.mjs` covers lifecycle phases,
|
|
2745
2757
|
fault injection, error classification, scoping, all copies and exact patch replay.
|
|
2746
2758
|
`JOHNNESS_TEST_LAUNCHD=1 node --test --test-concurrency=1 tests/launchd-self-restart.macos.test.mjs`
|