home-hosted 0.6.2 → 0.6.4
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/AGENTS.md +51 -9
- package/README.md +16 -1
- package/dist/cli.js +932 -59
- package/dist/cli.js.map +1 -1
- package/docs/SERVERS.md +47 -0
- package/package.json +1 -1
- package/uis/stock/dist/assets/{index-sj5eYUeD.js → index-CCPLyw-2.js} +6 -6
- package/uis/stock/dist/index.html +1 -1
package/dist/cli.js
CHANGED
|
@@ -263,6 +263,43 @@ var init_io = __esmMin((() => {
|
|
|
263
263
|
};
|
|
264
264
|
}));
|
|
265
265
|
//#endregion
|
|
266
|
+
//#region src/helpers/runtime.ts
|
|
267
|
+
/**
|
|
268
|
+
* How to run this CLI again in the same runtime. Under tsx that means passing the
|
|
269
|
+
* resolved loader too, because a spawned child's working directory is the project's,
|
|
270
|
+
* not the package's. Shared by `up` (which re-spawns itself detached) and by the
|
|
271
|
+
* panel (which spawns a persistent entry's nanny).
|
|
272
|
+
*
|
|
273
|
+
* Nothing here reads a state directory, which is why the CLI can import it before
|
|
274
|
+
* `--home`/`--project` have been applied.
|
|
275
|
+
*/
|
|
276
|
+
function runtimeArgs() {
|
|
277
|
+
let resolved = null;
|
|
278
|
+
const resolveTsx = () => resolved ??= import.meta.resolve("tsx");
|
|
279
|
+
return process.execArgv.map((arg) => {
|
|
280
|
+
if (arg === "tsx") return resolveTsx();
|
|
281
|
+
if (arg.startsWith("--import=") && arg.slice(9) === "tsx") return `--import=${resolveTsx()}`;
|
|
282
|
+
return arg;
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
function nannyArgv(entry, id, specPath, statePath) {
|
|
286
|
+
return [
|
|
287
|
+
...runtimeArgs(),
|
|
288
|
+
entry,
|
|
289
|
+
NANNY_COMMAND,
|
|
290
|
+
"--id",
|
|
291
|
+
id,
|
|
292
|
+
"--spec",
|
|
293
|
+
specPath,
|
|
294
|
+
"--state",
|
|
295
|
+
statePath
|
|
296
|
+
];
|
|
297
|
+
}
|
|
298
|
+
var NANNY_COMMAND;
|
|
299
|
+
var init_runtime = __esmMin((() => {
|
|
300
|
+
NANNY_COMMAND = "__nanny";
|
|
301
|
+
}));
|
|
302
|
+
//#endregion
|
|
266
303
|
//#region src/helpers/cookies.ts
|
|
267
304
|
function parseCookies(header) {
|
|
268
305
|
const cookies = {};
|
|
@@ -304,7 +341,7 @@ function parseBind(value) {
|
|
|
304
341
|
const parsed = bindSchema(value);
|
|
305
342
|
return parsed instanceof type.errors ? null : parsed;
|
|
306
343
|
}
|
|
307
|
-
var bindSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, settingsPatchSchema, authStatusSchema, tlsStatusSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupPathSchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, uiStatusSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema;
|
|
344
|
+
var bindSchema, portSchema, onPortConflictSchema, onPortConflictDefaultSchema, restartSchema, httpCheckSchema, resourcesSchema, healthSchema, stopSchema, bootstrapSchema, bootstrapOrNullSchema, logBufferLinesSchema, serverSchema, authSchema, telegramSchema, notificationsSchema, logsSchema, tlsSchema, hostSchema, backupsSchema, controlSchema, defaultsSchema, restartPatchSchema, httpCheckPatchSchema, resourcesPatchSchema, healthPatchSchema, stopPatchSchema, authPatchSchema, telegramPatchSchema, notificationsPatchSchema, hostPatchSchema, backupsPatchSchema, logsPatchSchema, tlsPatchSchema, controlPatchSchema, defaultsPatchSchema, editableFields, serverPatchSchema, serverCreateSchema, nannySpecSchema, nannyExitSchema, nannyStateSchema, settingsPatchSchema, authStatusSchema, tlsStatusSchema, telegramStatusSchema, notificationViewSchema, sessionViewSchema, loginSchema, passwordValueSchema, passwordSchema, serverStatusSchema, healthStateSchema, portStateSchema, logStreamSchema, logLineSchema, historyEventSchema, serverHistorySchema, processResourcesSchema, hostDiskSchema, hostViewSchema, backupFileSchema, backupPathSchema, backupsViewSchema, restoreItemSchema, restorePlanSchema, backupCreateSchema, restoreRequestSchema, serverViewSchema, controlViewSchema, appStateSchema, logQuerySchema, freePortResultSchema, logHistoryQuerySchema, logFileInfoSchema, logServerViewSchema, logServersViewSchema, notificationActionSchema, telegramTokenSchema, apiErrorSchema, uiMetaSchema, uiStatusSchema, tlsUploadSchema, settingsViewSchema, settingsSavedSchema;
|
|
308
345
|
var init_contracts = __esmMin((() => {
|
|
309
346
|
bindSchema = type("\"local\" | \"lan\" | /^\\d{1,3}(?:\\.\\d{1,3}){3}$/");
|
|
310
347
|
portSchema = type("1 <= number.integer <= 65535 | null");
|
|
@@ -368,6 +405,12 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
368
405
|
label: "string?",
|
|
369
406
|
enabled: "boolean = true",
|
|
370
407
|
autostart: "boolean = false",
|
|
408
|
+
/**
|
|
409
|
+
* Handed to a nanny process that owns the pipes, so the entry keeps running when
|
|
410
|
+
* this panel stops, restarts or is killed. `down`/`stop-all` leave it alone and
|
|
411
|
+
* report it; only an explicit stop or restart stops it.
|
|
412
|
+
*/
|
|
413
|
+
persistent: "boolean = false",
|
|
371
414
|
command: "string >= 1",
|
|
372
415
|
args: type("string[]").default(() => []),
|
|
373
416
|
cwd: "string = \".\"",
|
|
@@ -562,6 +605,7 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
562
605
|
label: "string?",
|
|
563
606
|
enabled: "boolean?",
|
|
564
607
|
autostart: "boolean?",
|
|
608
|
+
persistent: "boolean?",
|
|
565
609
|
command: "string?",
|
|
566
610
|
args: "string[]?",
|
|
567
611
|
cwd: "string?",
|
|
@@ -587,6 +631,34 @@ maxRssBytes: "number.integer >= 0 = 0" }).onUndeclaredKey("reject");
|
|
|
587
631
|
...editableFields,
|
|
588
632
|
command: "string"
|
|
589
633
|
}).onUndeclaredKey("reject");
|
|
634
|
+
nannySpecSchema = type({
|
|
635
|
+
serverId: "string",
|
|
636
|
+
command: "string",
|
|
637
|
+
args: "string[]",
|
|
638
|
+
cwd: "string",
|
|
639
|
+
env: "Record<string, string>",
|
|
640
|
+
/** Directory of the JSONL the nanny writes; `<dir>/<id>.log`, exactly as the panel names it. */
|
|
641
|
+
logDir: "string",
|
|
642
|
+
/** The same retention the panel applies, so the nanny rotates identically. */
|
|
643
|
+
logs: logsSchema,
|
|
644
|
+
/** How the nanny stops the child it owns. */
|
|
645
|
+
stop: stopSchema
|
|
646
|
+
}).onUndeclaredKey("reject");
|
|
647
|
+
nannyExitSchema = type({
|
|
648
|
+
code: "number | null",
|
|
649
|
+
signal: "string | null",
|
|
650
|
+
at: "number",
|
|
651
|
+
runtimeMs: "number"
|
|
652
|
+
}).onUndeclaredKey("reject");
|
|
653
|
+
nannyStateSchema = type({
|
|
654
|
+
"serverId": "string",
|
|
655
|
+
"nannyPid": "number",
|
|
656
|
+
"childPid": "number | null",
|
|
657
|
+
"startedAt": "number",
|
|
658
|
+
"logFile": "string",
|
|
659
|
+
"heartbeatAt": "number",
|
|
660
|
+
"lastExit?": nannyExitSchema
|
|
661
|
+
}).onUndeclaredKey("reject");
|
|
590
662
|
settingsPatchSchema = type({
|
|
591
663
|
control: controlPatchSchema.optional(),
|
|
592
664
|
defaults: defaultsPatchSchema.optional(),
|
|
@@ -1744,6 +1816,7 @@ var paths_exports = /* @__PURE__ */ __exportAll({
|
|
|
1744
1816
|
defaultConfigPath: () => defaultConfigPath,
|
|
1745
1817
|
defaultHistoryPath: () => defaultHistoryPath,
|
|
1746
1818
|
defaultLogsDir: () => defaultLogsDir,
|
|
1819
|
+
defaultNannyDir: () => defaultNannyDir,
|
|
1747
1820
|
defaultSecretsPath: () => defaultSecretsPath,
|
|
1748
1821
|
defaultTlsDir: () => defaultTlsDir,
|
|
1749
1822
|
projectDir: () => projectDir,
|
|
@@ -1781,7 +1854,7 @@ function resolveUserPath(target, base = projectDir) {
|
|
|
1781
1854
|
else if (value.startsWith("~/") || value.startsWith("~\\")) value = path.join(os.homedir(), value.slice(2));
|
|
1782
1855
|
return path.isAbsolute(value) ? value : path.resolve(base, value);
|
|
1783
1856
|
}
|
|
1784
|
-
var dataRoot, projectDir, defaultConfigPath, configSchemaPath, defaultSecretsPath, defaultLogsDir, defaultHistoryPath, defaultTlsDir, runtimePath, daemonLogPath;
|
|
1857
|
+
var dataRoot, projectDir, defaultConfigPath, configSchemaPath, defaultSecretsPath, defaultLogsDir, defaultHistoryPath, defaultTlsDir, defaultNannyDir, runtimePath, daemonLogPath;
|
|
1785
1858
|
var init_paths = __esmMin((() => {
|
|
1786
1859
|
dataRoot = resolveDataRoot();
|
|
1787
1860
|
projectDir = resolveProjectDir();
|
|
@@ -1791,6 +1864,7 @@ var init_paths = __esmMin((() => {
|
|
|
1791
1864
|
defaultLogsDir = path.join(dataRoot, ".logs");
|
|
1792
1865
|
defaultHistoryPath = path.join(dataRoot, ".logs", "history.json");
|
|
1793
1866
|
defaultTlsDir = path.join(dataRoot, ".tls");
|
|
1867
|
+
defaultNannyDir = path.join(dataRoot, ".state");
|
|
1794
1868
|
runtimePath = path.join(dataRoot, "run.json");
|
|
1795
1869
|
daemonLogPath = path.join(dataRoot, ".logs", "home-hosted.log");
|
|
1796
1870
|
}));
|
|
@@ -4615,6 +4689,33 @@ function collectTree(rootPid, children) {
|
|
|
4615
4689
|
return pids;
|
|
4616
4690
|
}
|
|
4617
4691
|
/**
|
|
4692
|
+
* The roots and every descendant of theirs, from one scan of the process table.
|
|
4693
|
+
*
|
|
4694
|
+
* Ownership has to mean the *tree*: a persistent entry runs under a nanny, and a
|
|
4695
|
+
* wrapper entry spawns the real server one generation down. Both keep the pid the
|
|
4696
|
+
* panel recorded, but the process holding the port is a descendant of it — and a
|
|
4697
|
+
* descendant classified as a stranger is one `kill`/`free-port` away from stopping
|
|
4698
|
+
* a server this panel is responsible for.
|
|
4699
|
+
*
|
|
4700
|
+
* A backend that cannot run answers with the roots alone, which is the old,
|
|
4701
|
+
* narrower behaviour rather than a wrong one.
|
|
4702
|
+
*/
|
|
4703
|
+
async function processTreePids(roots) {
|
|
4704
|
+
const pids = new Set(roots);
|
|
4705
|
+
if (roots.length === 0) return pids;
|
|
4706
|
+
try {
|
|
4707
|
+
const rows = await readProcesses();
|
|
4708
|
+
const children = /* @__PURE__ */ new Map();
|
|
4709
|
+
for (const row of rows) {
|
|
4710
|
+
const siblings = children.get(row.ppid) ?? [];
|
|
4711
|
+
siblings.push(row.pid);
|
|
4712
|
+
children.set(row.ppid, siblings);
|
|
4713
|
+
}
|
|
4714
|
+
for (const root of roots) for (const pid of collectTree(root, children)) pids.add(pid);
|
|
4715
|
+
} catch {}
|
|
4716
|
+
return pids;
|
|
4717
|
+
}
|
|
4718
|
+
/**
|
|
4618
4719
|
* Does this process carry the environment the supervisor gave the entry?
|
|
4619
4720
|
*
|
|
4620
4721
|
* A program that restarts itself — especially a plugin doing it — leaves behind a
|
|
@@ -4940,6 +5041,130 @@ var init_identity = __esmMin((() => {
|
|
|
4940
5041
|
windowsFilter = /^\d+$/;
|
|
4941
5042
|
}));
|
|
4942
5043
|
//#endregion
|
|
5044
|
+
//#region src/providers/nanny.ts
|
|
5045
|
+
var nanny_exports$2 = /* @__PURE__ */ __exportAll({
|
|
5046
|
+
NANNY_HEARTBEAT_MS: () => NANNY_HEARTBEAT_MS,
|
|
5047
|
+
SPEC_SUFFIX: () => SPEC_SUFFIX,
|
|
5048
|
+
clearNannySpec: () => clearNannySpec,
|
|
5049
|
+
clearNannyState: () => clearNannyState,
|
|
5050
|
+
describeNannyExit: () => describeNannyExit,
|
|
5051
|
+
nannyEntryPoint: () => nannyEntryPoint,
|
|
5052
|
+
nannyIsAlive: () => nannyIsAlive,
|
|
5053
|
+
nannyLogFile: () => nannyLogFile,
|
|
5054
|
+
nannySpecPath: () => nannySpecPath,
|
|
5055
|
+
nannyStatePath: () => nannyStatePath,
|
|
5056
|
+
readNannyState: () => readNannyState,
|
|
5057
|
+
sweepNannySpecs: () => sweepNannySpecs,
|
|
5058
|
+
takeNannySpec: () => takeNannySpec,
|
|
5059
|
+
writeNannySpec: () => writeNannySpec,
|
|
5060
|
+
writeNannyState: () => writeNannyState
|
|
5061
|
+
});
|
|
5062
|
+
/**
|
|
5063
|
+
* The state directory is passed in rather than read from a global: the panel injects
|
|
5064
|
+
* `$HHOSTED_HOME/.state`, and a test injects its own temp directory.
|
|
5065
|
+
*/
|
|
5066
|
+
function nannySpecPath(dir, id) {
|
|
5067
|
+
return path.join(dir, `${id}${SPEC_SUFFIX}`);
|
|
5068
|
+
}
|
|
5069
|
+
function nannyStatePath(dir, id) {
|
|
5070
|
+
return path.join(dir, `${id}.json`);
|
|
5071
|
+
}
|
|
5072
|
+
/** The file a nanny writes and the panel tails; one derivation, so they cannot drift. */
|
|
5073
|
+
function nannyLogFile(logDir, id) {
|
|
5074
|
+
return path.join(logDir, `${id}.log`);
|
|
5075
|
+
}
|
|
5076
|
+
/**
|
|
5077
|
+
* How to run this CLI again as a nanny. The panel was itself launched through this
|
|
5078
|
+
* entry point (`up` re-spawns itself with it), so `process.argv[1]` is the one path
|
|
5079
|
+
* that works under tsx, from `dist/cli.js` and through the published bin alike.
|
|
5080
|
+
*/
|
|
5081
|
+
function nannyEntryPoint() {
|
|
5082
|
+
return process.argv[1] ?? "";
|
|
5083
|
+
}
|
|
5084
|
+
/** Written before the spawn, consumed by the nanny, never left behind. */
|
|
5085
|
+
function writeNannySpec(file, spec) {
|
|
5086
|
+
writeFileAtomic(file, JSON.stringify(spec), { mode: SPEC_MODE });
|
|
5087
|
+
}
|
|
5088
|
+
function clearNannySpec(file) {
|
|
5089
|
+
fs.rmSync(file, { force: true });
|
|
5090
|
+
}
|
|
5091
|
+
/**
|
|
5092
|
+
* Reads and unlinks in one step: the spec holds expanded secrets, and a spec left
|
|
5093
|
+
* behind — a nanny that died before reading it, a panel killed mid-spawn — is a
|
|
5094
|
+
* secret file with no owner. It is consumed even when it cannot be parsed.
|
|
5095
|
+
*/
|
|
5096
|
+
function takeNannySpec(file) {
|
|
5097
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
5098
|
+
fs.rmSync(file, { force: true });
|
|
5099
|
+
const parsed = nannySpecSchema(JSON.parse(raw));
|
|
5100
|
+
if (parsed instanceof type.errors) throw new Error(`invalid nanny spec: ${parsed.summary}`);
|
|
5101
|
+
return parsed;
|
|
5102
|
+
}
|
|
5103
|
+
/**
|
|
5104
|
+
* Nothing can be waiting on a spec before this panel has spawned a nanny, so every
|
|
5105
|
+
* one still on disk at boot belongs to a nanny that never read it.
|
|
5106
|
+
*/
|
|
5107
|
+
function sweepNannySpecs(dir) {
|
|
5108
|
+
let swept = 0;
|
|
5109
|
+
for (const name of readSpecNames(dir)) {
|
|
5110
|
+
fs.rmSync(path.join(dir, name), { force: true });
|
|
5111
|
+
swept += 1;
|
|
5112
|
+
}
|
|
5113
|
+
return swept;
|
|
5114
|
+
}
|
|
5115
|
+
function readSpecNames(dir) {
|
|
5116
|
+
try {
|
|
5117
|
+
return fs.readdirSync(dir).filter((name) => name.endsWith(SPEC_SUFFIX));
|
|
5118
|
+
} catch {
|
|
5119
|
+
return [];
|
|
5120
|
+
}
|
|
5121
|
+
}
|
|
5122
|
+
/** A stale or foreign file reads as "no state", never as a throw. */
|
|
5123
|
+
function readNannyState(file) {
|
|
5124
|
+
try {
|
|
5125
|
+
const parsed = nannyStateSchema(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
5126
|
+
return parsed instanceof type.errors ? null : parsed;
|
|
5127
|
+
} catch {
|
|
5128
|
+
return null;
|
|
5129
|
+
}
|
|
5130
|
+
}
|
|
5131
|
+
function writeNannyState(file, state) {
|
|
5132
|
+
writeFileAtomic(file, JSON.stringify(state));
|
|
5133
|
+
}
|
|
5134
|
+
function clearNannyState(file) {
|
|
5135
|
+
fs.rmSync(file, { force: true });
|
|
5136
|
+
}
|
|
5137
|
+
/** The same wording the supervisor uses for a child's exit, so history reads alike. */
|
|
5138
|
+
function describeNannyExit(exit) {
|
|
5139
|
+
return exit.signal !== null ? `signal ${exit.signal}` : `code ${exit.code}`;
|
|
5140
|
+
}
|
|
5141
|
+
/**
|
|
5142
|
+
* Is the nanny this state describes still ours? A live pid proves nothing on its
|
|
5143
|
+
* own — pids are reused. A fresh heartbeat means a process is still rewriting the
|
|
5144
|
+
* file; the environment marker (Linux, macOS) or the argv we spawned it with answers
|
|
5145
|
+
* everywhere else, Windows included.
|
|
5146
|
+
*/
|
|
5147
|
+
async function nannyIsAlive(state, id) {
|
|
5148
|
+
if (state.nannyPid <= 0 || !isProcessAlive(state.nannyPid)) return false;
|
|
5149
|
+
if (Date.now() - state.heartbeatAt <= HEARTBEAT_STALE_MS) return true;
|
|
5150
|
+
if (await processCarriesServerId(state.nannyPid, id)) return true;
|
|
5151
|
+
const words = await processArgv(state.nannyPid);
|
|
5152
|
+
return words !== null && words.includes("__nanny") && words.some((word) => word.endsWith(`${id}.spec.json`));
|
|
5153
|
+
}
|
|
5154
|
+
var SPEC_MODE, SPEC_SUFFIX, NANNY_HEARTBEAT_MS, HEARTBEAT_STALE_MS;
|
|
5155
|
+
var init_nanny$2 = __esmMin((() => {
|
|
5156
|
+
init_atomic();
|
|
5157
|
+
init_runtime();
|
|
5158
|
+
init_identity();
|
|
5159
|
+
init_port();
|
|
5160
|
+
init_proc();
|
|
5161
|
+
init_contracts();
|
|
5162
|
+
SPEC_MODE = 384;
|
|
5163
|
+
SPEC_SUFFIX = ".spec.json";
|
|
5164
|
+
NANNY_HEARTBEAT_MS = 5e3;
|
|
5165
|
+
HEARTBEAT_STALE_MS = 3e4;
|
|
5166
|
+
}));
|
|
5167
|
+
//#endregion
|
|
4943
5168
|
//#region src/providers/process.ts
|
|
4944
5169
|
/**
|
|
4945
5170
|
* Resolves a bare command through the entry's own directory and the project's
|
|
@@ -4963,14 +5188,14 @@ function resolveCwd(cwd, base = projectDir) {
|
|
|
4963
5188
|
function needsShell(command) {
|
|
4964
5189
|
return process.platform === "win32" && /\.(?:cmd|bat)$/i.test(command);
|
|
4965
5190
|
}
|
|
4966
|
-
function spawnManaged(spec) {
|
|
5191
|
+
function spawnManaged(spec, options = {}) {
|
|
4967
5192
|
return spawn(spec.command, spec.args, {
|
|
4968
5193
|
cwd: spec.cwd,
|
|
4969
5194
|
env: {
|
|
4970
5195
|
...process.env,
|
|
4971
5196
|
...spec.env
|
|
4972
5197
|
},
|
|
4973
|
-
detached: true,
|
|
5198
|
+
detached: options.detached ?? true,
|
|
4974
5199
|
stdio: [
|
|
4975
5200
|
"ignore",
|
|
4976
5201
|
"pipe",
|
|
@@ -5174,6 +5399,230 @@ var init_log_buffer = __esmMin((() => {
|
|
|
5174
5399
|
};
|
|
5175
5400
|
}));
|
|
5176
5401
|
//#endregion
|
|
5402
|
+
//#region src/providers/log-tail.ts
|
|
5403
|
+
/**
|
|
5404
|
+
* Cheap structural guard rather than a full schema pass: this runs per line on a hot
|
|
5405
|
+
* path, and the writer is our own nanny (which serializes `LogLine` unchanged).
|
|
5406
|
+
*/
|
|
5407
|
+
function parseLogLine(raw) {
|
|
5408
|
+
try {
|
|
5409
|
+
const value = JSON.parse(raw);
|
|
5410
|
+
if (typeof value.ts !== "number" || typeof value.text !== "string" || typeof value.stream !== "string") return null;
|
|
5411
|
+
if (!LOG_STREAMS.has(value.stream)) return null;
|
|
5412
|
+
return {
|
|
5413
|
+
ts: value.ts,
|
|
5414
|
+
stream: value.stream,
|
|
5415
|
+
text: value.text
|
|
5416
|
+
};
|
|
5417
|
+
} catch {
|
|
5418
|
+
return null;
|
|
5419
|
+
}
|
|
5420
|
+
}
|
|
5421
|
+
var MAX_BYTES_PER_READ, MAX_TAIL_BYTES, MAX_CARRY_CHARS, LOG_STREAMS, LogTailer;
|
|
5422
|
+
var init_log_tail = __esmMin((() => {
|
|
5423
|
+
MAX_BYTES_PER_READ = 262144;
|
|
5424
|
+
MAX_TAIL_BYTES = 262144;
|
|
5425
|
+
MAX_CARRY_CHARS = 1048576;
|
|
5426
|
+
LOG_STREAMS = /* @__PURE__ */ new Set([
|
|
5427
|
+
"stdout",
|
|
5428
|
+
"stderr",
|
|
5429
|
+
"system"
|
|
5430
|
+
]);
|
|
5431
|
+
LogTailer = class {
|
|
5432
|
+
file;
|
|
5433
|
+
offset = 0;
|
|
5434
|
+
identity = null;
|
|
5435
|
+
carry = "";
|
|
5436
|
+
constructor(file) {
|
|
5437
|
+
this.file = file;
|
|
5438
|
+
}
|
|
5439
|
+
/** Bytes consumed so far; equal to the file size once it has been drained. */
|
|
5440
|
+
get position() {
|
|
5441
|
+
return this.offset;
|
|
5442
|
+
}
|
|
5443
|
+
/** Start from the beginning again — used when a fresh process takes the entry over. */
|
|
5444
|
+
reset() {
|
|
5445
|
+
this.offset = 0;
|
|
5446
|
+
this.identity = null;
|
|
5447
|
+
this.carry = "";
|
|
5448
|
+
}
|
|
5449
|
+
/**
|
|
5450
|
+
* The last `count` lines, with the read position moved to the end of the file in the
|
|
5451
|
+
* same operation.
|
|
5452
|
+
*
|
|
5453
|
+
* A panel attaching to a running entry wants both a backfill and a live stream, and
|
|
5454
|
+
* only one thing may own the offset. Reading the tail through the same tailer is what
|
|
5455
|
+
* keeps a line from being replayed as news or skipped as history — and it is why this
|
|
5456
|
+
* does not delegate to `LogFiles.readTail()`, which knows nothing of this position.
|
|
5457
|
+
*/
|
|
5458
|
+
readTailLines(count) {
|
|
5459
|
+
let stats;
|
|
5460
|
+
try {
|
|
5461
|
+
stats = fs.statSync(this.file);
|
|
5462
|
+
} catch {
|
|
5463
|
+
return [];
|
|
5464
|
+
}
|
|
5465
|
+
const size = stats.size;
|
|
5466
|
+
this.carry = "";
|
|
5467
|
+
this.offset = size;
|
|
5468
|
+
this.identity = stats.ino === 0 ? stats.birthtimeMs : stats.ino;
|
|
5469
|
+
if (size === 0 || count <= 0) return [];
|
|
5470
|
+
const start = Math.max(0, size - MAX_TAIL_BYTES);
|
|
5471
|
+
let handle;
|
|
5472
|
+
try {
|
|
5473
|
+
handle = fs.openSync(this.file, "r");
|
|
5474
|
+
} catch {
|
|
5475
|
+
return [];
|
|
5476
|
+
}
|
|
5477
|
+
try {
|
|
5478
|
+
const buffer = Buffer$1.alloc(size - start);
|
|
5479
|
+
const bytes = fs.readSync(handle, buffer, 0, buffer.length, start);
|
|
5480
|
+
const raw = buffer.subarray(0, bytes).toString("utf8").split("\n").filter((line) => line.trim().length > 0);
|
|
5481
|
+
return (start > 0 ? raw.slice(1) : raw).slice(-count).map(parseLogLine).filter((line) => line !== null);
|
|
5482
|
+
} catch {
|
|
5483
|
+
return [];
|
|
5484
|
+
} finally {
|
|
5485
|
+
fs.closeSync(handle);
|
|
5486
|
+
}
|
|
5487
|
+
}
|
|
5488
|
+
/**
|
|
5489
|
+
* Ignore what is already in the file and follow it from here.
|
|
5490
|
+
*/
|
|
5491
|
+
skipToEnd() {
|
|
5492
|
+
this.carry = "";
|
|
5493
|
+
try {
|
|
5494
|
+
const stats = fs.statSync(this.file);
|
|
5495
|
+
this.offset = stats.size;
|
|
5496
|
+
this.identity = stats.ino === 0 ? stats.birthtimeMs : stats.ino;
|
|
5497
|
+
} catch {
|
|
5498
|
+
this.offset = 0;
|
|
5499
|
+
this.identity = null;
|
|
5500
|
+
}
|
|
5501
|
+
}
|
|
5502
|
+
/** Every complete line written since the previous call. */
|
|
5503
|
+
read() {
|
|
5504
|
+
let stats;
|
|
5505
|
+
try {
|
|
5506
|
+
stats = fs.statSync(this.file);
|
|
5507
|
+
} catch {
|
|
5508
|
+
return [];
|
|
5509
|
+
}
|
|
5510
|
+
const identity = stats.ino === 0 ? stats.birthtimeMs : stats.ino;
|
|
5511
|
+
if (this.identity !== null && (identity !== this.identity || stats.size < this.offset)) {
|
|
5512
|
+
this.carry = "";
|
|
5513
|
+
this.offset = 0;
|
|
5514
|
+
}
|
|
5515
|
+
this.identity = identity;
|
|
5516
|
+
if (stats.size <= this.offset) return [];
|
|
5517
|
+
const lines = [];
|
|
5518
|
+
let handle;
|
|
5519
|
+
try {
|
|
5520
|
+
handle = fs.openSync(this.file, "r");
|
|
5521
|
+
} catch {
|
|
5522
|
+
return [];
|
|
5523
|
+
}
|
|
5524
|
+
try {
|
|
5525
|
+
const buffer = Buffer$1.alloc(Math.min(MAX_BYTES_PER_READ, stats.size - this.offset));
|
|
5526
|
+
while (this.offset < stats.size) {
|
|
5527
|
+
const length = Math.min(buffer.length, stats.size - this.offset);
|
|
5528
|
+
const bytes = fs.readSync(handle, buffer, 0, length, this.offset);
|
|
5529
|
+
if (bytes <= 0) break;
|
|
5530
|
+
this.offset += bytes;
|
|
5531
|
+
this.consume(buffer.subarray(0, bytes).toString("utf8"), lines);
|
|
5532
|
+
if (bytes < buffer.length) break;
|
|
5533
|
+
}
|
|
5534
|
+
} catch {} finally {
|
|
5535
|
+
fs.closeSync(handle);
|
|
5536
|
+
}
|
|
5537
|
+
return lines;
|
|
5538
|
+
}
|
|
5539
|
+
consume(text, lines) {
|
|
5540
|
+
if (this.carry.length === 0) this.carry = text;
|
|
5541
|
+
else this.carry += text;
|
|
5542
|
+
const parts = this.carry.split("\n");
|
|
5543
|
+
this.carry = parts.pop() ?? "";
|
|
5544
|
+
for (const part of parts) {
|
|
5545
|
+
if (part.trim().length === 0) continue;
|
|
5546
|
+
const line = parseLogLine(part);
|
|
5547
|
+
if (line !== null) lines.push(line);
|
|
5548
|
+
}
|
|
5549
|
+
if (this.carry.length > MAX_CARRY_CHARS) this.carry = "";
|
|
5550
|
+
}
|
|
5551
|
+
};
|
|
5552
|
+
}));
|
|
5553
|
+
//#endregion
|
|
5554
|
+
//#region src/services/log-relay.ts
|
|
5555
|
+
var TAIL_INTERVAL_MS, LogRelay;
|
|
5556
|
+
var init_log_relay = __esmMin((() => {
|
|
5557
|
+
init_logger();
|
|
5558
|
+
init_log_tail();
|
|
5559
|
+
TAIL_INTERVAL_MS = 250;
|
|
5560
|
+
LogRelay = class {
|
|
5561
|
+
options;
|
|
5562
|
+
followed = /* @__PURE__ */ new Map();
|
|
5563
|
+
timer = null;
|
|
5564
|
+
closed = false;
|
|
5565
|
+
constructor(options) {
|
|
5566
|
+
this.options = options;
|
|
5567
|
+
}
|
|
5568
|
+
/**
|
|
5569
|
+
* Idempotent: following the same file again keeps the position already read.
|
|
5570
|
+
*
|
|
5571
|
+
* With `backfill`, the last lines already on disk are returned *and* the live position
|
|
5572
|
+
* is set past them by the same tailer, so the caller can fill a buffer without a line
|
|
5573
|
+
* being both history and news. Without it, the live stream simply starts at the end.
|
|
5574
|
+
*/
|
|
5575
|
+
follow(serverId, file, options = {}) {
|
|
5576
|
+
if (this.closed) return [];
|
|
5577
|
+
const current = this.followed.get(serverId);
|
|
5578
|
+
if (current !== void 0 && current.file === file) return [];
|
|
5579
|
+
const tailer = new LogTailer(file);
|
|
5580
|
+
const backfill = options.backfill ?? 0;
|
|
5581
|
+
const history = backfill > 0 ? tailer.readTailLines(backfill) : [];
|
|
5582
|
+
if (backfill <= 0) tailer.skipToEnd();
|
|
5583
|
+
this.followed.set(serverId, {
|
|
5584
|
+
file,
|
|
5585
|
+
tailer
|
|
5586
|
+
});
|
|
5587
|
+
this.start();
|
|
5588
|
+
return history;
|
|
5589
|
+
}
|
|
5590
|
+
unfollow(serverId) {
|
|
5591
|
+
this.followed.delete(serverId);
|
|
5592
|
+
if (this.followed.size === 0) this.stopTimer();
|
|
5593
|
+
}
|
|
5594
|
+
followedIds() {
|
|
5595
|
+
return [...this.followed.keys()];
|
|
5596
|
+
}
|
|
5597
|
+
dispose() {
|
|
5598
|
+
this.closed = true;
|
|
5599
|
+
this.followed.clear();
|
|
5600
|
+
this.stopTimer();
|
|
5601
|
+
}
|
|
5602
|
+
start() {
|
|
5603
|
+
if (this.timer !== null) return;
|
|
5604
|
+
this.timer = setInterval(() => {
|
|
5605
|
+
try {
|
|
5606
|
+
this.poll();
|
|
5607
|
+
} catch (error) {
|
|
5608
|
+
logger.warn(`tail failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
5609
|
+
}
|
|
5610
|
+
}, this.options.intervalMs ?? TAIL_INTERVAL_MS);
|
|
5611
|
+
this.timer.unref();
|
|
5612
|
+
}
|
|
5613
|
+
stopTimer() {
|
|
5614
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
5615
|
+
this.timer = null;
|
|
5616
|
+
}
|
|
5617
|
+
poll() {
|
|
5618
|
+
for (const [serverId, { tailer }] of this.followed) {
|
|
5619
|
+
const lines = tailer.read();
|
|
5620
|
+
if (lines.length > 0) this.options.onLines(serverId, lines);
|
|
5621
|
+
}
|
|
5622
|
+
}
|
|
5623
|
+
};
|
|
5624
|
+
}));
|
|
5625
|
+
//#endregion
|
|
5177
5626
|
//#region src/services/supervisor.ts
|
|
5178
5627
|
function delay(ms) {
|
|
5179
5628
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -5194,32 +5643,41 @@ function serverTemplateVars(config) {
|
|
|
5194
5643
|
home: os.homedir()
|
|
5195
5644
|
};
|
|
5196
5645
|
}
|
|
5197
|
-
var HISTORY_WINDOW_MS, HISTORY_CACHE_MS, TICK_INTERVAL_MS, PORT_STATE_INTERVAL_MS, PORT_RELEASE_RECHECK_MS, RESOURCE_SAMPLE_INTERVAL_MS, Supervisor;
|
|
5646
|
+
var HISTORY_WINDOW_MS, HISTORY_CACHE_MS, TICK_INTERVAL_MS, PORT_STATE_INTERVAL_MS, PORT_RELEASE_RECHECK_MS, RESOURCE_SAMPLE_INTERVAL_MS, PERSISTENT_BACKFILL_LINES, Supervisor;
|
|
5198
5647
|
var init_supervisor = __esmMin((() => {
|
|
5199
5648
|
init_backoff();
|
|
5200
5649
|
init_bind();
|
|
5201
5650
|
init_env_file();
|
|
5202
5651
|
init_logger();
|
|
5203
5652
|
init_paths();
|
|
5653
|
+
init_runtime();
|
|
5204
5654
|
init_template();
|
|
5205
5655
|
init_health_check();
|
|
5206
5656
|
init_identity();
|
|
5657
|
+
init_nanny$2();
|
|
5207
5658
|
init_port();
|
|
5208
5659
|
init_proc();
|
|
5209
5660
|
init_process();
|
|
5210
5661
|
init_dependencies();
|
|
5211
5662
|
init_log_buffer();
|
|
5663
|
+
init_log_relay();
|
|
5212
5664
|
HISTORY_WINDOW_MS = 864e5;
|
|
5213
5665
|
HISTORY_CACHE_MS = 5e3;
|
|
5214
5666
|
TICK_INTERVAL_MS = 1e3;
|
|
5215
5667
|
PORT_STATE_INTERVAL_MS = 1e4;
|
|
5216
5668
|
PORT_RELEASE_RECHECK_MS = 300;
|
|
5217
5669
|
RESOURCE_SAMPLE_INTERVAL_MS = 5e3;
|
|
5670
|
+
PERSISTENT_BACKFILL_LINES = 200;
|
|
5218
5671
|
Supervisor = class {
|
|
5219
5672
|
store;
|
|
5220
5673
|
hub;
|
|
5221
5674
|
options;
|
|
5222
5675
|
sampler = new ProcessSampler();
|
|
5676
|
+
/**
|
|
5677
|
+
* Live output of persistent entries. A normal entry's logs arrive down the pipe the
|
|
5678
|
+
* panel owns; a persistent one's are written by its nanny, so they are read back.
|
|
5679
|
+
*/
|
|
5680
|
+
relay = new LogRelay({ onLines: (serverId, lines) => this.ingestExternalLines(serverId, lines) });
|
|
5223
5681
|
entries = /* @__PURE__ */ new Map();
|
|
5224
5682
|
tickTimer;
|
|
5225
5683
|
disposed = false;
|
|
@@ -5228,6 +5686,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
5228
5686
|
this.store = store;
|
|
5229
5687
|
this.hub = hub;
|
|
5230
5688
|
this.options = options;
|
|
5689
|
+
const swept = sweepNannySpecs(this.options.nannyDir);
|
|
5690
|
+
if (swept > 0) logger.warn(`removed ${swept} unread nanny spec file(s) from ${this.options.nannyDir}`);
|
|
5231
5691
|
this.sync();
|
|
5232
5692
|
this.store.onChange(() => this.sync());
|
|
5233
5693
|
this.tickTimer = setInterval(() => {
|
|
@@ -5245,12 +5705,38 @@ var init_supervisor = __esmMin((() => {
|
|
|
5245
5705
|
return this.entries.get(id)?.logs.list(limit) ?? [];
|
|
5246
5706
|
}
|
|
5247
5707
|
async startAll(options = {}) {
|
|
5708
|
+
for (const entry of [...this.entries.values()]) {
|
|
5709
|
+
if (!entry.config.persistent || this.isActive(entry)) continue;
|
|
5710
|
+
if (entry.config.enabled) {
|
|
5711
|
+
await this.resumePersistent(entry).catch((error) => {
|
|
5712
|
+
logger.error(`could not reattach the persistent server ${entry.config.id}`, error);
|
|
5713
|
+
});
|
|
5714
|
+
continue;
|
|
5715
|
+
}
|
|
5716
|
+
try {
|
|
5717
|
+
if (await this.resumePersistent(entry)) await this.stop(entry.config.id);
|
|
5718
|
+
} catch (error) {
|
|
5719
|
+
logger.error(`could not stop the disabled persistent server ${entry.config.id}`, error);
|
|
5720
|
+
}
|
|
5721
|
+
}
|
|
5248
5722
|
const targets = [...this.entries.values()].filter((entry) => !options.autostartOnly || entry.config.autostart).map((entry) => entry.config);
|
|
5249
5723
|
for (const config of orderByDependencies(targets)) await this.start(config.id);
|
|
5250
5724
|
}
|
|
5725
|
+
/**
|
|
5726
|
+
* Stops every entry this command owns. A persistent entry is deliberately not among
|
|
5727
|
+
* them: that is the whole point of the flag, and it is said out loud rather than
|
|
5728
|
+
* silently skipped, so "stopped everything" is never claimed about a running server.
|
|
5729
|
+
*/
|
|
5251
5730
|
async stopAll() {
|
|
5252
5731
|
const targets = orderByDependencies([...this.entries.values()].map((entry) => entry.config)).reverse();
|
|
5253
|
-
for (const config of targets)
|
|
5732
|
+
for (const config of targets) {
|
|
5733
|
+
const entry = this.entries.get(config.id);
|
|
5734
|
+
if (entry !== void 0 && entry.config.persistent) {
|
|
5735
|
+
if (this.isActive(entry)) this.log(entry, "system", "persistent: left running (stop it explicitly to end it)");
|
|
5736
|
+
continue;
|
|
5737
|
+
}
|
|
5738
|
+
await this.stop(config.id);
|
|
5739
|
+
}
|
|
5254
5740
|
}
|
|
5255
5741
|
async start(id, options = {}) {
|
|
5256
5742
|
const entry = this.entries.get(id);
|
|
@@ -5284,6 +5770,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5284
5770
|
entry.status = "starting";
|
|
5285
5771
|
entry.health = entry.config.health.enabled ? "unknown" : "disabled";
|
|
5286
5772
|
this.publishServer(entry);
|
|
5773
|
+
if (entry.config.persistent && await this.resumePersistent(entry)) return { ok: true };
|
|
5287
5774
|
await this.runBootstrap(entry);
|
|
5288
5775
|
if (entry.stopping) return {
|
|
5289
5776
|
ok: false,
|
|
@@ -5396,17 +5883,32 @@ var init_supervisor = __esmMin((() => {
|
|
|
5396
5883
|
* in an old banner from killing a recycled pid.
|
|
5397
5884
|
*/
|
|
5398
5885
|
async portHolders(port) {
|
|
5399
|
-
const supervised = this.supervisedPids();
|
|
5886
|
+
const supervised = await this.supervisedPids();
|
|
5400
5887
|
const holders = await listPortHolders(port);
|
|
5401
5888
|
return {
|
|
5402
5889
|
ours: holders.filter((pid) => supervised.has(pid)),
|
|
5403
5890
|
foreign: holders.filter((pid) => !supervised.has(pid))
|
|
5404
5891
|
};
|
|
5405
5892
|
}
|
|
5406
|
-
/**
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5893
|
+
/**
|
|
5894
|
+
* Pids of the processes this panel owns, plus itself — the whole *tree* of every
|
|
5895
|
+
* entry, not just the pid it recorded.
|
|
5896
|
+
*
|
|
5897
|
+
* Both kinds of indirection are covered by that: a persistent entry runs under a
|
|
5898
|
+
* nanny, and a wrapper entry spawns the real server one generation down. The process
|
|
5899
|
+
* holding the port is a descendant in both cases, and a descendant mistaken for a
|
|
5900
|
+
* stranger is one `kill`/`free-port` away from stopping a server we own.
|
|
5901
|
+
*
|
|
5902
|
+
* The panel's own tree is deliberately *not* walked: "ours" has to mean a process a
|
|
5903
|
+
* server owns, or anything the panel happened to spawn would be unkillable and
|
|
5904
|
+
* unattributable.
|
|
5905
|
+
*/
|
|
5906
|
+
async supervisedPids() {
|
|
5907
|
+
const roots = [];
|
|
5908
|
+
for (const entry of this.entries.values()) if (entry.pid !== null) roots.push(entry.pid);
|
|
5909
|
+
else if (entry.child?.pid !== void 0) roots.push(entry.child.pid);
|
|
5910
|
+
const pids = await processTreePids(roots);
|
|
5911
|
+
pids.add(process.pid);
|
|
5410
5912
|
return pids;
|
|
5411
5913
|
}
|
|
5412
5914
|
/** The port's own answer, with the same short recheck the preflight uses. */
|
|
@@ -5428,7 +5930,8 @@ var init_supervisor = __esmMin((() => {
|
|
|
5428
5930
|
this.disposed = true;
|
|
5429
5931
|
clearInterval(this.tickTimer);
|
|
5430
5932
|
for (const entry of this.entries.values()) this.clearRetry(entry);
|
|
5431
|
-
await Promise.all([...this.entries.values()].map((entry) => this.stopEntry(entry)));
|
|
5933
|
+
await Promise.all([...this.entries.values()].filter((entry) => !entry.config.persistent).map((entry) => this.stopEntry(entry)));
|
|
5934
|
+
this.relay.dispose();
|
|
5432
5935
|
}
|
|
5433
5936
|
/**
|
|
5434
5937
|
* Starts whatever this server depends on and waits for it to accept
|
|
@@ -5497,20 +6000,38 @@ var init_supervisor = __esmMin((() => {
|
|
|
5497
6000
|
}
|
|
5498
6001
|
entry.status = "stopping";
|
|
5499
6002
|
this.publishServer(entry);
|
|
6003
|
+
const nannyState = entry.config.persistent ? readNannyState(nannyStatePath(this.options.nannyDir, entry.config.id)) : null;
|
|
5500
6004
|
if ((entry.child === null && entry.pid !== null ? await terminatePid(entry.pid, entry.config.stop) : await terminate(entry.child, entry.config.stop)) === "force-killed") this.log(entry, "system", "force-killed after grace period");
|
|
6005
|
+
if (nannyState?.childPid != null && !entry.config.stop.killGroup) await terminatePid(nannyState.childPid, {
|
|
6006
|
+
...entry.config.stop,
|
|
6007
|
+
killGroup: false
|
|
6008
|
+
});
|
|
5501
6009
|
const { port, stop } = entry.config;
|
|
5502
6010
|
if (stop.killPortHolders && port !== null) {
|
|
5503
|
-
const leftover = await killPortHolders(port, this.supervisedPids());
|
|
6011
|
+
const leftover = await killPortHolders(port, await this.supervisedPids());
|
|
5504
6012
|
if (leftover.length > 0) this.log(entry, "system", `port ${port} was still held by pid ${leftover.join(", ")} — killed`);
|
|
5505
6013
|
}
|
|
6014
|
+
const survivor = entry.config.persistent && entry.pid !== null && isProcessAlive(entry.pid) ? entry.pid : null;
|
|
6015
|
+
if (entry.config.persistent) {
|
|
6016
|
+
if (survivor === null) {
|
|
6017
|
+
clearNannyState(nannyStatePath(this.options.nannyDir, entry.config.id));
|
|
6018
|
+
clearNannySpec(nannySpecPath(this.options.nannyDir, entry.config.id));
|
|
6019
|
+
} else this.log(entry, "system", `pid ${survivor} is still alive after the stop — its state file is kept`);
|
|
6020
|
+
this.relay.unfollow(entry.config.id);
|
|
6021
|
+
}
|
|
5506
6022
|
entry.stopping = false;
|
|
5507
6023
|
entry.child = null;
|
|
5508
6024
|
entry.pid = null;
|
|
5509
6025
|
entry.adopted = false;
|
|
5510
|
-
entry.
|
|
5511
|
-
|
|
6026
|
+
entry.nannyExit = null;
|
|
6027
|
+
entry.status = survivor === null ? "stopped" : "conflict";
|
|
6028
|
+
if (survivor !== null) entry.lastError = `pid ${survivor} survived the stop`;
|
|
6029
|
+
this.log(entry, "system", survivor === null ? "stopped" : entry.lastError);
|
|
5512
6030
|
this.publishServer(entry);
|
|
5513
|
-
return { ok: true }
|
|
6031
|
+
return survivor === null ? { ok: true } : {
|
|
6032
|
+
ok: false,
|
|
6033
|
+
error: entry.lastError
|
|
6034
|
+
};
|
|
5514
6035
|
}
|
|
5515
6036
|
createEntry(config) {
|
|
5516
6037
|
return {
|
|
@@ -5533,6 +6054,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5533
6054
|
lastOccupancyProbeAt: 0,
|
|
5534
6055
|
probing: false,
|
|
5535
6056
|
adopted: false,
|
|
6057
|
+
nannyExit: null,
|
|
5536
6058
|
starting: false,
|
|
5537
6059
|
stopping: false,
|
|
5538
6060
|
bootstrapDone: !config.bootstrap,
|
|
@@ -5549,6 +6071,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5549
6071
|
const config = wanted.get(id);
|
|
5550
6072
|
if (!config) {
|
|
5551
6073
|
this.entries.delete(id);
|
|
6074
|
+
this.relay.unfollow(id);
|
|
5552
6075
|
this.stopEntry(entry).catch((error) => {
|
|
5553
6076
|
logger.error(`could not stop the removed server ${id}`, error);
|
|
5554
6077
|
});
|
|
@@ -5556,6 +6079,7 @@ var init_supervisor = __esmMin((() => {
|
|
|
5556
6079
|
}
|
|
5557
6080
|
const bufferChanged = entry.config.logBufferLines !== config.logBufferLines;
|
|
5558
6081
|
entry.config = config;
|
|
6082
|
+
if (!config.persistent) this.relay.unfollow(id);
|
|
5559
6083
|
if (bufferChanged) {
|
|
5560
6084
|
const kept = entry.logs.list(config.logBufferLines);
|
|
5561
6085
|
entry.logs = new LogBuffer(config.logBufferLines);
|
|
@@ -5701,6 +6225,11 @@ var init_supervisor = __esmMin((() => {
|
|
|
5701
6225
|
* Takes over a detached successor: no spawn, no duplicate. The pid is supervised
|
|
5702
6226
|
* from here on (liveness, health probe, resources, stop), while its output stays
|
|
5703
6227
|
* wherever it was redirected.
|
|
6228
|
+
*
|
|
6229
|
+
* For a persistent entry that output is not a mystery: its nanny is still writing
|
|
6230
|
+
* the entry's log file, so the relay is started here too. Without this, adopting a
|
|
6231
|
+
* persistent entry whose state file was lost would show its history but never
|
|
6232
|
+
* another live line.
|
|
5704
6233
|
*/
|
|
5705
6234
|
adoptEntry(entry, pid) {
|
|
5706
6235
|
entry.adopted = true;
|
|
@@ -5711,13 +6240,17 @@ var init_supervisor = __esmMin((() => {
|
|
|
5711
6240
|
entry.startedAt = Date.now();
|
|
5712
6241
|
entry.lastError = null;
|
|
5713
6242
|
this.log(entry, "system", `adopted pid ${pid}: a detached restart of this entry is already serving port ${entry.config.port}`);
|
|
6243
|
+
if (entry.config.persistent) this.followPersistent(entry);
|
|
5714
6244
|
this.publishServer(entry);
|
|
5715
6245
|
return { ok: true };
|
|
5716
6246
|
}
|
|
5717
6247
|
/** An adopted successor disappeared: fall back to the normal lifecycle. */
|
|
5718
6248
|
handleAdoptedExit(entry) {
|
|
5719
6249
|
if (entry.pid !== null) this.sampler.forget(entry.pid);
|
|
5720
|
-
|
|
6250
|
+
if (entry.config.persistent) this.consumeNannyExit(entry);
|
|
6251
|
+
const ranForMs = entry.nannyExit?.runtimeMs ?? (entry.startedAt === null ? 0 : Date.now() - entry.startedAt);
|
|
6252
|
+
const detail = entry.nannyExit === null ? "adopted process exited" : describeNannyExit(entry.nannyExit);
|
|
6253
|
+
entry.nannyExit = null;
|
|
5721
6254
|
entry.adopted = false;
|
|
5722
6255
|
entry.pid = null;
|
|
5723
6256
|
entry.resources = null;
|
|
@@ -5726,11 +6259,11 @@ var init_supervisor = __esmMin((() => {
|
|
|
5726
6259
|
entry.exitSignal = null;
|
|
5727
6260
|
this.options.history.record(entry.config.id, {
|
|
5728
6261
|
type: "exit",
|
|
5729
|
-
detail
|
|
6262
|
+
detail,
|
|
5730
6263
|
runtimeMs: ranForMs
|
|
5731
6264
|
});
|
|
5732
6265
|
this.log(entry, "system", `the adopted process is gone after ${Math.max(1, Math.round(ranForMs / 1e3))}s`);
|
|
5733
|
-
this.afterExit(entry,
|
|
6266
|
+
this.afterExit(entry, detail, ranForMs, false);
|
|
5734
6267
|
}
|
|
5735
6268
|
async runBootstrap(entry) {
|
|
5736
6269
|
const spec = entry.config.bootstrap;
|
|
@@ -5818,6 +6351,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
5818
6351
|
};
|
|
5819
6352
|
}
|
|
5820
6353
|
spawnEntry(entry) {
|
|
6354
|
+
return entry.config.persistent ? this.spawnNanny(entry) : this.spawnDirect(entry);
|
|
6355
|
+
}
|
|
6356
|
+
spawnDirect(entry) {
|
|
5821
6357
|
const { command, args, env, cwd, loggedArgs } = this.resolveSpawn(entry);
|
|
5822
6358
|
this.log(entry, "system", `start: ${command} ${loggedArgs.join(" ")}`);
|
|
5823
6359
|
let child;
|
|
@@ -5829,46 +6365,198 @@ var init_supervisor = __esmMin((() => {
|
|
|
5829
6365
|
env
|
|
5830
6366
|
});
|
|
5831
6367
|
} catch (error) {
|
|
5832
|
-
entry.
|
|
5833
|
-
entry.lastError = error.message;
|
|
5834
|
-
this.log(entry, "system", `spawn failed: ${entry.lastError}`);
|
|
5835
|
-
this.publishServer(entry);
|
|
5836
|
-
return {
|
|
5837
|
-
ok: false,
|
|
5838
|
-
error: entry.lastError
|
|
5839
|
-
};
|
|
6368
|
+
return this.failSpawn(entry, error.message);
|
|
5840
6369
|
}
|
|
6370
|
+
const stdout = new LineSplitter((stream, text) => this.log(entry, stream, text));
|
|
6371
|
+
const stderr = new LineSplitter((stream, text) => this.log(entry, stream, text));
|
|
6372
|
+
child.stdout?.on("data", (chunk) => stdout.push("stdout", chunk));
|
|
6373
|
+
child.stderr?.on("data", (chunk) => stderr.push("stderr", chunk));
|
|
6374
|
+
this.attachChild(entry, child, `${command} ${args.join(" ")}`.trim(), () => {
|
|
6375
|
+
stdout.flush("stdout");
|
|
6376
|
+
stderr.flush("stderr");
|
|
6377
|
+
});
|
|
6378
|
+
return { ok: true };
|
|
6379
|
+
}
|
|
6380
|
+
/**
|
|
6381
|
+
* Starts a persistent entry: the panel spawns a *nanny*, which spawns the entry and
|
|
6382
|
+
* owns its pipes. From here the nanny is the child this supervisor tracks — its exit
|
|
6383
|
+
* event drives the normal lifecycle (backoff, history, notifications) — while the
|
|
6384
|
+
* real exit cause is read back from the state file the nanny leaves behind.
|
|
6385
|
+
*/
|
|
6386
|
+
spawnNanny(entry) {
|
|
6387
|
+
const { command, args, env, cwd, loggedArgs } = this.resolveSpawn(entry);
|
|
6388
|
+
this.log(entry, "system", `start (persistent): ${command} ${loggedArgs.join(" ")}`);
|
|
6389
|
+
const entryPoint = nannyEntryPoint();
|
|
6390
|
+
if (entryPoint.length === 0) return this.failSpawn(entry, "cannot locate this CLI to start a persistent entry");
|
|
6391
|
+
const id = entry.config.id;
|
|
6392
|
+
const specPath = nannySpecPath(this.options.nannyDir, id);
|
|
6393
|
+
const statePath = nannyStatePath(this.options.nannyDir, id);
|
|
6394
|
+
const spec = {
|
|
6395
|
+
serverId: id,
|
|
6396
|
+
command,
|
|
6397
|
+
args,
|
|
6398
|
+
cwd,
|
|
6399
|
+
env,
|
|
6400
|
+
logDir: this.options.logFiles.directory,
|
|
6401
|
+
logs: this.options.logFiles.config,
|
|
6402
|
+
stop: entry.config.stop
|
|
6403
|
+
};
|
|
6404
|
+
let child;
|
|
6405
|
+
try {
|
|
6406
|
+
writeNannySpec(specPath, spec);
|
|
6407
|
+
child = spawn(process.execPath, nannyArgv(entryPoint, id, specPath, statePath), {
|
|
6408
|
+
cwd,
|
|
6409
|
+
env: {
|
|
6410
|
+
...process.env,
|
|
6411
|
+
...env,
|
|
6412
|
+
HHOSTED_HOME: dataRoot,
|
|
6413
|
+
HHOSTED_PROJECT: projectDir
|
|
6414
|
+
},
|
|
6415
|
+
stdio: [
|
|
6416
|
+
"ignore",
|
|
6417
|
+
"ignore",
|
|
6418
|
+
"ignore"
|
|
6419
|
+
],
|
|
6420
|
+
detached: true,
|
|
6421
|
+
windowsHide: true
|
|
6422
|
+
});
|
|
6423
|
+
} catch (error) {
|
|
6424
|
+
clearNannySpec(specPath);
|
|
6425
|
+
return this.failSpawn(entry, error.message);
|
|
6426
|
+
}
|
|
6427
|
+
child.unref();
|
|
6428
|
+
this.attachChild(entry, child, `nanny → ${command} ${args.join(" ")}`.trim());
|
|
6429
|
+
this.followPersistent(entry);
|
|
6430
|
+
return { ok: true };
|
|
6431
|
+
}
|
|
6432
|
+
failSpawn(entry, message) {
|
|
6433
|
+
entry.status = "crashed";
|
|
6434
|
+
entry.lastError = message;
|
|
6435
|
+
this.log(entry, "system", `spawn failed: ${message}`);
|
|
6436
|
+
this.publishServer(entry);
|
|
6437
|
+
return {
|
|
6438
|
+
ok: false,
|
|
6439
|
+
error: message
|
|
6440
|
+
};
|
|
6441
|
+
}
|
|
6442
|
+
/** The bookkeeping every spawn shares, plus the exit events that end it. */
|
|
6443
|
+
attachChild(entry, child, detail, flush) {
|
|
5841
6444
|
entry.child = child;
|
|
5842
6445
|
entry.pid = child.pid ?? null;
|
|
5843
6446
|
entry.startedAt = Date.now();
|
|
5844
6447
|
this.options.history.record(entry.config.id, {
|
|
5845
6448
|
type: "start",
|
|
5846
|
-
detail
|
|
6449
|
+
detail
|
|
5847
6450
|
});
|
|
5848
6451
|
entry.exitCode = null;
|
|
5849
6452
|
entry.exitSignal = null;
|
|
6453
|
+
entry.nannyExit = null;
|
|
5850
6454
|
entry.lastProbeAt = 0;
|
|
5851
6455
|
entry.healthFailures = 0;
|
|
5852
6456
|
entry.unhealthySince = null;
|
|
5853
6457
|
this.publishServer(entry);
|
|
5854
|
-
const stdout = new LineSplitter((stream, text) => this.log(entry, stream, text));
|
|
5855
|
-
const stderr = new LineSplitter((stream, text) => this.log(entry, stream, text));
|
|
5856
|
-
child.stdout?.on("data", (chunk) => stdout.push("stdout", chunk));
|
|
5857
|
-
child.stderr?.on("data", (chunk) => stderr.push("stderr", chunk));
|
|
5858
6458
|
child.once("error", (error) => {
|
|
5859
6459
|
entry.lastError = error.message;
|
|
5860
6460
|
this.log(entry, "system", `process error: ${entry.lastError}`);
|
|
5861
|
-
|
|
5862
|
-
stderr.flush("stderr");
|
|
6461
|
+
flush?.();
|
|
5863
6462
|
this.handleExit(entry, child, null, null);
|
|
5864
6463
|
});
|
|
5865
6464
|
child.once("exit", (code, signal) => {
|
|
5866
|
-
|
|
5867
|
-
stderr.flush("stderr");
|
|
6465
|
+
flush?.();
|
|
5868
6466
|
this.handleExit(entry, child, code, signal);
|
|
5869
6467
|
});
|
|
5870
6468
|
this.awaitReadiness(entry, child);
|
|
5871
|
-
|
|
6469
|
+
}
|
|
6470
|
+
/**
|
|
6471
|
+
* Live output for a persistent entry, which arrives by reading its log file rather
|
|
6472
|
+
* than down a pipe. It is pushed into the same ring buffer and published as the same
|
|
6473
|
+
* SSE frame a normal entry's output is, so nothing downstream knows the difference.
|
|
6474
|
+
*/
|
|
6475
|
+
ingestExternalLines(serverId, lines) {
|
|
6476
|
+
const entry = this.entries.get(serverId);
|
|
6477
|
+
if (entry === void 0 || !entry.config.persistent || lines.length === 0) return;
|
|
6478
|
+
for (const line of lines) entry.logs.push(line);
|
|
6479
|
+
this.hub.publish({
|
|
6480
|
+
type: "log",
|
|
6481
|
+
ts: lines[lines.length - 1].ts,
|
|
6482
|
+
serverId,
|
|
6483
|
+
lines
|
|
6484
|
+
});
|
|
6485
|
+
}
|
|
6486
|
+
/**
|
|
6487
|
+
* Follows a persistent entry's log file, filling the ring buffer from what is on disk.
|
|
6488
|
+
*
|
|
6489
|
+
* The backfill is read *through* the tailer, which is the only thing that may own the
|
|
6490
|
+
* offset: reading it from `LogFiles` first and seeking to EOF second would either
|
|
6491
|
+
* replay a line as news or skip one as history, depending on which order the two
|
|
6492
|
+
* syscalls happen to land in.
|
|
6493
|
+
*/
|
|
6494
|
+
followPersistent(entry) {
|
|
6495
|
+
const id = entry.config.id;
|
|
6496
|
+
const backfill = entry.logs.size === 0 && this.options.logFiles.config.persist ? PERSISTENT_BACKFILL_LINES : 0;
|
|
6497
|
+
const history = this.relay.follow(id, nannyLogFile(this.options.logFiles.directory, id), { backfill });
|
|
6498
|
+
if (history.length > 0) entry.logs.extend(history);
|
|
6499
|
+
}
|
|
6500
|
+
/**
|
|
6501
|
+
* Reattaches to a persistent entry that is still running, or reports how it ended
|
|
6502
|
+
* while nobody was watching. Never starts anything: that is `start()`'s job, and it
|
|
6503
|
+
* is what keeps this honest about "not starting entries on your own".
|
|
6504
|
+
*/
|
|
6505
|
+
async resumePersistent(entry) {
|
|
6506
|
+
const id = entry.config.id;
|
|
6507
|
+
const state = readNannyState(nannyStatePath(this.options.nannyDir, id));
|
|
6508
|
+
if (state !== null && state.serverId === id && await nannyIsAlive(state, id)) {
|
|
6509
|
+
entry.adopted = true;
|
|
6510
|
+
entry.child = null;
|
|
6511
|
+
entry.pid = state.nannyPid;
|
|
6512
|
+
entry.startedAt = state.startedAt;
|
|
6513
|
+
entry.status = "running";
|
|
6514
|
+
entry.health = entry.config.health.enabled ? "unknown" : "disabled";
|
|
6515
|
+
entry.portState = entry.config.port === null ? "unknown" : "in-use";
|
|
6516
|
+
entry.lastError = null;
|
|
6517
|
+
entry.nannyExit = null;
|
|
6518
|
+
const child = state.childPid === null ? "" : `, server pid ${state.childPid}`;
|
|
6519
|
+
this.log(entry, "system", `persistent: still running (nanny pid ${state.nannyPid}${child}) — reattached`);
|
|
6520
|
+
this.followPersistent(entry);
|
|
6521
|
+
this.publishServer(entry);
|
|
6522
|
+
return true;
|
|
6523
|
+
}
|
|
6524
|
+
if (state === null) return false;
|
|
6525
|
+
this.consumeNannyExit(entry);
|
|
6526
|
+
const exit = entry.nannyExit;
|
|
6527
|
+
entry.nannyExit = null;
|
|
6528
|
+
if (exit === null) return false;
|
|
6529
|
+
const detail = exit.code === null && exit.signal === null ? "it could not start" : describeNannyExit(exit);
|
|
6530
|
+
const ranFor = `${Math.max(1, Math.round(exit.runtimeMs / 1e3))}s`;
|
|
6531
|
+
this.options.history.record(id, {
|
|
6532
|
+
type: "exit",
|
|
6533
|
+
detail: `while the panel was away: ${detail}`,
|
|
6534
|
+
runtimeMs: exit.runtimeMs
|
|
6535
|
+
});
|
|
6536
|
+
this.log(entry, "system", `persistent: exited while the panel was away with ${detail} after ${ranFor}`);
|
|
6537
|
+
if (exit.code !== 0 || exit.signal !== null) {
|
|
6538
|
+
entry.status = "crashed";
|
|
6539
|
+
entry.lastError = `exited while the panel was away with ${detail}`;
|
|
6540
|
+
this.options.history.record(id, {
|
|
6541
|
+
type: "crash",
|
|
6542
|
+
detail: entry.lastError,
|
|
6543
|
+
runtimeMs: exit.runtimeMs
|
|
6544
|
+
});
|
|
6545
|
+
this.notify(entry, "crash", entry.lastError);
|
|
6546
|
+
}
|
|
6547
|
+
this.publishServer(entry);
|
|
6548
|
+
return false;
|
|
6549
|
+
}
|
|
6550
|
+
/**
|
|
6551
|
+
* The nanny's last word, then the state file goes: a panel that has read how the
|
|
6552
|
+
* entry ended must not report it twice.
|
|
6553
|
+
*/
|
|
6554
|
+
consumeNannyExit(entry) {
|
|
6555
|
+
const statePath = nannyStatePath(this.options.nannyDir, entry.config.id);
|
|
6556
|
+
const state = readNannyState(statePath);
|
|
6557
|
+
if (state?.lastExit !== void 0) entry.nannyExit = state.lastExit;
|
|
6558
|
+
clearNannyState(statePath);
|
|
6559
|
+
this.relay.unfollow(entry.config.id);
|
|
5872
6560
|
}
|
|
5873
6561
|
/** One probe using the configured mode (TCP or HTTP), with timing. */
|
|
5874
6562
|
async probeEntryHealth(entry) {
|
|
@@ -5921,6 +6609,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
5921
6609
|
handleExit(entry, child, code, signal) {
|
|
5922
6610
|
if (entry.child !== child) return;
|
|
5923
6611
|
if (entry.pid !== null) this.sampler.forget(entry.pid);
|
|
6612
|
+
if (entry.config.persistent) this.consumeNannyExit(entry);
|
|
6613
|
+
const nannyExit = entry.nannyExit;
|
|
6614
|
+
entry.nannyExit = null;
|
|
5924
6615
|
entry.child = null;
|
|
5925
6616
|
entry.pid = null;
|
|
5926
6617
|
entry.adopted = false;
|
|
@@ -5928,9 +6619,9 @@ var init_supervisor = __esmMin((() => {
|
|
|
5928
6619
|
entry.responseMs = null;
|
|
5929
6620
|
entry.exitCode = code;
|
|
5930
6621
|
entry.exitSignal = signal;
|
|
5931
|
-
const neverStarted = code === null && signal === null && entry.lastError !== null;
|
|
5932
|
-
const detail = neverStarted ? entry.lastError : signal !== null ? `signal ${signal}` : `code ${code}`;
|
|
5933
|
-
const ranForMs = entry.startedAt === null ? 0 : Date.now() - entry.startedAt;
|
|
6622
|
+
const neverStarted = nannyExit !== null ? nannyExit.code === null && nannyExit.signal === null : code === null && signal === null && entry.lastError !== null;
|
|
6623
|
+
const detail = neverStarted ? entry.lastError ?? "the entry could not start" : nannyExit !== null ? describeNannyExit(nannyExit) : signal !== null ? `signal ${signal}` : `code ${code}`;
|
|
6624
|
+
const ranForMs = nannyExit?.runtimeMs ?? (entry.startedAt === null ? 0 : Date.now() - entry.startedAt);
|
|
5934
6625
|
this.options.history.record(entry.config.id, {
|
|
5935
6626
|
type: "exit",
|
|
5936
6627
|
detail,
|
|
@@ -7342,6 +8033,10 @@ var init_log_files = __esmMin((() => {
|
|
|
7342
8033
|
get directory() {
|
|
7343
8034
|
return this.dir;
|
|
7344
8035
|
}
|
|
8036
|
+
/** The retention a nanny has to apply itself, on the same files. */
|
|
8037
|
+
get config() {
|
|
8038
|
+
return this.getConfig();
|
|
8039
|
+
}
|
|
7345
8040
|
append(serverId, line) {
|
|
7346
8041
|
if (this.closed || !this.getConfig().persist) return;
|
|
7347
8042
|
const bucket = this.pending.get(serverId) ?? [];
|
|
@@ -7369,6 +8064,11 @@ var init_log_files = __esmMin((() => {
|
|
|
7369
8064
|
const config = this.getConfig();
|
|
7370
8065
|
const files = [];
|
|
7371
8066
|
let sizeBytes = 0;
|
|
8067
|
+
if (!config.persist) return {
|
|
8068
|
+
enabled: false,
|
|
8069
|
+
sizeBytes,
|
|
8070
|
+
files
|
|
8071
|
+
};
|
|
7372
8072
|
for (const file of this.rotateTargets(serverId)) try {
|
|
7373
8073
|
const stats = fs.statSync(file);
|
|
7374
8074
|
files.push({
|
|
@@ -7383,8 +8083,15 @@ var init_log_files = __esmMin((() => {
|
|
|
7383
8083
|
files
|
|
7384
8084
|
};
|
|
7385
8085
|
}
|
|
7386
|
-
/**
|
|
8086
|
+
/**
|
|
8087
|
+
* Reads the last `tail` lines, newest file first, padding from one rotation back.
|
|
8088
|
+
*
|
|
8089
|
+
* With `persist` off there is nothing this panel is willing to serve, even though a
|
|
8090
|
+
* persistent entry's nanny still writes its file: that file is the log's transport —
|
|
8091
|
+
* there is no pipe to carry it — and `persist` decides what counts as history.
|
|
8092
|
+
*/
|
|
7387
8093
|
readTail(serverId, tail) {
|
|
8094
|
+
if (!this.getConfig().persist) return [];
|
|
7388
8095
|
const sources = [this.currentPath(serverId), this.rotatedPath(serverId, 1)];
|
|
7389
8096
|
const lines = [];
|
|
7390
8097
|
for (const file of sources) {
|
|
@@ -8608,7 +9315,8 @@ async function runControlPlane(options) {
|
|
|
8608
9315
|
history,
|
|
8609
9316
|
logFiles,
|
|
8610
9317
|
notifications,
|
|
8611
|
-
hostMonitor
|
|
9318
|
+
hostMonitor,
|
|
9319
|
+
nannyDir: defaultNannyDir
|
|
8612
9320
|
});
|
|
8613
9321
|
/**
|
|
8614
9322
|
* A config edited by hand — a text editor, a `git checkout`, a config-management
|
|
@@ -8790,20 +9498,6 @@ function toUpFlags(args) {
|
|
|
8790
9498
|
printConfig: args.printConfig === true
|
|
8791
9499
|
};
|
|
8792
9500
|
}
|
|
8793
|
-
/**
|
|
8794
|
-
* How to run this CLI again in the same runtime. Under tsx that means passing the
|
|
8795
|
-
* resolved loader too, because the daemon's working directory is the project's,
|
|
8796
|
-
* not the package's.
|
|
8797
|
-
*/
|
|
8798
|
-
function runtimeArgs() {
|
|
8799
|
-
let resolved = null;
|
|
8800
|
-
const resolveTsx = () => resolved ??= import.meta.resolve("tsx");
|
|
8801
|
-
return process.execArgv.map((arg) => {
|
|
8802
|
-
if (arg === "tsx") return resolveTsx();
|
|
8803
|
-
if (arg.startsWith("--import=") && arg.slice(9) === "tsx") return `--import=${resolveTsx()}`;
|
|
8804
|
-
return arg;
|
|
8805
|
-
});
|
|
8806
|
-
}
|
|
8807
9501
|
/** One rotation is enough for a console log. */
|
|
8808
9502
|
function rotateLog(file) {
|
|
8809
9503
|
try {
|
|
@@ -8905,6 +9599,7 @@ var DEFAULT_PORT$1, LOG_ROTATE_BYTES, upArgs;
|
|
|
8905
9599
|
var init_up = __esmMin((() => {
|
|
8906
9600
|
init_args();
|
|
8907
9601
|
init_io();
|
|
9602
|
+
init_runtime();
|
|
8908
9603
|
DEFAULT_PORT$1 = 3999;
|
|
8909
9604
|
LOG_ROTATE_BYTES = 5242880;
|
|
8910
9605
|
upArgs = {
|
|
@@ -8965,6 +9660,7 @@ async function runDown() {
|
|
|
8965
9660
|
if (await waitForExit(runtime.pid, 2e4)) {
|
|
8966
9661
|
clearRuntime();
|
|
8967
9662
|
process.stdout.write(`${green("stopped")}\n`);
|
|
9663
|
+
await reportPersistent();
|
|
8968
9664
|
return;
|
|
8969
9665
|
}
|
|
8970
9666
|
process.stdout.write(`${dim("it did not stop in time — forcing")}\n`);
|
|
@@ -8972,6 +9668,32 @@ async function runDown() {
|
|
|
8972
9668
|
await waitForExit(runtime.pid, 5e3);
|
|
8973
9669
|
clearRuntime();
|
|
8974
9670
|
process.stdout.write(`${green("stopped")} (forced)\n`);
|
|
9671
|
+
await reportPersistent();
|
|
9672
|
+
}
|
|
9673
|
+
/**
|
|
9674
|
+
* `down` stops what this panel supervises — a persistent entry is the one thing it
|
|
9675
|
+
* deliberately does not, so the silence about it has to be broken here. The state
|
|
9676
|
+
* files are the only record that survives the panel, so they answer it.
|
|
9677
|
+
*/
|
|
9678
|
+
async function reportPersistent() {
|
|
9679
|
+
const { readNannyState, nannyIsAlive, SPEC_SUFFIX } = await Promise.resolve().then(() => (init_nanny$2(), nanny_exports$2));
|
|
9680
|
+
const { defaultNannyDir } = await Promise.resolve().then(() => (init_paths(), paths_exports));
|
|
9681
|
+
const { readdirSync } = await import("node:fs");
|
|
9682
|
+
const path = await import("node:path");
|
|
9683
|
+
let files = [];
|
|
9684
|
+
try {
|
|
9685
|
+
files = readdirSync(defaultNannyDir).filter((name) => name.endsWith(".json") && !name.endsWith(SPEC_SUFFIX));
|
|
9686
|
+
} catch {
|
|
9687
|
+
return;
|
|
9688
|
+
}
|
|
9689
|
+
const running = [];
|
|
9690
|
+
for (const file of files) {
|
|
9691
|
+
const state = readNannyState(path.join(defaultNannyDir, file));
|
|
9692
|
+
if (state !== null && await nannyIsAlive(state, state.serverId)) running.push(state.serverId);
|
|
9693
|
+
}
|
|
9694
|
+
if (running.length === 0) return;
|
|
9695
|
+
process.stdout.write(`${dim(`${running.length} persistent server(s) left running: ${running.join(", ")}`)}\n`);
|
|
9696
|
+
process.stdout.write(`${dim("stop one from the panel, or restart it here to reattach")}\n`);
|
|
8975
9697
|
}
|
|
8976
9698
|
async function waitForExit(pid, timeoutMs) {
|
|
8977
9699
|
const { isProcessAlive } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
|
|
@@ -10000,9 +10722,150 @@ var init_ui_revert = __esmMin((() => {
|
|
|
10000
10722
|
});
|
|
10001
10723
|
}));
|
|
10002
10724
|
//#endregion
|
|
10725
|
+
//#region src/services/nanny.ts
|
|
10726
|
+
var nanny_exports$1 = /* @__PURE__ */ __exportAll({ runNanny: () => runNanny });
|
|
10727
|
+
/**
|
|
10728
|
+
* The nanny of one persistent entry: it spawns the child, owns its pipes, writes its
|
|
10729
|
+
* output to the entry's JSONL log and mirrors the child's exit.
|
|
10730
|
+
*
|
|
10731
|
+
* This is the whole reason a persistent entry survives the panel. The pipes belong to
|
|
10732
|
+
* a process that is not the panel, so a panel stop, restart or SIGKILL cannot break
|
|
10733
|
+
* them; the log keeps being written; and the exit code the panel would have collected
|
|
10734
|
+
* as a parent is left in the state file for whoever comes back.
|
|
10735
|
+
*
|
|
10736
|
+
* It deliberately does *not* restart anything: retries, backoff and health stay the
|
|
10737
|
+
* panel's business, so there is only ever one supervisor making that decision.
|
|
10738
|
+
*/
|
|
10739
|
+
async function runNanny(spec, statePath) {
|
|
10740
|
+
const id = spec.serverId;
|
|
10741
|
+
const startedAt = Date.now();
|
|
10742
|
+
const logFile = nannyLogFile(spec.logDir, id);
|
|
10743
|
+
const logFiles = new LogFiles(spec.logDir, () => ({
|
|
10744
|
+
...spec.logs,
|
|
10745
|
+
persist: true
|
|
10746
|
+
}));
|
|
10747
|
+
let childPid = null;
|
|
10748
|
+
let lastExit = null;
|
|
10749
|
+
let exitCode = 0;
|
|
10750
|
+
const persistState = () => {
|
|
10751
|
+
const state = {
|
|
10752
|
+
serverId: id,
|
|
10753
|
+
nannyPid: process.pid,
|
|
10754
|
+
childPid,
|
|
10755
|
+
startedAt,
|
|
10756
|
+
logFile,
|
|
10757
|
+
heartbeatAt: Date.now()
|
|
10758
|
+
};
|
|
10759
|
+
if (lastExit !== null) state.lastExit = lastExit;
|
|
10760
|
+
writeNannyState(statePath, state);
|
|
10761
|
+
};
|
|
10762
|
+
const emit = (stream, text) => {
|
|
10763
|
+
logFiles.append(id, {
|
|
10764
|
+
ts: Date.now(),
|
|
10765
|
+
stream,
|
|
10766
|
+
text
|
|
10767
|
+
});
|
|
10768
|
+
};
|
|
10769
|
+
const child = spawnManaged({
|
|
10770
|
+
command: spec.command,
|
|
10771
|
+
args: spec.args,
|
|
10772
|
+
cwd: spec.cwd,
|
|
10773
|
+
env: spec.env
|
|
10774
|
+
}, { detached: false });
|
|
10775
|
+
childPid = child.pid ?? null;
|
|
10776
|
+
persistState();
|
|
10777
|
+
emit("system", `persistent: nanny pid ${process.pid} runs this entry`);
|
|
10778
|
+
const stdout = new LineSplitter((stream, text) => emit(stream, text));
|
|
10779
|
+
const stderr = new LineSplitter((stream, text) => emit(stream, text));
|
|
10780
|
+
child.stdout?.on("data", (chunk) => stdout.push("stdout", chunk));
|
|
10781
|
+
child.stderr?.on("data", (chunk) => stderr.push("stderr", chunk));
|
|
10782
|
+
const heartbeat = setInterval(persistState, NANNY_HEARTBEAT_MS);
|
|
10783
|
+
heartbeat.unref();
|
|
10784
|
+
let stopping = false;
|
|
10785
|
+
const onSignal = (signal) => {
|
|
10786
|
+
if (stopping) return;
|
|
10787
|
+
stopping = true;
|
|
10788
|
+
emit("system", `${signal} — stopping this entry (grace ${spec.stop.graceMs}ms)`);
|
|
10789
|
+
terminate(child, {
|
|
10790
|
+
signal: spec.stop.signal,
|
|
10791
|
+
killGroup: false,
|
|
10792
|
+
graceMs: spec.stop.graceMs
|
|
10793
|
+
});
|
|
10794
|
+
};
|
|
10795
|
+
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
|
10796
|
+
process.on("SIGINT", () => onSignal("SIGINT"));
|
|
10797
|
+
await new Promise((resolve) => {
|
|
10798
|
+
let settled = false;
|
|
10799
|
+
const finish = (code, signal, runtimeMs, note) => {
|
|
10800
|
+
if (settled) return;
|
|
10801
|
+
settled = true;
|
|
10802
|
+
clearInterval(heartbeat);
|
|
10803
|
+
stdout.flush("stdout");
|
|
10804
|
+
stderr.flush("stderr");
|
|
10805
|
+
lastExit = {
|
|
10806
|
+
code,
|
|
10807
|
+
signal,
|
|
10808
|
+
at: Date.now(),
|
|
10809
|
+
runtimeMs
|
|
10810
|
+
};
|
|
10811
|
+
emit("system", note ?? (signal !== null ? `persistent: exited with signal ${signal}` : `persistent: exited with code ${code}`));
|
|
10812
|
+
persistState();
|
|
10813
|
+
logFiles.dispose();
|
|
10814
|
+
exitCode = code ?? (signal === null ? 0 : 1);
|
|
10815
|
+
resolve();
|
|
10816
|
+
};
|
|
10817
|
+
child.once("exit", (code, signal) => finish(code, signal, Date.now() - startedAt));
|
|
10818
|
+
child.once("error", (error) => {
|
|
10819
|
+
finish(null, null, 0, `persistent: could not start the entry: ${error.message}`);
|
|
10820
|
+
});
|
|
10821
|
+
});
|
|
10822
|
+
process.exit(exitCode);
|
|
10823
|
+
}
|
|
10824
|
+
var init_nanny$1 = __esmMin((() => {
|
|
10825
|
+
init_nanny$2();
|
|
10826
|
+
init_process();
|
|
10827
|
+
init_log_buffer();
|
|
10828
|
+
init_log_files();
|
|
10829
|
+
}));
|
|
10830
|
+
//#endregion
|
|
10831
|
+
//#region src/cli/nanny.ts
|
|
10832
|
+
var nanny_exports = /* @__PURE__ */ __exportAll({ nannyCommand: () => nannyCommand });
|
|
10833
|
+
var nannyCommand;
|
|
10834
|
+
var init_nanny = __esmMin((() => {
|
|
10835
|
+
init_runtime();
|
|
10836
|
+
nannyCommand = defineCommand({
|
|
10837
|
+
meta: {
|
|
10838
|
+
name: NANNY_COMMAND,
|
|
10839
|
+
description: "internal: run one persistent entry"
|
|
10840
|
+
},
|
|
10841
|
+
args: {
|
|
10842
|
+
id: {
|
|
10843
|
+
type: "string",
|
|
10844
|
+
required: true
|
|
10845
|
+
},
|
|
10846
|
+
spec: {
|
|
10847
|
+
type: "string",
|
|
10848
|
+
required: true
|
|
10849
|
+
},
|
|
10850
|
+
state: {
|
|
10851
|
+
type: "string",
|
|
10852
|
+
required: true
|
|
10853
|
+
}
|
|
10854
|
+
},
|
|
10855
|
+
run: async ({ args }) => {
|
|
10856
|
+
const { takeNannySpec } = await Promise.resolve().then(() => (init_nanny$2(), nanny_exports$2));
|
|
10857
|
+
const { runNanny } = await Promise.resolve().then(() => (init_nanny$1(), nanny_exports$1));
|
|
10858
|
+
const spec = takeNannySpec(args.spec);
|
|
10859
|
+
if (spec.serverId !== args.id) throw new Error(`this spec runs "${spec.serverId}", not "${args.id}"`);
|
|
10860
|
+
await runNanny(spec, args.state);
|
|
10861
|
+
}
|
|
10862
|
+
});
|
|
10863
|
+
}));
|
|
10864
|
+
//#endregion
|
|
10003
10865
|
//#region src/cli.ts
|
|
10004
10866
|
init_args();
|
|
10005
10867
|
init_io();
|
|
10868
|
+
init_runtime();
|
|
10006
10869
|
/**
|
|
10007
10870
|
* The command line, and nothing else. Two things happen before citty is asked
|
|
10008
10871
|
* anything: `--home`/`--project` are peeled off and applied (because
|
|
@@ -10012,9 +10875,10 @@ init_io();
|
|
|
10012
10875
|
* own `#src` imports are safe: by the time one is imported, the directories are
|
|
10013
10876
|
* already in the environment.
|
|
10014
10877
|
*
|
|
10015
|
-
* The only static imports here are node builtins, citty, and the
|
|
10016
|
-
*
|
|
10017
|
-
* module) is a dynamic import
|
|
10878
|
+
* The only static imports here are node builtins, citty, and the path-free local
|
|
10879
|
+
* modules the pre-pass needs (`helpers/runtime.ts` only names the hidden nanny
|
|
10880
|
+
* command); every command (and so every state-reading module) is a dynamic import
|
|
10881
|
+
* behind `subCommands`.
|
|
10018
10882
|
*/
|
|
10019
10883
|
var CLI_ENTRY = fileURLToPath(import.meta.url);
|
|
10020
10884
|
/**
|
|
@@ -10252,6 +11116,15 @@ async function main() {
|
|
|
10252
11116
|
const dirFlags = extractDirFlags(process.argv.slice(2));
|
|
10253
11117
|
if (dirFlags.error !== void 0) fail(dirFlags.error);
|
|
10254
11118
|
applyDirFlags(dirFlags);
|
|
11119
|
+
if (dirFlags.rest[0] === "__nanny") {
|
|
11120
|
+
const { nannyCommand } = await Promise.resolve().then(() => (init_nanny(), nanny_exports));
|
|
11121
|
+
try {
|
|
11122
|
+
await runCommand(nannyCommand, { rawArgs: dirFlags.rest });
|
|
11123
|
+
} catch (error) {
|
|
11124
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
11125
|
+
}
|
|
11126
|
+
return;
|
|
11127
|
+
}
|
|
10255
11128
|
const invocation = resolveInvocation(dirFlags.rest, commandNames);
|
|
10256
11129
|
if (invocation.kind === "help") {
|
|
10257
11130
|
process.stdout.write(invocation.command === void 0 ? USAGE : commandHelp(invocation.command));
|