@1e0zj/dsh-plugin-mall 0.4.7 → 0.4.12

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.
@@ -2,10 +2,33 @@
2
2
  // standalone guard CLI. The parent must not infer readiness from a child pid:
3
3
  // an incompatible CLI can spawn successfully and then die on argument parsing.
4
4
 
5
+ import { readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
6
+
5
7
  export const RESTART_HELPER_READY_TYPE = "@1e0zj/dsh-plugin-mall:restart-helper-ready";
6
8
  export const RESTART_HELPER_PROTOCOL_VERSION = 1;
7
9
  export const RESTART_RESPONSE_DRAIN_MS = 1000;
8
10
 
11
+ // cmd.exe metacharacters. Rather than "escaping" these for a cmd round trip
12
+ // (cmd's quoting rules are famously inconsistent), the launch wrapper refuses
13
+ // them outright — a dsh invocation never needs them.
14
+ export const CMD_METACHAR_RE = /[&|<>^%!\r\n]/;
15
+
16
+ /**
17
+ * Quote one token for a %ComSpec% /d /s /c command line. Follows the MSVCRT /
18
+ * CommandLineToArgvW rules (backslashes before a quote or the closing quote are
19
+ * doubled, quotes become \") and rejects cmd metacharacters instead of trying
20
+ * to escape them. The command after `--` is never concatenated unquoted.
21
+ */
22
+ export function quoteCmdArg(token) {
23
+ const value = String(token ?? "");
24
+ if (value.length === 0) return '""';
25
+ if (CMD_METACHAR_RE.test(value)) {
26
+ throw new Error(`cannot quote safely for cmd.exe (shell metacharacter present): ${JSON.stringify(value)}`);
27
+ }
28
+ const escaped = value.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/, "$1$1");
29
+ return `"${escaped}"`;
30
+ }
31
+
9
32
  export function createRestartHelperReadyMessage(awaitExitPid) {
10
33
  return {
11
34
  type: RESTART_HELPER_READY_TYPE,
@@ -142,3 +165,238 @@ export function superviseRestartHelper(child, {
142
165
  state: () => phase,
143
166
  };
144
167
  }
168
+
169
+ // ── restart plan & file-channel handoff (Windows visible console) ────────────
170
+ //
171
+ // A visible restart launches the guard through `cmd /c start`: the guard runs
172
+ // in a brand-new console as a grandchild, so neither stdio nor an IPC channel
173
+ // connects it back to the old Host. The launch plan travels as a JSON file
174
+ // (never concatenated into the cmd command line), and readiness is
175
+ // acknowledged through a second file with the same semantics the IPC message
176
+ // carries.
177
+
178
+ export const RESTART_PLAN_TYPE = "@1e0zj/dsh-plugin-mall:restart-plan";
179
+ export const RESTART_PLAN_VERSION = 1;
180
+
181
+ /**
182
+ * Validate the payload of a restart plan file. Structural checks only:
183
+ * profile-name safety is enforced where the name builds paths (the guard's
184
+ * profileDirOf / the plugin's assertSafeProfileName), not here.
185
+ */
186
+ export function validateRestartPlanPayload(value) {
187
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
188
+ return { ok: false, error: "restart plan is not a JSON object" };
189
+ }
190
+ if (value.version !== RESTART_PLAN_VERSION) {
191
+ return { ok: false, error: `restart plan version ${JSON.stringify(value.version)} is not supported (expected ${RESTART_PLAN_VERSION})` };
192
+ }
193
+ if (value.type !== RESTART_PLAN_TYPE) {
194
+ return { ok: false, error: `restart plan type ${JSON.stringify(value.type)} does not match ${RESTART_PLAN_TYPE}` };
195
+ }
196
+ for (const key of ["profile", "logPath", "readyFile", "cwd", "command"]) {
197
+ if (typeof value[key] !== "string" || value[key].length === 0) {
198
+ return { ok: false, error: `restart plan field ${JSON.stringify(key)} must be a non-empty string` };
199
+ }
200
+ }
201
+ if (!Number.isInteger(value.awaitExitPid) || value.awaitExitPid <= 0) {
202
+ return { ok: false, error: `restart plan awaitExitPid ${JSON.stringify(value.awaitExitPid)} must be a positive integer` };
203
+ }
204
+ if (!Array.isArray(value.args) || value.args.length === 0 || value.args.some((entry) => typeof entry !== "string")) {
205
+ return { ok: false, error: "restart plan args must be a non-empty array of strings" };
206
+ }
207
+ return { ok: true, plan: value };
208
+ }
209
+
210
+ /**
211
+ * Atomically publish the helper-ready handshake as a file: write a sibling
212
+ * .tmp, then rename onto the final path (nonce-unique, so it does not exist
213
+ * yet), so the parent never observes half-written JSON. The parent — and only
214
+ * the parent — deletes the file after consuming it; the writer never touches
215
+ * it again, which is what keeps a healthy handoff from racing its own cleanup.
216
+ */
217
+ export function writeRestartHelperReadyFile(readyFile, { awaitExitPid, guardPid }) {
218
+ const message = {
219
+ type: RESTART_HELPER_READY_TYPE,
220
+ protocol: RESTART_HELPER_PROTOCOL_VERSION,
221
+ awaitExitPid,
222
+ guardPid,
223
+ };
224
+ const tmp = `${readyFile}.tmp`;
225
+ writeFileSync(tmp, `${JSON.stringify(message)}\n`);
226
+ renameSync(tmp, readyFile);
227
+ }
228
+
229
+ /** Read a ready file; a missing or unparseable file reads as "not yet". */
230
+ export function readRestartHelperReadyFile(readyFile) {
231
+ let text;
232
+ try {
233
+ text = readFileSync(readyFile, "utf8");
234
+ } catch {
235
+ return undefined;
236
+ }
237
+ try {
238
+ return JSON.parse(text);
239
+ } catch {
240
+ return undefined;
241
+ }
242
+ }
243
+
244
+ function defaultProbePid(pid) {
245
+ try {
246
+ process.kill(pid, 0);
247
+ return true;
248
+ } catch (error) {
249
+ if (error?.code === "EPERM") return true; // exists, not ours to signal
250
+ // ESRCH — and anything unprobeable — reads as gone: fail closed and keep
251
+ // the old Host rather than trusting an uncertain probe.
252
+ return false;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * File-channel twin of superviseRestartHelper, phase for phase: handshake
258
+ * (poll the ready file) → stability (probe the guard pid) → accepted (the RPC
259
+ * may answer now — but keep probing) → committed (onHostExit). Continuing to
260
+ * probe through accepted mirrors the IPC version's live exit listener: a guard
261
+ * that dies after the RPC answered still cancels the old Host's exit instead
262
+ * of leaving it gone with no successor.
263
+ *
264
+ * The guard pid is only ever killed from a payload whose type identifies it
265
+ * as one of our restart helpers; garbage or unrelated files never turn into a
266
+ * kill of an unrelated (possibly recycled) pid. `failFast` lets the caller
267
+ * surface an early external failure (e.g. cmd itself exited nonzero before
268
+ * the guard ever started) without waiting out the handshake timeout.
269
+ */
270
+ export function superviseRestartHelperFile({
271
+ readyFile,
272
+ awaitExitPid,
273
+ handshakeTimeoutMs = 5000,
274
+ stabilityMs = 600,
275
+ responseDelayMs = RESTART_RESPONSE_DRAIN_MS,
276
+ pollMs = 100,
277
+ probe = defaultProbePid,
278
+ kill = (pid) => process.kill(pid),
279
+ onHostExit = () => process.exit(0),
280
+ onFailure = () => {},
281
+ } = {}) {
282
+ let phase = "handshake";
283
+ let deadlineTimer;
284
+ let pollTimer;
285
+ let probeTimer;
286
+ let guardPid;
287
+ let readySettled = false;
288
+ let resolveReady;
289
+ const ready = new Promise((resolvePromise) => { resolveReady = resolvePromise; });
290
+
291
+ const clearTimers = () => {
292
+ if (deadlineTimer !== undefined) {
293
+ clearTimeout(deadlineTimer);
294
+ deadlineTimer = undefined;
295
+ }
296
+ // pollTimer included: a terminal state must leave no polling interval
297
+ // behind, or the Host process can never exit naturally.
298
+ if (pollTimer !== undefined) {
299
+ clearInterval(pollTimer);
300
+ pollTimer = undefined;
301
+ }
302
+ if (probeTimer !== undefined) {
303
+ clearInterval(probeTimer);
304
+ probeTimer = undefined;
305
+ }
306
+ };
307
+ const bestEffortUnlink = () => {
308
+ try { unlinkSync(readyFile); } catch { /* nonce-named residue is inert */ }
309
+ };
310
+ const settleReady = (result) => {
311
+ if (readySettled) return;
312
+ readySettled = true;
313
+ resolveReady(result);
314
+ };
315
+ const terminateGuard = () => {
316
+ if (guardPid === undefined) return;
317
+ try { kill(guardPid); } catch { /* already gone */ }
318
+ };
319
+ const fail = (message, { terminate = false } = {}) => {
320
+ if (phase === "failed" || phase === "disposed" || phase === "committed") return;
321
+ const afterReady = readySettled;
322
+ phase = "failed";
323
+ clearTimers();
324
+ if (terminate) terminateGuard();
325
+ bestEffortUnlink();
326
+ settleReady({ ok: false, error: message });
327
+ try { onFailure(message, { afterReady }); } catch { /* diagnostics are best effort */ }
328
+ };
329
+
330
+ function acceptHandshake(message) {
331
+ phase = "stability";
332
+ clearTimers(); // the handshake deadline no longer applies
333
+ guardPid = message.guardPid;
334
+ bestEffortUnlink(); // the parent owns the ready file's lifecycle
335
+ probeTimer = setInterval(() => {
336
+ if (phase !== "stability" && phase !== "accepted") return;
337
+ if (!probe(message.guardPid)) {
338
+ fail(
339
+ `restart helper (pid ${message.guardPid}) exited ${phase === "stability" ? "during the stability window" : "after the handoff was accepted"}`,
340
+ );
341
+ }
342
+ }, pollMs);
343
+ deadlineTimer = setTimeout(() => {
344
+ if (phase !== "stability") return;
345
+ phase = "accepted";
346
+ settleReady({ ok: true });
347
+ // Keep watching until committed; a death here cancels the pending exit.
348
+ deadlineTimer = setTimeout(() => {
349
+ if (phase !== "accepted") return;
350
+ phase = "committed";
351
+ clearTimers();
352
+ try {
353
+ onHostExit();
354
+ } catch (error) {
355
+ phase = "failed";
356
+ try { onFailure(`could not exit old Host: ${error?.message ?? String(error)}`, { afterReady: true }); } catch { /* best effort */ }
357
+ }
358
+ }, responseDelayMs);
359
+ }, stabilityMs);
360
+ }
361
+
362
+ function pollOnce() {
363
+ if (phase !== "handshake") return;
364
+ const message = readRestartHelperReadyFile(readyFile);
365
+ if (message?.type !== RESTART_HELPER_READY_TYPE) return; // missing/half-written/unrelated
366
+ if (!Number.isInteger(message.guardPid) || message.guardPid <= 0) return;
367
+ if (message.protocol !== RESTART_HELPER_PROTOCOL_VERSION || message.awaitExitPid !== awaitExitPid) {
368
+ // Identified as one of our helpers but speaking the wrong protocol or
369
+ // waiting for a different Host: stop it rather than let it linger.
370
+ guardPid = message.guardPid;
371
+ fail(
372
+ `restart helper protocol mismatch (expected v${RESTART_HELPER_PROTOCOL_VERSION} for pid ${awaitExitPid})`,
373
+ { terminate: true },
374
+ );
375
+ return;
376
+ }
377
+ acceptHandshake(message);
378
+ }
379
+
380
+ deadlineTimer = setTimeout(() => {
381
+ if (phase !== "handshake") return;
382
+ fail(`restart helper did not write ${readyFile} within ${handshakeTimeoutMs}ms`);
383
+ }, handshakeTimeoutMs);
384
+ pollTimer = setInterval(pollOnce, pollMs);
385
+ pollOnce(); // an already-present ready file must not wait out one interval
386
+
387
+ const dispose = () => {
388
+ if (phase === "failed" || phase === "disposed" || phase === "committed") return;
389
+ phase = "disposed";
390
+ clearTimers();
391
+ terminateGuard();
392
+ bestEffortUnlink();
393
+ settleReady({ ok: false, error: "restart handoff cancelled because the plugin unloaded" });
394
+ };
395
+
396
+ return {
397
+ ready,
398
+ dispose,
399
+ failFast: (message) => fail(message, { terminate: true }),
400
+ state: () => phase,
401
+ };
402
+ }