@juspay/neurolink 9.93.2 → 9.94.1

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/browser/neurolink.min.js +263 -263
  3. package/dist/cli/commands/proxy.js +672 -182
  4. package/dist/cli/utils/audioPlayer.d.ts +44 -9
  5. package/dist/cli/utils/audioPlayer.js +162 -65
  6. package/dist/lib/proxy/rollingProxyServer.d.ts +3 -0
  7. package/dist/lib/proxy/rollingProxyServer.js +149 -0
  8. package/dist/lib/proxy/rollingWorkerProcess.d.ts +2 -0
  9. package/dist/lib/proxy/rollingWorkerProcess.js +194 -0
  10. package/dist/lib/proxy/rollingWorkerProtocol.d.ts +5 -0
  11. package/dist/lib/proxy/rollingWorkerProtocol.js +43 -0
  12. package/dist/lib/proxy/rollingWorkerSupervisor.d.ts +39 -0
  13. package/dist/lib/proxy/rollingWorkerSupervisor.js +402 -0
  14. package/dist/lib/proxy/socketWorkerRuntime.d.ts +14 -0
  15. package/dist/lib/proxy/socketWorkerRuntime.js +294 -0
  16. package/dist/lib/skills/skillMatcher.d.ts +7 -1
  17. package/dist/lib/skills/skillMatcher.js +23 -8
  18. package/dist/lib/types/cli.d.ts +47 -0
  19. package/dist/lib/types/proxy.d.ts +156 -0
  20. package/dist/lib/utils/errorHandling.d.ts +8 -0
  21. package/dist/lib/utils/errorHandling.js +20 -0
  22. package/dist/proxy/rollingProxyServer.d.ts +3 -0
  23. package/dist/proxy/rollingProxyServer.js +148 -0
  24. package/dist/proxy/rollingWorkerProcess.d.ts +2 -0
  25. package/dist/proxy/rollingWorkerProcess.js +193 -0
  26. package/dist/proxy/rollingWorkerProtocol.d.ts +5 -0
  27. package/dist/proxy/rollingWorkerProtocol.js +42 -0
  28. package/dist/proxy/rollingWorkerSupervisor.d.ts +39 -0
  29. package/dist/proxy/rollingWorkerSupervisor.js +401 -0
  30. package/dist/proxy/socketWorkerRuntime.d.ts +14 -0
  31. package/dist/proxy/socketWorkerRuntime.js +293 -0
  32. package/dist/skills/skillMatcher.d.ts +7 -1
  33. package/dist/skills/skillMatcher.js +23 -8
  34. package/dist/types/cli.d.ts +47 -0
  35. package/dist/types/proxy.d.ts +156 -0
  36. package/dist/utils/errorHandling.d.ts +8 -0
  37. package/dist/utils/errorHandling.js +20 -0
  38. package/package.json +3 -2
@@ -27,6 +27,10 @@ import { flushProxyLifecycleEvents, getProxyLifecycleLoggerSnapshot, hashProxyLi
27
27
  import { describeInstallFailure, getGlobalInstallArgs, resolveGlobalInstaller, validateInstalledVersion, } from "../../lib/proxy/globalInstaller.js";
28
28
  import { startUpdaterWorkerSupervisor } from "../../lib/proxy/updaterSupervisor.js";
29
29
  import { openProxyWorkerLog } from "../../lib/proxy/workerLog.js";
30
+ import { startRollingProxyServer } from "../../lib/proxy/rollingProxyServer.js";
31
+ import { spawnProxySocketWorker } from "../../lib/proxy/rollingWorkerProcess.js";
32
+ import { PROXY_ROLLING_SUPERVISOR_ENV, PROXY_SOCKET_WORKER_ENV, } from "../../lib/proxy/rollingWorkerProtocol.js";
33
+ import { attachSocketWorkerProcess } from "../../lib/proxy/socketWorkerRuntime.js";
30
34
  import { waitForProxyUpdateWindow } from "../../lib/proxy/updateCoordinator.js";
31
35
  import { abandonPendingUpdate, clearUpdateDeferral, isVersionSuppressed, loadUpdateState, recordCheck, recordSuccessfulUpdate, recordUpdateDeferred, recordUpdateFailure, recordUpdateInstalled, suppressVersion, } from "../../lib/proxy/updateState.js";
32
36
  import { loadProxyEnvFile, resolveProxyEnvFile, } from "../../lib/proxy/proxyEnv.js";
@@ -43,12 +47,14 @@ const PROXY_UPDATE_CONTROL_TOKEN = process.env.NEUROLINK_PROXY_UPDATE_CONTROL_TO
43
47
  // STATE MANAGEMENT
44
48
  // =============================================================================
45
49
  let proxyStateManager = new StateFileManager("proxy-state.json");
50
+ let proxySupervisorStateManager = new StateFileManager("proxy-supervisor-state.json");
46
51
  /**
47
52
  * Reinitialise the state manager with a custom base directory.
48
53
  * Called when --dev redirects writable paths to .neurolink-dev/.
49
54
  */
50
55
  function setProxyStateDir(baseDir) {
51
56
  proxyStateManager = new StateFileManager("proxy-state.json", baseDir);
57
+ proxySupervisorStateManager = new StateFileManager("proxy-supervisor-state.json", baseDir);
52
58
  }
53
59
  function saveProxyState(state) {
54
60
  proxyStateManager.save(state);
@@ -59,6 +65,15 @@ function loadProxyState() {
59
65
  function clearProxyState() {
60
66
  proxyStateManager.clear();
61
67
  }
68
+ function saveProxySupervisorState(state) {
69
+ proxySupervisorStateManager.save(state);
70
+ }
71
+ function loadProxySupervisorState() {
72
+ return proxySupervisorStateManager.load();
73
+ }
74
+ function clearProxySupervisorState() {
75
+ proxySupervisorStateManager.clear();
76
+ }
62
77
  const CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
63
78
  const PLIST_LABEL = "com.neurolink.proxy";
64
79
  const PLIST_DIR = join(homedir(), "Library", "LaunchAgents");
@@ -82,6 +97,67 @@ function getProcessStatus(pid) {
82
97
  return "not_running";
83
98
  }
84
99
  }
100
+ /**
101
+ * Best-effort check that a pid actually belongs to a neurolink proxy process,
102
+ * so a stale/recycled supervisor pid is never mistaken for a live supervisor
103
+ * and signalled. Returns false when the process cannot be confirmed as ours.
104
+ */
105
+ async function processLooksLikeProxySupervisor(pid) {
106
+ try {
107
+ const { execFileSync } = await import("node:child_process");
108
+ const args = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
109
+ encoding: "utf-8",
110
+ stdio: ["ignore", "pipe", "ignore"],
111
+ });
112
+ return /neurolink/i.test(args);
113
+ }
114
+ catch {
115
+ // `ps` unavailable or the pid vanished — do not signal an unverified pid.
116
+ return false;
117
+ }
118
+ }
119
+ /**
120
+ * Ensure any supervisor recorded in state is actually stopped before its state
121
+ * record is cleared. A running supervisor still owns the listening socket;
122
+ * deleting its record would orphan it with no way to find it again. Returns
123
+ * false when a running supervisor could not be stopped, so callers can abort
124
+ * the uninstall instead of silently discarding the record.
125
+ */
126
+ async function ensureSupervisorStoppedBeforeClear() {
127
+ const pid = loadProxySupervisorState()?.pid;
128
+ if (!pid || getProcessStatus(pid) === "not_running") {
129
+ return true;
130
+ }
131
+ // Guard against a stale/recycled pid: only signal a process that still looks
132
+ // like a neurolink proxy supervisor, so uninstall never terminates an
133
+ // unrelated, same-user process that inherited the recorded pid.
134
+ if (!(await processLooksLikeProxySupervisor(pid))) {
135
+ return true;
136
+ }
137
+ try {
138
+ process.kill(pid, "SIGTERM");
139
+ }
140
+ catch (error) {
141
+ if (error.code === "ESRCH") {
142
+ return true;
143
+ }
144
+ return false;
145
+ }
146
+ for (let attempt = 0; attempt < 25; attempt += 1) {
147
+ if (getProcessStatus(pid) === "not_running") {
148
+ return true;
149
+ }
150
+ await sleep(200);
151
+ }
152
+ try {
153
+ process.kill(pid, "SIGKILL");
154
+ }
155
+ catch {
156
+ /* already gone or not permitted */
157
+ }
158
+ await sleep(200);
159
+ return getProcessStatus(pid) === "not_running";
160
+ }
85
161
  /** Resolve the primary-account info shown in /status. Reads the operator's
86
162
  * configured email from proxy config and cross-checks it against the token
87
163
  * store; falls back to the first enabled anthropic account when not set or
@@ -149,6 +225,16 @@ async function isLaunchdManaging() {
149
225
  function isLaunchdManagedProcess() {
150
226
  return process.platform === "darwin" && process.ppid === 1;
151
227
  }
228
+ function isProxySocketWorkerProcess() {
229
+ return process.env[PROXY_SOCKET_WORKER_ENV] === "1";
230
+ }
231
+ function getProxyWorkerGeneration() {
232
+ const generation = Number(process.env.NEUROLINK_PROXY_WORKER_GENERATION);
233
+ if (!Number.isSafeInteger(generation) || generation <= 0) {
234
+ throw new Error("proxy socket worker generation is missing or invalid");
235
+ }
236
+ return generation;
237
+ }
152
238
  function isProxyAutoUpdateEnabled(value = process.env.NEUROLINK_PROXY_AUTO_UPDATE) {
153
239
  return !["0", "off", "false"].includes((value ?? "").trim().toLowerCase());
154
240
  }
@@ -375,6 +461,75 @@ async function getProxyRuntimeActivity(host, port, timeoutMs = 3_000) {
375
461
  return null;
376
462
  }
377
463
  }
464
+ async function getRollingActivationFailure(host, port, expectedVersion) {
465
+ try {
466
+ const response = await fetch(`http://${host}:${port}/status`, {
467
+ signal: AbortSignal.timeout(3_000),
468
+ });
469
+ if (!response.ok) {
470
+ return null;
471
+ }
472
+ const payload = (await response.json());
473
+ const failure = payload.autoUpdate?.rolling?.lastFailure;
474
+ if (failure?.version !== expectedVersion ||
475
+ typeof failure.phase !== "string" ||
476
+ typeof failure.message !== "string") {
477
+ return null;
478
+ }
479
+ return `${failure.phase}: ${failure.message}`;
480
+ }
481
+ catch {
482
+ return null;
483
+ }
484
+ }
485
+ /** Read the version the proxy currently reports healthy, or undefined. */
486
+ async function fetchProxyHealthVersion(host, port) {
487
+ try {
488
+ const response = await fetch(`http://${host}:${port}/health`, {
489
+ signal: AbortSignal.timeout(3000),
490
+ });
491
+ if (!response.ok) {
492
+ return undefined;
493
+ }
494
+ const data = (await response.json());
495
+ return data.version;
496
+ }
497
+ catch {
498
+ return undefined;
499
+ }
500
+ }
501
+ /**
502
+ * After a failed rolling update, re-point the live supervisor at the reinstalled
503
+ * running version (mirroring the forward path: record the pending target, then
504
+ * signal SIGUSR2) and wait until the supervisor actually serves that version
505
+ * again. Returns false if it never reports the running version healthy in time.
506
+ */
507
+ async function activateRollbackVersion(host, port, parentPid, runningVersion, timeoutMs) {
508
+ recordUpdateInstalled(runningVersion);
509
+ try {
510
+ const start = Date.now();
511
+ while (Date.now() - start < timeoutMs) {
512
+ try {
513
+ process.kill(parentPid, "SIGUSR2");
514
+ }
515
+ catch {
516
+ // Supervisor may be mid-restart; its recovery loop also re-targets
517
+ // runningVersion once no worker is active.
518
+ }
519
+ await new Promise((resolve) => setTimeout(resolve, 2000));
520
+ if ((await fetchProxyHealthVersion(host, port)) === runningVersion) {
521
+ return true;
522
+ }
523
+ if (getProcessStatus(parentPid) === "not_running") {
524
+ return false;
525
+ }
526
+ }
527
+ return false;
528
+ }
529
+ finally {
530
+ abandonPendingUpdate(runningVersion);
531
+ }
532
+ }
378
533
  async function setProxyUpdateDrain(host, port, draining) {
379
534
  const confirmState = async () => {
380
535
  try {
@@ -476,6 +631,31 @@ function writeTrampoline() {
476
631
  # Resolves a working neurolink binary on every launchd invocation so the
477
632
  # plist never gets pinned to a broken/stale shim.
478
633
 
634
+ BAKED_NODE=${shEscape(bakedNode)}
635
+ BAKED_SCRIPT=${shEscape(bakedScript)}
636
+
637
+ # IPC workers must preserve the inherited Node channel until the final exec.
638
+ # Do not launch a --version probe subprocess on that descriptor; the parent
639
+ # validates the worker's exact package version before activation instead.
640
+ if [ "\${NEUROLINK_PROXY_TRAMPOLINE_EXEC_ONLY:-0}" = "1" ]; then
641
+ if [ -n "\${NEUROLINK_BIN:-}" ] && [ -x "$NEUROLINK_BIN" ]; then
642
+ exec "$NEUROLINK_BIN" "$@"
643
+ fi
644
+ for cand in \
645
+ "$(command -v neurolink 2>/dev/null || true)" \
646
+ "\${PNPM_HOME:-}/neurolink" \
647
+ "$HOME/.local/share/pnpm/neurolink" \
648
+ "$HOME/Library/pnpm/neurolink" \
649
+ "/usr/local/bin/neurolink" \
650
+ "/opt/homebrew/bin/neurolink"; do
651
+ [ -n "$cand" ] && [ -x "$cand" ] && exec "$cand" "$@"
652
+ done
653
+ if [ -x "$BAKED_NODE" ] && [ -f "$BAKED_SCRIPT" ]; then
654
+ exec "$BAKED_NODE" "$BAKED_SCRIPT" "$@"
655
+ fi
656
+ exit 127
657
+ fi
658
+
479
659
  # Probe a candidate: must be executable and respond to --version cleanly.
480
660
  _try() {
481
661
  [ -n "$1" ] && [ -x "$1" ] || return 1
@@ -507,8 +687,6 @@ done
507
687
  # 3. Baked-in fallback: the exact node + script that worked at install time.
508
688
  # Always valid at install time; may become stale after package updates
509
689
  # (but at that point the PATH candidates above should work).
510
- BAKED_NODE=${shEscape(bakedNode)}
511
- BAKED_SCRIPT=${shEscape(bakedScript)}
512
690
  if [ -x "$BAKED_NODE" ] && [ -f "$BAKED_SCRIPT" ]; then
513
691
  exec "$BAKED_NODE" "$BAKED_SCRIPT" "$@"
514
692
  fi
@@ -560,7 +738,7 @@ function spawnFailOpenGuard(host, port, parentPid) {
560
738
  workerLog.close();
561
739
  }
562
740
  }
563
- function spawnProxyUpdater(host, port, parentPid) {
741
+ function spawnProxyUpdater(host, port, parentPid, rollingSupervisor = false) {
564
742
  if (!isProxyAutoUpdateEnabled()) {
565
743
  logger.always("[proxy] automatic updates disabled by environment");
566
744
  return undefined;
@@ -570,8 +748,9 @@ function spawnProxyUpdater(host, port, parentPid) {
570
748
  logger.always("[proxy] updater disabled: CLI entry script is unavailable");
571
749
  return undefined;
572
750
  }
751
+ const command = rollingSupervisor ? TRAMPOLINE_PATH : process.execPath;
573
752
  const args = [
574
- entryScript,
753
+ ...(rollingSupervisor ? [] : [entryScript]),
575
754
  "proxy",
576
755
  "guard",
577
756
  "--host",
@@ -588,12 +767,13 @@ function spawnProxyUpdater(host, port, parentPid) {
588
767
  logger.always(`[proxy] updater logging disabled: ${workerLog.error}`);
589
768
  }
590
769
  try {
591
- const child = spawn(process.execPath, args, {
770
+ const child = spawn(command, args, {
592
771
  detached: true,
593
772
  stdio: ["ignore", workerLog.stdio, workerLog.stdio],
594
773
  env: {
595
774
  ...process.env,
596
775
  NEUROLINK_PROXY_UPDATE_CONTROL_TOKEN: PROXY_UPDATE_CONTROL_TOKEN,
776
+ [PROXY_ROLLING_SUPERVISOR_ENV]: rollingSupervisor ? "1" : "0",
597
777
  },
598
778
  });
599
779
  child.once("error", (error) => {
@@ -701,9 +881,29 @@ function getProxyRuntimeErrorCode(error) {
701
881
  async function ensureProxyStartAllowed(spinner) {
702
882
  const ignoreLaunchd = process.env.NEUROLINK_PROXY_IGNORE_LAUNCHD === "1" ||
703
883
  process.env.NEUROLINK_PROXY_IGNORE_LAUNCHD === "true";
884
+ const existingSupervisor = loadProxySupervisorState();
885
+ if (existingSupervisor) {
886
+ if (existingSupervisor.pid !== process.pid &&
887
+ isProcessRunning(existingSupervisor.pid) &&
888
+ !ignoreLaunchd) {
889
+ if (spinner) {
890
+ spinner.fail(chalk.red(`Proxy supervisor already running on port ${existingSupervisor.port} (PID: ${existingSupervisor.pid})`));
891
+ }
892
+ process.exit(process.ppid === 1 ? 0 : 1);
893
+ }
894
+ if (!isProcessRunning(existingSupervisor.pid)) {
895
+ clearProxySupervisorState();
896
+ }
897
+ }
704
898
  const existingState = loadProxyState();
705
899
  if (existingState) {
706
900
  if (isProcessRunning(existingState.pid)) {
901
+ // A newly relaunched stable supervisor may inherit state from an orphaned
902
+ // worker that is draining old connections but no longer owns a listener.
903
+ // launchd must be allowed to restore listener ownership in that case.
904
+ if (process.ppid === 1) {
905
+ return;
906
+ }
707
907
  // Test / dev escape hatch: when NEUROLINK_PROXY_IGNORE_LAUNCHD is set,
708
908
  // allow starting a second proxy on the test's requested port even if
709
909
  // a launchd-managed instance is using a different port (its state
@@ -1177,6 +1377,7 @@ export async function createProxyStartApp(params) {
1177
1377
  const { loadAccountCooldowns } = await import("../../lib/proxy/accountCooldown.js");
1178
1378
  const stats = getStats();
1179
1379
  const runtimeState = loadProxyState();
1380
+ const supervisorState = loadProxySupervisorState();
1180
1381
  const updateState = loadUpdateState();
1181
1382
  const cooldowns = await loadAccountCooldowns();
1182
1383
  const storedAccountKeys = new Set();
@@ -1196,6 +1397,7 @@ export async function createProxyStartApp(params) {
1196
1397
  version: PROXY_VERSION,
1197
1398
  });
1198
1399
  const primaryAccount = await resolveStatusPrimaryAccount(activeProxyConfig);
1400
+ const activeUpdaterPid = supervisorState?.updaterPid ?? runtimeState?.updaterPid;
1199
1401
  return c.json({
1200
1402
  status: "running",
1201
1403
  ready: health.ready,
@@ -1254,9 +1456,11 @@ export async function createProxyStartApp(params) {
1254
1456
  },
1255
1457
  autoUpdate: {
1256
1458
  enabled: isProxyAutoUpdateEnabled(),
1257
- updaterPid: runtimeState?.updaterPid ?? null,
1258
- updaterRunning: runtimeState?.updaterPid
1259
- ? isProcessRunning(runtimeState.updaterPid)
1459
+ supervisorPid: supervisorState?.pid ?? null,
1460
+ rolling: supervisorState?.rolling ?? null,
1461
+ updaterPid: activeUpdaterPid ?? null,
1462
+ updaterRunning: activeUpdaterPid
1463
+ ? isProcessRunning(activeUpdaterPid)
1260
1464
  : false,
1261
1465
  liveVersion: PROXY_VERSION,
1262
1466
  latestVersion: updateState?.lastCheckVersion || null,
@@ -1532,7 +1736,7 @@ function registerProxyShutdownHandlers(params) {
1532
1736
  }
1533
1737
  });
1534
1738
  };
1535
- const shutdown = async (signal) => {
1739
+ const shutdown = async (signal, options) => {
1536
1740
  if (shutdownStarted) {
1537
1741
  return;
1538
1742
  }
@@ -1542,13 +1746,15 @@ function registerProxyShutdownHandlers(params) {
1542
1746
  params.updaterSupervisor?.stop();
1543
1747
  params.stopRuntimeConfig?.();
1544
1748
  logger.always(`\nShutting down proxy (${signal})...`);
1545
- let exitCode = signal === "SIGINT" ? 0 : 1;
1546
- try {
1547
- await closeServer();
1548
- }
1549
- catch (error) {
1550
- exitCode = 1;
1551
- logger.error(`[proxy] failed to drain server during shutdown: ${error instanceof Error ? error.message : String(error)}`);
1749
+ let exitCode = signal === "SIGINT" || signal === "ROLLING_DRAIN" ? 0 : 1;
1750
+ if (!options?.skipServerClose) {
1751
+ try {
1752
+ await closeServer();
1753
+ }
1754
+ catch (error) {
1755
+ exitCode = 1;
1756
+ logger.error(`[proxy] failed to drain server during shutdown: ${error instanceof Error ? error.message : String(error)}`);
1757
+ }
1552
1758
  }
1553
1759
  try {
1554
1760
  await withTimeout(flushProxyLifecycleEvents(), PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS, "Timed out flushing proxy lifecycle metadata during shutdown");
@@ -1581,7 +1787,10 @@ function registerProxyShutdownHandlers(params) {
1581
1787
  }
1582
1788
  }
1583
1789
  try {
1584
- clearProxyState();
1790
+ const state = loadProxyState();
1791
+ if (state?.pid === process.pid) {
1792
+ clearProxyState();
1793
+ }
1585
1794
  }
1586
1795
  catch (error) {
1587
1796
  exitCode = 1;
@@ -1593,21 +1802,30 @@ function registerProxyShutdownHandlers(params) {
1593
1802
  logger.error(`[proxy] unexpected shutdown failure: ${error instanceof Error ? error.message : String(error)}`);
1594
1803
  process.exit(1);
1595
1804
  };
1596
- process.on("SIGTERM", () => {
1597
- void shutdown("SIGTERM").catch(forceExitAfterShutdownFailure);
1598
- });
1599
- process.on("SIGINT", () => {
1600
- void shutdown("SIGINT").catch(forceExitAfterShutdownFailure);
1601
- });
1805
+ if (params.registerSignals ?? true) {
1806
+ process.on("SIGTERM", () => {
1807
+ void shutdown("SIGTERM").catch(forceExitAfterShutdownFailure);
1808
+ });
1809
+ process.on("SIGINT", () => {
1810
+ void shutdown("SIGINT").catch(forceExitAfterShutdownFailure);
1811
+ });
1812
+ }
1813
+ return shutdown;
1602
1814
  }
1603
1815
  async function startProxyRuntime(params) {
1604
- const { serve } = await import("@hono/node-server");
1605
- const server = serve({
1606
- fetch: params.app.fetch,
1607
- port: params.port,
1608
- hostname: params.host,
1609
- });
1610
- const managedByLaunchd = isLaunchdManagedProcess();
1816
+ const socketWorker = isProxySocketWorkerProcess();
1817
+ const { createAdaptorServer, serve } = await import("@hono/node-server");
1818
+ const server = socketWorker
1819
+ ? createAdaptorServer({
1820
+ fetch: params.app.fetch,
1821
+ hostname: params.host,
1822
+ })
1823
+ : serve({
1824
+ fetch: params.app.fetch,
1825
+ port: params.port,
1826
+ hostname: params.host,
1827
+ });
1828
+ const managedByLaunchd = isLaunchdManagedProcess() || socketWorker;
1611
1829
  // launchd already owns restart supervision. A second detached supervisor can
1612
1830
  // outlive its parent and terminate a healthy replacement, so the guard is
1613
1831
  // reserved for foreground mode where it only cleans stale client settings.
@@ -1615,19 +1833,31 @@ async function startProxyRuntime(params) {
1615
1833
  ? undefined
1616
1834
  : spawnFailOpenGuard(params.host, params.port, process.pid);
1617
1835
  const readinessHost = params.host === "0.0.0.0" ? "127.0.0.1" : params.host;
1618
- await waitForProxyReadiness({
1619
- host: readinessHost,
1620
- port: params.port,
1621
- });
1836
+ if (!socketWorker) {
1837
+ await waitForProxyReadiness({
1838
+ host: readinessHost,
1839
+ port: params.port,
1840
+ });
1841
+ }
1622
1842
  markProxyReady(params.readiness);
1623
- try {
1624
- const { reconcileRunningUpdate } = await import("../../lib/proxy/updateState.js");
1625
- if (reconcileRunningUpdate(PROXY_VERSION)) {
1626
- logger.always(`[proxy] confirmed pending update is now running at v${PROXY_VERSION}`);
1843
+ let updateReconciled = false;
1844
+ const reconcileActivatedUpdate = async () => {
1845
+ if (updateReconciled) {
1846
+ return;
1627
1847
  }
1628
- }
1629
- catch (error) {
1630
- logger.always(`[proxy] WARNING: failed to reconcile update state: ${error instanceof Error ? error.message : String(error)}`);
1848
+ updateReconciled = true;
1849
+ try {
1850
+ const { reconcileRunningUpdate } = await import("../../lib/proxy/updateState.js");
1851
+ if (reconcileRunningUpdate(PROXY_VERSION)) {
1852
+ logger.always(`[proxy] confirmed pending update is now running at v${PROXY_VERSION}`);
1853
+ }
1854
+ }
1855
+ catch (error) {
1856
+ logger.always(`[proxy] WARNING: failed to reconcile update state: ${error instanceof Error ? error.message : String(error)}`);
1857
+ }
1858
+ };
1859
+ if (!socketWorker) {
1860
+ await reconcileActivatedUpdate();
1631
1861
  }
1632
1862
  /** Mirror the supervised worker PID into the proxy state used by status. */
1633
1863
  const updatePersistedUpdaterPid = (updaterPid) => {
@@ -1637,7 +1867,7 @@ async function startProxyRuntime(params) {
1637
1867
  }
1638
1868
  saveProxyState({ ...state, updaterPid });
1639
1869
  };
1640
- const updaterSupervisor = managedByLaunchd && !params.argv.dev
1870
+ const updaterSupervisor = managedByLaunchd && !params.argv.dev && !socketWorker
1641
1871
  ? startUpdaterWorkerSupervisor({
1642
1872
  spawnWorker: () => spawnProxyUpdater(readinessHost, params.port, process.pid),
1643
1873
  isProcessRunning,
@@ -1665,32 +1895,41 @@ async function startProxyRuntime(params) {
1665
1895
  model: entry.model,
1666
1896
  }));
1667
1897
  const initialConfigStatus = params.runtimeConfigStore?.getStatus();
1668
- saveProxyState({
1669
- pid: process.pid,
1670
- port: params.port,
1671
- host: params.host,
1672
- strategy: activeStrategy,
1673
- startTime: new Date().toISOString(),
1674
- ready: true,
1675
- readyAt: params.readiness.readyAtMs
1676
- ? new Date(params.readiness.readyAtMs).toISOString()
1677
- : undefined,
1678
- healthPath: "/health",
1679
- statusPath: "/status",
1680
- envFile: params.loadedEnvFile,
1681
- fallbackChain,
1682
- accountAllowlist: activeAccountAllowlist
1683
- ? [...activeAccountAllowlist]
1684
- : undefined,
1685
- guardPid,
1686
- updaterPid,
1687
- managedBy: managedByLaunchd ? "launchd" : "manual",
1688
- passthrough: activePassthrough,
1689
- configGeneration: initialRuntimeConfig?.generation,
1690
- configLoadedAt: initialRuntimeConfig?.loadedAt,
1691
- lastConfigReloadError: initialConfigStatus?.lastReloadError,
1692
- configFile: initialConfigStatus?.configPath,
1693
- });
1898
+ const persistInitialProxyState = () => {
1899
+ const supervisorState = socketWorker ? loadProxySupervisorState() : null;
1900
+ saveProxyState({
1901
+ pid: process.pid,
1902
+ port: params.port,
1903
+ host: params.host,
1904
+ strategy: activeStrategy,
1905
+ startTime: new Date().toISOString(),
1906
+ ready: true,
1907
+ readyAt: params.readiness.readyAtMs
1908
+ ? new Date(params.readiness.readyAtMs).toISOString()
1909
+ : undefined,
1910
+ healthPath: "/health",
1911
+ statusPath: "/status",
1912
+ envFile: params.loadedEnvFile,
1913
+ fallbackChain,
1914
+ accountAllowlist: activeAccountAllowlist
1915
+ ? [...activeAccountAllowlist]
1916
+ : undefined,
1917
+ guardPid,
1918
+ updaterPid: socketWorker ? supervisorState?.updaterPid : updaterPid,
1919
+ supervisorPid: socketWorker
1920
+ ? (supervisorState?.pid ?? process.ppid)
1921
+ : undefined,
1922
+ managedBy: managedByLaunchd ? "launchd" : "manual",
1923
+ passthrough: activePassthrough,
1924
+ configGeneration: initialRuntimeConfig?.generation,
1925
+ configLoadedAt: initialRuntimeConfig?.loadedAt,
1926
+ lastConfigReloadError: initialConfigStatus?.lastReloadError,
1927
+ configFile: initialConfigStatus?.configPath,
1928
+ });
1929
+ };
1930
+ if (!socketWorker) {
1931
+ persistInitialProxyState();
1932
+ }
1694
1933
  const persistRuntimeConfig = (snapshot) => {
1695
1934
  const state = loadProxyState();
1696
1935
  if (!state || state.pid !== process.pid) {
@@ -1778,20 +2017,152 @@ async function startProxyRuntime(params) {
1778
2017
  const maintenance = startProxyBackgroundMaintenance(params.cleanupLogs, () => params.runtimeConfigStore
1779
2018
  ? params.runtimeConfigStore.getSnapshot().accountAllowlist
1780
2019
  : params.accountAllowlist);
1781
- registerProxyShutdownHandlers({
2020
+ const shutdown = registerProxyShutdownHandlers({
1782
2021
  server,
1783
2022
  host: params.host,
1784
2023
  port: params.port,
1785
2024
  isDev,
1786
2025
  updaterSupervisor,
1787
2026
  stopRuntimeConfig,
2027
+ registerSignals: !socketWorker,
1788
2028
  ...maintenance,
1789
2029
  });
2030
+ if (socketWorker) {
2031
+ attachSocketWorkerProcess(server, {
2032
+ generation: getProxyWorkerGeneration(),
2033
+ version: PROXY_VERSION,
2034
+ onActivated: () => {
2035
+ persistInitialProxyState();
2036
+ void reconcileActivatedUpdate();
2037
+ },
2038
+ onDrained: () => {
2039
+ void shutdown("ROLLING_DRAIN", { skipServerClose: true });
2040
+ },
2041
+ });
2042
+ }
2043
+ }
2044
+ async function runLaunchdProxySupervisor(argv, spinner) {
2045
+ await ensureProxyStartAllowed(spinner);
2046
+ const entryScript = process.argv[1];
2047
+ if (!entryScript) {
2048
+ throw new Error("proxy supervisor cannot resolve the CLI entry script");
2049
+ }
2050
+ const host = argv.host ?? "127.0.0.1";
2051
+ const port = argv.port ?? 55669;
2052
+ const workerArgs = process.argv.slice(2);
2053
+ const supervisorStartedAt = new Date().toISOString();
2054
+ let currentUpdaterPid;
2055
+ const rollingServer = await startRollingProxyServer({
2056
+ host,
2057
+ port,
2058
+ initialVersion: PROXY_VERSION,
2059
+ spawnWorker: (generation, expectedVersion) => spawnProxySocketWorker({
2060
+ generation,
2061
+ expectedVersion,
2062
+ command: TRAMPOLINE_PATH,
2063
+ args: workerArgs,
2064
+ env: {
2065
+ NEUROLINK_PROXY_UPDATE_CONTROL_TOKEN: PROXY_UPDATE_CONTROL_TOKEN,
2066
+ NEUROLINK_PROXY_TRAMPOLINE_EXEC_ONLY: "1",
2067
+ },
2068
+ }),
2069
+ onStateChange: (snapshot) => {
2070
+ saveProxySupervisorState({
2071
+ pid: process.pid,
2072
+ host,
2073
+ port,
2074
+ startTime: supervisorStartedAt,
2075
+ updaterPid: currentUpdaterPid,
2076
+ rolling: snapshot,
2077
+ });
2078
+ },
2079
+ log: (message) => logger.always(message),
2080
+ });
2081
+ let rollingReplacement = null;
2082
+ const activatePendingUpdate = () => {
2083
+ if (rollingReplacement) {
2084
+ return;
2085
+ }
2086
+ let pendingVersion;
2087
+ try {
2088
+ pendingVersion = loadUpdateState()?.pendingRestartVersion ?? undefined;
2089
+ }
2090
+ catch (error) {
2091
+ logger.always(`[proxy-supervisor] failed to read pending update state: ${error instanceof Error ? error.message : String(error)}`);
2092
+ return;
2093
+ }
2094
+ if (!pendingVersion || !/^\d+\.\d+\.\d+$/.test(pendingVersion)) {
2095
+ logger.always("[proxy-supervisor] ignored update activation without a valid pending version");
2096
+ return;
2097
+ }
2098
+ logger.always(`[proxy-supervisor] preparing rolling activation for v${pendingVersion}`);
2099
+ rollingReplacement = rollingServer
2100
+ .replace(pendingVersion)
2101
+ .then(() => {
2102
+ logger.always(`[proxy-supervisor] rolling activation complete version=${pendingVersion}`);
2103
+ })
2104
+ .catch((error) => {
2105
+ logger.always(`[proxy-supervisor] rolling activation failed version=${pendingVersion}: ${error instanceof Error ? error.message : String(error)}`);
2106
+ })
2107
+ .finally(() => {
2108
+ rollingReplacement = null;
2109
+ });
2110
+ };
2111
+ process.on("SIGUSR2", activatePendingUpdate);
2112
+ const readinessHost = host === "0.0.0.0" ? "127.0.0.1" : host;
2113
+ const updatePersistedUpdaterPid = (updaterPid) => {
2114
+ currentUpdaterPid = updaterPid;
2115
+ const state = loadProxySupervisorState();
2116
+ if (!state || state.pid !== process.pid) {
2117
+ return;
2118
+ }
2119
+ saveProxySupervisorState({ ...state, updaterPid });
2120
+ };
2121
+ const updaterSupervisor = startUpdaterWorkerSupervisor({
2122
+ spawnWorker: () => spawnProxyUpdater(readinessHost, rollingServer.address.port, process.pid, true),
2123
+ isProcessRunning,
2124
+ stopWorker: (pid) => process.kill(pid, "SIGTERM"),
2125
+ onPidChange: updatePersistedUpdaterPid,
2126
+ log: (message) => logger.always(message),
2127
+ });
2128
+ updatePersistedUpdaterPid(updaterSupervisor.currentPid());
2129
+ if (spinner) {
2130
+ spinner.succeed(chalk.green("Claude proxy supervisor started successfully"));
2131
+ }
2132
+ logger.always(`[proxy-supervisor] listening on ${host}:${rollingServer.address.port} workerPid=${rollingServer.snapshot().active?.pid ?? "unknown"} version=${PROXY_VERSION}`);
2133
+ let stopping = false;
2134
+ const shutdown = async (signal) => {
2135
+ if (stopping) {
2136
+ return;
2137
+ }
2138
+ stopping = true;
2139
+ logger.always(`[proxy-supervisor] shutting down (${signal})`);
2140
+ process.off("SIGUSR2", activatePendingUpdate);
2141
+ updaterSupervisor.stop();
2142
+ await rollingServer.close();
2143
+ const supervisorState = loadProxySupervisorState();
2144
+ if (supervisorState?.pid === process.pid) {
2145
+ clearProxySupervisorState();
2146
+ }
2147
+ process.exit(0);
2148
+ };
2149
+ process.once("SIGTERM", () => {
2150
+ void shutdown("SIGTERM");
2151
+ });
2152
+ process.once("SIGINT", () => {
2153
+ void shutdown("SIGINT");
2154
+ });
2155
+ await new Promise(() => undefined);
1790
2156
  }
1791
2157
  async function startProxyCommandHandler(argv) {
1792
2158
  const spinner = argv.quiet ? null : ora("Starting Claude proxy...").start();
1793
2159
  const isDev = argv.dev ?? false;
2160
+ const socketWorker = isProxySocketWorkerProcess();
1794
2161
  try {
2162
+ if (!isDev && isLaunchdManagedProcess() && !socketWorker) {
2163
+ await runLaunchdProxySupervisor(argv, spinner);
2164
+ return;
2165
+ }
1795
2166
  // In dev mode: redirect writable state to .neurolink-dev/ and skip singleton check
1796
2167
  let devPaths;
1797
2168
  if (isDev) {
@@ -1808,7 +2179,7 @@ async function startProxyCommandHandler(argv) {
1808
2179
  mkdirSync(devPaths.stateDir, { recursive: true, mode: 0o700 });
1809
2180
  }
1810
2181
  }
1811
- if (!isDev) {
2182
+ if (!isDev && !socketWorker) {
1812
2183
  await ensureProxyStartAllowed(spinner);
1813
2184
  }
1814
2185
  const baseEnv = { ...process.env };
@@ -2043,6 +2414,7 @@ export const proxyStatusCommand = {
2043
2414
  handler: async (argv) => {
2044
2415
  try {
2045
2416
  const state = loadProxyState();
2417
+ const supervisorState = loadProxySupervisorState();
2046
2418
  const updateState = loadUpdateState();
2047
2419
  const status = {
2048
2420
  running: false,
@@ -2061,6 +2433,10 @@ export const proxyStatusCommand = {
2061
2433
  configLoadedAt: null,
2062
2434
  lastConfigReloadError: null,
2063
2435
  autoUpdateEnabled: isProxyAutoUpdateEnabled(),
2436
+ workerVersion: null,
2437
+ supervisorPid: null,
2438
+ supervisorRunning: false,
2439
+ rolling: null,
2064
2440
  updaterPid: null,
2065
2441
  updaterRunning: false,
2066
2442
  latestVersion: updateState?.lastCheckVersion || null,
@@ -2068,25 +2444,52 @@ export const proxyStatusCommand = {
2068
2444
  deferredUpdate: updateState?.deferredUpdate ?? null,
2069
2445
  lastUpdateFailure: updateState?.lastFailure ?? null,
2070
2446
  };
2071
- if (state && isProcessRunning(state.pid)) {
2447
+ const workerRunning = state ? isProcessRunning(state.pid) : false;
2448
+ const supervisorPid = supervisorState?.pid ?? state?.supervisorPid;
2449
+ const supervisorRunning = supervisorPid
2450
+ ? isProcessRunning(supervisorPid)
2451
+ : false;
2452
+ const servingWorker = workerRunning &&
2453
+ (supervisorPid
2454
+ ? supervisorRunning &&
2455
+ (!supervisorState ||
2456
+ supervisorState.rolling.active?.pid === state?.pid)
2457
+ : true);
2458
+ if ((state || supervisorState) && (servingWorker || supervisorRunning)) {
2459
+ const activeHost = state?.host ?? supervisorState?.host ?? null;
2460
+ const activePort = state?.port ?? supervisorState?.port ?? null;
2072
2461
  status.running = true;
2073
- status.pid = state.pid;
2074
- status.port = state.port;
2075
- status.host = state.host;
2076
- status.mode = state.passthrough ? "passthrough" : "full";
2077
- status.strategy = state.strategy;
2078
- status.startTime = state.startTime;
2079
- status.uptime = Date.now() - new Date(state.startTime).getTime();
2080
- status.url = `http://${state.host === "0.0.0.0" ? "localhost" : state.host}:${state.port}`;
2081
- status.envFile = state.envFile ?? null;
2082
- status.fallbackChain = state.fallbackChain ?? null;
2083
- status.accountAllowlist = state.accountAllowlist ?? null;
2084
- status.configGeneration = state.configGeneration ?? null;
2085
- status.configLoadedAt = state.configLoadedAt ?? null;
2086
- status.lastConfigReloadError = state.lastConfigReloadError ?? null;
2087
- status.updaterPid = state.updaterPid ?? null;
2088
- status.updaterRunning = state.updaterPid
2089
- ? isProcessRunning(state.updaterPid)
2462
+ status.pid = servingWorker && state ? state.pid : null;
2463
+ status.port = activePort;
2464
+ status.host = activeHost;
2465
+ status.mode = state
2466
+ ? state.passthrough
2467
+ ? "passthrough"
2468
+ : "full"
2469
+ : null;
2470
+ status.strategy = state?.strategy ?? null;
2471
+ status.startTime =
2472
+ state?.startTime ?? supervisorState?.startTime ?? null;
2473
+ status.uptime = status.startTime
2474
+ ? Date.now() - new Date(status.startTime).getTime()
2475
+ : null;
2476
+ status.url =
2477
+ activeHost && activePort
2478
+ ? `http://${activeHost === "0.0.0.0" ? "localhost" : activeHost}:${activePort}`
2479
+ : null;
2480
+ status.envFile = state?.envFile ?? null;
2481
+ status.fallbackChain = state?.fallbackChain ?? null;
2482
+ status.accountAllowlist = state?.accountAllowlist ?? null;
2483
+ status.configGeneration = state?.configGeneration ?? null;
2484
+ status.configLoadedAt = state?.configLoadedAt ?? null;
2485
+ status.lastConfigReloadError = state?.lastConfigReloadError ?? null;
2486
+ status.supervisorPid = supervisorPid ?? null;
2487
+ status.supervisorRunning = supervisorRunning;
2488
+ status.rolling = supervisorState?.rolling ?? null;
2489
+ status.updaterPid =
2490
+ supervisorState?.updaterPid ?? state?.updaterPid ?? null;
2491
+ status.updaterRunning = status.updaterPid
2492
+ ? isProcessRunning(status.updaterPid)
2090
2493
  : false;
2091
2494
  }
2092
2495
  // Fetch live stats before rendering (JSON or text)
@@ -2094,11 +2497,17 @@ export const proxyStatusCommand = {
2094
2497
  let liveConfig = null;
2095
2498
  if (status.running && status.url) {
2096
2499
  try {
2097
- const statusResp = await fetch(`${status.url}/status`);
2500
+ const statusResp = await fetch(`${status.url}/status`, {
2501
+ signal: AbortSignal.timeout(2_000),
2502
+ });
2098
2503
  if (statusResp.ok) {
2099
2504
  const statusData = (await statusResp.json());
2100
2505
  liveStats = statusData.stats;
2101
2506
  liveConfig = statusData.config;
2507
+ status.workerVersion =
2508
+ typeof statusData.version === "string"
2509
+ ? statusData.version
2510
+ : null;
2102
2511
  if (typeof liveConfig?.generation === "number") {
2103
2512
  status.configGeneration = liveConfig.generation;
2104
2513
  }
@@ -2125,8 +2534,15 @@ export const proxyStatusCommand = {
2125
2534
  logger.always(chalk.gray("=".repeat(50)));
2126
2535
  logger.always("");
2127
2536
  if (status.running) {
2128
- logger.always(` ${chalk.bold("Status:")} ${chalk.green("RUNNING")}`);
2129
- logger.always(` ${chalk.bold("PID:")} ${chalk.cyan(status.pid)}`);
2537
+ const serving = status.pid !== null;
2538
+ logger.always(` ${chalk.bold("Status:")} ${serving ? chalk.green("RUNNING") : chalk.yellow("RECOVERING")}`);
2539
+ logger.always(` ${chalk.bold("Worker PID:")} ${serving ? chalk.cyan(status.pid) : chalk.yellow("unavailable")}`);
2540
+ if (status.supervisorPid) {
2541
+ logger.always(` ${chalk.bold("Supervisor:")} ${status.supervisorRunning ? chalk.cyan(status.supervisorPid) : chalk.red(`${status.supervisorPid} (not running)`)}`);
2542
+ }
2543
+ if (status.workerVersion) {
2544
+ logger.always(` ${chalk.bold("Version:")} ${chalk.cyan(`v${status.workerVersion}`)}`);
2545
+ }
2130
2546
  logger.always(` ${chalk.bold("URL:")} ${chalk.cyan(status.url)}`);
2131
2547
  logger.always(` ${chalk.bold("Strategy:")} ${chalk.cyan(status.strategy)}`);
2132
2548
  logger.always(` ${chalk.bold("Mode:")} ${chalk.cyan(status.mode ?? "full")}`);
@@ -2145,6 +2561,12 @@ export const proxyStatusCommand = {
2145
2561
  if (status.pendingRestartVersion) {
2146
2562
  logger.always(` ${chalk.bold("Pending:")} ${chalk.yellow(`v${status.pendingRestartVersion} installed; restart pending`)}`);
2147
2563
  }
2564
+ if (status.rolling?.candidate) {
2565
+ logger.always(` ${chalk.bold("Handoff:")} ${chalk.yellow(`preparing v${status.rolling.candidate.expectedVersion} (PID ${status.rolling.candidate.pid})`)}`);
2566
+ }
2567
+ else if (status.rolling?.draining.length) {
2568
+ logger.always(` ${chalk.bold("Handoff:")} ${chalk.cyan(`${status.rolling.draining.length} previous worker(s) draining`)}`);
2569
+ }
2148
2570
  if (status.deferredUpdate) {
2149
2571
  const active = status.deferredUpdate.activeRequests === null
2150
2572
  ? "unknown activity"
@@ -2178,7 +2600,9 @@ export const proxyStatusCommand = {
2178
2600
  }
2179
2601
  // Try to fetch live status from the running proxy
2180
2602
  try {
2181
- const response = await fetch(`${status.url}/health`);
2603
+ const response = await fetch(`${status.url}/health`, {
2604
+ signal: AbortSignal.timeout(2_000),
2605
+ });
2182
2606
  if (response.ok) {
2183
2607
  const liveStatus = (await response.json());
2184
2608
  logger.always("");
@@ -2193,7 +2617,9 @@ export const proxyStatusCommand = {
2193
2617
  // Try to get detailed stats
2194
2618
  try {
2195
2619
  const liveUrl = status.url;
2196
- const statusResp = await fetch(`${liveUrl}/status`);
2620
+ const statusResp = await fetch(`${liveUrl}/status`, {
2621
+ signal: AbortSignal.timeout(2_000),
2622
+ });
2197
2623
  if (statusResp.ok) {
2198
2624
  const statusData = (await statusResp.json());
2199
2625
  if (statusData.stats) {
@@ -2326,6 +2752,7 @@ export const proxyGuardCommand = {
2326
2752
  const failureThreshold = Math.max(1, Number(argv.failureThreshold ?? 5));
2327
2753
  const pollIntervalMs = Math.max(250, Number(argv.pollIntervalMs ?? 1_000));
2328
2754
  const updaterOnly = argv.updaterOnly === true;
2755
+ const rollingSupervisor = process.env[PROXY_ROLLING_SUPERVISOR_ENV] === "1";
2329
2756
  if (!Number.isFinite(parentPid) || parentPid <= 0) {
2330
2757
  return;
2331
2758
  }
@@ -2411,56 +2838,62 @@ export const proxyGuardCommand = {
2411
2838
  return;
2412
2839
  }
2413
2840
  logger.always(`[updater] update available: ${runningVersion} → ${result.latestVersion}`);
2414
- // 2. Prefer a naturally quiet period. Sustained traffic is handled by
2415
- // a bounded graceful drain so update checks cannot wait forever.
2416
- let lastDeferralSignature = "";
2417
- const waitForWindow = async (quietWaitMs) => {
2418
- const window = await waitForProxyUpdateWindow({
2419
- quietThresholdMs: QUIET_THRESHOLD_MS,
2420
- quietWaitMs,
2421
- drainWaitMs: UPDATE_DRAIN_TIMEOUT_MS,
2422
- pollIntervalMs: UPDATE_ACTIVITY_POLL_MS,
2423
- getActivity: () => getProxyRuntimeActivity(host, port),
2424
- setDraining: async (draining) => {
2425
- const changed = await setProxyUpdateDrain(host, port, draining);
2426
- if (changed) {
2427
- logger.always(`[updater] ${draining ? "draining new inference requests" : "inference admission resumed"}`);
2428
- }
2429
- return changed;
2430
- },
2431
- isStopping: () => guardStopping,
2432
- isParentAlive: () => getProcessStatus(parentPid) !== "not_running",
2433
- onPhase: (phase, activity) => {
2434
- const reason = phase === "waiting_for_quiet" && !activity
2435
- ? "activity_unavailable"
2436
- : phase;
2437
- const signature = `${reason}:${activity?.activeRequests ?? "unknown"}`;
2438
- if (signature !== lastDeferralSignature) {
2439
- lastDeferralSignature = signature;
2440
- persistUpdaterState("record update deferral", () => recordUpdateDeferred(result.latestVersion, reason, activity?.activeRequests ?? null));
2441
- }
2442
- logger.debug(`[updater] ${reason} (${activity?.activeRequests ?? "unknown"} active requests)`);
2443
- },
2444
- });
2445
- drainActive = drainActive || window.draining;
2446
- return window;
2447
- };
2448
- let updateWindow = await waitForWindow(NATURAL_WINDOW_WAIT_MS);
2449
- if (!updateWindow.ready) {
2450
- if (updateWindow.reason === "drain_failed") {
2451
- persistUpdaterState("record unavailable update drain", () => recordUpdateDeferred(result.latestVersion, "drain_unavailable", null));
2452
- }
2453
- scheduleUpdateRetry();
2454
- return;
2455
- }
2456
- // Close the small race between observing zero activity and package
2457
- // mutation by holding admission closed through install and restart.
2458
- if (!drainActive) {
2459
- updateWindow = await waitForWindow(0);
2841
+ if (!rollingSupervisor) {
2842
+ // Legacy launchd services must become idle before their listener is
2843
+ // restarted. Rolling services keep the current worker serving while
2844
+ // the candidate package is installed and validated.
2845
+ let lastDeferralSignature = "";
2846
+ const waitForWindow = async (quietWaitMs) => {
2847
+ const window = await waitForProxyUpdateWindow({
2848
+ quietThresholdMs: QUIET_THRESHOLD_MS,
2849
+ quietWaitMs,
2850
+ drainWaitMs: UPDATE_DRAIN_TIMEOUT_MS,
2851
+ pollIntervalMs: UPDATE_ACTIVITY_POLL_MS,
2852
+ getActivity: () => getProxyRuntimeActivity(host, port),
2853
+ setDraining: async (draining) => {
2854
+ const changed = await setProxyUpdateDrain(host, port, draining);
2855
+ if (changed) {
2856
+ logger.always(`[updater] ${draining ? "draining new inference requests" : "inference admission resumed"}`);
2857
+ }
2858
+ return changed;
2859
+ },
2860
+ isStopping: () => guardStopping,
2861
+ isParentAlive: () => getProcessStatus(parentPid) !== "not_running",
2862
+ onPhase: (phase, activity) => {
2863
+ const reason = phase === "waiting_for_quiet" && !activity
2864
+ ? "activity_unavailable"
2865
+ : phase;
2866
+ const signature = `${reason}:${activity?.activeRequests ?? "unknown"}`;
2867
+ if (signature !== lastDeferralSignature) {
2868
+ lastDeferralSignature = signature;
2869
+ persistUpdaterState("record update deferral", () => recordUpdateDeferred(result.latestVersion, reason, activity?.activeRequests ?? null));
2870
+ }
2871
+ logger.debug(`[updater] ${reason} (${activity?.activeRequests ?? "unknown"} active requests)`);
2872
+ },
2873
+ });
2874
+ drainActive = drainActive || window.draining;
2875
+ return window;
2876
+ };
2877
+ let updateWindow = await waitForWindow(NATURAL_WINDOW_WAIT_MS);
2460
2878
  if (!updateWindow.ready) {
2879
+ if (updateWindow.reason === "drain_failed") {
2880
+ persistUpdaterState("record unavailable update drain", () => recordUpdateDeferred(result.latestVersion, "drain_unavailable", null));
2881
+ }
2461
2882
  scheduleUpdateRetry();
2462
2883
  return;
2463
2884
  }
2885
+ // Hold admission closed through install and restart after a quiet
2886
+ // observation, closing the race with a newly admitted request.
2887
+ if (!drainActive) {
2888
+ updateWindow = await waitForWindow(0);
2889
+ if (!updateWindow.ready) {
2890
+ scheduleUpdateRetry();
2891
+ return;
2892
+ }
2893
+ }
2894
+ }
2895
+ else {
2896
+ logger.always(`[updater] rolling supervisor will keep v${runningVersion} serving during installation`);
2464
2897
  }
2465
2898
  // Refresh after waiting so a long deferral can never install a stale
2466
2899
  // target while a newer release is already available.
@@ -2478,7 +2911,7 @@ export const proxyGuardCommand = {
2478
2911
  return;
2479
2912
  }
2480
2913
  persistUpdaterState("clear update deferral", () => clearUpdateDeferral());
2481
- logger.always(`[updater] safe update window acquired for v${result.latestVersion}`);
2914
+ logger.always(`[updater] ${rollingSupervisor ? "rolling activation prepared" : "safe update window acquired"} for v${result.latestVersion}`);
2482
2915
  // 3. Install update (validate version string before passing to shell)
2483
2916
  if (!/^\d+\.\d+\.\d+$/.test(result.latestVersion)) {
2484
2917
  const message = `invalid version format: ${result.latestVersion}`;
@@ -2551,31 +2984,47 @@ export const proxyGuardCommand = {
2551
2984
  if (guardStopping || getProcessStatus(parentPid) === "not_running") {
2552
2985
  return;
2553
2986
  }
2554
- // Admission has remained closed since active requests reached zero, so
2555
- // restart cannot interrupt an accepted request or long-lived stream.
2556
- // Signal the health loop to not exit when it detects
2557
- // the parent PID is gone — we're intentionally restarting.
2558
2987
  updateRestartInProgress = true;
2559
- logger.always(`[updater] restarting proxy via launchctl kickstart`);
2560
- const uid = process.getuid?.() ?? 501;
2561
- try {
2562
- execFileSync("launchctl", ["kickstart", "-k", `gui/${uid}/${PLIST_LABEL}`], {
2563
- timeout: 10_000,
2564
- stdio: "pipe",
2565
- });
2988
+ if (rollingSupervisor) {
2989
+ logger.always(`[updater] requesting rolling activation of v${result.latestVersion}`);
2990
+ try {
2991
+ process.kill(parentPid, "SIGUSR2");
2992
+ }
2993
+ catch (activationError) {
2994
+ updateRestartInProgress = false;
2995
+ const message = activationError instanceof Error
2996
+ ? activationError.message
2997
+ : String(activationError);
2998
+ logger.always(`[updater] WARNING: rolling activation request failed: ${message}`);
2999
+ persistUpdaterState("record update failure", () => recordUpdateFailure(result.latestVersion, "restart", message));
3000
+ return;
3001
+ }
2566
3002
  }
2567
- catch (restartErr) {
2568
- updateRestartInProgress = false;
2569
- const msg = restartErr instanceof Error
2570
- ? restartErr.message
2571
- : String(restartErr);
2572
- logger.always(`[updater] WARNING: launchctl kickstart failed: ${msg}`);
2573
- persistUpdaterState("record update failure", () => recordUpdateFailure(result.latestVersion, "restart", msg));
2574
- return;
3003
+ else {
3004
+ // Compatibility fallback for services installed before rolling
3005
+ // supervision was enabled.
3006
+ logger.always(`[updater] restarting proxy via launchctl kickstart`);
3007
+ const uid = process.getuid?.() ?? 501;
3008
+ try {
3009
+ execFileSync("launchctl", ["kickstart", "-k", `gui/${uid}/${PLIST_LABEL}`], {
3010
+ timeout: 10_000,
3011
+ stdio: "pipe",
3012
+ });
3013
+ }
3014
+ catch (restartErr) {
3015
+ updateRestartInProgress = false;
3016
+ const message = restartErr instanceof Error
3017
+ ? restartErr.message
3018
+ : String(restartErr);
3019
+ logger.always(`[updater] WARNING: launchctl kickstart failed: ${message}`);
3020
+ persistUpdaterState("record update failure", () => recordUpdateFailure(result.latestVersion, "restart", message));
3021
+ return;
3022
+ }
2575
3023
  }
2576
3024
  drainActive = false;
2577
3025
  // 5. Wait for healthy restart
2578
3026
  let healthy = false;
3027
+ let rollingFailure = null;
2579
3028
  const restartStart = Date.now();
2580
3029
  while (Date.now() - restartStart < UPDATE_TIMEOUT_MS) {
2581
3030
  await new Promise((r) => setTimeout(r, 2000));
@@ -2594,6 +3043,12 @@ export const proxyGuardCommand = {
2594
3043
  catch {
2595
3044
  /* retry */
2596
3045
  }
3046
+ if (rollingSupervisor) {
3047
+ rollingFailure = await getRollingActivationFailure(host, port, result.latestVersion);
3048
+ if (rollingFailure) {
3049
+ break;
3050
+ }
3051
+ }
2597
3052
  }
2598
3053
  if (healthy) {
2599
3054
  logger.always(`[updater] update successful: now running ${result.latestVersion}`);
@@ -2603,7 +3058,49 @@ export const proxyGuardCommand = {
2603
3058
  }
2604
3059
  else {
2605
3060
  logger.always(`[updater] WARNING: proxy unhealthy after update to ${result.latestVersion}`);
2606
- persistUpdaterState("record update failure", () => recordUpdateFailure(result.latestVersion, "health", `proxy did not report v${result.latestVersion} healthy within ${UPDATE_TIMEOUT_MS}ms`));
3061
+ let failureMessage = rollingFailure
3062
+ ? `rolling candidate failed: ${rollingFailure}`
3063
+ : `proxy did not report v${result.latestVersion} healthy within ${UPDATE_TIMEOUT_MS}ms`;
3064
+ if (rollingSupervisor) {
3065
+ try {
3066
+ const rollbackResolution = resolveGlobalInstaller({
3067
+ entryScript: process.argv[1],
3068
+ });
3069
+ const rollbackInstaller = rollbackResolution.installer;
3070
+ if (!rollbackInstaller) {
3071
+ throw new Error("no usable package manager is available for rollback");
3072
+ }
3073
+ logger.always(`[updater] rolling activation failed; restoring @juspay/neurolink@${runningVersion}`);
3074
+ execFileSync(rollbackInstaller.bin, getGlobalInstallArgs(rollbackInstaller.kind, `@juspay/neurolink@${runningVersion}`), { timeout: 120_000, stdio: "pipe" });
3075
+ writeTrampoline();
3076
+ const rollbackValidation = await validateInstalledVersion({
3077
+ binPath: TRAMPOLINE_PATH,
3078
+ expectedVersion: runningVersion,
3079
+ });
3080
+ if (rollbackValidation.version !== runningVersion) {
3081
+ throw new Error(`rollback trampoline reported v${rollbackValidation.version ?? "unknown"}; expected v${runningVersion}`);
3082
+ }
3083
+ // Reinstalling the old package is not enough: the supervisor was
3084
+ // told to activate result.latestVersion, so its recovery loop
3085
+ // keeps demanding the new version against the now-downgraded
3086
+ // installation and can never serve a worker again. Re-point the
3087
+ // supervisor at runningVersion and report success only once it
3088
+ // actually serves that version again.
3089
+ const rollbackActivated = await activateRollbackVersion(host, port, parentPid, runningVersion, UPDATE_TIMEOUT_MS);
3090
+ if (!rollbackActivated) {
3091
+ throw new Error(`supervisor did not report v${runningVersion} healthy within ${UPDATE_TIMEOUT_MS}ms after rollback`);
3092
+ }
3093
+ logger.always(`[updater] package rollback complete; v${runningVersion} active again`);
3094
+ }
3095
+ catch (rollbackError) {
3096
+ const rollbackMessage = rollbackError instanceof Error
3097
+ ? rollbackError.message
3098
+ : String(rollbackError);
3099
+ failureMessage += `; package rollback failed: ${rollbackMessage}`;
3100
+ logger.always(`[updater] WARNING: ${failureMessage}`);
3101
+ }
3102
+ }
3103
+ persistUpdaterState("record update failure", () => recordUpdateFailure(result.latestVersion, "health", failureMessage));
2607
3104
  persistUpdaterState("abandon unhealthy pending update", () => abandonPendingUpdate(result.latestVersion));
2608
3105
  persistUpdaterState("suppress unhealthy update", () => suppressVersion(result.latestVersion, "unhealthy_after_restart"));
2609
3106
  updateRestartInProgress = false;
@@ -3055,28 +3552,6 @@ export const proxyInstallCommand = {
3055
3552
  console.info(chalk.red(`Failed to load service: ${e}`));
3056
3553
  process.exit(1);
3057
3554
  }
3058
- // Wait briefly for launchd to start the process, then persist state
3059
- await new Promise((resolve) => setTimeout(resolve, 2_000));
3060
- try {
3061
- const { execFileSync } = await import("node:child_process");
3062
- const uid = process.getuid?.() ?? 501;
3063
- const output = execFileSync("launchctl", ["print", `gui/${uid}/${PLIST_LABEL}`], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
3064
- const pidMatch = output.match(/pid\s*=\s*(\d+)/);
3065
- if (pidMatch) {
3066
- saveProxyState({
3067
- pid: Number(pidMatch[1]),
3068
- port,
3069
- host,
3070
- strategy: "fill-first",
3071
- startTime: new Date().toISOString(),
3072
- envFile,
3073
- managedBy: "launchd",
3074
- });
3075
- }
3076
- }
3077
- catch {
3078
- /* non-fatal — state will be written by the proxy process itself */
3079
- }
3080
3555
  console.info("");
3081
3556
  console.info(chalk.bold("Proxy is now a persistent service:"));
3082
3557
  console.info(` • Auto-starts on login`);
@@ -3099,6 +3574,12 @@ export const proxyUninstallCommand = {
3099
3574
  }
3100
3575
  const { existsSync, unlinkSync } = await import("fs");
3101
3576
  if (!existsSync(PLIST_PATH)) {
3577
+ if (!(await ensureSupervisorStoppedBeforeClear())) {
3578
+ console.info(chalk.red("A proxy supervisor is still running and could not be stopped; leaving its state intact. Stop it manually and retry."));
3579
+ process.exit(1);
3580
+ }
3581
+ clearProxyState();
3582
+ clearProxySupervisorState();
3102
3583
  console.info(chalk.yellow("No proxy service installed."));
3103
3584
  return;
3104
3585
  }
@@ -3110,7 +3591,16 @@ export const proxyUninstallCommand = {
3110
3591
  catch {
3111
3592
  /* may not be loaded */
3112
3593
  }
3594
+ // Do not delete supervisor state while a supervisor still owns the
3595
+ // listener: a swallowed unload failure (or a supervisor started outside
3596
+ // launchd) would otherwise be orphaned.
3597
+ if (!(await ensureSupervisorStoppedBeforeClear())) {
3598
+ console.info(chalk.red("launchctl could not stop the running supervisor; aborting uninstall so its state is preserved. Stop it manually and retry."));
3599
+ process.exit(1);
3600
+ }
3113
3601
  unlinkSync(PLIST_PATH);
3602
+ clearProxyState();
3603
+ clearProxySupervisorState();
3114
3604
  console.info(chalk.green(`✓ Plist removed from ${PLIST_PATH}`));
3115
3605
  console.info(chalk.green(`✓ Proxy service uninstalled`));
3116
3606
  },