gm-plugkit 2.0.1541 → 2.0.1543

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/bootstrap.js CHANGED
@@ -606,7 +606,25 @@ function copyWasmToGmTools(wasmPath, version) {
606
606
  if (cur === src) wasmFresh = true;
607
607
  } catch (_) {}
608
608
  }
609
- if (!wasmFresh) fs.copyFileSync(wasmPath, target);
609
+ if (!wasmFresh) {
610
+ // copyFileSync truncates the target before streaming ~149MB, leaving a window where
611
+ // a crash or a concurrent watcher load sees a truncated/absent wasm (the
612
+ // "self-heal: wasm not installed" crash-loop during an upgrade). Copy to a
613
+ // pid-suffixed temp and rename over the target: same-volume rename is atomic,
614
+ // with the Windows EEXIST/EPERM unlink+retry.
615
+ const tmp = `${target}.partial-${process.pid}`;
616
+ fs.copyFileSync(wasmPath, tmp);
617
+ try { fs.renameSync(tmp, target); }
618
+ catch (err) {
619
+ if (err.code === 'EEXIST' || err.code === 'EPERM') {
620
+ try { fs.unlinkSync(target); } catch (_) {}
621
+ fs.renameSync(tmp, target);
622
+ } else {
623
+ try { fs.unlinkSync(tmp); } catch (_) {}
624
+ throw err;
625
+ }
626
+ }
627
+ }
610
628
  fs.writeFileSync(path.join(dst, 'plugkit.version'), version);
611
629
 
612
630
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gm-plugkit",
3
- "version": "2.0.1541",
3
+ "version": "2.0.1543",
4
4
  "description": "Bootstrap and daemon-spawn tool for gm plugkit binary. Downloads the correct platform binary, verifies SHA256, and starts the spool watcher daemon. Includes plugkit-wasm-wrapper for WASM-based spool watching.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -2212,6 +2212,32 @@ async function runSpoolWatcher(instance, spoolDir) {
2212
2212
  detected_at: Date.now(),
2213
2213
  });
2214
2214
  try { console.error(`[plugkit-wasm] VERB ABORT detected: prior watcher pid=${priorVerb.pid} died inside verb=${priorVerb.verb} task=${priorVerb.task}`); } catch (_) {}
2215
+ // The aborted dispatch otherwise gets NO response file: the in-file was consumed,
2216
+ // the prior watcher died before writing out/, and the agent waits forever on a
2217
+ // Read that never lands (then must git-archaeology whether the verb's side effects
2218
+ // happened). Write a definite failure response so the agent's Read returns
2219
+ // immediately and it re-dispatches. Both out-name shapes are written because
2220
+ // .verb-active.json does not record whether the dispatch was root or nested;
2221
+ // the agent reads whichever its dispatch shape expects, the other is swept.
2222
+ if (priorVerb.verb && priorVerb.task) {
2223
+ try {
2224
+ const abortBody = JSON.stringify({
2225
+ ok: false,
2226
+ error: `verb aborted: watcher pid=${priorVerb.pid} died mid-verb; side effects may be partial -- verify state, then re-dispatch`,
2227
+ verb_aborted: true,
2228
+ verb: priorVerb.verb,
2229
+ task: priorVerb.task,
2230
+ });
2231
+ const abortOutDir = path.join(path.dirname(STATUS_PATH_FOR_VERB_ABORT), 'out');
2232
+ fs.mkdirSync(abortOutDir, { recursive: true });
2233
+ const nestedName = path.join(abortOutDir, `${priorVerb.verb}-${priorVerb.task}.json`);
2234
+ if (!fs.existsSync(nestedName)) fs.writeFileSync(nestedName, abortBody);
2235
+ if (priorVerb.verb !== priorVerb.task) {
2236
+ const rootName = path.join(abortOutDir, `${priorVerb.task}.json`);
2237
+ if (!fs.existsSync(rootName)) fs.writeFileSync(rootName, abortBody);
2238
+ }
2239
+ } catch (_) {}
2240
+ }
2215
2241
  }
2216
2242
  try { fs.unlinkSync(VERB_ACTIVE_PATH); } catch (_) {}
2217
2243
  }
@@ -3628,7 +3654,24 @@ async function selfHealFromGithubReleases() {
3628
3654
  }
3629
3655
  const toolsDir = GM_TOOLS_ROOT;
3630
3656
  fs.mkdirSync(toolsDir, { recursive: true });
3631
- fs.writeFileSync(path.join(toolsDir, 'plugkit.wasm'), wasm);
3657
+ // Replace the live wasm atomically. A direct writeFileSync truncates the target
3658
+ // before streaming ~149MB, so a crash mid-write or a concurrent watcher load in
3659
+ // that window sees a truncated or absent wasm ("self-heal: wasm not installed"
3660
+ // crash-loop). Write to a pid-suffixed temp and rename over the target; rename
3661
+ // on the same volume is atomic, with the Windows EEXIST/EPERM unlink+retry.
3662
+ const wasmTarget = path.join(toolsDir, 'plugkit.wasm');
3663
+ const wasmTmp = `${wasmTarget}.partial-${process.pid}`;
3664
+ fs.writeFileSync(wasmTmp, wasm);
3665
+ try { fs.renameSync(wasmTmp, wasmTarget); }
3666
+ catch (renameErr) {
3667
+ if (renameErr.code === 'EEXIST' || renameErr.code === 'EPERM') {
3668
+ try { fs.unlinkSync(wasmTarget); } catch (_) {}
3669
+ fs.renameSync(wasmTmp, wasmTarget);
3670
+ } else {
3671
+ try { fs.unlinkSync(wasmTmp); } catch (_) {}
3672
+ throw renameErr;
3673
+ }
3674
+ }
3632
3675
  fs.writeFileSync(path.join(toolsDir, 'plugkit.version'), version);
3633
3676
  const wrapperSrc = __filename;
3634
3677
  const wrapperDst = path.join(toolsDir, 'plugkit-wasm-wrapper.js');
package/plugkit.version CHANGED
@@ -1 +1 @@
1
- 0.1.639
1
+ 0.1.640
package/supervisor.js CHANGED
@@ -85,10 +85,39 @@ function acquireSingleInstance() {
85
85
  let other = NaN;
86
86
  try { other = parseInt(fs.readFileSync(SUPERVISOR_PID_PATH, 'utf-8').trim(), 10); } catch (_) {}
87
87
  if (Number.isFinite(other) && other !== process.pid && pidAlive(other)) {
88
- logEvent('supervisor.refused-duplicate', { existing_pid: other, severity: 'warn' });
89
- return false;
88
+ // An alive holder pid is not the same as a working holder: a wedged supervisor
89
+ // (event loop stuck, watcher dead, neither .supervisor.json nor .status.json
90
+ // advancing) blocks every newcomer forever under a pidAlive-only check, forcing
91
+ // manual process kills to recover the spool. Discriminate by progress, not
92
+ // liveness: holder is wedged only when its own status heartbeat AND the spool
93
+ // status are both stale past the takeover window, honoring a future busy_until
94
+ // exactly as checkWatcherHealth does.
95
+ const TAKEOVER_STALE_MS = 45_000;
96
+ const now = Date.now();
97
+ let supTs = 0;
98
+ try { supTs = (JSON.parse(fs.readFileSync(SUPERVISOR_PATH, 'utf-8')).ts) || 0; } catch (_) {}
99
+ const spool = readStatus();
100
+ const spoolBusy = spool && spool.busy_until && spool.busy_until > now;
101
+ const spoolTs = (spool && spool.ts) || 0;
102
+ const holderWedged = !spoolBusy
103
+ && (now - supTs) > TAKEOVER_STALE_MS
104
+ && (now - spoolTs) > TAKEOVER_STALE_MS;
105
+ if (!holderWedged) {
106
+ logEvent('supervisor.refused-duplicate', { existing_pid: other, severity: 'warn' });
107
+ return false;
108
+ }
109
+ logEvent('supervisor.takeover-wedged', {
110
+ existing_pid: other,
111
+ supervisor_status_age_ms: now - supTs,
112
+ spool_status_age_ms: now - spoolTs,
113
+ severity: 'critical',
114
+ });
115
+ try { process.kill(other, 'SIGTERM'); } catch (_) {}
116
+ if (process.platform === 'win32') {
117
+ try { spawnSync('taskkill', ['/F', '/T', '/PID', String(other)], { stdio: 'ignore', windowsHide: true, timeout: 3000 }); } catch (_) {}
118
+ }
90
119
  }
91
- // Holder is dead/stale: remove and retry the exclusive create once.
120
+ // Holder is dead/stale/wedged: remove and retry the exclusive create once.
92
121
  try { fs.unlinkSync(SUPERVISOR_PID_PATH); } catch (_) {}
93
122
  continue;
94
123
  }