deepline 0.2.55 → 0.2.56
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/dist/bundling-sources/sdk/src/client.ts +14 -0
- package/dist/bundling-sources/sdk/src/http.ts +19 -1
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/sdk/src/stream-reconnect.ts +6 -0
- package/dist/bundling-sources/sdk/src/types.ts +7 -0
- package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +146 -12
- package/dist/bundling-sources/shared_libs/play-runtime/batch-runtime.ts +6 -3
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +1631 -748
- package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +20 -0
- package/dist/bundling-sources/shared_libs/play-runtime/durable-receipt-execution.ts +81 -52
- package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +146 -6
- package/dist/bundling-sources/shared_libs/play-runtime/gateway-auth-session.ts +56 -22
- package/dist/bundling-sources/shared_libs/play-runtime/governor/app-runtime-rate-state-backend.ts +14 -11
- package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +21 -2
- package/dist/bundling-sources/shared_libs/play-runtime/governor/policy.ts +81 -21
- package/dist/bundling-sources/shared_libs/play-runtime/output-size-limits.ts +7 -0
- package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +3 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +21 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-payload-transport.ts +27 -2
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-runtime-watchdog.ts +49 -10
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +96 -3
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +341 -67
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-capacity-policy.ts +10 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-contract.ts +8 -1
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-pg-driver-pg.ts +17 -2
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-pg-driver.ts +4 -1
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-receipt-store-adapter.ts +17 -1
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-receipt-writer.ts +16 -2
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-sheet-row-writer.ts +25 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +3 -0
- package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +2 -0
- package/dist/bundling-sources/shared_libs/play-runtime/test-runtime-seams.ts +80 -1
- package/dist/bundling-sources/shared_libs/security/safe-fetch.ts +9 -0
- package/dist/bundling-sources/shared_libs/security/safe-outbound-fetch.ts +85 -48
- package/dist/cli/index.js +409 -51
- package/dist/cli/index.mjs +389 -25
- package/dist/index.d.mts +19 -1
- package/dist/index.d.ts +19 -1
- package/dist/index.js +29 -2
- package/dist/index.mjs +29 -2
- package/package.json +1 -1
|
@@ -11,6 +11,20 @@ import {
|
|
|
11
11
|
} from './runtime-pg-driver';
|
|
12
12
|
|
|
13
13
|
function wrapPgClient(client: PoolClient): RuntimePoolClient {
|
|
14
|
+
let released = false;
|
|
15
|
+
const releaseClient = (destroy = false) => {
|
|
16
|
+
if (released) return;
|
|
17
|
+
released = true;
|
|
18
|
+
client.release(destroy);
|
|
19
|
+
};
|
|
20
|
+
const destroyTransport = () => {
|
|
21
|
+
// `release(true)` is node-postgres' supported pool-client destruction
|
|
22
|
+
// path. The pool removes the client and Client.end() destroys the socket
|
|
23
|
+
// when a query is active, while keeping pool accounting correct. Runtime
|
|
24
|
+
// callers still run their ordinary `finally { release() }` path after a
|
|
25
|
+
// deadline destroys the transport, so this wrapper owns idempotence.
|
|
26
|
+
releaseClient(true);
|
|
27
|
+
};
|
|
14
28
|
return {
|
|
15
29
|
query: <R extends Record<string, unknown> = Record<string, unknown>>(
|
|
16
30
|
text: string,
|
|
@@ -24,9 +38,10 @@ function wrapPgClient(client: PoolClient): RuntimePoolClient {
|
|
|
24
38
|
)(text, params).then((result) => ({
|
|
25
39
|
rows: result.rows,
|
|
26
40
|
})),
|
|
27
|
-
release: () => {
|
|
28
|
-
|
|
41
|
+
release: (destroy = false) => {
|
|
42
|
+
releaseClient(destroy);
|
|
29
43
|
},
|
|
44
|
+
destroy: destroyTransport,
|
|
30
45
|
};
|
|
31
46
|
}
|
|
32
47
|
|
|
@@ -13,7 +13,10 @@ export interface RuntimePoolClient {
|
|
|
13
13
|
text: string,
|
|
14
14
|
params?: unknown[],
|
|
15
15
|
): Promise<{ rows: R[] }>;
|
|
16
|
-
|
|
16
|
+
/** Destroy the physical connection when an active query exceeded its bound. */
|
|
17
|
+
release(destroy?: boolean): void;
|
|
18
|
+
/** Interrupt an active query by destroying the physical transport. */
|
|
19
|
+
destroy?(error?: Error): void;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
export interface RuntimePool {
|
|
@@ -9,7 +9,10 @@ import type {
|
|
|
9
9
|
RuntimeStepReceipt,
|
|
10
10
|
SkipRuntimeStepReceiptInput,
|
|
11
11
|
} from './ctx-types';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
RUNTIME_RECEIPT_GATEWAY_BATCH_MAX_BYTES,
|
|
14
|
+
RUNTIME_RECEIPT_WRITER_TARGET_BATCH_BYTES,
|
|
15
|
+
} from './output-size-limits';
|
|
13
16
|
import {
|
|
14
17
|
acquireRuntimeReceiptExecutionLockViaAppRuntime,
|
|
15
18
|
AppRuntimeApiResponseError,
|
|
@@ -106,6 +109,12 @@ export type RuntimeReceiptStoreHandlers = Required<
|
|
|
106
109
|
Pick<ContextOptions, ReceiptHandlerName>
|
|
107
110
|
>;
|
|
108
111
|
|
|
112
|
+
// Four ambiguous 30-second receipt requests previously consumed the writer's
|
|
113
|
+
// entire two-minute budget. Receipt operations are idempotent and buffered, so
|
|
114
|
+
// keep retry ownership here and allow independent transports enough attempts
|
|
115
|
+
// to recover from a short gateway/network impairment.
|
|
116
|
+
export const RUNTIME_RECEIPT_STORE_RETRY_BUDGET_MS = 5 * 60_000;
|
|
117
|
+
|
|
109
118
|
/**
|
|
110
119
|
* The runner's single Work Receipt Adapter.
|
|
111
120
|
*
|
|
@@ -116,6 +125,7 @@ export type RuntimeReceiptStoreHandlers = Required<
|
|
|
116
125
|
*/
|
|
117
126
|
export class RuntimeReceiptStoreAdapter {
|
|
118
127
|
readonly handlers: RuntimeReceiptStoreHandlers;
|
|
128
|
+
readonly claimsEstablishExecutionFence = true;
|
|
119
129
|
readonly #writer: RuntimeReceiptWriter<ReceiptCommand, ReceiptOutput>;
|
|
120
130
|
#serial = 0;
|
|
121
131
|
|
|
@@ -125,6 +135,7 @@ export class RuntimeReceiptStoreAdapter {
|
|
|
125
135
|
runId: string;
|
|
126
136
|
maxBatchSize?: number;
|
|
127
137
|
maxBatchBytes?: number;
|
|
138
|
+
targetBatchBytes?: number;
|
|
128
139
|
maxFlushMs?: number;
|
|
129
140
|
maxBufferedBytes?: number;
|
|
130
141
|
onRetryTelemetry?: (line: string) => void;
|
|
@@ -138,8 +149,11 @@ export class RuntimeReceiptStoreAdapter {
|
|
|
138
149
|
maxBatchSize: options.maxBatchSize,
|
|
139
150
|
maxBatchBytes:
|
|
140
151
|
options.maxBatchBytes ?? RUNTIME_RECEIPT_GATEWAY_BATCH_MAX_BYTES,
|
|
152
|
+
targetBatchBytes:
|
|
153
|
+
options.targetBatchBytes ?? RUNTIME_RECEIPT_WRITER_TARGET_BATCH_BYTES,
|
|
141
154
|
maxFlushMs: options.maxFlushMs,
|
|
142
155
|
maxBufferedBytes: options.maxBufferedBytes,
|
|
156
|
+
maxRetryElapsedMs: RUNTIME_RECEIPT_STORE_RETRY_BUDGET_MS,
|
|
143
157
|
classifyRetryableError: classifyReceiptRetry,
|
|
144
158
|
onRetryEvent: (event) =>
|
|
145
159
|
emitReceiptWriterRetryTelemetry({
|
|
@@ -309,6 +323,7 @@ function receiptRetryErrorTelemetry(error: unknown): Record<string, unknown> {
|
|
|
309
323
|
errorName: error.name,
|
|
310
324
|
transportAttempts: error.attempts,
|
|
311
325
|
transportAction: error.action,
|
|
326
|
+
transportAttemptId: error.transportAttemptId,
|
|
312
327
|
};
|
|
313
328
|
}
|
|
314
329
|
if (error instanceof AppRuntimeApiResponseError) {
|
|
@@ -343,6 +358,7 @@ function emitReceiptWriterRetryTelemetry(input: {
|
|
|
343
358
|
action: receiptCommandTelemetryAction(event.firstInput),
|
|
344
359
|
batchId: event.batchId,
|
|
345
360
|
batchSize: event.batchSize,
|
|
361
|
+
batchBytes: event.batchBytes,
|
|
346
362
|
attempt: event.attempt,
|
|
347
363
|
elapsedMs: event.elapsedMs,
|
|
348
364
|
retryAfterMs: event.retryAfterMs,
|
|
@@ -27,6 +27,7 @@ export type RuntimeReceiptWriterRetryEvent<Input> = {
|
|
|
27
27
|
attempt: number;
|
|
28
28
|
batchId: number;
|
|
29
29
|
batchSize: number;
|
|
30
|
+
batchBytes: number;
|
|
30
31
|
firstInput: Input;
|
|
31
32
|
elapsedMs: number;
|
|
32
33
|
retryAfterMs: number;
|
|
@@ -103,6 +104,7 @@ export class RuntimeReceiptWriter<Input, Output> {
|
|
|
103
104
|
readonly #estimateBytes: (input: Input) => number;
|
|
104
105
|
readonly #maxBatchSize: number;
|
|
105
106
|
readonly #maxBatchBytes: number;
|
|
107
|
+
readonly #targetBatchBytes: number;
|
|
106
108
|
readonly #maxBufferedBytes: number;
|
|
107
109
|
readonly #maxFlushMs: number;
|
|
108
110
|
readonly #maxRetryElapsedMs: number;
|
|
@@ -135,6 +137,7 @@ export class RuntimeReceiptWriter<Input, Output> {
|
|
|
135
137
|
estimateBytes?: (input: Input) => number;
|
|
136
138
|
maxBatchSize?: number;
|
|
137
139
|
maxBatchBytes?: number;
|
|
140
|
+
targetBatchBytes?: number;
|
|
138
141
|
maxBufferedBytes?: number;
|
|
139
142
|
maxFlushMs?: number;
|
|
140
143
|
maxRetryElapsedMs?: number;
|
|
@@ -154,6 +157,10 @@ export class RuntimeReceiptWriter<Input, Output> {
|
|
|
154
157
|
normalizePositiveInteger(options.maxBatchBytes, this.#maxBufferedBytes),
|
|
155
158
|
this.#maxBufferedBytes,
|
|
156
159
|
);
|
|
160
|
+
this.#targetBatchBytes = Math.min(
|
|
161
|
+
normalizePositiveInteger(options.targetBatchBytes, this.#maxBatchBytes),
|
|
162
|
+
this.#maxBatchBytes,
|
|
163
|
+
);
|
|
157
164
|
this.#maxFlushMs = Math.max(0, Math.floor(options.maxFlushMs ?? 5));
|
|
158
165
|
this.#maxRetryElapsedMs = normalizePositiveInteger(
|
|
159
166
|
options.maxRetryElapsedMs,
|
|
@@ -251,7 +258,7 @@ export class RuntimeReceiptWriter<Input, Output> {
|
|
|
251
258
|
this.#bufferedBytes += pending.bytes;
|
|
252
259
|
if (
|
|
253
260
|
this.#queued.length >= this.#maxBatchSize ||
|
|
254
|
-
this.#queuedBytesAtHead() >= this.#
|
|
261
|
+
this.#queuedBytesAtHead() >= this.#targetBatchBytes
|
|
255
262
|
) {
|
|
256
263
|
this.#clearFlushTimer();
|
|
257
264
|
this.#startPump();
|
|
@@ -334,7 +341,10 @@ export class RuntimeReceiptWriter<Input, Output> {
|
|
|
334
341
|
while (batch.length < this.#maxBatchSize) {
|
|
335
342
|
const next = this.#queued[0];
|
|
336
343
|
if (!next || !Object.is(this.#batchKey(next.input), key)) break;
|
|
337
|
-
if (
|
|
344
|
+
if (
|
|
345
|
+
batch.length > 0 &&
|
|
346
|
+
batchBytes + next.bytes > this.#targetBatchBytes
|
|
347
|
+
) {
|
|
338
348
|
break;
|
|
339
349
|
}
|
|
340
350
|
this.#queued.shift();
|
|
@@ -427,6 +437,10 @@ export class RuntimeReceiptWriter<Input, Output> {
|
|
|
427
437
|
attempt: input.attempt,
|
|
428
438
|
batchId: input.batchId,
|
|
429
439
|
batchSize: input.batch.length,
|
|
440
|
+
batchBytes: input.batch.reduce(
|
|
441
|
+
(total, pending) => total + pending.bytes,
|
|
442
|
+
0,
|
|
443
|
+
),
|
|
430
444
|
firstInput,
|
|
431
445
|
elapsedMs: Date.now() - input.startedAt,
|
|
432
446
|
retryAfterMs: input.retryAfterMs,
|
|
@@ -70,6 +70,14 @@ export type RuntimeSheetRowWriterSummary = {
|
|
|
70
70
|
terminalRows: number;
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
+
export type RuntimeSheetRowWriterDiagnostics = {
|
|
74
|
+
activeKind: 'terminal' | 'checkpoint' | null;
|
|
75
|
+
activeRows: number;
|
|
76
|
+
activeStartedAt: number | null;
|
|
77
|
+
blockedTerminalRows: number;
|
|
78
|
+
queuedTerminalRows: number;
|
|
79
|
+
};
|
|
80
|
+
|
|
73
81
|
export type RuntimeSheetRowSettlement = {
|
|
74
82
|
/**
|
|
75
83
|
* Resolves once the bounded live buffer owns this row. Awaiting this promise
|
|
@@ -159,6 +167,7 @@ export class RuntimeSheetRowWriter<Row, Update> {
|
|
|
159
167
|
readonly #controller = new AbortController();
|
|
160
168
|
|
|
161
169
|
#active: PendingWrite<Row, Update>[] | null = null;
|
|
170
|
+
#activeStartedAt: number | null = null;
|
|
162
171
|
#bufferedBytes = 0;
|
|
163
172
|
#liveBytes = 0;
|
|
164
173
|
#timer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -264,6 +273,20 @@ export class RuntimeSheetRowWriter<Row, Update> {
|
|
|
264
273
|
return this.#finishPromise;
|
|
265
274
|
}
|
|
266
275
|
|
|
276
|
+
diagnostics(): RuntimeSheetRowWriterDiagnostics {
|
|
277
|
+
return {
|
|
278
|
+
activeKind: this.#active?.[0]?.kind ?? null,
|
|
279
|
+
activeRows: this.#active?.length ?? 0,
|
|
280
|
+
activeStartedAt: this.#activeStartedAt,
|
|
281
|
+
blockedTerminalRows: this.#blocked.filter(
|
|
282
|
+
(entry) => entry.kind === 'terminal',
|
|
283
|
+
).length,
|
|
284
|
+
queuedTerminalRows: [...this.#live.values()].filter(
|
|
285
|
+
(entry) => entry.kind === 'terminal',
|
|
286
|
+
).length,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
267
290
|
#enqueueCheckpointUpdate(update: Update): Promise<void> {
|
|
268
291
|
const key = normalizedKey(this.#checkpointKey(update));
|
|
269
292
|
if (this.#terminalAcceptedKeys.has(key)) return Promise.resolve();
|
|
@@ -403,6 +426,7 @@ export class RuntimeSheetRowWriter<Row, Update> {
|
|
|
403
426
|
if (batch.length === 0) return;
|
|
404
427
|
const batchKind = batch[0]!.kind;
|
|
405
428
|
this.#active = batch;
|
|
429
|
+
this.#activeStartedAt = Date.now();
|
|
406
430
|
try {
|
|
407
431
|
const result = await this.#writeIdenticalBatchWithRetry(batch);
|
|
408
432
|
const resultCommitted = result ? result.committed : undefined;
|
|
@@ -429,6 +453,7 @@ export class RuntimeSheetRowWriter<Row, Update> {
|
|
|
429
453
|
);
|
|
430
454
|
this.#bufferedBytes = Math.max(0, this.#bufferedBytes - releasedBytes);
|
|
431
455
|
this.#active = null;
|
|
456
|
+
this.#activeStartedAt = null;
|
|
432
457
|
this.#admitBlocked();
|
|
433
458
|
this.#notifyStateChanged();
|
|
434
459
|
}
|
|
@@ -58,6 +58,9 @@ export type PlaySchedulerSubmitInput = {
|
|
|
58
58
|
force?: boolean;
|
|
59
59
|
/** Explicit cache bypass for completed ctx.tools.execute receipts. */
|
|
60
60
|
forceToolRefresh?: boolean | null;
|
|
61
|
+
/** Validated per-run ceiling for provider-tool executions and ctx.fetch. */
|
|
62
|
+
maxConcurrentExternalCalls?: number | null;
|
|
63
|
+
maxConcurrentRows?: number | null;
|
|
61
64
|
inputFile?: {
|
|
62
65
|
name?: string;
|
|
63
66
|
path?: string;
|
|
@@ -56,6 +56,8 @@ export type PlayExecutionSuspension =
|
|
|
56
56
|
exitCodePath: string;
|
|
57
57
|
/** Exact customer-code completion fence inside the Daytona sandbox. */
|
|
58
58
|
runtimeCompletedPath?: string;
|
|
59
|
+
/** Bounded watchdog-owned cause marker for crash-only diagnostics. */
|
|
60
|
+
terminationDiagnosticPath?: string;
|
|
59
61
|
startedAtMs: number;
|
|
60
62
|
/** Scheduler-owned liveness deadline. The sandbox can renew it only by
|
|
61
63
|
* persisting `heartbeat_at` through the receipt gateway. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getRuntimeEnv } from '../runtime-env';
|
|
2
|
+
import type { PlayRunnerEvent } from './protocol';
|
|
2
3
|
|
|
3
4
|
export const PLAY_RUNTIME_TEST_FAULT_HEADER = 'x-deepline-test-fault';
|
|
4
5
|
|
|
@@ -13,7 +14,23 @@ export type RuntimeTestFaultName =
|
|
|
13
14
|
* deterministic real-Pg-pool pressure window without changing an action's
|
|
14
15
|
* SQL or outcome.
|
|
15
16
|
*/
|
|
16
|
-
| 'receipt_gateway_hold_ms'
|
|
17
|
+
| 'receipt_gateway_hold_ms'
|
|
18
|
+
/**
|
|
19
|
+
* Synthetic-only bulk-claim fault. The runner forwards this token on the
|
|
20
|
+
* first qualifying physical claim request only. The gateway holds the real
|
|
21
|
+
* checked-out scheduler client for the supplied milliseconds, allowing the
|
|
22
|
+
* claim-operation deadline and connection destruction path to be exercised
|
|
23
|
+
* end to end without making the fault Machine-local.
|
|
24
|
+
*/
|
|
25
|
+
| 'receipt_claim_query_hold_once_ms'
|
|
26
|
+
/**
|
|
27
|
+
* Synthetic-only response-boundary fault. The runner fully receives a
|
|
28
|
+
* successful bulk receipt-claim response, then reports the configured first
|
|
29
|
+
* N physical requests as request timeouts. This proves ambiguous-delivery
|
|
30
|
+
* replay without depending on which gateway Machine accepted the request.
|
|
31
|
+
*/
|
|
32
|
+
| 'receipt_claim_response_timeout'
|
|
33
|
+
| 'runtime_sheet_page_tail_hold_ms';
|
|
17
34
|
|
|
18
35
|
export type RuntimeTestFaultRegistry = {
|
|
19
36
|
consume(name: RuntimeTestFaultName): boolean;
|
|
@@ -75,6 +92,9 @@ const SUPPORTED_RUNTIME_TEST_FAULTS = new Set<RuntimeTestFaultName>([
|
|
|
75
92
|
'worker_receipt_complete_write_fail',
|
|
76
93
|
'invocation_response_delivery_abort',
|
|
77
94
|
'receipt_gateway_hold_ms',
|
|
95
|
+
'receipt_claim_query_hold_once_ms',
|
|
96
|
+
'receipt_claim_response_timeout',
|
|
97
|
+
'runtime_sheet_page_tail_hold_ms',
|
|
78
98
|
]);
|
|
79
99
|
|
|
80
100
|
const RUNTIME_TEST_POLICY_MS_FIELDS = new Set([
|
|
@@ -149,6 +169,65 @@ function parseRuntimeTestFaultHeader(
|
|
|
149
169
|
return faults;
|
|
150
170
|
}
|
|
151
171
|
|
|
172
|
+
/** Parse a known fault count without authorizing a request. Unknown/skewed headers return 0. */
|
|
173
|
+
export function recognizedRuntimeTestFaultCount(
|
|
174
|
+
rawHeader: string | null | undefined,
|
|
175
|
+
name: RuntimeTestFaultName,
|
|
176
|
+
): number {
|
|
177
|
+
const header = rawHeader?.trim();
|
|
178
|
+
if (!header) return 0;
|
|
179
|
+
const parsed = parseRuntimeTestFaultHeader(header);
|
|
180
|
+
if ('error' in parsed) return 0;
|
|
181
|
+
return parsed.get(name) ?? 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function runtimeSheetPageTailRowsContainTarget(
|
|
185
|
+
rows: readonly unknown[],
|
|
186
|
+
): boolean {
|
|
187
|
+
return rows.some((row) => {
|
|
188
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) return false;
|
|
189
|
+
const record = row as Record<string, unknown>;
|
|
190
|
+
return record.inputIndex === 999 || record.input_index === 999;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function runtimeSheetPageTailWriteMarkerEvent(
|
|
195
|
+
runtimeTestFaultHeader: string | null | undefined,
|
|
196
|
+
rows: readonly Record<string, unknown>[],
|
|
197
|
+
tableNamespace: string,
|
|
198
|
+
dbSessionStrategy: string | null | undefined,
|
|
199
|
+
): PlayRunnerEvent | null {
|
|
200
|
+
if (
|
|
201
|
+
dbSessionStrategy !== 'gateway_only' ||
|
|
202
|
+
recognizedRuntimeTestFaultCount(
|
|
203
|
+
runtimeTestFaultHeader,
|
|
204
|
+
'runtime_sheet_page_tail_hold_ms',
|
|
205
|
+
) <= 0 ||
|
|
206
|
+
!runtimeSheetPageTailRowsContainTarget(rows)
|
|
207
|
+
) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
type: 'log',
|
|
212
|
+
at: new Date().toISOString(),
|
|
213
|
+
source: 'play',
|
|
214
|
+
line: `[runtime.sheet-page-tail-write] phase=start table=${tableNamespace} rows=${rows.length}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function runtimeSheetPageTailHoldCompletedEvent(
|
|
219
|
+
holdMs: number | null | undefined,
|
|
220
|
+
tableNamespace: string,
|
|
221
|
+
): PlayRunnerEvent | null {
|
|
222
|
+
if (!Number.isInteger(holdMs) || (holdMs ?? 0) <= 0) return null;
|
|
223
|
+
return {
|
|
224
|
+
type: 'log',
|
|
225
|
+
at: new Date().toISOString(),
|
|
226
|
+
source: 'play',
|
|
227
|
+
line: `[runtime.sheet-page-tail-hold] phase=finish table=${tableNamespace} hold_ms=${holdMs}`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
152
231
|
function readPositiveIntegerField(input: {
|
|
153
232
|
value: unknown;
|
|
154
233
|
path: string;
|
|
@@ -19,6 +19,10 @@ function removeHeader(headers: Headers, name: string) {
|
|
|
19
19
|
if (headers.has(name)) headers.delete(name);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function cancelResponseBodyBestEffort(response: Response): void {
|
|
23
|
+
void response.body?.cancel().catch(() => undefined);
|
|
24
|
+
}
|
|
25
|
+
|
|
22
26
|
function requestInitForRedirect(
|
|
23
27
|
init: RequestInit,
|
|
24
28
|
from: URL,
|
|
@@ -83,6 +87,7 @@ export async function safePublicFetch(
|
|
|
83
87
|
return response;
|
|
84
88
|
}
|
|
85
89
|
if (redirectMode === 'error') {
|
|
90
|
+
cancelResponseBodyBestEffort(response);
|
|
86
91
|
throw new Error(
|
|
87
92
|
`Redirect blocked while fetching ${currentUrl.toString()}.`,
|
|
88
93
|
);
|
|
@@ -96,11 +101,15 @@ export async function safePublicFetch(
|
|
|
96
101
|
return response;
|
|
97
102
|
}
|
|
98
103
|
if (redirectCount === maxRedirects) {
|
|
104
|
+
cancelResponseBodyBestEffort(response);
|
|
99
105
|
throw new Error(
|
|
100
106
|
`Too many redirects while fetching ${currentUrl.toString()}.`,
|
|
101
107
|
);
|
|
102
108
|
}
|
|
103
109
|
|
|
110
|
+
// A redirect response can stream an unbounded body. The next request must
|
|
111
|
+
// not leave that body/socket flowing in the background.
|
|
112
|
+
cancelResponseBodyBestEffort(response);
|
|
104
113
|
const nextUrl = resolveRedirectUrl(location, currentUrl);
|
|
105
114
|
currentInit = requestInitForRedirect(
|
|
106
115
|
currentInit,
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
type NodeSafeFetchOptions = {
|
|
15
15
|
maxRedirects?: number;
|
|
16
16
|
maxResponseBytes?: number;
|
|
17
|
+
/** Return after validated headers so the caller can enforce a body deadline. */
|
|
18
|
+
streamResponseBody?: boolean;
|
|
17
19
|
truncateResponseBody?: boolean;
|
|
18
20
|
sensitiveHeaders?: Iterable<string>;
|
|
19
21
|
validateUrl?: (url: URL) => void;
|
|
@@ -219,62 +221,83 @@ function createRequest(
|
|
|
219
221
|
);
|
|
220
222
|
return;
|
|
221
223
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
let receivedBytes = 0;
|
|
225
|
-
let settled = false;
|
|
226
|
-
const resolveResponse = () => {
|
|
227
|
-
if (settled) return;
|
|
228
|
-
settled = true;
|
|
224
|
+
if (noBodyResponse) {
|
|
225
|
+
response.resume();
|
|
229
226
|
resolve(
|
|
230
|
-
new Response(
|
|
227
|
+
new Response(null, {
|
|
231
228
|
status,
|
|
232
229
|
statusText: response.statusMessage,
|
|
233
230
|
headers: response.headers as HeadersInit,
|
|
234
231
|
}),
|
|
235
232
|
);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let receivedBytes = 0;
|
|
237
|
+
let bodySettled = false;
|
|
238
|
+
const body = new ReadableStream<Uint8Array>({
|
|
239
|
+
start(controller) {
|
|
240
|
+
response.on('data', (chunk) => {
|
|
241
|
+
if (bodySettled) return;
|
|
242
|
+
const buffer = Buffer.isBuffer(chunk)
|
|
243
|
+
? chunk
|
|
244
|
+
: Buffer.from(chunk);
|
|
245
|
+
const previousReceivedBytes = receivedBytes;
|
|
246
|
+
receivedBytes += buffer.byteLength;
|
|
247
|
+
if (
|
|
248
|
+
maxResponseBytes !== undefined &&
|
|
249
|
+
receivedBytes > maxResponseBytes &&
|
|
250
|
+
!truncateResponseBody
|
|
251
|
+
) {
|
|
252
|
+
bodySettled = true;
|
|
253
|
+
const error = new Error(
|
|
254
|
+
`Response body exceeds ${maxResponseBytes} byte limit.`,
|
|
255
|
+
);
|
|
256
|
+
controller.error(error);
|
|
257
|
+
response.destroy(error);
|
|
258
|
+
return;
|
|
262
259
|
}
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
|
|
260
|
+
if (maxResponseBytes !== undefined && truncateResponseBody) {
|
|
261
|
+
const remainingBytes = Math.max(
|
|
262
|
+
0,
|
|
263
|
+
maxResponseBytes - previousReceivedBytes,
|
|
264
|
+
);
|
|
265
|
+
if (remainingBytes > 0) {
|
|
266
|
+
controller.enqueue(buffer.subarray(0, remainingBytes));
|
|
267
|
+
}
|
|
268
|
+
if (receivedBytes >= maxResponseBytes) {
|
|
269
|
+
bodySettled = true;
|
|
270
|
+
controller.close();
|
|
271
|
+
response.destroy();
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
266
274
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
275
|
+
controller.enqueue(buffer);
|
|
276
|
+
});
|
|
277
|
+
response.on('error', (error) => {
|
|
278
|
+
if (bodySettled) return;
|
|
279
|
+
bodySettled = true;
|
|
280
|
+
controller.error(error);
|
|
281
|
+
});
|
|
282
|
+
response.on('end', () => {
|
|
283
|
+
if (bodySettled) return;
|
|
284
|
+
bodySettled = true;
|
|
285
|
+
controller.close();
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
cancel(reason) {
|
|
289
|
+
if (bodySettled) return;
|
|
290
|
+
bodySettled = true;
|
|
291
|
+
response.destroy(reason instanceof Error ? reason : undefined);
|
|
292
|
+
},
|
|
271
293
|
});
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
294
|
+
resolve(
|
|
295
|
+
new Response(body, {
|
|
296
|
+
status,
|
|
297
|
+
statusText: response.statusMessage,
|
|
298
|
+
headers: response.headers as HeadersInit,
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
278
301
|
},
|
|
279
302
|
);
|
|
280
303
|
|
|
@@ -359,9 +382,21 @@ export async function safeOutboundFetch(
|
|
|
359
382
|
});
|
|
360
383
|
|
|
361
384
|
if (!isRedirectStatus(response.status)) {
|
|
362
|
-
return response;
|
|
385
|
+
if (options.streamResponseBody) return response;
|
|
386
|
+
const body = response.body ? await response.arrayBuffer() : null;
|
|
387
|
+
const buffered = new Response(body, {
|
|
388
|
+
status: response.status,
|
|
389
|
+
statusText: response.statusText,
|
|
390
|
+
headers: response.headers,
|
|
391
|
+
});
|
|
392
|
+
Object.defineProperty(buffered, 'url', {
|
|
393
|
+
configurable: true,
|
|
394
|
+
value: response.url,
|
|
395
|
+
});
|
|
396
|
+
return buffered;
|
|
363
397
|
}
|
|
364
398
|
if (redirectMode === 'error') {
|
|
399
|
+
void response.body?.cancel().catch(() => undefined);
|
|
365
400
|
throw new Error(
|
|
366
401
|
`Redirect blocked while fetching ${currentUrl.toString()}.`,
|
|
367
402
|
);
|
|
@@ -375,11 +410,13 @@ export async function safeOutboundFetch(
|
|
|
375
410
|
return response;
|
|
376
411
|
}
|
|
377
412
|
if (redirectCount === maxRedirects) {
|
|
413
|
+
void response.body?.cancel().catch(() => undefined);
|
|
378
414
|
throw new Error(
|
|
379
415
|
`Too many redirects while fetching ${currentUrl.toString()}.`,
|
|
380
416
|
);
|
|
381
417
|
}
|
|
382
418
|
|
|
419
|
+
void response.body?.cancel().catch(() => undefined);
|
|
383
420
|
const nextUrl = resolveRedirectUrl(location, currentUrl);
|
|
384
421
|
currentInit = initForRedirect(
|
|
385
422
|
currentInit,
|