@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.
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/cli.js +924 -65
- package/src/client.js +24 -2
- package/src/index.js +593 -30
- package/src/restart-protocol.js +258 -0
package/src/restart-protocol.js
CHANGED
|
@@ -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
|
+
}
|