@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.
- package/CHANGELOG.md +6 -0
- package/dist/browser/neurolink.min.js +333 -336
- package/dist/cli/commands/proxy.d.ts +1 -1
- package/dist/cli/commands/proxy.js +124 -56
- package/dist/cli/commands/proxyAnalyze.js +10 -1
- package/dist/lib/proxy/proxyAnalysis.js +136 -12
- package/dist/lib/proxy/requestLogger.d.ts +2 -0
- package/dist/lib/proxy/requestLogger.js +72 -13
- package/dist/lib/proxy/rollingProxyServer.js +79 -8
- package/dist/lib/proxy/rollingWorkerProcess.js +20 -15
- package/dist/lib/proxy/rollingWorkerProtocol.js +3 -0
- package/dist/lib/proxy/rollingWorkerSupervisor.d.ts +1 -0
- package/dist/lib/proxy/rollingWorkerSupervisor.js +31 -11
- package/dist/lib/proxy/runtimeConfig.d.ts +1 -0
- package/dist/lib/proxy/runtimeConfig.js +20 -5
- package/dist/lib/proxy/socketWorkerRuntime.js +41 -13
- package/dist/lib/server/routes/claudeProxyRoutes.js +63 -75
- package/dist/lib/types/proxy.d.ts +31 -1
- package/dist/proxy/proxyAnalysis.js +136 -12
- package/dist/proxy/requestLogger.d.ts +2 -0
- package/dist/proxy/requestLogger.js +72 -13
- package/dist/proxy/rollingProxyServer.js +79 -8
- package/dist/proxy/rollingWorkerProcess.js +20 -15
- package/dist/proxy/rollingWorkerProtocol.js +3 -0
- package/dist/proxy/rollingWorkerSupervisor.d.ts +1 -0
- package/dist/proxy/rollingWorkerSupervisor.js +31 -11
- package/dist/proxy/runtimeConfig.d.ts +1 -0
- package/dist/proxy/runtimeConfig.js +20 -5
- package/dist/proxy/socketWorkerRuntime.js +41 -13
- package/dist/server/routes/claudeProxyRoutes.js +63 -75
- package/dist/types/proxy.d.ts +31 -1
- 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 {
|
|
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
|
|
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
|
|
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, {
|
|
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
|
|
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
|
|
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
|
-
|
|
670
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
+
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,
|
|
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,6 +38,9 @@ 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
|
}
|
|
43
46
|
//# sourceMappingURL=rollingWorkerProtocol.js.map
|
|
@@ -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
|
-
|
|
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
|
|
168
|
+
const envFileContent = await readFile(options.envFilePath, "utf8");
|
|
168
169
|
const { parse } = await import("dotenv");
|
|
169
|
-
|
|
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;
|