@juspay/neurolink 10.4.2 → 10.4.3

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 (32) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/browser/neurolink.min.js +333 -336
  3. package/dist/cli/commands/proxy.d.ts +1 -1
  4. package/dist/cli/commands/proxy.js +124 -56
  5. package/dist/cli/commands/proxyAnalyze.js +10 -1
  6. package/dist/lib/proxy/proxyAnalysis.js +136 -12
  7. package/dist/lib/proxy/requestLogger.d.ts +2 -0
  8. package/dist/lib/proxy/requestLogger.js +72 -13
  9. package/dist/lib/proxy/rollingProxyServer.js +79 -8
  10. package/dist/lib/proxy/rollingWorkerProcess.js +20 -15
  11. package/dist/lib/proxy/rollingWorkerProtocol.js +3 -0
  12. package/dist/lib/proxy/rollingWorkerSupervisor.d.ts +1 -0
  13. package/dist/lib/proxy/rollingWorkerSupervisor.js +31 -11
  14. package/dist/lib/proxy/runtimeConfig.d.ts +1 -0
  15. package/dist/lib/proxy/runtimeConfig.js +20 -5
  16. package/dist/lib/proxy/socketWorkerRuntime.js +41 -13
  17. package/dist/lib/server/routes/claudeProxyRoutes.js +63 -75
  18. package/dist/lib/types/proxy.d.ts +31 -1
  19. package/dist/proxy/proxyAnalysis.js +136 -12
  20. package/dist/proxy/requestLogger.d.ts +2 -0
  21. package/dist/proxy/requestLogger.js +72 -13
  22. package/dist/proxy/rollingProxyServer.js +79 -8
  23. package/dist/proxy/rollingWorkerProcess.js +20 -15
  24. package/dist/proxy/rollingWorkerProtocol.js +3 -0
  25. package/dist/proxy/rollingWorkerSupervisor.d.ts +1 -0
  26. package/dist/proxy/rollingWorkerSupervisor.js +31 -11
  27. package/dist/proxy/runtimeConfig.d.ts +1 -0
  28. package/dist/proxy/runtimeConfig.js +20 -5
  29. package/dist/proxy/socketWorkerRuntime.js +41 -13
  30. package/dist/server/routes/claudeProxyRoutes.js +63 -75
  31. package/dist/types/proxy.d.ts +31 -1
  32. package/package.json +1 -1
@@ -9,15 +9,49 @@ import { join } from "path";
9
9
  import { homedir } from "os";
10
10
  import { logger } from "../utils/logger.js";
11
11
  import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, unlinkSync, } from "fs";
12
- import { appendFile, writeFile } from "fs/promises";
12
+ import { writeFile } from "fs/promises";
13
13
  import { createHash } from "crypto";
14
14
  import { promisify } from "util";
15
15
  import { gzip as gzipCallback } from "zlib";
16
16
  import { OtelBridge } from "../observability/otelBridge.js";
17
17
  import { SeverityNumber } from "@opentelemetry/api-logs";
18
18
  import { configureProxyLifecycleLogger } from "./proxyLifecycle.js";
19
+ import { withTimeout } from "../utils/async/withTimeout.js";
19
20
  let logDir = null;
20
21
  let logEnabled = false;
22
+ const pendingLogOperations = new Set();
23
+ const REQUEST_LOG_IO_TIMEOUT_MS = 5_000;
24
+ function trackLogOperation(operation) {
25
+ pendingLogOperations.add(operation);
26
+ void operation.then(() => pendingLogOperations.delete(operation), () => pendingLogOperations.delete(operation));
27
+ return operation;
28
+ }
29
+ /** Wait, up to a bounded deadline, for admitted request/body writes to settle. */
30
+ export async function flushRequestLogs(timeoutMs = REQUEST_LOG_IO_TIMEOUT_MS) {
31
+ const deadline = Date.now() + Math.max(1, timeoutMs);
32
+ while (pendingLogOperations.size > 0) {
33
+ const admitted = [...pendingLogOperations];
34
+ const remainingMs = Math.max(1, deadline - Date.now());
35
+ try {
36
+ await withTimeout(Promise.allSettled(admitted), remainingMs, `Timed out flushing ${admitted.length} proxy request log operation(s)`);
37
+ }
38
+ catch (error) {
39
+ for (const operation of admitted) {
40
+ pendingLogOperations.delete(operation);
41
+ }
42
+ throw error;
43
+ }
44
+ if (Date.now() >= deadline && pendingLogOperations.size > 0) {
45
+ const remaining = pendingLogOperations.size;
46
+ throw new Error(`Timed out flushing ${remaining} proxy request log operation(s)`);
47
+ }
48
+ }
49
+ }
50
+ /** @internal Test-only hook for exercising shutdown behavior without real I/O. */
51
+ export const __requestLoggerTestHooks = {
52
+ pendingOperationCount: () => pendingLogOperations.size,
53
+ trackLogOperation,
54
+ };
21
55
  /**
22
56
  * Lazily-resolved LoggerProvider from OTel instrumentation.
23
57
  * null = not resolved yet (will retry), LoggerProvider = resolved, false = permanently unavailable.
@@ -86,7 +120,11 @@ export async function logRequest(entry) {
86
120
  const logFile = join(logDir, `proxy-${new Date().toISOString().split("T")[0]}.jsonl`);
87
121
  const line = JSON.stringify(entry) + "\n";
88
122
  try {
89
- await appendFile(logFile, line, { mode: 0o600 });
123
+ await trackLogOperation(writeFile(logFile, line, {
124
+ mode: 0o600,
125
+ flag: "a",
126
+ signal: AbortSignal.timeout(REQUEST_LOG_IO_TIMEOUT_MS),
127
+ }));
90
128
  }
91
129
  catch {
92
130
  // Non-fatal — don't crash proxy for logging failures
@@ -114,7 +152,11 @@ export async function logRequestAttempt(entry) {
114
152
  const logFile = join(logDir, `proxy-attempts-${new Date().toISOString().split("T")[0]}.jsonl`);
115
153
  const line = JSON.stringify(entry) + "\n";
116
154
  try {
117
- await appendFile(logFile, line, { mode: 0o600 });
155
+ await trackLogOperation(writeFile(logFile, line, {
156
+ mode: 0o600,
157
+ flag: "a",
158
+ signal: AbortSignal.timeout(REQUEST_LOG_IO_TIMEOUT_MS),
159
+ }));
118
160
  }
119
161
  catch {
120
162
  // Non-fatal — don't crash proxy for logging failures
@@ -353,7 +395,7 @@ function collectManagedLogFiles(rootDir) {
353
395
  continue;
354
396
  }
355
397
  const isTopLevelProxyLog = directory === rootDir &&
356
- /^proxy(?:-attempts|-debug)?-.*\.jsonl$/.test(entry.name);
398
+ /^proxy(?:-attempts|-debug|-lifecycle)?-.*\.jsonl$/.test(entry.name);
357
399
  const isBodyArtifact = entry.name.endsWith(".json.gz") &&
358
400
  entryPath.includes(`${join(rootDir, "bodies")}`);
359
401
  if (!isTopLevelProxyLog && !isBodyArtifact) {
@@ -427,7 +469,10 @@ async function writeBodyArtifact(entry, redactedHeaders, redactedBody, bodyTrunc
427
469
  metadata: entry.metadata,
428
470
  });
429
471
  const compressed = await gzip(payload);
430
- await writeFile(bodyPath, compressed, { mode: 0o600 });
472
+ await writeFile(bodyPath, compressed, {
473
+ mode: 0o600,
474
+ signal: AbortSignal.timeout(REQUEST_LOG_IO_TIMEOUT_MS),
475
+ });
431
476
  return {
432
477
  bodyPath,
433
478
  bodySha256: sha256(redactedBody),
@@ -510,7 +555,7 @@ export async function logBodyCapture(entry) {
510
555
  const preparedBody = prepareRedactedBody(entry.body);
511
556
  let stored;
512
557
  try {
513
- stored = await writeBodyArtifact(entry, redactedHeaders, preparedBody.value, preparedBody.truncated);
558
+ stored = await trackLogOperation(writeBodyArtifact(entry, redactedHeaders, preparedBody.value, preparedBody.truncated));
514
559
  }
515
560
  catch (writeError) {
516
561
  logger.warn("[RequestLogger] writeBodyArtifact failed, falling back to in-memory body for OTLP", { error: writeError });
@@ -518,6 +563,7 @@ export async function logBodyCapture(entry) {
518
563
  redactedBody: preparedBody.value,
519
564
  redactedBodyBytes: preparedBody.bytes,
520
565
  bodyTruncated: preparedBody.truncated,
566
+ bodyWriteFailed: true,
521
567
  };
522
568
  }
523
569
  const dateStr = new Date(entry.timestamp).toISOString().split("T")[0];
@@ -542,6 +588,7 @@ export async function logBodyCapture(entry) {
542
588
  redactedBodyBytes: stored.redactedBodyBytes ?? preparedBody.bytes,
543
589
  storedFileBytes: stored.storedFileBytes,
544
590
  bodyTruncated: stored.bodyTruncated ?? preparedBody.truncated,
591
+ bodyWriteFailed: stored.bodyWriteFailed,
545
592
  metadata: entry.metadata,
546
593
  };
547
594
  if (traceCtx) {
@@ -549,9 +596,11 @@ export async function logBodyCapture(entry) {
549
596
  indexEntry.spanId = traceCtx.spanId;
550
597
  }
551
598
  try {
552
- await appendFile(logFile, JSON.stringify(indexEntry) + "\n", {
599
+ await trackLogOperation(writeFile(logFile, JSON.stringify(indexEntry) + "\n", {
553
600
  mode: 0o600,
554
- });
601
+ flag: "a",
602
+ signal: AbortSignal.timeout(REQUEST_LOG_IO_TIMEOUT_MS),
603
+ }));
555
604
  }
556
605
  catch {
557
606
  // Non-fatal
@@ -623,9 +672,11 @@ export async function logStreamError(entry) {
623
672
  logEntry.spanId = traceCtx.spanId;
624
673
  }
625
674
  try {
626
- await appendFile(logFile, JSON.stringify(logEntry) + "\n", {
675
+ await trackLogOperation(writeFile(logFile, JSON.stringify(logEntry) + "\n", {
627
676
  mode: 0o600,
628
- });
677
+ flag: "a",
678
+ signal: AbortSignal.timeout(REQUEST_LOG_IO_TIMEOUT_MS),
679
+ }));
629
680
  }
630
681
  catch {
631
682
  // Non-fatal — don't crash proxy for logging failures
@@ -644,13 +695,16 @@ export function cleanupLogs(maxAgeDays = 7, maxSizeMb = 500) {
644
695
  try {
645
696
  const activeLogDir = logDir;
646
697
  const files = collectManagedLogFiles(activeLogDir).sort((a, b) => a.mtime - b.mtime); // oldest first
698
+ const currentDate = new Date().toISOString().split("T")[0];
699
+ const currentMetadataLogs = new Set(["proxy", "proxy-attempts", "proxy-debug", "proxy-lifecycle"].map((prefix) => join(activeLogDir, `${prefix}-${currentDate}.jsonl`)));
700
+ const canDelete = (file) => !currentMetadataLogs.has(file.path);
647
701
  const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
648
702
  let deletedCount = 0;
649
703
  let freedBytes = 0;
650
704
  // Pass 1: delete files older than maxAgeDays
651
705
  const remaining = [];
652
706
  for (const file of files) {
653
- if (file.mtime < cutoff) {
707
+ if (file.mtime < cutoff && canDelete(file)) {
654
708
  unlinkSync(file.path);
655
709
  deletedCount++;
656
710
  freedBytes += file.size;
@@ -666,8 +720,13 @@ export function cleanupLogs(maxAgeDays = 7, maxSizeMb = 500) {
666
720
  // Pass 2: if total size exceeds maxSizeMb, delete oldest until under limit
667
721
  const maxBytes = maxSizeMb * 1024 * 1024;
668
722
  let totalSize = remaining.reduce((sum, f) => sum + f.size, 0);
669
- while (totalSize > maxBytes && remaining.length > 0) {
670
- const oldest = remaining.shift();
723
+ const deletionCandidates = remaining.filter(canDelete);
724
+ // Current-day metadata is the only reliable source for final-request,
725
+ // attempt, lifecycle, and body-index reconciliation. Keep those indexes
726
+ // intact during size cleanup; body artifacts and older indexes remain
727
+ // eligible for eviction.
728
+ while (totalSize > maxBytes && deletionCandidates.length > 0) {
729
+ const oldest = deletionCandidates.shift();
671
730
  if (!oldest) {
672
731
  break;
673
732
  }
@@ -10,8 +10,28 @@ export async function startRollingProxyServer(options) {
10
10
  let listening = false;
11
11
  let recoveryFailures = 0;
12
12
  let recoveryTimer;
13
+ let requestedReplacementTimer;
14
+ let requestedReplacementSchedule = 0;
15
+ let requestedReplacementPending = false;
16
+ let replacementQueueTail = null;
13
17
  const recoveryDelayMs = Math.max(1, options.recoveryDelayMs ?? DEFAULT_RECOVERY_DELAY_MS);
14
18
  const maxRecoveryDelayMs = Math.max(recoveryDelayMs, options.maxRecoveryDelayMs ?? DEFAULT_MAX_RECOVERY_DELAY_MS);
19
+ const queueReplacement = (operation) => {
20
+ const predecessor = replacementQueueTail;
21
+ const result = (predecessor ?? Promise.resolve()).then(operation);
22
+ const completion = result.then(() => undefined, () => undefined);
23
+ replacementQueueTail = completion;
24
+ void completion.finally(() => {
25
+ if (replacementQueueTail !== completion) {
26
+ return;
27
+ }
28
+ replacementQueueTail = null;
29
+ if (requestedReplacementPending) {
30
+ scheduleRequestedReplacement();
31
+ }
32
+ });
33
+ return result;
34
+ };
15
35
  const scheduleRecovery = () => {
16
36
  if (closing || !listening || recoveryTimer) {
17
37
  return;
@@ -23,11 +43,13 @@ export async function startRollingProxyServer(options) {
23
43
  // An explicit replace() may have started (or completed) a generation
24
44
  // while this timer was pending. Re-validate before recovering so we never
25
45
  // launch a duplicate generation that conflicts with the requested worker.
26
- const snapshot = supervisor.snapshot();
27
- if (closing || snapshot.active || snapshot.candidate) {
28
- return;
29
- }
30
- void supervisor.replace(desiredVersion).then(() => {
46
+ void queueReplacement(async () => {
47
+ const snapshot = supervisor.snapshot();
48
+ if (closing || snapshot.active || snapshot.candidate) {
49
+ return;
50
+ }
51
+ await supervisor.replace(desiredVersion);
52
+ }).then(() => {
31
53
  recoveryFailures = 0;
32
54
  }, (error) => {
33
55
  recoveryFailures += 1;
@@ -48,6 +70,38 @@ export async function startRollingProxyServer(options) {
48
70
  scheduleRecovery();
49
71
  }
50
72
  };
73
+ function scheduleRequestedReplacement() {
74
+ if (closing) {
75
+ return;
76
+ }
77
+ requestedReplacementPending = true;
78
+ if (requestedReplacementTimer || replacementQueueTail) {
79
+ return;
80
+ }
81
+ const schedule = ++requestedReplacementSchedule;
82
+ requestedReplacementTimer = setTimeout(() => {
83
+ requestedReplacementTimer = undefined;
84
+ if (schedule !== requestedReplacementSchedule) {
85
+ return;
86
+ }
87
+ if (closing || !supervisor.snapshot().active) {
88
+ return;
89
+ }
90
+ requestedReplacementPending = false;
91
+ const replacementVersion = desiredVersion;
92
+ void queueReplacement(async () => {
93
+ if (closing || !supervisor.snapshot().active) {
94
+ return;
95
+ }
96
+ options.log?.(`[proxy-supervisor] preparing same-version worker replacement version=${replacementVersion} reason=environment`);
97
+ await supervisor.replace(replacementVersion);
98
+ options.log?.(`[proxy-supervisor] same-version worker replacement complete version=${replacementVersion} reason=environment`);
99
+ }).catch((error) => {
100
+ options.log?.(`[proxy-supervisor] same-version worker replacement failed version=${replacementVersion} reason=environment: ${error instanceof Error ? error.message : String(error)}`);
101
+ });
102
+ }, 50);
103
+ requestedReplacementTimer.unref?.();
104
+ }
51
105
  const supervisor = new RollingWorkerSupervisor({
52
106
  spawnWorker: options.spawnWorker,
53
107
  readyTimeoutMs: options.readyTimeoutMs,
@@ -55,9 +109,14 @@ export async function startRollingProxyServer(options) {
55
109
  socketQueueTimeoutMs: options.socketQueueTimeoutMs,
56
110
  shutdownTimeoutMs: options.shutdownTimeoutMs,
57
111
  onStateChange: stateChanged,
112
+ onReplacementRequested: scheduleRequestedReplacement,
58
113
  log: options.log,
59
114
  });
60
115
  const listener = createServer({ pauseOnConnect: true }, (socket) => {
116
+ // The parent keeps its descriptor until the worker commits the IPC
117
+ // transfer. Consume client resets during that interval so they cannot
118
+ // terminate the long-lived supervisor process.
119
+ socket.once("error", () => socket.destroy());
61
120
  supervisor.acceptSocket(socket);
62
121
  });
63
122
  await new Promise((resolve, reject) => {
@@ -89,8 +148,8 @@ export async function startRollingProxyServer(options) {
89
148
  }
90
149
  const address = listener.address();
91
150
  if (!address || typeof address === "string") {
92
- listener.close();
93
- void supervisor.close();
151
+ await new Promise((resolve) => listener.close(() => resolve()));
152
+ await supervisor.close().catch(() => undefined);
94
153
  throw ErrorFactory.proxyWorkerLifecycle("rolling proxy listener did not expose a TCP address");
95
154
  }
96
155
  return {
@@ -111,8 +170,14 @@ export async function startRollingProxyServer(options) {
111
170
  clearTimeout(recoveryTimer);
112
171
  recoveryTimer = undefined;
113
172
  }
173
+ if (requestedReplacementTimer) {
174
+ clearTimeout(requestedReplacementTimer);
175
+ requestedReplacementTimer = undefined;
176
+ }
177
+ requestedReplacementSchedule += 1;
178
+ requestedReplacementPending = false;
114
179
  try {
115
- const snapshot = await supervisor.replace(expectedVersion);
180
+ const snapshot = await queueReplacement(() => supervisor.replace(expectedVersion));
116
181
  recoveryFailures = 0;
117
182
  return snapshot;
118
183
  }
@@ -139,6 +204,12 @@ export async function startRollingProxyServer(options) {
139
204
  clearTimeout(recoveryTimer);
140
205
  recoveryTimer = undefined;
141
206
  }
207
+ if (requestedReplacementTimer) {
208
+ clearTimeout(requestedReplacementTimer);
209
+ requestedReplacementTimer = undefined;
210
+ }
211
+ requestedReplacementSchedule += 1;
212
+ requestedReplacementPending = false;
142
213
  const listenerClosed = new Promise((resolve, reject) => {
143
214
  listener.close((error) => (error ? reject(error) : resolve()));
144
215
  });
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { ErrorFactory } from "../utils/errorHandling.js";
3
3
  import { isProxyWorkerStatusMessage, PROXY_SOCKET_WORKER_ENV, } from "./rollingWorkerProtocol.js";
4
4
  export function spawnProxySocketWorker(options) {
5
- const socketAckTimeoutMs = Math.max(1, options.socketAckTimeoutMs ?? 5_000);
5
+ const socketAckTimeoutMs = Math.max(1, options.socketAckTimeoutMs ?? 30_000);
6
6
  let nextSocketId = 0;
7
7
  const pendingSockets = new Map();
8
8
  const statusListeners = new Set();
@@ -59,6 +59,18 @@ export function spawnProxySocketWorker(options) {
59
59
  }
60
60
  pendingSockets.delete(socketId);
61
61
  clearTimeout(pending.timeout);
62
+ if (error && child.connected && !pending.accepted) {
63
+ try {
64
+ child.send({
65
+ type: "proxy-worker:socket-cancel",
66
+ generation: options.generation,
67
+ socketId,
68
+ }, () => undefined);
69
+ }
70
+ catch {
71
+ // The supervisor will quarantine the worker after the failed transfer.
72
+ }
73
+ }
62
74
  if (!error) {
63
75
  pending.socket.destroy();
64
76
  }
@@ -92,9 +104,14 @@ export function spawnProxySocketWorker(options) {
92
104
  publishStatus(message);
93
105
  }
94
106
  });
95
- child.once("exit", () => {
107
+ child.once("exit", (code, signal) => {
96
108
  for (const socketId of [...pendingSockets.keys()]) {
97
- settleSocket(socketId, new Error(`proxy worker ${childPid} exited before accepting socket`));
109
+ settleSocket(socketId, ErrorFactory.proxyWorkerLifecycle(`proxy worker ${childPid} exited before socket transfer committed (code=${code ?? "none"}, signal=${signal ?? "none"})`, {
110
+ workerPid: childPid,
111
+ generation: options.generation,
112
+ exitCode: code,
113
+ signal,
114
+ }));
98
115
  }
99
116
  });
100
117
  const sendControl = (message) => {
@@ -117,18 +134,6 @@ export function spawnProxySocketWorker(options) {
117
134
  }
118
135
  const socketId = `${generation}:${++nextSocketId}`;
119
136
  const timeout = setTimeout(() => {
120
- if (child.connected) {
121
- try {
122
- child.send({
123
- type: "proxy-worker:socket-cancel",
124
- generation,
125
- socketId,
126
- });
127
- }
128
- catch {
129
- // The worker is terminated after the failed transfer is reported.
130
- }
131
- }
132
137
  settleSocket(socketId, new Error(`proxy worker ${childPid} did not accept socket within ${socketAckTimeoutMs}ms`));
133
138
  }, socketAckTimeoutMs);
134
139
  timeout.unref?.();
@@ -38,5 +38,8 @@ export function isProxyWorkerStatusMessage(value) {
38
38
  if (message.type === "proxy-worker:socket-accepted") {
39
39
  return typeof message.socketId === "string" && message.socketId.length > 0;
40
40
  }
41
+ if (message.type === "proxy-worker:replacement-requested") {
42
+ return message.reason === "environment";
43
+ }
41
44
  return (message.type === "proxy-worker:fatal" && typeof message.message === "string");
42
45
  }
@@ -34,6 +34,7 @@ export declare class RollingWorkerSupervisor {
34
34
  private transferSocket;
35
35
  private handleTransferFailure;
36
36
  private rejectSocket;
37
+ private describeTransferError;
37
38
  private recordFailure;
38
39
  private publishState;
39
40
  }
@@ -79,7 +79,6 @@ export class RollingWorkerSupervisor {
79
79
  ? this.replacement
80
80
  : Promise.reject(ErrorFactory.proxyWorkerLifecycle(`worker replacement for v${this.candidate?.expectedVersion ?? "unknown"} is already in progress`, { requestedVersion: expectedVersion }));
81
81
  }
82
- this.lastFailure = null;
83
82
  this.replacement = this.spawnCandidate(expectedVersion).finally(() => {
84
83
  this.replacement = null;
85
84
  });
@@ -242,6 +241,16 @@ export class RollingWorkerSupervisor {
242
241
  }
243
242
  return;
244
243
  }
244
+ if (message.type === "proxy-worker:replacement-requested") {
245
+ if (this.active?.generation === generation) {
246
+ this.options.onReplacementRequested?.({
247
+ generation,
248
+ pid: handle.pid,
249
+ reason: message.reason,
250
+ });
251
+ }
252
+ return;
253
+ }
245
254
  if (message.type === "proxy-worker:ready") {
246
255
  if (message.version !== expectedVersion) {
247
256
  finish(new Error(`worker ${handle.pid} reported v${message.version}; expected v${expectedVersion}`));
@@ -309,7 +318,7 @@ export class RollingWorkerSupervisor {
309
318
  if (!this.closed) {
310
319
  this.recordFailure(generation, expectedVersion, "runtime", `worker exited (code=${code ?? "none"}, signal=${signal ?? "none"})`);
311
320
  }
312
- this.options.log?.(`[proxy-supervisor] active worker exited generation=${generation} pid=${handle.pid}`);
321
+ this.options.log?.(`[proxy-supervisor] active worker exited generation=${generation} pid=${handle.pid} code=${code ?? "none"} signal=${signal ?? "none"}`);
313
322
  }
314
323
  const drained = this.draining.get(generation);
315
324
  if (drained) {
@@ -356,23 +365,27 @@ export class RollingWorkerSupervisor {
356
365
  try {
357
366
  worker.handle.sendSocket(worker.generation, socket, (error) => {
358
367
  if (error) {
359
- this.handleTransferFailure(worker, socket);
368
+ this.handleTransferFailure(worker, socket, error);
360
369
  }
361
370
  });
362
371
  }
363
- catch {
364
- this.handleTransferFailure(worker, socket);
372
+ catch (error) {
373
+ this.handleTransferFailure(worker, socket, error);
365
374
  }
366
375
  }
367
- handleTransferFailure(worker, socket) {
376
+ handleTransferFailure(worker, socket, error) {
368
377
  this.failedTransfers += 1;
369
- this.recordFailure(worker.generation, worker.version, "transfer", `worker ${worker.handle.pid} failed to accept a transferred socket`);
378
+ const detail = this.describeTransferError(error);
379
+ this.recordFailure(worker.generation, worker.version, "transfer", `worker ${worker.handle.pid} failed to accept a transferred socket: ${detail}`);
380
+ this.options.log?.(`[proxy-supervisor] socket transfer failed generation=${worker.generation} pid=${worker.handle.pid}: ${detail}`);
370
381
  if (this.active?.generation === worker.generation && !this.closed) {
371
- // Do not let worker-side graceful shutdown call shutdown(2) on an
372
- // offered-but-uncommitted duplicate descriptor.
373
- worker.dispose();
374
- worker.handle.terminate("SIGKILL");
375
382
  this.active = null;
383
+ this.draining.set(worker.generation, worker);
384
+ // The child may own a duplicate of an incompletely transferred socket.
385
+ // SIGKILL closes its descriptor without worker-side shutdown(2), after
386
+ // which the parent rejects its copy rather than attempting unsafe replay.
387
+ worker.handle.terminate("SIGKILL");
388
+ this.publishState();
376
389
  }
377
390
  this.rejectSocket(socket);
378
391
  }
@@ -381,6 +394,13 @@ export class RollingWorkerSupervisor {
381
394
  socket.destroy();
382
395
  this.publishState();
383
396
  }
397
+ describeTransferError(error) {
398
+ if (!(error instanceof Error)) {
399
+ return String(error ?? "unknown transfer failure");
400
+ }
401
+ const code = error.code;
402
+ return code ? `${code}: ${error.message}` : error.message;
403
+ }
384
404
  recordFailure(generation, version, phase, message) {
385
405
  this.lastFailure = {
386
406
  at: new Date().toISOString(),
@@ -10,6 +10,7 @@ export declare class ProxyRuntimeConfigStore {
10
10
  private reloadTimer;
11
11
  private configFileObserved;
12
12
  private envFileObserved;
13
+ private currentEnvFileHash;
13
14
  private readonly watchListener;
14
15
  private constructor();
15
16
  static create(options: ProxyRuntimeConfigStoreOptions): Promise<ProxyRuntimeConfigStore>;
@@ -162,11 +162,13 @@ function assertResolvedRoutingValues(value, path = "routing") {
162
162
  async function buildCandidate(options, generation, allowMissingConfig, allowMissingEnvFile, rejectInvalidHotEnv) {
163
163
  const effectiveEnv = { ...options.baseEnv };
164
164
  let envFilePresent = false;
165
+ let envFileValues = {};
165
166
  if (options.envFilePath) {
166
167
  try {
167
- const envContent = await readFile(options.envFilePath, "utf8");
168
+ const envFileContent = await readFile(options.envFilePath, "utf8");
168
169
  const { parse } = await import("dotenv");
169
- Object.assign(effectiveEnv, parse(envContent));
170
+ envFileValues = parse(envFileContent);
171
+ Object.assign(effectiveEnv, envFileValues);
170
172
  envFilePresent = true;
171
173
  }
172
174
  catch (error) {
@@ -218,6 +220,12 @@ async function buildCandidate(options, generation, allowMissingConfig, allowMiss
218
220
  accountAllowlist: routing.accountAllowlist,
219
221
  })
220
222
  : undefined;
223
+ const envFileHash = createHash("sha256")
224
+ .update(envFilePresent
225
+ ? JSON.stringify(Object.entries(envFileValues).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0))
226
+ : "[missing]")
227
+ .digest("hex")
228
+ .slice(0, 16);
221
229
  const fingerprintSource = JSON.stringify({
222
230
  strategy,
223
231
  passthrough: options.passthrough,
@@ -249,6 +257,7 @@ async function buildCandidate(options, generation, allowMissingConfig, allowMiss
249
257
  }),
250
258
  configFilePresent,
251
259
  envFilePresent,
260
+ envFileHash,
252
261
  };
253
262
  }
254
263
  /** Atomic last-known-good runtime configuration with file-triggered reloads. */
@@ -262,6 +271,7 @@ export class ProxyRuntimeConfigStore {
262
271
  reloadTimer;
263
272
  configFileObserved;
264
273
  envFileObserved;
274
+ currentEnvFileHash;
265
275
  watchListener = (current, previous) => {
266
276
  if (current.mtimeMs === previous.mtimeMs &&
267
277
  current.size === previous.size &&
@@ -270,11 +280,12 @@ export class ProxyRuntimeConfigStore {
270
280
  }
271
281
  this.scheduleWatchReload();
272
282
  };
273
- constructor(options, snapshot, configFileObserved, envFileObserved) {
283
+ constructor(options, snapshot, configFileObserved, envFileObserved, envFileHash) {
274
284
  this.options = { ...options, baseEnv: { ...options.baseEnv } };
275
285
  this.currentSnapshot = snapshot;
276
286
  this.configFileObserved = configFileObserved;
277
287
  this.envFileObserved = envFileObserved;
288
+ this.currentEnvFileHash = envFileHash;
278
289
  this.status = {
279
290
  configPath: options.configPath,
280
291
  ...(options.envFilePath ? { envFilePath: options.envFilePath } : {}),
@@ -287,7 +298,7 @@ export class ProxyRuntimeConfigStore {
287
298
  }
288
299
  static async create(options) {
289
300
  const candidate = await buildCandidate(options, 1, true, true, false);
290
- return new ProxyRuntimeConfigStore(options, candidate.snapshot, candidate.configFilePresent, candidate.envFilePresent);
301
+ return new ProxyRuntimeConfigStore(options, candidate.snapshot, candidate.configFilePresent, candidate.envFilePresent, candidate.envFileHash);
291
302
  }
292
303
  getSnapshot() {
293
304
  return this.currentSnapshot;
@@ -354,7 +365,8 @@ export class ProxyRuntimeConfigStore {
354
365
  const candidate = await buildCandidate(this.options, this.currentSnapshot.generation + 1, !this.configFileObserved, !this.envFileObserved, true);
355
366
  this.configFileObserved ||= candidate.configFilePresent;
356
367
  this.envFileObserved ||= candidate.envFilePresent;
357
- if (candidate.snapshot.configHash === this.currentSnapshot.configHash) {
368
+ if (candidate.snapshot.configHash === this.currentSnapshot.configHash &&
369
+ candidate.envFileHash === this.currentEnvFileHash) {
358
370
  this.status = {
359
371
  ...this.status,
360
372
  lastReloadAt: attemptedAt,
@@ -369,7 +381,9 @@ export class ProxyRuntimeConfigStore {
369
381
  this.notifyReloadListeners(result);
370
382
  return result;
371
383
  }
384
+ const environmentChanged = candidate.envFileHash !== this.currentEnvFileHash;
372
385
  this.currentSnapshot = candidate.snapshot;
386
+ this.currentEnvFileHash = candidate.envFileHash;
373
387
  this.status = {
374
388
  ...this.status,
375
389
  generation: candidate.snapshot.generation,
@@ -392,6 +406,7 @@ export class ProxyRuntimeConfigStore {
392
406
  applied: true,
393
407
  changed: true,
394
408
  generation: candidate.snapshot.generation,
409
+ ...(environmentChanged ? { environmentChanged: true } : {}),
395
410
  };
396
411
  this.notifyReloadListeners(result);
397
412
  return result;