@zackbart/connecta 0.7.4 → 0.7.6
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 +69 -0
- package/README.md +4 -2
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +11 -0
- package/dist/execute.js.map +1 -1
- package/dist/executor-admission.d.ts +18 -1
- package/dist/executor-admission.d.ts.map +1 -1
- package/dist/executor-admission.js +82 -3
- package/dist/executor-admission.js.map +1 -1
- package/dist/executors/quickjs-protocol.d.ts +1 -0
- package/dist/executors/quickjs-protocol.d.ts.map +1 -1
- package/dist/executors/quickjs-protocol.js +7 -4
- package/dist/executors/quickjs-protocol.js.map +1 -1
- package/dist/executors/quickjs-runtime.d.ts.map +1 -1
- package/dist/executors/quickjs-runtime.js +22 -9
- package/dist/executors/quickjs-runtime.js.map +1 -1
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +44 -10
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/index.d.ts +30 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +38 -1
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +175 -16
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +26 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/src/execute.ts +11 -0
- package/src/executor-admission.ts +90 -3
- package/src/executors/quickjs-protocol.ts +7 -4
- package/src/executors/quickjs-runtime.ts +31 -9
- package/src/executors/quickjs.ts +61 -12
- package/src/index.ts +87 -1
- package/src/server.ts +217 -29
- package/src/types.ts +27 -0
- package/src/version.ts +1 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AdmittingExecutor,
|
|
3
|
+
AdmissionSnapshot,
|
|
3
4
|
Executor,
|
|
5
|
+
ExecutorLease,
|
|
4
6
|
} from "./types.js";
|
|
5
7
|
|
|
6
8
|
export type ExecutorAdmissionErrorCode =
|
|
@@ -35,10 +37,13 @@ export class ExecutorAdmissionError extends Error {
|
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export interface AdmissionLease {
|
|
40
|
+
/** Time spent waiting behind active work. Zero for immediate admission. */
|
|
41
|
+
readonly waitMs: number;
|
|
38
42
|
release(): void;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
interface Waiter {
|
|
46
|
+
queuedAt: number;
|
|
42
47
|
resolve: (lease: AdmissionLease) => void;
|
|
43
48
|
reject: (error: ExecutorAdmissionError) => void;
|
|
44
49
|
signal?: AbortSignal;
|
|
@@ -82,6 +87,14 @@ export class AdmissionController {
|
|
|
82
87
|
private active = 0;
|
|
83
88
|
private closed = false;
|
|
84
89
|
private readonly waiters: Waiter[] = [];
|
|
90
|
+
private admittedTotal = 0;
|
|
91
|
+
private queuedTotal = 0;
|
|
92
|
+
private rejectedTotal = 0;
|
|
93
|
+
private cancelledTotal = 0;
|
|
94
|
+
private closedTotal = 0;
|
|
95
|
+
private queueWaitCount = 0;
|
|
96
|
+
private queueWaitTotalMs = 0;
|
|
97
|
+
private queueWaitMaxMs = 0;
|
|
85
98
|
|
|
86
99
|
constructor(options: AdmissionControllerOptions) {
|
|
87
100
|
this.concurrency = positiveWhole(options.concurrency, "concurrency");
|
|
@@ -107,8 +120,33 @@ export class AdmissionController {
|
|
|
107
120
|
return this.waiters.length;
|
|
108
121
|
}
|
|
109
122
|
|
|
123
|
+
snapshot(): AdmissionSnapshot {
|
|
124
|
+
return {
|
|
125
|
+
concurrency: this.concurrency,
|
|
126
|
+
maxQueueSize: this.maxQueueSize,
|
|
127
|
+
queueTimeoutMs: this.queueTimeoutMs,
|
|
128
|
+
retryAfterMs: this.retryAfterMs,
|
|
129
|
+
active: this.active,
|
|
130
|
+
queued: this.waiters.length,
|
|
131
|
+
closed: this.closed,
|
|
132
|
+
totals: {
|
|
133
|
+
admitted: this.admittedTotal,
|
|
134
|
+
queued: this.queuedTotal,
|
|
135
|
+
rejected: this.rejectedTotal,
|
|
136
|
+
cancelled: this.cancelledTotal,
|
|
137
|
+
closed: this.closedTotal,
|
|
138
|
+
},
|
|
139
|
+
queueWaitMs: {
|
|
140
|
+
count: this.queueWaitCount,
|
|
141
|
+
total: this.queueWaitTotalMs,
|
|
142
|
+
max: this.queueWaitMaxMs,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
110
147
|
acquire(options: { signal?: AbortSignal } = {}): Promise<AdmissionLease> {
|
|
111
148
|
if (this.closed) {
|
|
149
|
+
this.closedTotal++;
|
|
112
150
|
return Promise.reject(
|
|
113
151
|
new ExecutorAdmissionError(
|
|
114
152
|
"executor_closed",
|
|
@@ -117,6 +155,7 @@ export class AdmissionController {
|
|
|
117
155
|
);
|
|
118
156
|
}
|
|
119
157
|
if (options.signal?.aborted) {
|
|
158
|
+
this.cancelledTotal++;
|
|
120
159
|
return Promise.reject(
|
|
121
160
|
new ExecutorAdmissionError(
|
|
122
161
|
"executor_cancelled",
|
|
@@ -126,14 +165,17 @@ export class AdmissionController {
|
|
|
126
165
|
}
|
|
127
166
|
if (this.active < this.concurrency) {
|
|
128
167
|
this.active++;
|
|
129
|
-
|
|
168
|
+
this.admittedTotal++;
|
|
169
|
+
return Promise.resolve(this.makeLease(0));
|
|
130
170
|
}
|
|
131
171
|
if (this.waiters.length >= this.maxQueueSize) {
|
|
172
|
+
this.rejectedTotal++;
|
|
132
173
|
return Promise.reject(this.overloaded("Executor queue is full."));
|
|
133
174
|
}
|
|
134
175
|
|
|
135
176
|
return new Promise<AdmissionLease>((resolve, reject) => {
|
|
136
177
|
const waiter: Waiter = {
|
|
178
|
+
queuedAt: Date.now(),
|
|
137
179
|
resolve,
|
|
138
180
|
reject,
|
|
139
181
|
...(options.signal ? { signal: options.signal } : {}),
|
|
@@ -141,6 +183,7 @@ export class AdmissionController {
|
|
|
141
183
|
waiter.onAbort = () => {
|
|
142
184
|
if (!this.remove(waiter)) return;
|
|
143
185
|
this.cleanup(waiter);
|
|
186
|
+
this.cancelledTotal++;
|
|
144
187
|
reject(
|
|
145
188
|
new ExecutorAdmissionError(
|
|
146
189
|
"executor_cancelled",
|
|
@@ -152,6 +195,7 @@ export class AdmissionController {
|
|
|
152
195
|
waiter.timer = setTimeout(() => {
|
|
153
196
|
if (!this.remove(waiter)) return;
|
|
154
197
|
this.cleanup(waiter);
|
|
198
|
+
this.rejectedTotal++;
|
|
155
199
|
reject(
|
|
156
200
|
this.overloaded(
|
|
157
201
|
`Executor admission timed out after ${this.queueTimeoutMs}ms.`,
|
|
@@ -159,6 +203,7 @@ export class AdmissionController {
|
|
|
159
203
|
);
|
|
160
204
|
}, this.queueTimeoutMs);
|
|
161
205
|
this.waiters.push(waiter);
|
|
206
|
+
this.queuedTotal++;
|
|
162
207
|
});
|
|
163
208
|
}
|
|
164
209
|
|
|
@@ -167,6 +212,7 @@ export class AdmissionController {
|
|
|
167
212
|
this.closed = true;
|
|
168
213
|
for (const waiter of this.waiters.splice(0)) {
|
|
169
214
|
this.cleanup(waiter);
|
|
215
|
+
this.closedTotal++;
|
|
170
216
|
waiter.reject(
|
|
171
217
|
new ExecutorAdmissionError(
|
|
172
218
|
"executor_closed",
|
|
@@ -182,9 +228,10 @@ export class AdmissionController {
|
|
|
182
228
|
});
|
|
183
229
|
}
|
|
184
230
|
|
|
185
|
-
private makeLease(): AdmissionLease {
|
|
231
|
+
private makeLease(waitMs: number): AdmissionLease {
|
|
186
232
|
let released = false;
|
|
187
233
|
return {
|
|
234
|
+
waitMs,
|
|
188
235
|
release: () => {
|
|
189
236
|
if (released) return;
|
|
190
237
|
released = true;
|
|
@@ -199,8 +246,13 @@ export class AdmissionController {
|
|
|
199
246
|
const waiter = this.waiters.shift();
|
|
200
247
|
if (!waiter) return;
|
|
201
248
|
this.cleanup(waiter);
|
|
249
|
+
const waitMs = Math.max(0, Date.now() - waiter.queuedAt);
|
|
250
|
+
this.admittedTotal++;
|
|
251
|
+
this.queueWaitCount++;
|
|
252
|
+
this.queueWaitTotalMs += waitMs;
|
|
253
|
+
this.queueWaitMaxMs = Math.max(this.queueWaitMaxMs, waitMs);
|
|
202
254
|
this.active++;
|
|
203
|
-
waiter.resolve(this.makeLease());
|
|
255
|
+
waiter.resolve(this.makeLease(waitMs));
|
|
204
256
|
}
|
|
205
257
|
|
|
206
258
|
private remove(waiter: Waiter): boolean {
|
|
@@ -227,3 +279,38 @@ export function isAdmittingExecutor(
|
|
|
227
279
|
typeof (executor as { acquire?: unknown }).acquire === "function"
|
|
228
280
|
);
|
|
229
281
|
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Give a structurally-compatible but otherwise unbounded executor the same
|
|
285
|
+
* admission contract as the built-in Node executor. The wrapper owns only the
|
|
286
|
+
* queue; closing the underlying runtime remains the Connecta lifecycle's job.
|
|
287
|
+
*/
|
|
288
|
+
export function withExecutorAdmission(
|
|
289
|
+
executor: Executor,
|
|
290
|
+
admission: AdmissionController,
|
|
291
|
+
): AdmittingExecutor {
|
|
292
|
+
return {
|
|
293
|
+
async acquire(options = {}): Promise<ExecutorLease> {
|
|
294
|
+
const token = await admission.acquire(options);
|
|
295
|
+
let released = false;
|
|
296
|
+
return {
|
|
297
|
+
waitMs: token.waitMs,
|
|
298
|
+
execute: (code, providers) => executor.execute(code, providers),
|
|
299
|
+
release: () => {
|
|
300
|
+
if (released) return;
|
|
301
|
+
released = true;
|
|
302
|
+
token.release();
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
},
|
|
306
|
+
async execute(code, providers) {
|
|
307
|
+
const lease = await this.acquire();
|
|
308
|
+
try {
|
|
309
|
+
return await lease.execute(code, providers);
|
|
310
|
+
} finally {
|
|
311
|
+
lease.release();
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
admissionSnapshot: () => admission.snapshot(),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { ExecuteResult } from "../types.js";
|
|
2
2
|
import type { QuickJsRuntimeOptions } from "./quickjs-runtime.js";
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// An execution result is serialized once into payloadJson and again as that
|
|
5
|
+
// string is embedded in ChildToParentMessage. Captured logs therefore get a
|
|
6
|
+
// separate 512 KiB budget measured after both JSON encodings. The final result
|
|
7
|
+
// is shaped to 24k characters; even if every character takes its worst-case
|
|
8
|
+
// seven transport bytes, that leaves ample structural overhead below this hard
|
|
9
|
+
// process.send ceiling.
|
|
8
10
|
export const MAX_QUICKJS_IPC_BYTES = 1024 * 1024;
|
|
11
|
+
export const MAX_QUICKJS_LOG_TRANSPORT_BYTES = 512 * 1024;
|
|
9
12
|
/** Preserve #84's stopgap before a host value enters the child/WASM process. */
|
|
10
13
|
export const MAX_QUICKJS_HOST_RPC_BYTES = 256 * 1024;
|
|
11
14
|
|
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
type QuickJSDeferredPromise,
|
|
15
15
|
} from "quickjs-emscripten";
|
|
16
16
|
import type { ExecuteResult, ExecutorProvider } from "../types.js";
|
|
17
|
+
import {
|
|
18
|
+
MAX_QUICKJS_LOG_TRANSPORT_BYTES,
|
|
19
|
+
serializedBytes,
|
|
20
|
+
} from "./quickjs-protocol.js";
|
|
17
21
|
|
|
18
22
|
export interface QuickJsRuntimeOptions {
|
|
19
23
|
timeoutMs: number;
|
|
@@ -31,11 +35,16 @@ const MAX_LOG_ENTRIES = 200;
|
|
|
31
35
|
// Cap each entry AND the cumulative buffer at capture time so untrusted guest
|
|
32
36
|
// code can't retain unbounded host memory: a single `console.log("x".repeat(N))`
|
|
33
37
|
// otherwise copies the whole N-char guest string into a host array we hold for
|
|
34
|
-
// the entire execution.
|
|
35
|
-
//
|
|
36
|
-
// keeps the worst case — 200 maxed-out entries — bounded well under a MiB.
|
|
38
|
+
// the entire execution. The separate transport-byte budget below accounts for
|
|
39
|
+
// JSON escaping and is what keeps the result envelope below its IPC ceiling.
|
|
37
40
|
const MAX_LOG_ENTRY_CHARS = 8_000;
|
|
38
41
|
const MAX_LOG_TOTAL_CHARS = 256_000;
|
|
42
|
+
const LOG_ENTRY_LIMIT_MARKER = `[log truncated after ${MAX_LOG_ENTRIES} entries]`;
|
|
43
|
+
const LOG_SIZE_LIMIT_MARKER = "[log truncated: size budget exceeded]";
|
|
44
|
+
const MAX_LOG_MARKER_TRANSPORT_BYTES = Math.max(
|
|
45
|
+
logTransportBytes(LOG_ENTRY_LIMIT_MARKER),
|
|
46
|
+
logTransportBytes(LOG_SIZE_LIMIT_MARKER),
|
|
47
|
+
);
|
|
39
48
|
// Keep one host result below the range where quickjs-emscripten@0.32.0 can
|
|
40
49
|
// nondeterministically fail during runtime disposal under concurrent load.
|
|
41
50
|
// This still lets guest code reduce data more than ten times larger than
|
|
@@ -46,6 +55,12 @@ function msg(err: unknown): string {
|
|
|
46
55
|
return err instanceof Error ? err.message : String(err);
|
|
47
56
|
}
|
|
48
57
|
|
|
58
|
+
function logTransportBytes(entry: string): number {
|
|
59
|
+
// The log is encoded into ExecutionPayload, then that payloadJson string is
|
|
60
|
+
// encoded into ChildToParentMessage. Measure the units the IPC cap sees.
|
|
61
|
+
return serializedBytes(JSON.stringify(JSON.stringify(entry)));
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
function exceedsUtf8ByteLimit(value: string, limit: number): boolean {
|
|
50
65
|
let bytes = 0;
|
|
51
66
|
for (let index = 0; index < value.length; index += 1) {
|
|
@@ -175,15 +190,16 @@ function installBridge(
|
|
|
175
190
|
};
|
|
176
191
|
armWake(bridge);
|
|
177
192
|
|
|
178
|
-
//
|
|
179
|
-
// budget
|
|
180
|
-
//
|
|
193
|
+
// Keep both the in-memory character budget and the twice-JSON-encoded
|
|
194
|
+
// transport budget. Reserve enough byte budget for whichever truncation
|
|
195
|
+
// marker ends the stream.
|
|
181
196
|
let logTotalChars = 0;
|
|
197
|
+
let logTotalTransportBytes = 0;
|
|
182
198
|
let logBudgetSpent = false;
|
|
183
199
|
const logFn = ctx.newFunction("__log", (h) => {
|
|
184
200
|
if (logs.length >= MAX_LOG_ENTRIES) {
|
|
185
201
|
if (logs.length === MAX_LOG_ENTRIES) {
|
|
186
|
-
logs.push(
|
|
202
|
+
logs.push(LOG_ENTRY_LIMIT_MARKER);
|
|
187
203
|
}
|
|
188
204
|
return;
|
|
189
205
|
}
|
|
@@ -194,13 +210,19 @@ function installBridge(
|
|
|
194
210
|
if (entry.length > MAX_LOG_ENTRY_CHARS) {
|
|
195
211
|
entry = `${entry.slice(0, MAX_LOG_ENTRY_CHARS)}…[entry truncated]`;
|
|
196
212
|
}
|
|
197
|
-
|
|
198
|
-
|
|
213
|
+
const entryTransportBytes = logTransportBytes(entry);
|
|
214
|
+
if (
|
|
215
|
+
logTotalChars + entry.length > MAX_LOG_TOTAL_CHARS ||
|
|
216
|
+
logTotalTransportBytes + entryTransportBytes >
|
|
217
|
+
MAX_QUICKJS_LOG_TRANSPORT_BYTES - MAX_LOG_MARKER_TRANSPORT_BYTES
|
|
218
|
+
) {
|
|
219
|
+
logs.push(LOG_SIZE_LIMIT_MARKER);
|
|
199
220
|
logBudgetSpent = true;
|
|
200
221
|
return;
|
|
201
222
|
}
|
|
202
223
|
logs.push(entry);
|
|
203
224
|
logTotalChars += entry.length;
|
|
225
|
+
logTotalTransportBytes += entryTransportBytes;
|
|
204
226
|
});
|
|
205
227
|
ctx.setProp(ctx.global, "__log", logFn);
|
|
206
228
|
logFn.dispose();
|
package/src/executors/quickjs.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// child process: guest CPU, WASM aborts, and interpreter OOMs cannot block or
|
|
3
3
|
// terminate the HTTP-serving process.
|
|
4
4
|
|
|
5
|
+
import { Buffer } from "node:buffer";
|
|
5
6
|
import { fork, type ChildProcess } from "node:child_process";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
6
8
|
import { fileURLToPath } from "node:url";
|
|
7
9
|
import {
|
|
8
10
|
AdmissionController,
|
|
@@ -10,6 +12,7 @@ import {
|
|
|
10
12
|
} from "../executor-admission.js";
|
|
11
13
|
import type {
|
|
12
14
|
AdmittingExecutor,
|
|
15
|
+
AdmissionSnapshot,
|
|
13
16
|
ExecuteResult,
|
|
14
17
|
ExecutorLease,
|
|
15
18
|
ExecutorProvider,
|
|
@@ -84,12 +87,33 @@ const DEFAULT_MAX_QUEUE_SIZE = 32;
|
|
|
84
87
|
const DEFAULT_QUEUE_TIMEOUT_MS = 5_000;
|
|
85
88
|
const CHILD_EXIT_GRACE_MS = 250;
|
|
86
89
|
const CHILD_STARTUP_TIMEOUT_MS = 10_000;
|
|
90
|
+
const MAX_CHILD_STDERR_BYTES = 8 * 1024;
|
|
87
91
|
const MAX_ERROR_CHARS = 4_000;
|
|
88
92
|
|
|
89
93
|
function msg(err: unknown): string {
|
|
90
94
|
return err instanceof Error ? err.message : String(err);
|
|
91
95
|
}
|
|
92
96
|
|
|
97
|
+
function retainStderrTail(current: Buffer, chunk: Buffer | string): Buffer {
|
|
98
|
+
const incoming = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
99
|
+
if (incoming.length >= MAX_CHILD_STDERR_BYTES) {
|
|
100
|
+
return Buffer.from(
|
|
101
|
+
incoming.subarray(incoming.length - MAX_CHILD_STDERR_BYTES),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const combined = Buffer.concat([current, incoming]);
|
|
105
|
+
if (combined.length <= MAX_CHILD_STDERR_BYTES) return combined;
|
|
106
|
+
return Buffer.from(combined.subarray(combined.length - MAX_CHILD_STDERR_BYTES));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function childExitError(message: string, stderrTail: Buffer): Error {
|
|
110
|
+
if (stderrTail.length === 0) return new Error(message);
|
|
111
|
+
return new Error(
|
|
112
|
+
`${message}\nRecent child stderr (last ${stderrTail.length} bytes):\n` +
|
|
113
|
+
stderrTail.toString("utf8"),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
93
117
|
function positiveWhole(
|
|
94
118
|
value: number | undefined,
|
|
95
119
|
fallback: number,
|
|
@@ -256,6 +280,7 @@ class QuickJsChildPool implements AdmittingExecutor {
|
|
|
256
280
|
let released = false;
|
|
257
281
|
let executed = false;
|
|
258
282
|
return {
|
|
283
|
+
waitMs: admission.waitMs,
|
|
259
284
|
execute: async (code, providers) => {
|
|
260
285
|
if (released) throw new Error("Executor lease was already released.");
|
|
261
286
|
if (executed) throw new Error("Executor lease may execute only once.");
|
|
@@ -278,6 +303,10 @@ class QuickJsChildPool implements AdmittingExecutor {
|
|
|
278
303
|
};
|
|
279
304
|
}
|
|
280
305
|
|
|
306
|
+
admissionSnapshot(): AdmissionSnapshot {
|
|
307
|
+
return this.admission.snapshot();
|
|
308
|
+
}
|
|
309
|
+
|
|
281
310
|
async execute(
|
|
282
311
|
code: string,
|
|
283
312
|
providers: ExecutorProvider[],
|
|
@@ -425,10 +454,26 @@ class QuickJsChildPool implements AdmittingExecutor {
|
|
|
425
454
|
sourceMode ? "./quickjs-child.ts" : "./quickjs-child.js",
|
|
426
455
|
import.meta.url,
|
|
427
456
|
);
|
|
428
|
-
const
|
|
457
|
+
const childPath = fileURLToPath(childUrl);
|
|
458
|
+
if (!existsSync(childPath)) {
|
|
459
|
+
throw new Error(
|
|
460
|
+
`QuickJS child entry is missing at ${childPath}. ` +
|
|
461
|
+
"The @zackbart/connecta/quickjs subpath requires the package file " +
|
|
462
|
+
"layout on disk; externalize @zackbart/connecta (or at least " +
|
|
463
|
+
"@zackbart/connecta/quickjs) when bundling the server.",
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
const child = fork(childPath, [], {
|
|
429
467
|
execArgv: sourceMode ? ["--import", "tsx"] : [],
|
|
430
|
-
stdio: ["ignore", "ignore", "
|
|
468
|
+
stdio: ["ignore", "ignore", "pipe", "ipc"],
|
|
431
469
|
});
|
|
470
|
+
let stderrTail: Buffer = Buffer.alloc(0);
|
|
471
|
+
child.stderr?.on("data", (chunk: Buffer | string) => {
|
|
472
|
+
stderrTail = retainStderrTail(stderrTail, chunk);
|
|
473
|
+
});
|
|
474
|
+
(
|
|
475
|
+
child.stderr as (NodeJS.ReadableStream & { unref?: () => void }) | null
|
|
476
|
+
)?.unref?.();
|
|
432
477
|
slot.child = child;
|
|
433
478
|
slot.ready = new Promise<void>((resolve, reject) => {
|
|
434
479
|
slot.resolveReady = resolve;
|
|
@@ -453,26 +498,30 @@ class QuickJsChildPool implements AdmittingExecutor {
|
|
|
453
498
|
this.rejectActive(slot, error);
|
|
454
499
|
this.recycle(slot);
|
|
455
500
|
});
|
|
456
|
-
|
|
501
|
+
// `close`, unlike `exit`, runs after the stdio streams have closed, so the
|
|
502
|
+
// diagnostic includes stderr bytes flushed immediately before a crash.
|
|
503
|
+
child.on("close", (code, exitSignal) => {
|
|
457
504
|
const expected = slot.expectedExit === child;
|
|
458
505
|
if (slot.expectedExit === child) slot.expectedExit = undefined;
|
|
506
|
+
const exitDescription = `${
|
|
507
|
+
exitSignal ? `signal ${exitSignal}` : `code ${String(code)}`
|
|
508
|
+
}`;
|
|
459
509
|
this.rejectChildReady(
|
|
460
510
|
slot,
|
|
461
511
|
child,
|
|
462
|
-
|
|
463
|
-
`QuickJS child exited before becoming ready
|
|
464
|
-
|
|
512
|
+
childExitError(
|
|
513
|
+
`QuickJS child exited before becoming ready (${exitDescription}).`,
|
|
514
|
+
stderrTail,
|
|
465
515
|
),
|
|
466
516
|
);
|
|
467
517
|
if (slot.child === child) slot.child = undefined;
|
|
468
518
|
if (expected) return;
|
|
469
519
|
this.recordCrash(slot);
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
});
|
|
520
|
+
const error = childExitError(
|
|
521
|
+
`QuickJS child exited unexpectedly (${exitDescription}).`,
|
|
522
|
+
stderrTail,
|
|
523
|
+
);
|
|
524
|
+
this.resolveActive(slot, { result: undefined, error: error.message });
|
|
476
525
|
});
|
|
477
526
|
child.unref();
|
|
478
527
|
child.channel?.unref();
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
} from "./toolkits.js";
|
|
15
15
|
import { memoryStorage } from "./storage/memory.js";
|
|
16
16
|
import { CONNECTA_VERSION } from "./version.js";
|
|
17
|
+
import {
|
|
18
|
+
AdmissionController,
|
|
19
|
+
isAdmittingExecutor,
|
|
20
|
+
withExecutorAdmission,
|
|
21
|
+
} from "./executor-admission.js";
|
|
17
22
|
import type { ActivityReadGate, ActivityStore } from "./activity.js";
|
|
18
23
|
import type {
|
|
19
24
|
CredentialCheckResult,
|
|
@@ -108,6 +113,35 @@ export interface ConnectaCallsConfig {
|
|
|
108
113
|
maxResultBytes?: number;
|
|
109
114
|
}
|
|
110
115
|
|
|
116
|
+
export interface AdmissionPoolConfig {
|
|
117
|
+
/** Simultaneous work admitted to this pool. */
|
|
118
|
+
concurrency?: number;
|
|
119
|
+
/** Callers allowed to wait behind active work. Set zero to fail fast. */
|
|
120
|
+
maxQueueSize?: number;
|
|
121
|
+
/** Maximum queue wait in milliseconds. */
|
|
122
|
+
queueTimeoutMs?: number;
|
|
123
|
+
/** Retry hint returned with overload failures, in milliseconds. */
|
|
124
|
+
retryAfterMs?: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Runtime-portable server-memory boundaries. `/health` and operator routes do
|
|
129
|
+
* not consume these permits, so they remain responsive during MCP saturation.
|
|
130
|
+
*/
|
|
131
|
+
export interface ConnectaAdmissionConfig {
|
|
132
|
+
/**
|
|
133
|
+
* The `/mcp` request boundary. Defaults to 16 active, 32 queued, and a
|
|
134
|
+
* 5-second maximum wait.
|
|
135
|
+
*/
|
|
136
|
+
requests?: AdmissionPoolConfig;
|
|
137
|
+
/**
|
|
138
|
+
* Fallback pool for an `executor` that does not implement its own `acquire`.
|
|
139
|
+
* Defaults to 2 active, 8 queued, and a 5-second maximum wait. Bounded
|
|
140
|
+
* executors (including `quickJsExecutor`) keep their own tighter pool.
|
|
141
|
+
*/
|
|
142
|
+
code?: AdmissionPoolConfig;
|
|
143
|
+
}
|
|
144
|
+
|
|
111
145
|
export interface ConnectaConfig {
|
|
112
146
|
connectors: Connector[];
|
|
113
147
|
/**
|
|
@@ -159,6 +193,8 @@ export interface ConnectaConfig {
|
|
|
159
193
|
discovery?: ConnectaDiscoveryConfig;
|
|
160
194
|
/** Deployment-wide call deadlines and result paging threshold. */
|
|
161
195
|
calls?: ConnectaCallsConfig;
|
|
196
|
+
/** Bounded MCP and fallback code-mode admission. */
|
|
197
|
+
admission?: ConnectaAdmissionConfig;
|
|
162
198
|
/** Optional browser UI and OAuth result-page labels. */
|
|
163
199
|
branding?: ConnectaBranding;
|
|
164
200
|
logger?: Logger;
|
|
@@ -222,6 +258,32 @@ export interface Connecta {
|
|
|
222
258
|
close: () => Promise<void>;
|
|
223
259
|
}
|
|
224
260
|
|
|
261
|
+
const REQUEST_ADMISSION_DEFAULTS = {
|
|
262
|
+
concurrency: 16,
|
|
263
|
+
maxQueueSize: 32,
|
|
264
|
+
queueTimeoutMs: 5_000,
|
|
265
|
+
retryAfterMs: 1_000,
|
|
266
|
+
} as const;
|
|
267
|
+
|
|
268
|
+
const CODE_ADMISSION_DEFAULTS = {
|
|
269
|
+
concurrency: 2,
|
|
270
|
+
maxQueueSize: 8,
|
|
271
|
+
queueTimeoutMs: 5_000,
|
|
272
|
+
retryAfterMs: 1_000,
|
|
273
|
+
} as const;
|
|
274
|
+
|
|
275
|
+
function admissionController(
|
|
276
|
+
options: AdmissionPoolConfig | undefined,
|
|
277
|
+
defaults: typeof REQUEST_ADMISSION_DEFAULTS | typeof CODE_ADMISSION_DEFAULTS,
|
|
278
|
+
): AdmissionController {
|
|
279
|
+
return new AdmissionController({
|
|
280
|
+
concurrency: options?.concurrency ?? defaults.concurrency,
|
|
281
|
+
maxQueueSize: options?.maxQueueSize ?? defaults.maxQueueSize,
|
|
282
|
+
queueTimeoutMs: options?.queueTimeoutMs ?? defaults.queueTimeoutMs,
|
|
283
|
+
retryAfterMs: options?.retryAfterMs ?? defaults.retryAfterMs,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
225
287
|
function defaultLogger(): Logger {
|
|
226
288
|
return {
|
|
227
289
|
debug: (...a) => console.debug("[connecta]", ...a),
|
|
@@ -495,6 +557,26 @@ export function createConnecta(config: ConnectaConfig): Connecta {
|
|
|
495
557
|
// with a 403 its client reports as a transport failure. Throw here instead.
|
|
496
558
|
validateToolkitBindings(inboundAuth, toolkits);
|
|
497
559
|
warnInsecureConfig(config, inboundAuth, toolkits, logger);
|
|
560
|
+
const requestAdmission = admissionController(
|
|
561
|
+
config.admission?.requests,
|
|
562
|
+
REQUEST_ADMISSION_DEFAULTS,
|
|
563
|
+
);
|
|
564
|
+
const configuredCodeAdmission = admissionController(
|
|
565
|
+
config.admission?.code,
|
|
566
|
+
CODE_ADMISSION_DEFAULTS,
|
|
567
|
+
);
|
|
568
|
+
let codeAdmission: AdmissionController | undefined;
|
|
569
|
+
let executor = config.executor;
|
|
570
|
+
if (executor && !isAdmittingExecutor(executor)) {
|
|
571
|
+
codeAdmission = configuredCodeAdmission;
|
|
572
|
+
executor = withExecutorAdmission(executor, codeAdmission);
|
|
573
|
+
} else if (executor && config.admission?.code) {
|
|
574
|
+
logger.warn(
|
|
575
|
+
"[connecta] admission.code is ignored because the configured executor " +
|
|
576
|
+
"implements acquire() and owns its admission pool; configure that " +
|
|
577
|
+
"executor's concurrency and queue options instead.",
|
|
578
|
+
);
|
|
579
|
+
}
|
|
498
580
|
const handler = createFetchHandler({
|
|
499
581
|
registry,
|
|
500
582
|
auth: inboundAuth,
|
|
@@ -508,7 +590,8 @@ export function createConnecta(config: ConnectaConfig): Connecta {
|
|
|
508
590
|
activity: config.activity?.store,
|
|
509
591
|
activityReadGate: config.activity?.readGate,
|
|
510
592
|
activityDeploymentId: config.activity?.deploymentId,
|
|
511
|
-
executor
|
|
593
|
+
executor,
|
|
594
|
+
requestAdmission,
|
|
512
595
|
defaultToolTimeoutMs: config.calls?.defaultTimeoutMs,
|
|
513
596
|
probeTimeoutMs: config.discovery?.probeTimeoutMs,
|
|
514
597
|
credentialVault,
|
|
@@ -555,6 +638,8 @@ export function createConnecta(config: ConnectaConfig): Connecta {
|
|
|
555
638
|
},
|
|
556
639
|
close: async () => {
|
|
557
640
|
closePromise ??= Promise.resolve().then(async () => {
|
|
641
|
+
requestAdmission.close();
|
|
642
|
+
codeAdmission?.close();
|
|
558
643
|
await config.executor?.close?.();
|
|
559
644
|
});
|
|
560
645
|
await closePromise;
|
|
@@ -614,6 +699,7 @@ export type {
|
|
|
614
699
|
ConnectorStatus,
|
|
615
700
|
CredentialTestResult,
|
|
616
701
|
AdmittingExecutor,
|
|
702
|
+
AdmissionSnapshot,
|
|
617
703
|
ExecuteResult,
|
|
618
704
|
Executor,
|
|
619
705
|
ExecutorLease,
|