velocious 1.0.680 → 1.0.682
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/build/background-jobs/worker.js +121 -15
- package/build/http-server/client/index.js +160 -0
- package/build/http-server/client/request-runner.js +13 -5
- package/build/http-server/client/response.js +142 -0
- package/build/http-server/worker-handler/in-process.js +20 -2
- package/build/http-server/worker-handler/worker-thread.js +4 -1
- package/build/src/background-jobs/worker.d.ts +59 -10
- package/build/src/background-jobs/worker.d.ts.map +1 -1
- package/build/src/background-jobs/worker.js +117 -16
- package/build/src/http-server/client/index.d.ts +69 -0
- package/build/src/http-server/client/index.d.ts.map +1 -1
- package/build/src/http-server/client/index.js +147 -1
- package/build/src/http-server/client/request-runner.d.ts.map +1 -1
- package/build/src/http-server/client/request-runner.js +16 -7
- package/build/src/http-server/client/response.d.ts +88 -0
- package/build/src/http-server/client/response.d.ts.map +1 -1
- package/build/src/http-server/client/response.js +136 -1
- package/build/src/http-server/worker-handler/in-process.d.ts.map +1 -1
- package/build/src/http-server/worker-handler/in-process.js +20 -3
- package/build/src/http-server/worker-handler/worker-thread.d.ts.map +1 -1
- package/build/src/http-server/worker-handler/worker-thread.js +4 -2
- package/package.json +1 -1
- package/src/background-jobs/worker.js +121 -15
- package/src/http-server/client/index.js +160 -0
- package/src/http-server/client/request-runner.js +13 -5
- package/src/http-server/client/response.js +142 -0
- package/src/http-server/worker-handler/in-process.js +20 -2
- package/src/http-server/worker-handler/worker-thread.js +4 -1
|
@@ -322,6 +322,28 @@ export default class BackgroundJobsWorker {
|
|
|
322
322
|
// Monotonic dispatch counter for round-robin child selection: each dispatch stamps
|
|
323
323
|
// the chosen child, and selection prefers the child dispatched least recently.
|
|
324
324
|
this._pooledDispatchSeq = 0
|
|
325
|
+
// Waiters blocked in _runPooledJob because the pool is at its hard cap: a job may
|
|
326
|
+
// not spawn a child while total live children (working + draining) is at the cap.
|
|
327
|
+
/** @type {Set<() => void>} */
|
|
328
|
+
this._pooledSlotWaiters = new Set()
|
|
329
|
+
/** @type {ReturnType<typeof setInterval> | undefined} - Safety poll that re-checks the slot condition. */
|
|
330
|
+
this._pooledSlotWaitTimer = undefined
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Starts the slot-waiter safety poll if it is not already running. */
|
|
334
|
+
_startPooledSlotWaitPoll() {
|
|
335
|
+
if (this._pooledSlotWaitTimer) return
|
|
336
|
+
|
|
337
|
+
this._pooledSlotWaitTimer = setInterval(() => this._wakePooledSlotWaiters(), 50)
|
|
338
|
+
this._pooledSlotWaitTimer.unref()
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Stops the slot-waiter safety poll once no waiter is registered. */
|
|
342
|
+
_stopPooledSlotWaitPollIfIdle() {
|
|
343
|
+
if (this._pooledSlotWaiters.size > 0 || !this._pooledSlotWaitTimer) return
|
|
344
|
+
|
|
345
|
+
clearInterval(this._pooledSlotWaitTimer)
|
|
346
|
+
this._pooledSlotWaitTimer = undefined
|
|
325
347
|
}
|
|
326
348
|
|
|
327
349
|
/**
|
|
@@ -1079,27 +1101,72 @@ export default class BackgroundJobsWorker {
|
|
|
1079
1101
|
}
|
|
1080
1102
|
}
|
|
1081
1103
|
|
|
1104
|
+
/**
|
|
1105
|
+
* Hard cap on total live pooled children (working + draining): children the
|
|
1106
|
+
* pool may still spawn. Counting the whole live set — draining children
|
|
1107
|
+
* included — is what bounds pool memory: a draining child still holds its
|
|
1108
|
+
* RSS until its last in-flight job finishes, so it must occupy a cap slot.
|
|
1109
|
+
* @returns {number} - Number of children the pool may still spawn.
|
|
1110
|
+
*/
|
|
1111
|
+
_spawnablePooledChildren() {
|
|
1112
|
+
return Math.max(0, this.pooledRunnerCount - this.pooledChildren.size)
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Resolves once a non-retiring pooled child has a free concurrency slot or the
|
|
1117
|
+
* pool may spawn a new one. Pooled jobs admitted while the pool is at its
|
|
1118
|
+
* hard cap wait here instead of spawning an over-capacity child; the wake
|
|
1119
|
+
* points are the only capacity-freeing transitions (a job outcome and a child
|
|
1120
|
+
* exit), so no polling is needed.
|
|
1121
|
+
* @returns {Promise<void>} - Resolves when a slot is available.
|
|
1122
|
+
*/
|
|
1123
|
+
_waitPooledSlot() {
|
|
1124
|
+
this._startPooledSlotWaitPoll()
|
|
1125
|
+
|
|
1126
|
+
return new Promise((resolve) => {
|
|
1127
|
+
const waiter = () => {
|
|
1128
|
+
this._pooledSlotWaiters.delete(waiter)
|
|
1129
|
+
this._stopPooledSlotWaitPollIfIdle()
|
|
1130
|
+
resolve()
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
this._pooledSlotWaiters.add(waiter)
|
|
1134
|
+
})
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Resolves every registered waiter; waiters re-check the slot condition
|
|
1139
|
+
* themselves and only proceed when it holds.
|
|
1140
|
+
* @returns {void}
|
|
1141
|
+
*/
|
|
1142
|
+
_wakePooledSlotWaiters() {
|
|
1143
|
+
if (this._pooledSlotWaiters.size === 0) return
|
|
1144
|
+
|
|
1145
|
+
for (const resolve of [...this._pooledSlotWaiters]) resolve()
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1082
1148
|
/**
|
|
1083
1149
|
* Free pooled slots across the pool: open slots in non-retiring children plus
|
|
1084
|
-
* the slots we could add by spawning more children up to
|
|
1085
|
-
* Retiring children (draining before replacement) never
|
|
1150
|
+
* the slots we could add by spawning more children up to the hard cap on total
|
|
1151
|
+
* live children. Retiring children (draining before replacement) never
|
|
1152
|
+
* contribute capacity, and they count against the cap: while one is still
|
|
1153
|
+
* draining, no replacement is advertised (or spawned) — the pool advertises
|
|
1154
|
+
* exactly what it can serve instead of phantom capacity.
|
|
1086
1155
|
* @returns {number} - Number of pooled jobs the worker can accept right now.
|
|
1087
1156
|
*/
|
|
1088
1157
|
_availablePooledSlots() {
|
|
1089
1158
|
let openInExisting = 0
|
|
1090
|
-
let nonRetiringChildren = 0
|
|
1091
1159
|
let queuedReservations = 0
|
|
1092
1160
|
|
|
1093
1161
|
for (const child of this.pooledChildren) {
|
|
1094
1162
|
const state = this.pooledChildStates.get(child)
|
|
1095
1163
|
if (!state || state.retiring) continue
|
|
1096
|
-
nonRetiringChildren += 1
|
|
1097
1164
|
openInExisting += this.pooledRunnerConcurrency - state.inflight.size
|
|
1098
1165
|
}
|
|
1099
1166
|
|
|
1100
1167
|
for (const queue of this.pooledJobQueues.values()) queuedReservations += queue.length
|
|
1101
1168
|
|
|
1102
|
-
const spawnableChildren = Math.max(0, this.pooledRunnerCount -
|
|
1169
|
+
const spawnableChildren = Math.max(0, this.pooledRunnerCount - this.pooledChildren.size)
|
|
1103
1170
|
|
|
1104
1171
|
return Math.max(0, openInExisting + spawnableChildren * this.pooledRunnerConcurrency - queuedReservations)
|
|
1105
1172
|
}
|
|
@@ -1109,11 +1176,32 @@ export default class BackgroundJobsWorker {
|
|
|
1109
1176
|
* new child when every non-retiring child is full and the pool is below
|
|
1110
1177
|
* `pooledRunnerCount`. Each child runs up to `pooledRunnerConcurrency` jobs at
|
|
1111
1178
|
* once on its own event loop.
|
|
1179
|
+
*
|
|
1180
|
+
* When the pool is already at its hard cap (total live children, draining
|
|
1181
|
+
* included), the job waits for a slot instead of spawning: that is what keeps
|
|
1182
|
+
* the live-child count — and therefore the pool's total RSS — bounded. The
|
|
1183
|
+
* wait resolves on the only two capacity-freeing transitions (a job outcome,
|
|
1184
|
+
* a child exit); a safety poll covers anything missed.
|
|
1112
1185
|
* @param {import("./types.js").BackgroundJobPayload & {id: string}} payload - Job payload.
|
|
1113
1186
|
* @returns {Promise<void>} - Resolves after the durable report.
|
|
1114
1187
|
*/
|
|
1115
|
-
_runPooledJob(payload) {
|
|
1116
|
-
|
|
1188
|
+
async _runPooledJob(payload) {
|
|
1189
|
+
// At the hard cap (no free slot, no spawnable child) the job waits for a
|
|
1190
|
+
// slot instead of spawning an over-capacity child — that is what bounds
|
|
1191
|
+
// the live-child count and the pool's total RSS.
|
|
1192
|
+
let child = this._selectPooledChild()
|
|
1193
|
+
while (!child) {
|
|
1194
|
+
if (this._spawnablePooledChildren() === 0) {
|
|
1195
|
+
// Shutdown: main no longer dispatches and no slot will ever free —
|
|
1196
|
+
// stop waiting so the tracked job can settle and the drain completes.
|
|
1197
|
+
if (this.shouldStop) return
|
|
1198
|
+
await this._waitPooledSlot()
|
|
1199
|
+
child = this._selectPooledChild()
|
|
1200
|
+
continue
|
|
1201
|
+
}
|
|
1202
|
+
child = this._selectPooledChild() || this._createPooledChild()
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1117
1205
|
const state = this.pooledChildStates.get(child)
|
|
1118
1206
|
if (!state) throw new Error("Pooled runner state missing")
|
|
1119
1207
|
|
|
@@ -1232,12 +1320,16 @@ export default class BackgroundJobsWorker {
|
|
|
1232
1320
|
}
|
|
1233
1321
|
|
|
1234
1322
|
/**
|
|
1235
|
-
* Creates a reusable pooled child
|
|
1236
|
-
*
|
|
1323
|
+
* Creates a reusable pooled child, enforcing the hard cap on total live
|
|
1324
|
+
* children (working + draining). Returns undefined when the cap is already
|
|
1325
|
+
* met — the only way a new child may exist is a slot being open, so the
|
|
1326
|
+
* caller re-checks and waits again.
|
|
1327
|
+
* @returns {import("node:child_process").ChildProcess | undefined} - The new child, or undefined when the pool is at its cap.
|
|
1237
1328
|
*/
|
|
1238
1329
|
_createPooledChild() {
|
|
1239
1330
|
const configuration = this.configuration
|
|
1240
1331
|
if (!configuration) throw new Error("Background jobs worker configuration not initialized")
|
|
1332
|
+
if (this.pooledChildren.size >= this.pooledRunnerCount) return undefined
|
|
1241
1333
|
const child = fork(POOLED_RUNNER_ENTRY_PATH, [], {
|
|
1242
1334
|
cwd: configuration.getDirectory(), execArgv: [], stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
1243
1335
|
env: Object.assign({}, process.env, this._childBackgroundJobsEnvironment())
|
|
@@ -1332,6 +1424,9 @@ export default class BackgroundJobsWorker {
|
|
|
1332
1424
|
this._beginRetirePooledChild(child)
|
|
1333
1425
|
}
|
|
1334
1426
|
this._terminateIfDrained(child)
|
|
1427
|
+
// A job outcome frees a concurrency slot and may drain a retiring child —
|
|
1428
|
+
// the only two transitions that free capacity for waiters at the hard cap.
|
|
1429
|
+
this._wakePooledSlotWaiters()
|
|
1335
1430
|
}
|
|
1336
1431
|
|
|
1337
1432
|
/**
|
|
@@ -1362,11 +1457,14 @@ export default class BackgroundJobsWorker {
|
|
|
1362
1457
|
}
|
|
1363
1458
|
|
|
1364
1459
|
/**
|
|
1365
|
-
* Marks a pooled child for retirement and
|
|
1366
|
-
* (1-for-1) so its capacity is
|
|
1367
|
-
*
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1460
|
+
* Marks a pooled child for retirement and — when the pool is below its hard
|
|
1461
|
+
* cap — eagerly spawns a single replacement (1-for-1) so its capacity is
|
|
1462
|
+
* restored immediately without waiting for it to finish draining. The
|
|
1463
|
+
* replacement spawn is gated by the cap (the retiring child still counts as
|
|
1464
|
+
* live until it exits), so a full pool simply defers the replacement to the
|
|
1465
|
+
* retiring child's drain instead of spawning over capacity. The retiring
|
|
1466
|
+
* child stops receiving new jobs and is terminated only once its in-flight
|
|
1467
|
+
* set drains, so a long-running job (e.g. a build) is never cut off.
|
|
1370
1468
|
* @param {import("node:child_process").ChildProcess} child - Child to retire.
|
|
1371
1469
|
* @returns {void}
|
|
1372
1470
|
*/
|
|
@@ -1376,7 +1474,9 @@ export default class BackgroundJobsWorker {
|
|
|
1376
1474
|
|
|
1377
1475
|
state.retiring = true
|
|
1378
1476
|
// Best-effort pre-warm: skip when stopping (no new work) or before the
|
|
1379
|
-
// worker is initialized (no configuration to fork a child from).
|
|
1477
|
+
// worker is initialized (no configuration to fork a child from). The cap
|
|
1478
|
+
// inside _createPooledChild refuses the spawn while the pool is full, in
|
|
1479
|
+
// which case the replacement is deferred to the drain path.
|
|
1380
1480
|
if (!this.shouldStop && this.configuration) this._createPooledChild()
|
|
1381
1481
|
}
|
|
1382
1482
|
|
|
@@ -1394,6 +1494,9 @@ export default class BackgroundJobsWorker {
|
|
|
1394
1494
|
|
|
1395
1495
|
/**
|
|
1396
1496
|
* Retires a drained pooled child (removes it from tracking, then SIGTERMs it).
|
|
1497
|
+
* Because the hard cap counts live children, the exit of this child frees a
|
|
1498
|
+
* slot: any deferred replacement (the pool was full when the child retired)
|
|
1499
|
+
* is spawned now, and capacity is re-advertised so main can dispatch into it.
|
|
1397
1500
|
* @param {import("node:child_process").ChildProcess} child - Child process to retire.
|
|
1398
1501
|
* @returns {void}
|
|
1399
1502
|
*/
|
|
@@ -1503,6 +1606,9 @@ export default class BackgroundJobsWorker {
|
|
|
1503
1606
|
}
|
|
1504
1607
|
this.pooledChildren.delete(child)
|
|
1505
1608
|
this.inflightProcessChildren.delete(child)
|
|
1609
|
+
// Child exit frees a hard-cap slot even while its in-flight set is still
|
|
1610
|
+
// being reported — wake waiters now; their reports settle independently.
|
|
1611
|
+
this._wakePooledSlotWaiters()
|
|
1506
1612
|
|
|
1507
1613
|
const entries = state ? [...state.inflight.values()] : []
|
|
1508
1614
|
const runnerFailure = state
|
|
@@ -61,6 +61,11 @@ export default class VeoliciousHttpServerClient {
|
|
|
61
61
|
|
|
62
62
|
/** @type {Set<(result: "completed" | "aborted") => Promise<void>>} */
|
|
63
63
|
this.pendingFileResponses = new Set()
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Streams that have started but have not finished or been aborted yet.
|
|
67
|
+
* @type {Map<import("./response.js").default, {clientCount: number, request: import("./request.js").default}>} */
|
|
68
|
+
this._activeStreamResponses = new Map()
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
/**
|
|
@@ -181,6 +186,15 @@ export default class VeoliciousHttpServerClient {
|
|
|
181
186
|
|
|
182
187
|
this.requestRunners.push(requestRunner)
|
|
183
188
|
|
|
189
|
+
// A streaming response emits its headers and chunks to the client while
|
|
190
|
+
// the request is still running, so the response needs the owning client
|
|
191
|
+
// as its transport sink before the handler runs. Sub-requests (e.g.
|
|
192
|
+
// websocket request payloads) have no socket and keep transport null;
|
|
193
|
+
// their responses fail loudly if they attempt to stream.
|
|
194
|
+
const socketRequest = currentRequest
|
|
195
|
+
requestRunner.response.transport = this
|
|
196
|
+
requestRunner.response.transportRequest = socketRequest
|
|
197
|
+
|
|
184
198
|
requestRunner.events.on("done", this.requestDone)
|
|
185
199
|
requestRunner.run()
|
|
186
200
|
}
|
|
@@ -423,6 +437,15 @@ export default class VeoliciousHttpServerClient {
|
|
|
423
437
|
*/
|
|
424
438
|
async sendResponse(requestRunner) {
|
|
425
439
|
const response = digg(requestRunner, "response")
|
|
440
|
+
|
|
441
|
+
// A streaming response already emitted its status line, headers, every
|
|
442
|
+
// chunk, and the chunked terminator to the client while the request was
|
|
443
|
+
// running. Nothing left to send here — just log the completed request.
|
|
444
|
+
if (response.isStreaming()) {
|
|
445
|
+
await requestRunner.logCompletedRequest()
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
|
|
426
449
|
const request = requestRunner.getRequest()
|
|
427
450
|
const filePath = response.getFilePath()
|
|
428
451
|
const fileOnFinished = response.getFileOnFinished()
|
|
@@ -666,6 +689,143 @@ export default class VeoliciousHttpServerClient {
|
|
|
666
689
|
await Promise.all([...this.pendingFileResponses].map((settle) => settle("aborted")))
|
|
667
690
|
}
|
|
668
691
|
|
|
692
|
+
/**
|
|
693
|
+
* Sink the owning worker handler wires in for stream output. The
|
|
694
|
+
* in-process handler resolves it after the framed output has been enqueued
|
|
695
|
+
* for delivery to the socket, so stream chunks share the bounded, ordered
|
|
696
|
+
* delivery path (byte/frame limits and socket backpressure) instead of
|
|
697
|
+
* being buffered unboundedly for a stalled client. The worker-thread
|
|
698
|
+
* handler keeps null: its output crosses to the parent over IPC and no
|
|
699
|
+
* per-chunk acknowledgement is available.
|
|
700
|
+
* @type {((output: string) => Promise<void>) | null} */
|
|
701
|
+
streamOutputSink = null
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Narrows the response to the documented streaming transport shape.
|
|
705
|
+
* @param {import("./response.js").default} response - Response to stream.
|
|
706
|
+
* @returns {{streaming: boolean, streamEnded: boolean, streamAborted: boolean, headers: Record<string, string[]>, getStatusCode: () => number, getStatusMessage: () => string, streamCloseCallbacks: Set<() => void>}} - Streaming view of the response.
|
|
707
|
+
*/
|
|
708
|
+
_streamingResponse(response) {
|
|
709
|
+
return response
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Starts a live chunked stream for a socket-bound response: emits the
|
|
714
|
+
* status line and headers (with `Transfer-Encoding: chunked`) to the
|
|
715
|
+
* client immediately so subsequent `write()` chunks reach the client as
|
|
716
|
+
* they are produced.
|
|
717
|
+
* @param {import("./response.js").default} response - Response to stream.
|
|
718
|
+
* @param {import("./request.js").default} request - Socket-bound request.
|
|
719
|
+
* @returns {void} - No return value.
|
|
720
|
+
*/
|
|
721
|
+
beginStreamResponse(response, request) {
|
|
722
|
+
this._activeStreamResponses.set(response, {clientCount: this.clientCount, request})
|
|
723
|
+
|
|
724
|
+
const httpVersion = request.httpVersion()
|
|
725
|
+
|
|
726
|
+
// HTTP/1.0 has no chunked transfer encoding: terminate the stream with a
|
|
727
|
+
// connection close instead of a zero-length chunk.
|
|
728
|
+
if (httpVersion == "1.0" && !this.shouldCloseConnection(request)) {
|
|
729
|
+
response.setHeader("Connection", "Close")
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// The chunked framing owns the body length; a Content-Length header set
|
|
733
|
+
// by the application before stream() would desynchronize the framing.
|
|
734
|
+
response.removeHeader("Content-Length")
|
|
735
|
+
if (response.getHeader("Transfer-Encoding").length === 0) {
|
|
736
|
+
response.setHeader("Transfer-Encoding", "chunked")
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
response.setHeader("Date", new Date().toUTCString())
|
|
740
|
+
response.setHeader("Server", "Velocious")
|
|
741
|
+
|
|
742
|
+
const responseView = this._streamingResponse(response)
|
|
743
|
+
let headers = ""
|
|
744
|
+
headers += `HTTP/${httpVersion} ${responseView.getStatusCode()} ${responseView.getStatusMessage()}\r\n`
|
|
745
|
+
|
|
746
|
+
for (const headerKey in responseView.headers) {
|
|
747
|
+
for (const headerValue of responseView.headers[headerKey]) {
|
|
748
|
+
headers += `${headerKey}: ${headerValue}\r\n`
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
headers += "\r\n"
|
|
753
|
+
this.events.emit("output", headers)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Emits one chunked-encoded body chunk for an active stream.
|
|
758
|
+
* @param {string | Uint8Array} chunk - Chunk to emit.
|
|
759
|
+
* @returns {Promise<void>} - Settles after the chunk has been delivered to
|
|
760
|
+
* the client.
|
|
761
|
+
*/
|
|
762
|
+
async writeStreamChunk(chunk) {
|
|
763
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk)
|
|
764
|
+
const frame = `${bytes.length.toString(16)}\r\n${bytes}\r\n`
|
|
765
|
+
if (this.streamOutputSink === null) {
|
|
766
|
+
this.events.emit("output", frame)
|
|
767
|
+
return
|
|
768
|
+
}
|
|
769
|
+
await this.streamOutputSink(frame)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Emits the zero-length chunked terminator for a stream that finished on a
|
|
774
|
+
* live connection, then runs its close callbacks.
|
|
775
|
+
* @param {import("./response.js").default} response - Response to finish.
|
|
776
|
+
* @returns {Promise<void>} - Settles after the terminator was delivered and
|
|
777
|
+
* the close callbacks ran.
|
|
778
|
+
*/
|
|
779
|
+
async endStreamResponse(response) {
|
|
780
|
+
if (response.streamEnded) return
|
|
781
|
+
response.streamEnded = true
|
|
782
|
+
this._activeStreamResponses.delete(response)
|
|
783
|
+
if (this.streamOutputSink === null) {
|
|
784
|
+
this.events.emit("output", "0\r\n\r\n")
|
|
785
|
+
} else {
|
|
786
|
+
await this.streamOutputSink("0\r\n\r\n")
|
|
787
|
+
}
|
|
788
|
+
await this._runStreamCloseCallbacks(response)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Aborts every stream whose client connection went away: marks the stream
|
|
793
|
+
* aborted and runs the close callbacks so in-flight work can settle its
|
|
794
|
+
* resources. No terminator is emitted — the connection is already gone.
|
|
795
|
+
* @returns {Promise<void>} - Resolves after every stream settled.
|
|
796
|
+
*/
|
|
797
|
+
async abortStreamResponses() {
|
|
798
|
+
for (const response of [...this._activeStreamResponses.keys()]) {
|
|
799
|
+
if (response.streamAborted) continue
|
|
800
|
+
response.streamAborted = true
|
|
801
|
+
this._activeStreamResponses.delete(response)
|
|
802
|
+
await this._runStreamCloseCallbacks(response)
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Runs the close callbacks of a finished or aborted stream exactly once.
|
|
808
|
+
* @param {import("./response.js").default} response - Finished stream.
|
|
809
|
+
* @returns {Promise<void>} - Resolves after every callback ran.
|
|
810
|
+
*/
|
|
811
|
+
async _runStreamCloseCallbacks(response) {
|
|
812
|
+
if (response.streamCloseCallbacks.size === 0) return
|
|
813
|
+
|
|
814
|
+
for (const callback of response.streamCloseCallbacks) {
|
|
815
|
+
try {
|
|
816
|
+
callback()
|
|
817
|
+
} catch (error) {
|
|
818
|
+
const errorPayload = {
|
|
819
|
+
context: {clientCount: this.clientCount, stage: "stream-close-callback"},
|
|
820
|
+
error
|
|
821
|
+
}
|
|
822
|
+
this.configuration.getErrorEvents().emit("framework-error", errorPayload)
|
|
823
|
+
this.configuration.getErrorEvents().emit("all-error", {...errorPayload, errorType: "framework-error"})
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
response.streamCloseCallbacks.clear()
|
|
827
|
+
}
|
|
828
|
+
|
|
669
829
|
/**
|
|
670
830
|
* Runs should close connection.
|
|
671
831
|
* @param {import("./request.js").default | import("./websocket-request.js").default} request - Request object.
|
|
@@ -305,12 +305,20 @@ export default class VelociousHttpServerClientRequestRunner {
|
|
|
305
305
|
})
|
|
306
306
|
|
|
307
307
|
response.setStatus(500)
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
308
|
+
if (response.isStreaming()) {
|
|
309
|
+
// The headers already went to the client, so a plain error body is
|
|
310
|
+
// impossible. The handler owns the stream lifecycle: it aborts the
|
|
311
|
+
// stream (terminator + close callbacks) or, when it still controls
|
|
312
|
+
// the connection, writes an error SSE frame.
|
|
313
|
+
response.abortStream()
|
|
314
|
+
} else {
|
|
315
|
+
try {
|
|
316
|
+
response.setErrorBody(error)
|
|
317
|
+
} catch (responseError) {
|
|
318
|
+
if (!(responseError instanceof HttpResponseBodyTooLargeError)) throw responseError
|
|
312
319
|
|
|
313
|
-
|
|
320
|
+
response.setBody("")
|
|
321
|
+
}
|
|
314
322
|
}
|
|
315
323
|
}
|
|
316
324
|
|
|
@@ -105,6 +105,41 @@ export default class VelociousHttpServerClientResponse {
|
|
|
105
105
|
* @type {boolean} */
|
|
106
106
|
compressionDisabled = false
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Whether this response has been switched to live chunked streaming. Once
|
|
110
|
+
* streaming has started, the status line and headers are emitted to the
|
|
111
|
+
* client immediately and every `write()` is emitted as it happens, instead
|
|
112
|
+
* of the whole body being buffered and sent once after the handler
|
|
113
|
+
* returns.
|
|
114
|
+
* @type {boolean} */
|
|
115
|
+
streaming = false
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Whether the stream has been finished with `end()` (or finalized).
|
|
119
|
+
* @type {boolean} */
|
|
120
|
+
streamEnded = false
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whether the client connection dropped while the stream was in flight.
|
|
124
|
+
* @type {boolean} */
|
|
125
|
+
streamAborted = false
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Transport sink wired in by the owning client so the response can emit
|
|
129
|
+
* headers and chunks to the socket-bound connection.
|
|
130
|
+
* @type {import("./index.js").default | null} */
|
|
131
|
+
transport = null
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The socket-bound request this response belongs to.
|
|
135
|
+
* @type {import("./request.js").default | null} */
|
|
136
|
+
transportRequest = null
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Callbacks fired when the client disconnects mid-stream.
|
|
140
|
+
* @type {Set<() => void>} */
|
|
141
|
+
streamCloseCallbacks = new Set()
|
|
142
|
+
|
|
108
143
|
/**
|
|
109
144
|
* Runs constructor.
|
|
110
145
|
* @param {object} args - Options object.
|
|
@@ -240,6 +275,113 @@ export default class VelociousHttpServerClientResponse {
|
|
|
240
275
|
this.body = value
|
|
241
276
|
}
|
|
242
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Whether this response is (or was) a live chunked stream.
|
|
280
|
+
* @returns {boolean} - Whether streaming has started.
|
|
281
|
+
*/
|
|
282
|
+
isStreaming() {
|
|
283
|
+
return this.streaming
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Whether the client disconnected mid-stream.
|
|
288
|
+
* @returns {boolean} - Whether the stream was aborted by the client.
|
|
289
|
+
*/
|
|
290
|
+
isStreamAborted() {
|
|
291
|
+
return this.streamAborted
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Switches this response to live chunked streaming. The status line and
|
|
296
|
+
* headers are emitted to the client immediately (with a
|
|
297
|
+
* `Transfer-Encoding: chunked` framing header) so `write()` chunks reach
|
|
298
|
+
* the client as they are produced — instead of the whole body being
|
|
299
|
+
* buffered and emitted once after the handler returns.
|
|
300
|
+
*
|
|
301
|
+
* Streaming requires a socket-bound HTTP request and an HTTP version that
|
|
302
|
+
* supports chunked framing, a status that may carry a body, and a
|
|
303
|
+
* non-HEAD request. It cannot be combined with a buffered body, a file
|
|
304
|
+
* response, or a second `stream()` call.
|
|
305
|
+
* @returns {void} - No return value.
|
|
306
|
+
*/
|
|
307
|
+
stream() {
|
|
308
|
+
if (this.streaming) throw new Error("The response is already streaming")
|
|
309
|
+
if (this.filePath !== null) throw new Error("A file response cannot be switched to streaming")
|
|
310
|
+
if (this.body !== null && this.body !== undefined) throw new Error("A buffered body was already set; call stream() before setBody() to stream instead")
|
|
311
|
+
if (this.transport === null) throw new Error("Streaming responses require a socket-bound HTTP request")
|
|
312
|
+
|
|
313
|
+
const request = this.transportRequest
|
|
314
|
+
if (!request) throw new Error("Streaming responses require a socket-bound HTTP request")
|
|
315
|
+
|
|
316
|
+
if (request.httpMethod() === "HEAD") throw new Error("HEAD responses cannot stream a body")
|
|
317
|
+
|
|
318
|
+
const statusCode = this.getStatusCode()
|
|
319
|
+
if ((statusCode >= 100 && statusCode < 200) || statusCode === 204 || statusCode === 304) {
|
|
320
|
+
throw new Error(`Status ${statusCode} cannot carry a streaming body`)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
this.streaming = true
|
|
324
|
+
this.transport.beginStreamResponse(this, request)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Emits one chunk to the client as soon as it is produced. Returns a
|
|
329
|
+
* promise that settles once the chunk has been delivered to the socket, so
|
|
330
|
+
* a relay loop can `await response.write(chunk)` and get socket
|
|
331
|
+
* backpressure without an ad-hoc drain wait. Rejects when the stream has
|
|
332
|
+
* ended or been aborted, or when the outbound delivery queue cannot accept
|
|
333
|
+
* the chunk (a stalled client).
|
|
334
|
+
* @param {string | Uint8Array} value - Chunk to emit.
|
|
335
|
+
* @returns {Promise<void>} - Settles after the chunk is delivered.
|
|
336
|
+
*/
|
|
337
|
+
async write(value) {
|
|
338
|
+
if (this.transport === null) throw new Error("write() requires an active streaming response")
|
|
339
|
+
if (!this.streaming) throw new Error("write() requires stream() to be called first")
|
|
340
|
+
if (this.streamEnded || this.streamAborted) return
|
|
341
|
+
|
|
342
|
+
await this.transport.writeStreamChunk(value)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Finishes an active stream: emits the chunked terminator and releases the
|
|
347
|
+
* response for completion logging. Settles after the terminator has been
|
|
348
|
+
* delivered to the socket.
|
|
349
|
+
* @returns {Promise<void>} - Settles after the stream is finished.
|
|
350
|
+
*/
|
|
351
|
+
async end() {
|
|
352
|
+
if (this.transport === null) throw new Error("end() requires an active streaming response")
|
|
353
|
+
if (!this.streaming) throw new Error("end() requires stream() to be called first")
|
|
354
|
+
if (this.streamEnded) return
|
|
355
|
+
|
|
356
|
+
await this.transport.endStreamResponse(this)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Registers a callback fired when the client disconnects mid-stream, so
|
|
361
|
+
* the handler can release whatever the in-flight work reserved. Fired at
|
|
362
|
+
* most once; also fires when the stream is finalized after the client is
|
|
363
|
+
* already gone.
|
|
364
|
+
* @param {() => void} callback - Disconnect callback.
|
|
365
|
+
* @returns {void} - No return value.
|
|
366
|
+
*/
|
|
367
|
+
onStreamClose(callback) {
|
|
368
|
+
this.streamCloseCallbacks.add(callback)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Terminates an active stream after a framework-level failure (the handler
|
|
373
|
+
* threw after `stream()` started): emits the chunked terminator and runs
|
|
374
|
+
* the close callbacks so in-flight work settles its resources. No-op when
|
|
375
|
+
* the stream already ended or was aborted.
|
|
376
|
+
* @returns {void} - No return value.
|
|
377
|
+
*/
|
|
378
|
+
abortStream() {
|
|
379
|
+
if (!this.streaming || this.streamEnded || this.transport === null) return
|
|
380
|
+
|
|
381
|
+
this.streamEnded = true
|
|
382
|
+
this.transport.endStreamResponse(this)
|
|
383
|
+
}
|
|
384
|
+
|
|
243
385
|
/**
|
|
244
386
|
* Runs get file path.
|
|
245
387
|
* @returns {string | null} - File path.
|
|
@@ -69,6 +69,17 @@ export default class VelociousHttpServerInProcessHandler {
|
|
|
69
69
|
}
|
|
70
70
|
})
|
|
71
71
|
|
|
72
|
+
// Streaming responses emit chunks while the request is running. Route
|
|
73
|
+
// their framed output through the counted delivery path so a stalled
|
|
74
|
+
// client cannot buffer engine chunks unboundedly: each chunk settles only
|
|
75
|
+
// after it has been delivered to the socket, which gives the handler
|
|
76
|
+
// backpressure, and the byte/frame limits bound what a silent client can
|
|
77
|
+
// retain before the connection is torn down.
|
|
78
|
+
httpClient.streamOutputSink = (output) => deliveryQueue.enqueueFrame({
|
|
79
|
+
byteLength: Buffer.byteLength(output),
|
|
80
|
+
delivery: () => serverClient.send(output)
|
|
81
|
+
})
|
|
82
|
+
|
|
72
83
|
httpClient.events.on("output", (output, {websocketFrame = false} = {}) => {
|
|
73
84
|
if (output !== null && output !== undefined) {
|
|
74
85
|
const delivery = () => serverClient.send(output)
|
|
@@ -101,9 +112,13 @@ export default class VelociousHttpServerInProcessHandler {
|
|
|
101
112
|
|
|
102
113
|
serverClient.events.on("close", () => {
|
|
103
114
|
deliveryQueue.destroy()
|
|
104
|
-
const cleanup =
|
|
115
|
+
const cleanup = Promise.all([
|
|
116
|
+
httpClient.abortPendingFileResponses(),
|
|
117
|
+
httpClient.abortStreamResponses()
|
|
118
|
+
])
|
|
119
|
+
.then(() => {})
|
|
105
120
|
.catch((error) => {
|
|
106
|
-
this.logger.warn("Failed to abort
|
|
121
|
+
this.logger.warn("Failed to abort responses after client close", error)
|
|
107
122
|
})
|
|
108
123
|
.finally(() => {
|
|
109
124
|
this.pendingClientCloseCleanups.delete(cleanup)
|
|
@@ -160,6 +175,9 @@ export default class VelociousHttpServerInProcessHandler {
|
|
|
160
175
|
httpClient.abortPendingFileResponses().catch((error) => {
|
|
161
176
|
this.logger.warn("Failed to abort file responses during shutdown", error)
|
|
162
177
|
}),
|
|
178
|
+
httpClient.abortStreamResponses().catch((error) => {
|
|
179
|
+
this.logger.warn("Failed to abort streaming responses during shutdown", error)
|
|
180
|
+
}),
|
|
163
181
|
serverClient.end().catch((error) => {
|
|
164
182
|
this.logger.warn("Failed to close client during shutdown", error)
|
|
165
183
|
})
|
|
@@ -217,7 +217,10 @@ export default class VelociousHttpServerWorkerHandlerWorkerThread {
|
|
|
217
217
|
const settlements = []
|
|
218
218
|
const client = this.clients[clientCount]
|
|
219
219
|
|
|
220
|
-
if (client)
|
|
220
|
+
if (client) {
|
|
221
|
+
settlements.push(client.abortPendingFileResponses())
|
|
222
|
+
settlements.push(client.abortStreamResponses())
|
|
223
|
+
}
|
|
221
224
|
|
|
222
225
|
for (const [transferId, transfer] of this.fileTransfers) {
|
|
223
226
|
if (transfer.clientCount !== clientCount) continue
|