@cotal-ai/cli 0.72.1 → 0.76.0

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,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { createConnection, createServer } from "node:net";
4
4
  import { hostname } from "node:os";
5
- import { mkdirSync, writeFileSync, readFileSync, existsSync, openSync, statSync, readSync, closeSync, lstatSync, rmSync, realpathSync, readdirSync, } from "node:fs";
5
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, openSync, statSync, readSync, closeSync, lstatSync, realpathSync, readdirSync, } from "node:fs";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { isReachable, DEFAULT_SERVER, createSpaceAuth, serverConfig, openServerConfig, validateTlsMaterial, mintCreds, mintLifecycleUid, DEV_OWNER, mintConnectionEvictorCreds, mintMembershipObserverCreds, newIdentity, setupSpaceStreams, reconcileSpaceTtls, ttlBuckets, standaloneConnectOpts, requireBrokerFloor, parseServerVersion, presenceBucket, seedChannelRegistry, ensureDefaultDeliveryClass, LEASE_TTL_MS, mkSecretDir, writeSecretFile, } from "@cotal-ai/core";
8
8
  import { connect } from "@nats-io/transport-node";
@@ -903,8 +903,19 @@ async function runUp(args, inheritedLock, onAdopt) {
903
903
  process.exit(1);
904
904
  }
905
905
  }
906
+ const restored = resumeAttempt ? pendingRestores.get(resumeAttempt) : undefined;
907
+ const boundListener = restored ? {
908
+ serverName: restored.serverName,
909
+ serverNonce: restored.serverNonce,
910
+ onSpawn: (pid, startedAt) => bindSpawnedRestoreListener(restored, pid, startedAt, startupLock),
911
+ verify: async () => { await provePreparedRestoreListener(restored); },
912
+ } : ordinaryAttempt ? {
913
+ serverName: ordinaryAttempt.serverName,
914
+ serverNonce: ordinaryAttempt.serverNonce,
915
+ onSpawn: (pid, startedAt) => bindSpawnedOrdinaryResumeListener(ordinaryAttempt, pid, startedAt, startupLock),
916
+ verify: async () => { await verifySpawnedOrdinaryListener(ordinaryAttempt); },
917
+ } : undefined;
906
918
  if (values.detach) {
907
- const restored = resumeAttempt ? pendingRestores.get(resumeAttempt) : undefined;
908
919
  const { pid, source, authService, controlPlane, delivery, manager } = await startMeshDetached({
909
920
  transport,
910
921
  server,
@@ -925,21 +936,7 @@ async function runUp(args, inheritedLock, onAdopt) {
925
936
  noManager,
926
937
  resumeAttempt,
927
938
  resumeCommitToken: restored?.managerCommit?.durableCommitToken ?? ordinaryAttempt?.managerCommit?.durableCommitToken,
928
- ...(restored ? {
929
- boundListener: {
930
- serverName: restored.serverName,
931
- serverNonce: restored.serverNonce,
932
- onSpawn: (pid, startedAt) => bindSpawnedRestoreListener(restored, pid, startedAt, startupLock),
933
- verify: async () => { await provePreparedRestoreListener(restored); },
934
- },
935
- } : ordinaryAttempt ? {
936
- boundListener: {
937
- serverName: ordinaryAttempt.serverName,
938
- serverNonce: ordinaryAttempt.serverNonce,
939
- onSpawn: (pid, startedAt) => bindSpawnedOrdinaryResumeListener(ordinaryAttempt, pid, startedAt, startupLock),
940
- verify: async () => { await verifySpawnedOrdinaryListener(ordinaryAttempt); },
941
- },
942
- } : {}),
939
+ boundListener,
943
940
  });
944
941
  // Transport policy is committed inside startMeshDetached before delivery launch (S5+S9).
945
942
  console.log(c.dim(`Started nats-server (${source}).`));
@@ -979,7 +976,6 @@ async function runUp(args, inheritedLock, onAdopt) {
979
976
  assertServesDialHost(transport, new URL(server).hostname);
980
977
  const setup = useAuth ? await authSetup(storeDir, server, space, host, wantUser ? { idpUrl: values.idp } : undefined, transport, values["rotate-sys"], maxFileStore) : undefined;
981
978
  const port = Number(new URL(server).port) || 4222;
982
- const restored = resumeAttempt ? pendingRestores.get(resumeAttempt) : undefined;
983
979
  // Both modes go through a RENDERER, never bare CLI flags. Open mode used to start from
984
980
  // `-js -sd … -p … -a …`, which never called a renderer at all — so the required transport union
985
981
  // protected the auth path and was silent on the open one, and a cert/key pair passed to an
@@ -987,9 +983,7 @@ async function runUp(args, inheritedLock, onAdopt) {
987
983
  const confPath = setup ? setup.confPath : writeOpenBrokerConf(storeDir, { port, host, transport, maxFileStore });
988
984
  const natsArgs = [
989
985
  "-c", confPath,
990
- ...(restored ? ["--name", restored.serverName]
991
- : ordinaryAttempt ? ["--name", ordinaryAttempt.serverName]
992
- : []),
986
+ ...(boundListener ? ["--name", boundListener.serverName] : []),
993
987
  ];
994
988
  const { bin, source } = await resolveNatsServer();
995
989
  console.log(c.dim(`Starting nats-server (JetStream, ${useAuth ? "JWT auth" : "OPEN/no-auth"}, ${source}) - store: ${storeDir}, bind: ${host}`));
@@ -997,29 +991,7 @@ async function runUp(args, inheritedLock, onAdopt) {
997
991
  const listenerStartedAt = new Date().toISOString();
998
992
  const child = spawn(bin, natsArgs, { stdio: "inherit" });
999
993
  let activationFinished = !resumeAttempt;
1000
- if (!child.pid)
1001
- throw new Error("nats-server spawned with no pid");
1002
- writePidPair(cotalPath("nats.pid"), child.pid); // #969/#1238: publish the pair by rename, never torn
1003
- if (restored && process.env.COTAL_SMOKE_EXIT_AFTER_RESTORE_LISTENER_SPAWN === "1")
1004
- process.exit(87);
1005
- if (restored)
1006
- try {
1007
- bindSpawnedRestoreListener(restored, child.pid ?? 0, listenerStartedAt, startupLock);
1008
- }
1009
- catch (error) {
1010
- await stopUnboundRestoreListener(child);
1011
- removeMatchingNatsPid(child.pid ?? 0);
1012
- throw error;
1013
- }
1014
- if (ordinaryAttempt)
1015
- try {
1016
- bindSpawnedOrdinaryResumeListener(ordinaryAttempt, child.pid ?? 0, listenerStartedAt, startupLock);
1017
- }
1018
- catch (error) {
1019
- await stopUnboundRestoreListener(child);
1020
- removeMatchingNatsPid(child.pid ?? 0);
1021
- throw error;
1022
- }
994
+ await recordSpawnedListener(child, listenerStartedAt, boundListener);
1023
995
  releaseStartupLock();
1024
996
  child.on("error", (err) => {
1025
997
  console.error(c.red(`Failed to start nats-server: ${err.message}`));
@@ -1072,6 +1044,8 @@ async function runUp(args, inheritedLock, onAdopt) {
1072
1044
  // The broker is gone — drop it from the registry (and the `current` pointer if it was the default)
1073
1045
  // so a later `cotal spawn` doesn't try to join a dead mesh.
1074
1046
  child.on("exit", async (code, signal) => {
1047
+ if (failedListeners.has(child))
1048
+ return;
1075
1049
  removePidPair(cotalPath("nats.pid"), String(child.pid));
1076
1050
  // One teardown at a time: a stop started while another is in flight is refused the reservation
1077
1051
  // that one holds, so wait for a Ctrl-C teardown already running instead of racing it.
@@ -1114,20 +1088,7 @@ async function runUp(args, inheritedLock, onAdopt) {
1114
1088
  process.exit(code ?? 1);
1115
1089
  }
1116
1090
  });
1117
- const ready = await waitReady(server, setup?.creds);
1118
- if (!ready) {
1119
- child.kill("SIGTERM");
1120
- const reason = `nats-server did not become ready at ${server}`;
1121
- // `startupLock` is already released by here on this path (and the helper then takes its own);
1122
- // passed anyway so the call stays correct if the release above ever moves.
1123
- markPendingResumeDegraded(resumeAttempt ?? "", reason, startupLock);
1124
- throw new Error(reason);
1125
- }
1126
- const v = await brokerVersion(server, space, setup?.creds);
1127
- if (belowPresenceSafeBroker(v.version) && v.presenceFileBacked)
1128
- console.error(c.yellow(`! nats-server ${v.version} is below 2.14.5 and the presence bucket of "${space}" is file-backed: it can latch after a stall and close the mesh to new joins (#1356); put a nats-server 2.14.5 or newer on PATH (it wins over the bundled one)`));
1129
- if (restored)
1130
- await provePreparedRestoreListener(restored);
1091
+ await awaitSpawnedListener({ child, server, space, creds: setup?.creds, log: "the terminal output above", boundListener });
1131
1092
  {
1132
1093
  // A DAEMON THAT DIES UNDER THIS RUNNING BROKER IS RESTARTED HERE (#2469). It exits on its own once
1133
1094
  // it cannot reach the broker, and a starved host makes a running broker look unreachable, so it can
@@ -1294,15 +1255,6 @@ function restoreListenerOwner(pid, nonce, startedAt) {
1294
1255
  throw new Error("restore listener spawn returned no pid");
1295
1256
  return { pid, host: hostname(), startedAt, id: `restore-listener-${nonce}` };
1296
1257
  }
1297
- function removeMatchingNatsPid(pid) {
1298
- const path = cotalPath("nats.pid");
1299
- try {
1300
- const stat = lstatSync(path);
1301
- if (stat.isFile() && !stat.isSymbolicLink() && readFileSync(path, "utf8") === String(pid))
1302
- rmSync(path);
1303
- }
1304
- catch { /* absent, changed, or not owned by this spawn */ }
1305
- }
1306
1258
  function waitForChildExit(child, timeoutMs) {
1307
1259
  if (child.exitCode !== null || child.signalCode !== null)
1308
1260
  return Promise.resolve(true);
@@ -1317,15 +1269,23 @@ function waitForChildExit(child, timeoutMs) {
1317
1269
  child.once("exit", onExit);
1318
1270
  });
1319
1271
  }
1320
- async function stopUnboundRestoreListener(child) {
1321
- if (child.exitCode !== null || child.signalCode !== null)
1322
- return;
1272
+ /** Listeners {@link stopFailedListener} is stopping. Their launch reports its own error, so the
1273
+ * foreground's broker-exit handler must not report that exit as a crash and exit first. */
1274
+ const failedListeners = new WeakSet();
1275
+ /**
1276
+ * Stop a listener whose launch failed before it served. A signal is not proof of exit, so SIGTERM
1277
+ * escalates to SIGKILL and `nats.pid` is removed only once the exit is observed. A listener that
1278
+ * outlives both keeps its record and fails loud with its pid.
1279
+ */
1280
+ async function stopFailedListener(child) {
1281
+ failedListeners.add(child);
1323
1282
  child.kill("SIGTERM");
1324
- if (await waitForChildExit(child, 5_000))
1325
- return;
1326
- child.kill("SIGKILL");
1327
- if (!await waitForChildExit(child, 5_000))
1328
- throw new Error(`unbound restore listener process ${child.pid ?? "unknown"} did not exit`);
1283
+ if (!await waitForChildExit(child, 5_000)) {
1284
+ child.kill("SIGKILL");
1285
+ if (!await waitForChildExit(child, 5_000))
1286
+ throw new Error(`nats-server process ${child.pid ?? "unknown"} did not exit after SIGKILL`);
1287
+ }
1288
+ removePidPair(cotalPath("nats.pid"), String(child.pid));
1329
1289
  }
1330
1290
  function bindSpawnedRestoreListener(prepared, pid, startedAt, heldLock) {
1331
1291
  bindRestoreListenerOwner(prepared, restoreListenerOwner(pid, prepared.serverNonce, startedAt), heldLock);
@@ -2093,6 +2053,55 @@ tlsRequired, mgr) {
2093
2053
  return undefined;
2094
2054
  }
2095
2055
  }
2056
+ /**
2057
+ * Publish a spawned listener's pid, and bind a maintenance-bound one to its attempt, before anything
2058
+ * waits on it. A launch that dies before its listener serves then leaves `nats.pid` for `cotal down`
2059
+ * and a journal naming the exact process for recovery to adopt or retire. A bind that throws stops
2060
+ * the listener. Both launch modes call it right after the spawn.
2061
+ */
2062
+ async function recordSpawnedListener(child, startedAt, bound) {
2063
+ if (!child.pid)
2064
+ throw new Error("nats-server spawned with no pid");
2065
+ writePidPair(cotalPath("nats.pid"), child.pid); // #969/#1238: publish the pair by rename, never torn
2066
+ if (!bound)
2067
+ return;
2068
+ if (process.env.COTAL_SMOKE_EXIT_AFTER_RESTORE_LISTENER_SPAWN === "1")
2069
+ process.exit(87);
2070
+ try {
2071
+ bound.onSpawn(child.pid, startedAt);
2072
+ }
2073
+ catch (error) {
2074
+ await stopFailedListener(child);
2075
+ throw error;
2076
+ }
2077
+ }
2078
+ /**
2079
+ * Everything between spawning a listener and serving it: readiness, the broker version read and a
2080
+ * bound listener's identity check. Foreground `up` and {@link startMeshDetached} both call it, so a
2081
+ * guard added here reaches both launch modes (#2924).
2082
+ *
2083
+ * A listener that never answers or whose version read throws is stopped and its `nats.pid` removed
2084
+ * before `up` fails. Nothing has recorded the mesh yet, so a listener left running would hold the
2085
+ * port with no registry entry. A bound listener that fails its identity check is kept: its
2086
+ * maintenance journal is already bound to it.
2087
+ */
2088
+ async function awaitSpawnedListener(l) {
2089
+ const { child, server, space } = l;
2090
+ let facts;
2091
+ try {
2092
+ if (!(await waitReady(server, l.creds)))
2093
+ throw new Error(`nats-server did not become reachable at ${server} - see ${l.log}`);
2094
+ facts = await brokerVersion(server, space, l.creds);
2095
+ }
2096
+ catch (e) {
2097
+ await stopFailedListener(child);
2098
+ throw e;
2099
+ }
2100
+ if (belowPresenceSafeBroker(facts.version) && facts.presenceFileBacked)
2101
+ console.error(c.yellow(`! nats-server ${facts.version} is below 2.14.5 and the presence bucket of "${space}" is file-backed: it can latch after a stall and close the mesh to new joins (#1356); put a nats-server 2.14.5 or newer on PATH (it wins over the bundled one)`));
2102
+ if (l.boundListener)
2103
+ await l.boundListener.verify();
2104
+ }
2096
2105
  /**
2097
2106
  * Everything a launch runs once its listener answers: the space setup, the user-auth service, the
2098
2107
  * mesh record, the transport commit and the control plane. Foreground `up` and
@@ -2112,14 +2121,7 @@ async function serveReadyListener(l) {
2112
2121
  await postStart(server, space, setup, l.seedFile);
2113
2122
  }
2114
2123
  catch (e) {
2115
- try {
2116
- child.kill("SIGTERM");
2117
- }
2118
- catch { /* already gone */ }
2119
- try {
2120
- removePidPair(cotalPath("nats.pid"), String(child.pid));
2121
- }
2122
- catch { /* best effort */ }
2124
+ await stopFailedListener(child);
2123
2125
  throw e;
2124
2126
  }
2125
2127
  }
@@ -2224,50 +2226,17 @@ opts) {
2224
2226
  const listenerStartedAt = new Date().toISOString();
2225
2227
  const child = spawn(bin, args, { detached: true, stdio: ["ignore", fd, fd] });
2226
2228
  closeSync(fd);
2227
- if (opts.boundListener) {
2228
- if (!child.pid)
2229
- throw new Error("nats-server spawned with no pid");
2230
- writePidPair(cotalPath("nats.pid"), child.pid); // #969/#1238: publish the pair by rename, never torn
2231
- if (process.env.COTAL_SMOKE_EXIT_AFTER_RESTORE_LISTENER_SPAWN === "1")
2232
- process.exit(87);
2233
- try {
2234
- opts.boundListener.onSpawn(child.pid ?? 0, listenerStartedAt);
2235
- }
2236
- catch (error) {
2237
- await stopUnboundRestoreListener(child);
2238
- removeMatchingNatsPid(child.pid ?? 0);
2239
- throw error;
2240
- }
2241
- }
2229
+ await recordSpawnedListener(child, listenerStartedAt, opts.boundListener);
2242
2230
  child.unref();
2243
2231
  let tailing = Boolean(opts.onLine);
2244
2232
  if (opts.onLine)
2245
2233
  tailLines(logPath, startOffset, opts.onLine, () => !tailing);
2246
- const ready = await waitReady(server, setup?.creds);
2247
- tailing = false;
2248
- if (!ready) {
2249
- child.kill("SIGTERM");
2250
- removePidPair(cotalPath("nats.pid"), String(child.pid));
2251
- throw new Error(`nats-server did not become reachable at ${server} - see ${logPath}`);
2252
- }
2253
- let brokerVer;
2254
2234
  try {
2255
- brokerVer = await brokerVersion(server, space, setup?.creds);
2256
- }
2257
- catch (e) {
2258
- child.kill("SIGTERM");
2259
- removePidPair(cotalPath("nats.pid"), String(child.pid));
2260
- throw e;
2235
+ await awaitSpawnedListener({ child, server, space, creds: setup?.creds, log: logPath, boundListener: opts.boundListener });
2261
2236
  }
2262
- if (belowPresenceSafeBroker(brokerVer.version) && brokerVer.presenceFileBacked)
2263
- console.error(c.yellow(`! nats-server ${brokerVer.version} is below 2.14.5 and the presence bucket of "${space}" is file-backed: it can latch after a stall and close the mesh to new joins (#1356); put a nats-server 2.14.5 or newer on PATH (it wins over the bundled one)`));
2264
- if (!opts.boundListener) {
2265
- if (!child.pid)
2266
- throw new Error("nats-server spawned with no pid");
2267
- writePidPair(cotalPath("nats.pid"), child.pid); // #969/#1238: publish the pair by rename, never torn
2237
+ finally {
2238
+ tailing = false;
2268
2239
  }
2269
- if (opts.boundListener)
2270
- await opts.boundListener.verify();
2271
2240
  const { authService, controlPlane } = await serveReadyListener({
2272
2241
  child, server, space, storeDir, setup, seedFile, transport,
2273
2242
  host: opts.host,