dsh-home-hosted 0.7.5 → 0.7.6

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/docs/DESIGN.md CHANGED
@@ -38,7 +38,7 @@ its page.
38
38
  `onPortConflict: kill` arrived in home-hosted 0.6.0 and `persistent` in 0.6.3, and an older panel
39
39
  handed either key can refuse to *boot* from the config. The plugin used to consult the answering
40
40
  panel's version and refuse or drop them (`KILL_UNSUPPORTED`). That is gone on purpose: it pins
41
- `home-hosted@^0.7.19` and autostarts its own copy, so a panel older than those keys is only reachable by
41
+ `home-hosted@^0.7.20` and autostarts its own copy, so a panel older than those keys is only reachable by
42
42
  deliberately preferring an old global install — and this is pre-1.0. Supporting older panels again means
43
43
  bringing that check back from history, not re-deriving it.
44
44
 
package/docs/PANEL.md CHANGED
@@ -38,7 +38,16 @@ a `null` at all — exactly the two whose schema is `… | null` — while the p
38
38
  drift was real but latent; it is fixed because a copy of the panel's function is a fork of its
39
39
  semantics, and the next field to allow `null` would have made it reachable.
40
40
 
41
- Verified against a real 0.7.19 panel and its own CLI: `PATCH {port:null}` deletes the key; only `port`
41
+ **0.7.20 is one fix, and it found a live bug here.** It teaches that a pid is not an identity: the OS
42
+ recycles pids and a zombie still answers signal 0, so the panel's `up`/`status`/`down` now decide with
43
+ the pid *plus* `run.json`'s `startedAt`, and `down` refuses to signal a pid it cannot prove is the
44
+ panel. This plugin's two generated hand-over helpers had re-derived the weak predicate and escalated
45
+ `SIGTERM`→`SIGKILL` on "the pid answers" — **measured** by running the generated script against a live
46
+ `sleep` holding the recorded pid, and watching the stranger's own `SIGTERM` handler fire. A takeover
47
+ could therefore kill an unrelated process. Both helpers now leave every signal to the CLI's own `down`,
48
+ which is the only component that can prove identity; what they kept is a wait, never a signal.
49
+
50
+ Verified against a real 0.7.20 panel and its own CLI: `PATCH {port:null}` deletes the key; only `port`
42
51
  and `bootstrap` have a schema that admits `null`; `_hh` is still exactly four routes; and every route,
43
52
  subcommand and flag this plugin drives is unchanged. The console **file** — `.hh/.logs/home-hosted.log`
44
53
  plus its `.1`, and the 5 MB rotation — is unchanged too, and this plugin's reader still matches
package/lib/index.js CHANGED
@@ -2863,7 +2863,7 @@ import { createRequire } from "node:module";
2863
2863
  import fs11 from "node:fs";
2864
2864
  import path9 from "node:path";
2865
2865
  import process5 from "node:process";
2866
- var EXPECTED_RANGE = "^0.7.19";
2866
+ var EXPECTED_RANGE = "^0.7.20";
2867
2867
  var MIN_SUPPORTED_VERSION = "0.7.0";
2868
2868
  function pinnedManifestPath() {
2869
2869
  try {
@@ -4229,18 +4229,27 @@ log('stopping the answering panel: ' + STOP.join(' '))
4229
4229
  const stopped = spawnSync(STOP[0], STOP.slice(1), { stdio: 'ignore', timeout: 90000 })
4230
4230
  log('down exited ' + String(stopped.status ?? stopped.error))
4231
4231
 
4232
- if (alive(OLD_PID)) {
4233
- log('the panel is still alive; sending SIGTERM')
4234
- try { process.kill(OLD_PID, 'SIGTERM') } catch {}
4232
+ /**
4233
+ * Never signal OLD_PID from here.
4234
+ *
4235
+ * The CLI's own 'down' is the only thing that may stop the panel, and it already escalates
4236
+ * (SIGTERM, then SIGKILL) for a panel it can prove is alive. A pid is not an identity: the OS
4237
+ * recycles pids, so the number in run.json may belong to an unrelated process, and this helper
4238
+ * has no birth time to tell them apart. Signalling on "the pid answers" is how a takeover
4239
+ * SIGTERMs a stranger \u2014 measured, before this guard existed.
4240
+ *
4241
+ * 'down' succeeds on both real cases, so there is usually nothing to wait for: it returns once
4242
+ * the panel exited, and since home-hosted 0.7.20 the start below succeeds even when the recorded
4243
+ * pid was recycled (its 'up' overwrites a record it cannot prove is live), where earlier releases
4244
+ * refused with "already running". The wait is kept for a 'down' that did not report success, and
4245
+ * it never signals.
4246
+ */
4247
+ if (stopped.status !== 0 && alive(OLD_PID)) {
4248
+ log('down did not report success and the recorded pid is still alive; waiting, and NOT signalling it \u2014 it may not be the panel any more')
4235
4249
  for (let i = 0; i < 40 && alive(OLD_PID); i += 1)
4236
4250
  await sleep(250)
4237
- }
4238
-
4239
- if (alive(OLD_PID)) {
4240
- log('still alive; sending SIGKILL')
4241
- try { process.kill(OLD_PID, 'SIGKILL') } catch {}
4242
- for (let i = 0; i < 20 && alive(OLD_PID); i += 1)
4243
- await sleep(250)
4251
+ if (alive(OLD_PID))
4252
+ log('the recorded pid is still alive after waiting; the start below may be refused')
4244
4253
  }
4245
4254
 
4246
4255
  for (const step of STEPS) {
@@ -4295,22 +4304,22 @@ log('stopping the answering panel: ' + PROGRAM + ' ' + DOWN_ARGS.join(' '))
4295
4304
  const down = spawnSync(PROGRAM, DOWN_ARGS, { env: { ...process.env, ...ENV }, stdio: 'ignore' })
4296
4305
  log('down exited ' + String(down.status))
4297
4306
 
4298
- for (let i = 0; i < 40 && alive(OLD_PID); i += 1)
4307
+ /**
4308
+ * Never signal OLD_PID from here \u2014 see the same guard in the activation helper.
4309
+ *
4310
+ * 'down' is the only thing that may stop the panel, and it escalates for a panel it can prove is
4311
+ * alive. This helper has no birth time, so "the pid answers" cannot tell the panel from a stranger
4312
+ * that inherited the number; signalling on it SIGTERMs an unrelated process.
4313
+ *
4314
+ * A takeover of a live panel ends with the panel stopped either way: 'down' stops it, and it
4315
+ * escalates itself when the panel ignores SIGTERM. A pid that is still alive after a successful
4316
+ * 'down' is therefore not the panel, and the start below must not race it.
4317
+ */
4318
+ for (let i = 0; i < 40 && down.status !== 0 && alive(OLD_PID); i += 1)
4299
4319
  await sleep(250)
4300
4320
 
4301
- if (alive(OLD_PID)) {
4302
- log('still alive; sending SIGTERM')
4303
- try { process.kill(OLD_PID, 'SIGTERM') } catch {}
4304
- for (let i = 0; i < 40 && alive(OLD_PID); i += 1)
4305
- await sleep(250)
4306
- }
4307
-
4308
- if (alive(OLD_PID)) {
4309
- log('still alive; sending SIGKILL')
4310
- try { process.kill(OLD_PID, 'SIGKILL') } catch {}
4311
- for (let i = 0; i < 20 && alive(OLD_PID); i += 1)
4312
- await sleep(250)
4313
- }
4321
+ if (down.status !== 0 && alive(OLD_PID))
4322
+ log('down did not report success and the recorded pid is still alive; NOT signalling it \u2014 it may not be the panel any more')
4314
4323
 
4315
4324
  log('starting ' + PROGRAM + ' ' + ARGS.join(' '))
4316
4325
  const result = spawnSync(PROGRAM, ARGS, { env: { ...process.env, ...ENV }, stdio: 'ignore' })
@@ -5171,7 +5180,6 @@ var HomeHostedService = class extends Service {
5171
5180
  const runtime = this.runtime();
5172
5181
  const stored = readStoredToken(this.options.stateDir);
5173
5182
  const enrolledOnDisk = apiTokenEnrolled(this.options.home);
5174
- const reachable = runtime !== null && (pidAlive(runtime.pid) || await probePanel(runtime.url));
5175
5183
  const answered = runtime !== null && await probePanel(runtime.url);
5176
5184
  let token = "unknown";
5177
5185
  let tokenVerified = false;
@@ -5397,6 +5405,15 @@ var HomeHostedService = class extends Service {
5397
5405
  }
5398
5406
  writeConfig(this.options.home, upsertEntry(raw, entry), this.writtenBy(), workspace);
5399
5407
  }
5408
+ /**
5409
+ * Is a panel up and watching this workspace's config?
5410
+ *
5411
+ * Deliberately still `pid alive OR answering`, unlike the status read: this only chooses between
5412
+ * one write and two, and a live pid errs toward the *cautious* two-step write (add disabled, then
5413
+ * flip autostart) for a panel too wedged to answer — whose watcher may still fire and would start
5414
+ * a new autostart entry at once. A recycled pid therefore costs a redundant second write, which is
5415
+ * the harmless direction; the harmful one was signalling it, which no helper here does any more.
5416
+ */
5400
5417
  async panelRunning() {
5401
5418
  const runtime = this.runtime();
5402
5419
  return runtime !== null && (pidAlive(runtime.pid) || await probePanel(runtime.url));