velocious 1.0.686 → 1.0.688

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.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Serves a static file from `root` directory to the response as a
3
+ * length-determined body (no chunked transfer-encoding).
4
+ *
5
+ * This is the proxy-safe alternative to `response.setFilePath()` /
6
+ * `response.stream()`: the file is read into memory and sent with an
7
+ * explicit `Content-Length` header, which avoids issues with proxies
8
+ * (e.g. Rollbridge) that do not support chunked transfer encoding.
9
+ * @param {import("./response.js").default} response
10
+ * The HTTP response to write to.
11
+ * @param {string} root - Absolute directory path that files must live under.
12
+ * @param {string} name - File name (or relative path) to serve from within `root`.
13
+ * @param {object} [options] - Serving options.
14
+ * @param {string} [options.contentType] - MIME type to set on the response.
15
+ * @param {string} [options.cacheControl] - Value for the `Cache-Control` header.
16
+ * @returns {Promise<boolean>} - `true` if the file was served, `false` if the
17
+ * file was not found or the name resolved outside the root (404 set in both cases).
18
+ */
19
+ export default function serveFile(response: import("./response.js").default, root: string, name: string, options?: {
20
+ contentType?: string;
21
+ cacheControl?: string;
22
+ }): Promise<boolean>;
23
+ //# sourceMappingURL=serve-file.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve-file.d.ts","sourceRoot":"","sources":["../../../../src/http-server/client/serve-file.js"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAA8B,SAAS,CAAC,QAAQ,EAVrC,OAAO,eAAe,EAAE,OAUa,EAAE,IAAI,EAR3C,MAQ2C,EAAE,IAAI,EAPjD,MAOiD,EAAE,OAAO,AANlE,CAGA,EAFA;IAAyB,WAAW,AAApC,CACA,EADQ,MAAM,CACd;IAAyB,YAAY,AAArC,CACA,EADQ,MAAM,CACd;CAGuE,GAH7D,OAAO,CAAC,OAAO,CAAC,CA2C5B"}
@@ -0,0 +1,56 @@
1
+ // @ts-check
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ /**
5
+ * Serves a static file from `root` directory to the response as a
6
+ * length-determined body (no chunked transfer-encoding).
7
+ *
8
+ * This is the proxy-safe alternative to `response.setFilePath()` /
9
+ * `response.stream()`: the file is read into memory and sent with an
10
+ * explicit `Content-Length` header, which avoids issues with proxies
11
+ * (e.g. Rollbridge) that do not support chunked transfer encoding.
12
+ * @param {import("./response.js").default} response
13
+ * The HTTP response to write to.
14
+ * @param {string} root - Absolute directory path that files must live under.
15
+ * @param {string} name - File name (or relative path) to serve from within `root`.
16
+ * @param {object} [options] - Serving options.
17
+ * @param {string} [options.contentType] - MIME type to set on the response.
18
+ * @param {string} [options.cacheControl] - Value for the `Cache-Control` header.
19
+ * @returns {Promise<boolean>} - `true` if the file was served, `false` if the
20
+ * file was not found or the name resolved outside the root (404 set in both cases).
21
+ */
22
+ export default async function serveFile(response, root, name, options = {}) {
23
+ const { contentType, cacheControl } = options;
24
+ // Reject empty or null names.
25
+ if (!name || typeof name !== "string") {
26
+ response.setStatus(404);
27
+ return false;
28
+ }
29
+ // Resolve the target path and verify it stays within root.
30
+ const resolvedRoot = path.resolve(root);
31
+ const targetPath = path.resolve(resolvedRoot, name);
32
+ // Path containment check: the resolved target must be inside root.
33
+ if (targetPath !== resolvedRoot && !targetPath.startsWith(resolvedRoot + path.sep)) {
34
+ response.setStatus(404);
35
+ return false;
36
+ }
37
+ // lstat to reject symlinks and confirm the path is a regular file.
38
+ let stat;
39
+ try {
40
+ stat = await fs.lstat(targetPath);
41
+ }
42
+ catch {
43
+ response.setStatus(404);
44
+ return false;
45
+ }
46
+ if (!stat.isFile() || stat.isSymbolicLink()) {
47
+ response.setStatus(404);
48
+ return false;
49
+ }
50
+ const data = await fs.readFile(targetPath);
51
+ response.setBody(data);
52
+ response.setHeader("Content-Type", contentType || "application/octet-stream");
53
+ response.setHeader("Cache-Control", cacheControl || "no-store");
54
+ return true;
55
+ }
56
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2VydmUtZmlsZS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3NyYy9odHRwLXNlcnZlci9jbGllbnQvc2VydmUtZmlsZS5qcyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxZQUFZO0FBRVosT0FBTyxFQUFFLE1BQU0sa0JBQWtCLENBQUE7QUFDakMsT0FBTyxJQUFJLE1BQU0sV0FBVyxDQUFBO0FBRTVCOzs7Ozs7Ozs7Ozs7Ozs7OztHQWlCRztBQUNILE1BQU0sQ0FBQyxPQUFPLENBQUMsS0FBSyxVQUFVLFNBQVMsQ0FBQyxRQUFRLEVBQUUsSUFBSSxFQUFFLElBQUksRUFBRSxPQUFPLEdBQUcsRUFBRTtJQUN4RSxNQUFNLEVBQUMsV0FBVyxFQUFFLFlBQVksRUFBQyxHQUFHLE9BQU8sQ0FBQTtJQUUzQyw4QkFBOEI7SUFDOUIsSUFBSSxDQUFDLElBQUksSUFBSSxPQUFPLElBQUksS0FBSyxRQUFRLEVBQUUsQ0FBQztRQUN0QyxRQUFRLENBQUMsU0FBUyxDQUFDLEdBQUcsQ0FBQyxDQUFBO1FBQ3ZCLE9BQU8sS0FBSyxDQUFBO0lBQ2QsQ0FBQztJQUVELDJEQUEyRDtJQUMzRCxNQUFNLFlBQVksR0FBRyxJQUFJLENBQUMsT0FBTyxDQUFDLElBQUksQ0FBQyxDQUFBO0lBQ3ZDLE1BQU0sVUFBVSxHQUFHLElBQUksQ0FBQyxPQUFPLENBQUMsWUFBWSxFQUFFLElBQUksQ0FBQyxDQUFBO0lBRW5ELG1FQUFtRTtJQUNuRSxJQUFJLFVBQVUsS0FBSyxZQUFZLElBQUksQ0FBQyxVQUFVLENBQUMsVUFBVSxDQUFDLFlBQVksR0FBRyxJQUFJLENBQUMsR0FBRyxDQUFDLEVBQUUsQ0FBQztRQUNuRixRQUFRLENBQUMsU0FBUyxDQUFDLEdBQUcsQ0FBQyxDQUFBO1FBQ3ZCLE9BQU8sS0FBSyxDQUFBO0lBQ2QsQ0FBQztJQUVELG1FQUFtRTtJQUNuRSxJQUFJLElBQUksQ0FBQTtJQUNSLElBQUksQ0FBQztRQUNILElBQUksR0FBRyxNQUFNLEVBQUUsQ0FBQyxLQUFLLENBQUMsVUFBVSxDQUFDLENBQUE7SUFDbkMsQ0FBQztJQUFDLE1BQU0sQ0FBQztRQUNQLFFBQVEsQ0FBQyxTQUFTLENBQUMsR0FBRyxDQUFDLENBQUE7UUFDdkIsT0FBTyxLQUFLLENBQUE7SUFDZCxDQUFDO0lBRUQsSUFBSSxDQUFDLElBQUksQ0FBQyxNQUFNLEVBQUUsSUFBSSxJQUFJLENBQUMsY0FBYyxFQUFFLEVBQUUsQ0FBQztRQUM1QyxRQUFRLENBQUMsU0FBUyxDQUFDLEdBQUcsQ0FBQyxDQUFBO1FBQ3ZCLE9BQU8sS0FBSyxDQUFBO0lBQ2QsQ0FBQztJQUVELE1BQU0sSUFBSSxHQUFHLE1BQU0sRUFBRSxDQUFDLFFBQVEsQ0FBQyxVQUFVLENBQUMsQ0FBQTtJQUUxQyxRQUFRLENBQUMsT0FBTyxDQUFDLElBQUksQ0FBQyxDQUFBO0lBQ3RCLFFBQVEsQ0FBQyxTQUFTLENBQUMsY0FBYyxFQUFFLFdBQVcsSUFBSSwwQkFBMEIsQ0FBQyxDQUFBO0lBQzdFLFFBQVEsQ0FBQyxTQUFTLENBQUMsZUFBZSxFQUFFLFlBQVksSUFBSSxVQUFVLENBQUMsQ0FBQTtJQUUvRCxPQUFPLElBQUksQ0FBQTtBQUNiLENBQUMiLCJzb3VyY2VzQ29udGVudCI6WyIvLyBAdHMtY2hlY2tcblxuaW1wb3J0IGZzIGZyb20gXCJub2RlOmZzL3Byb21pc2VzXCJcbmltcG9ydCBwYXRoIGZyb20gXCJub2RlOnBhdGhcIlxuXG4vKipcbiAqIFNlcnZlcyBhIHN0YXRpYyBmaWxlIGZyb20gYHJvb3RgIGRpcmVjdG9yeSB0byB0aGUgcmVzcG9uc2UgYXMgYVxuICogbGVuZ3RoLWRldGVybWluZWQgYm9keSAobm8gY2h1bmtlZCB0cmFuc2Zlci1lbmNvZGluZykuXG4gKlxuICogVGhpcyBpcyB0aGUgcHJveHktc2FmZSBhbHRlcm5hdGl2ZSB0byBgcmVzcG9uc2Uuc2V0RmlsZVBhdGgoKWAgL1xuICogYHJlc3BvbnNlLnN0cmVhbSgpYDogdGhlIGZpbGUgaXMgcmVhZCBpbnRvIG1lbW9yeSBhbmQgc2VudCB3aXRoIGFuXG4gKiBleHBsaWNpdCBgQ29udGVudC1MZW5ndGhgIGhlYWRlciwgd2hpY2ggYXZvaWRzIGlzc3VlcyB3aXRoIHByb3hpZXNcbiAqIChlLmcuIFJvbGxicmlkZ2UpIHRoYXQgZG8gbm90IHN1cHBvcnQgY2h1bmtlZCB0cmFuc2ZlciBlbmNvZGluZy5cbiAqIEBwYXJhbSB7aW1wb3J0KFwiLi9yZXNwb25zZS5qc1wiKS5kZWZhdWx0fSByZXNwb25zZVxuICogICBUaGUgSFRUUCByZXNwb25zZSB0byB3cml0ZSB0by5cbiAqIEBwYXJhbSB7c3RyaW5nfSByb290IC0gQWJzb2x1dGUgZGlyZWN0b3J5IHBhdGggdGhhdCBmaWxlcyBtdXN0IGxpdmUgdW5kZXIuXG4gKiBAcGFyYW0ge3N0cmluZ30gbmFtZSAtIEZpbGUgbmFtZSAob3IgcmVsYXRpdmUgcGF0aCkgdG8gc2VydmUgZnJvbSB3aXRoaW4gYHJvb3RgLlxuICogQHBhcmFtIHtvYmplY3R9IFtvcHRpb25zXSAtIFNlcnZpbmcgb3B0aW9ucy5cbiAqIEBwYXJhbSB7c3RyaW5nfSBbb3B0aW9ucy5jb250ZW50VHlwZV0gLSBNSU1FIHR5cGUgdG8gc2V0IG9uIHRoZSByZXNwb25zZS5cbiAqIEBwYXJhbSB7c3RyaW5nfSBbb3B0aW9ucy5jYWNoZUNvbnRyb2xdIC0gVmFsdWUgZm9yIHRoZSBgQ2FjaGUtQ29udHJvbGAgaGVhZGVyLlxuICogQHJldHVybnMge1Byb21pc2U8Ym9vbGVhbj59IC0gYHRydWVgIGlmIHRoZSBmaWxlIHdhcyBzZXJ2ZWQsIGBmYWxzZWAgaWYgdGhlXG4gKiAgIGZpbGUgd2FzIG5vdCBmb3VuZCBvciB0aGUgbmFtZSByZXNvbHZlZCBvdXRzaWRlIHRoZSByb290ICg0MDQgc2V0IGluIGJvdGggY2FzZXMpLlxuICovXG5leHBvcnQgZGVmYXVsdCBhc3luYyBmdW5jdGlvbiBzZXJ2ZUZpbGUocmVzcG9uc2UsIHJvb3QsIG5hbWUsIG9wdGlvbnMgPSB7fSkge1xuICBjb25zdCB7Y29udGVudFR5cGUsIGNhY2hlQ29udHJvbH0gPSBvcHRpb25zXG5cbiAgLy8gUmVqZWN0IGVtcHR5IG9yIG51bGwgbmFtZXMuXG4gIGlmICghbmFtZSB8fCB0eXBlb2YgbmFtZSAhPT0gXCJzdHJpbmdcIikge1xuICAgIHJlc3BvbnNlLnNldFN0YXR1cyg0MDQpXG4gICAgcmV0dXJuIGZhbHNlXG4gIH1cblxuICAvLyBSZXNvbHZlIHRoZSB0YXJnZXQgcGF0aCBhbmQgdmVyaWZ5IGl0IHN0YXlzIHdpdGhpbiByb290LlxuICBjb25zdCByZXNvbHZlZFJvb3QgPSBwYXRoLnJlc29sdmUocm9vdClcbiAgY29uc3QgdGFyZ2V0UGF0aCA9IHBhdGgucmVzb2x2ZShyZXNvbHZlZFJvb3QsIG5hbWUpXG5cbiAgLy8gUGF0aCBjb250YWlubWVudCBjaGVjazogdGhlIHJlc29sdmVkIHRhcmdldCBtdXN0IGJlIGluc2lkZSByb290LlxuICBpZiAodGFyZ2V0UGF0aCAhPT0gcmVzb2x2ZWRSb290ICYmICF0YXJnZXRQYXRoLnN0YXJ0c1dpdGgocmVzb2x2ZWRSb290ICsgcGF0aC5zZXApKSB7XG4gICAgcmVzcG9uc2Uuc2V0U3RhdHVzKDQwNClcbiAgICByZXR1cm4gZmFsc2VcbiAgfVxuXG4gIC8vIGxzdGF0IHRvIHJlamVjdCBzeW1saW5rcyBhbmQgY29uZmlybSB0aGUgcGF0aCBpcyBhIHJlZ3VsYXIgZmlsZS5cbiAgbGV0IHN0YXRcbiAgdHJ5IHtcbiAgICBzdGF0ID0gYXdhaXQgZnMubHN0YXQodGFyZ2V0UGF0aClcbiAgfSBjYXRjaCB7XG4gICAgcmVzcG9uc2Uuc2V0U3RhdHVzKDQwNClcbiAgICByZXR1cm4gZmFsc2VcbiAgfVxuXG4gIGlmICghc3RhdC5pc0ZpbGUoKSB8fCBzdGF0LmlzU3ltYm9saWNMaW5rKCkpIHtcbiAgICByZXNwb25zZS5zZXRTdGF0dXMoNDA0KVxuICAgIHJldHVybiBmYWxzZVxuICB9XG5cbiAgY29uc3QgZGF0YSA9IGF3YWl0IGZzLnJlYWRGaWxlKHRhcmdldFBhdGgpXG5cbiAgcmVzcG9uc2Uuc2V0Qm9keShkYXRhKVxuICByZXNwb25zZS5zZXRIZWFkZXIoXCJDb250ZW50LVR5cGVcIiwgY29udGVudFR5cGUgfHwgXCJhcHBsaWNhdGlvbi9vY3RldC1zdHJlYW1cIilcbiAgcmVzcG9uc2Uuc2V0SGVhZGVyKFwiQ2FjaGUtQ29udHJvbFwiLCBjYWNoZUNvbnRyb2wgfHwgXCJuby1zdG9yZVwiKVxuXG4gIHJldHVybiB0cnVlXG59XG4iXX0=
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "velocious": "build/bin/velocious.js"
4
4
  },
5
5
  "name": "velocious",
6
- "version": "1.0.686",
6
+ "version": "1.0.688",
7
7
  "main": "build/index.js",
8
8
  "types": "build/index.d.ts",
9
9
  "files": [
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
 
3
+ import timeout, { TimeoutError } from "awaitery/build/timeout.js"
3
4
  import { randomUUID } from "crypto"
4
5
  import net from "net"
5
6
  import JsonSocket from "./json-socket.js"
@@ -146,6 +147,7 @@ export default class BackgroundJobsMain {
146
147
  this.port = typeof port === "number" ? port : config.port
147
148
  this.dispatchStrategy = config.dispatchStrategy
148
149
  this.pollIntervalMs = config.pollIntervalMs
150
+ this.drainStoreOperationTimeoutMs = config.drainStoreOperationTimeoutMs
149
151
  this.retention = config.retention
150
152
  // A worker that stops sending anything (heartbeat/ready/report) for this
151
153
  // long is treated as wedged/dead: its leases are released and it is dropped.
@@ -2064,10 +2066,49 @@ export default class BackgroundJobsMain {
2064
2066
  return false
2065
2067
  } catch (error) {
2066
2068
  this.logger.error(() => ["Background jobs drain failed:", error])
2069
+ if (error instanceof TimeoutError) this._reportDrainStallError(error)
2067
2070
  return true
2068
2071
  }
2069
2072
  }
2070
2073
 
2074
+ /**
2075
+ * Bounds one drain-critical store operation so a store call that never
2076
+ * settles rejects into the drain error/retry path instead of stalling the
2077
+ * coalesced drain and every later dispatch queued behind it.
2078
+ * @template T
2079
+ * @param {string} operation - Operation label for the timeout error.
2080
+ * @param {() => Promise<T>} callback - Drain store operation.
2081
+ * @returns {Promise<T>} - Operation result.
2082
+ */
2083
+ async _boundedDrainStoreOperation(operation, callback) {
2084
+ return await timeout({
2085
+ errorMessage: `Background jobs drain store operation timed out after ${this.drainStoreOperationTimeoutMs}ms: ${operation}`,
2086
+ timeout: this.drainStoreOperationTimeoutMs
2087
+ }, callback)
2088
+ }
2089
+
2090
+ /**
2091
+ * Surfaces a drain pass whose store operation never settled. The bounded
2092
+ * store timeout keeps dispatch moving through the error-retry path; this
2093
+ * report makes the stall observable for process-level bug reporters.
2094
+ * @param {Error} error - Drain stall failure.
2095
+ * @returns {void}
2096
+ */
2097
+ _reportDrainStallError(error) {
2098
+ const payload = {
2099
+ context: {
2100
+ stage: "background-jobs-drain-stall",
2101
+ drainStoreOperationTimeoutMs: this.drainStoreOperationTimeoutMs,
2102
+ dispatchStrategy: this.dispatchStrategy
2103
+ },
2104
+ error
2105
+ }
2106
+ const errorEvents = this.configuration.getErrorEvents()
2107
+
2108
+ errorEvents.emit("framework-error", payload)
2109
+ errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
2110
+ }
2111
+
2071
2112
  /**
2072
2113
  * Arms a one-shot `setTimeout` to retry `_drain` after a transient
2073
2114
  * failure. Idempotent — repeated calls while a retry is already
@@ -2127,7 +2168,7 @@ export default class BackgroundJobsMain {
2127
2168
  */
2128
2169
  async _drainOnce() {
2129
2170
  while (this.readyWorkers.size > 0 && !this._stopped && this.lifecycleState === "active" && this._activeOwnershipReady) {
2130
- const job = await this.nextAvailableJobForReadyWorkers()
2171
+ const job = await this._boundedDrainStoreOperation("next-available-job", async () => await this.nextAvailableJobForReadyWorkers())
2131
2172
  if (!job) return
2132
2173
 
2133
2174
  const worker = this.readyWorkerForJob(job)
@@ -2138,13 +2179,13 @@ export default class BackgroundJobsMain {
2138
2179
  let handoff
2139
2180
 
2140
2181
  try {
2141
- handoff = await this.store.markHandedOff({handoffId: requestedHandoffId, jobId: job.id, workerId: worker.workerId})
2182
+ handoff = await this._boundedDrainStoreOperation("mark-handed-off", async () => await this.store.markHandedOff({handoffId: requestedHandoffId, jobId: job.id, workerId: worker.workerId}))
2142
2183
  } catch (error) {
2143
2184
  this._rememberHandoffRecovery({handoffId: requestedHandoffId, jobId: job.id})
2144
2185
  this._restoreWorkerAdmission({...admission, worker})
2145
2186
 
2146
2187
  try {
2147
- await this._recoverHandoff({handoffId: requestedHandoffId, jobId: job.id})
2188
+ await this._boundedDrainStoreOperation("recover-handoff", async () => await this._recoverHandoff({handoffId: requestedHandoffId, jobId: job.id}))
2148
2189
  } catch (recoveryError) {
2149
2190
  this._reportHandoffRecoveryError({error: recoveryError, handoffId: requestedHandoffId, jobId: job.id})
2150
2191
  }
@@ -2164,7 +2205,7 @@ export default class BackgroundJobsMain {
2164
2205
  if (!handoffs || !this.workers.has(worker) || worker.isDraining || this.lifecycleState !== "active" || !this._activeOwnershipReady) {
2165
2206
  this._rememberHandoffRecovery({handoffId: handoff.handoffId, jobId: job.id})
2166
2207
  try {
2167
- await this._recoverHandoff({handoffId: handoff.handoffId, jobId: job.id})
2208
+ await this._boundedDrainStoreOperation("recover-handoff", async () => await this._recoverHandoff({handoffId: handoff.handoffId, jobId: job.id}))
2168
2209
  } catch (recoveryError) {
2169
2210
  this._reportHandoffRecoveryError({error: recoveryError, handoffId: handoff.handoffId, jobId: job.id})
2170
2211
  throw recoveryError
@@ -1147,6 +1147,24 @@ export default class BackgroundJobsWorker {
1147
1147
  return Math.max(0, this.pooledRunnerCount - this.pooledChildren.size)
1148
1148
  }
1149
1149
 
1150
+ /**
1151
+ * Counts live children still able to run new work. The pool must always
1152
+ * keep — or restore, through the bounded over-cap liveness replacement — at
1153
+ * least one non-retiring child: with every live child retiring, advertised
1154
+ * capacity is zero for the entire drain window, which can stretch to the
1155
+ * longest in-flight job, and dispatch silently stalls.
1156
+ * @returns {number} - Number of non-retiring pooled children.
1157
+ */
1158
+ _nonRetiringPooledChildCount() {
1159
+ let count = 0
1160
+
1161
+ for (const state of this.pooledChildStates.values()) {
1162
+ if (!state.retiring) count += 1
1163
+ }
1164
+
1165
+ return count
1166
+ }
1167
+
1150
1168
  /**
1151
1169
  * Resolves once a non-retiring pooled child has a free concurrency slot or the
1152
1170
  * pool may spawn a new one. Pooled jobs admitted while the pool is at its
@@ -1358,13 +1376,18 @@ export default class BackgroundJobsWorker {
1358
1376
  * Creates a reusable pooled child, enforcing the hard cap on total live
1359
1377
  * children (working + draining). Returns undefined when the cap is already
1360
1378
  * met — the only way a new child may exist is a slot being open, so the
1361
- * caller re-checks and waits again.
1379
+ * caller re-checks and waits again. `allowOverCap` is reserved for the
1380
+ * bounded liveness replacement: at most one child beyond the cap may exist,
1381
+ * and only when every other live child is already retiring (see
1382
+ * `_beginRetirePooledChild` for the gate).
1383
+ * @param {object} [args] - Spawn options.
1384
+ * @param {boolean} [args.allowOverCap] - Allow one over-cap spawn for the liveness replacement.
1362
1385
  * @returns {import("node:child_process").ChildProcess | undefined} - The new child, or undefined when the pool is at its cap.
1363
1386
  */
1364
- _createPooledChild() {
1387
+ _createPooledChild({allowOverCap = false} = {}) {
1365
1388
  const configuration = this.configuration
1366
1389
  if (!configuration) throw new Error("Background jobs worker configuration not initialized")
1367
- if (this.pooledChildren.size >= this.pooledRunnerCount) return undefined
1390
+ if (!allowOverCap && this.pooledChildren.size >= this.pooledRunnerCount) return undefined
1368
1391
  const child = fork(POOLED_RUNNER_ENTRY_PATH, [], {
1369
1392
  cwd: configuration.getDirectory(), execArgv: [], stdio: ["ignore", "ignore", "ignore", "ipc"],
1370
1393
  env: Object.assign({}, process.env, this._childBackgroundJobsEnvironment())
@@ -1577,10 +1600,19 @@ export default class BackgroundJobsWorker {
1577
1600
  * cap — eagerly spawns a single replacement (1-for-1) so its capacity is
1578
1601
  * restored immediately without waiting for it to finish draining. The
1579
1602
  * replacement spawn is gated by the cap (the retiring child still counts as
1580
- * live until it exits), so a full pool simply defers the replacement to the
1581
- * retiring child's drain instead of spawning over capacity. The retiring
1582
- * child stops receiving new jobs and is terminated only once its in-flight
1583
- * set drains, so a long-running job (e.g. a build) is never cut off.
1603
+ * live until it exits), so a full pool defers the replacement to the drain
1604
+ * path. One bounded exception keeps the pool from ever advertising zero
1605
+ * capacity: when this mark leaves no non-retiring child, a single liveness
1606
+ * replacement spawns over the cap — live children stay bounded at
1607
+ * `pooledRunnerCount + 1`, and the extra child is absorbed by the next
1608
+ * draining exit. Without it, a full retirement wave (e.g. every child
1609
+ * crossing the peak-RSS threshold after the same memory burst) silently
1610
+ * removes all pooled capacity for the whole drain window, which can stretch
1611
+ * to the longest in-flight job. When even the liveness spawn is unavailable
1612
+ * (an over-cap child is already draining), the starvation is logged so it is
1613
+ * visible instead of silent. The retiring child stops receiving new jobs and
1614
+ * is terminated only once its in-flight set drains, so a long-running job
1615
+ * (e.g. a build) is never cut off.
1584
1616
  * @param {import("node:child_process").ChildProcess} child - Child to retire.
1585
1617
  * @returns {void}
1586
1618
  */
@@ -1592,8 +1624,55 @@ export default class BackgroundJobsWorker {
1592
1624
  // Best-effort pre-warm: skip when stopping (no new work) or before the
1593
1625
  // worker is initialized (no configuration to fork a child from). The cap
1594
1626
  // inside _createPooledChild refuses the spawn while the pool is full, in
1595
- // which case the replacement is deferred to the drain path.
1596
- if (!this.shouldStop && this.configuration) this._createPooledChild()
1627
+ // which case the bounded liveness replacement below takes over.
1628
+ if (this.shouldStop || !this.configuration) return
1629
+
1630
+ if (this._createPooledChild()) return
1631
+ if (this._nonRetiringPooledChildCount() > 0) return
1632
+
1633
+ // Liveness replacement: this mark emptied the working pool, so advertised
1634
+ // capacity would be zero until a draining child exits. Spawn one
1635
+ // replacement over the hard cap (bounded: the gate admits it only when the
1636
+ // live count is at most the cap, so at most one over-cap child exists and
1637
+ // the next draining exit absorbs it).
1638
+ if (this.pooledChildren.size <= this.pooledRunnerCount) {
1639
+ const livenessChild = this._createPooledChild({allowOverCap: true})
1640
+
1641
+ if (livenessChild) {
1642
+ this._logPooledCapacityEvent("pooled-capacity-liveness-spawn")
1643
+ return
1644
+ }
1645
+ }
1646
+
1647
+ // An over-cap child is already draining: the pool waits for the next
1648
+ // draining exit, which frees a cap slot for a lazy replacement. Log it so
1649
+ // this bounded starvation is not silent.
1650
+ this._logPooledCapacityEvent("pooled-capacity-starved")
1651
+ }
1652
+
1653
+ /**
1654
+ * Logs one compact pooled-capacity event to the worker's own stderr (the
1655
+ * surface that reaches the production log). Used by the bounded liveness
1656
+ * replacement in `_beginRetirePooledChild`: the spawn that keeps dispatch
1657
+ * alive through a full retirement wave, or the bounded starvation when an
1658
+ * over-cap child is already draining and the pool must wait for an exit.
1659
+ * @param {"pooled-capacity-liveness-spawn" | "pooled-capacity-starved"} event - Event name.
1660
+ * @returns {void}
1661
+ */
1662
+ _logPooledCapacityEvent(event) {
1663
+ let retiringChildren = 0
1664
+
1665
+ for (const state of this.pooledChildStates.values()) {
1666
+ if (state.retiring) retiringChildren += 1
1667
+ }
1668
+
1669
+ console.error(JSON.stringify({
1670
+ event,
1671
+ liveChildren: this.pooledChildren.size,
1672
+ retiringChildren,
1673
+ cap: this.pooledRunnerCount,
1674
+ concurrency: this.pooledRunnerConcurrency
1675
+ }))
1597
1676
  }
1598
1677
 
1599
1678
  /**
@@ -224,6 +224,14 @@
224
224
  * to restore the legacy fixed-interval poll.
225
225
  * @property {number} [pollIntervalMs] - Poll interval in milliseconds. Only used
226
226
  * when `dispatchStrategy === "polling"`. Default: `1000`.
227
+ * @property {number} [drainStoreOperationTimeoutMs] - Bounds one drain-critical
228
+ * store operation in the main process: the queued-job lookup, the durable
229
+ * handoff claim, and handoff-recovery transitions. A store call that never
230
+ * settles (wedged DB connection, lost acknowledgement) would otherwise freeze
231
+ * the coalesced drain and every later dispatch queued behind it until the
232
+ * process is killed; on timeout the drain fails into its existing
233
+ * error/retry path and the stall is reported as a framework error.
234
+ * Default: `60000`.
227
235
  * @property {number | null} [jobTimeoutMs] - Wall-clock backstop, in ms, for a
228
236
  * `"forked"` job runner. A forked job still running after this is terminated
229
237
  * (SIGTERM, then SIGKILL after the reaping grace) and reported failed, so a
@@ -1687,6 +1687,9 @@ export default class VelociousConfiguration {
1687
1687
  const pollIntervalMs = typeof configured.pollIntervalMs === "number" && configured.pollIntervalMs >= 1
1688
1688
  ? configured.pollIntervalMs
1689
1689
  : (typeof envPollInterval === "number" && Number.isFinite(envPollInterval) && envPollInterval >= 1 ? envPollInterval : 1000)
1690
+ const drainStoreOperationTimeoutMs = typeof configured.drainStoreOperationTimeoutMs === "number" && configured.drainStoreOperationTimeoutMs >= 1
1691
+ ? configured.drainStoreOperationTimeoutMs
1692
+ : 60_000
1690
1693
  const queues = configured.queues && typeof configured.queues === "object" ? configured.queues : {}
1691
1694
  // An explicit config value wins over the env var — including `null`/`0`,
1692
1695
  // which disable the backstop even when the environment sets a default.
@@ -1712,7 +1715,7 @@ export default class VelociousConfiguration {
1712
1715
 
1713
1716
  const jobClasses = this.getBackgroundJobClasses()
1714
1717
 
1715
- return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, mode, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, dispatchStrategy, pollIntervalMs, queues, jobClasses, jobTimeoutMs, retention, generationId, initialGenerationState, lifecycleSocketPath}
1718
+ return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, mode, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, dispatchStrategy, pollIntervalMs, drainStoreOperationTimeoutMs, queues, jobClasses, jobTimeoutMs, retention, generationId, initialGenerationState, lifecycleSocketPath}
1716
1719
  }
1717
1720
 
1718
1721
  /**
@@ -0,0 +1,64 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs/promises"
4
+ import path from "node:path"
5
+
6
+ /**
7
+ * Serves a static file from `root` directory to the response as a
8
+ * length-determined body (no chunked transfer-encoding).
9
+ *
10
+ * This is the proxy-safe alternative to `response.setFilePath()` /
11
+ * `response.stream()`: the file is read into memory and sent with an
12
+ * explicit `Content-Length` header, which avoids issues with proxies
13
+ * (e.g. Rollbridge) that do not support chunked transfer encoding.
14
+ * @param {import("./response.js").default} response
15
+ * The HTTP response to write to.
16
+ * @param {string} root - Absolute directory path that files must live under.
17
+ * @param {string} name - File name (or relative path) to serve from within `root`.
18
+ * @param {object} [options] - Serving options.
19
+ * @param {string} [options.contentType] - MIME type to set on the response.
20
+ * @param {string} [options.cacheControl] - Value for the `Cache-Control` header.
21
+ * @returns {Promise<boolean>} - `true` if the file was served, `false` if the
22
+ * file was not found or the name resolved outside the root (404 set in both cases).
23
+ */
24
+ export default async function serveFile(response, root, name, options = {}) {
25
+ const {contentType, cacheControl} = options
26
+
27
+ // Reject empty or null names.
28
+ if (!name || typeof name !== "string") {
29
+ response.setStatus(404)
30
+ return false
31
+ }
32
+
33
+ // Resolve the target path and verify it stays within root.
34
+ const resolvedRoot = path.resolve(root)
35
+ const targetPath = path.resolve(resolvedRoot, name)
36
+
37
+ // Path containment check: the resolved target must be inside root.
38
+ if (targetPath !== resolvedRoot && !targetPath.startsWith(resolvedRoot + path.sep)) {
39
+ response.setStatus(404)
40
+ return false
41
+ }
42
+
43
+ // lstat to reject symlinks and confirm the path is a regular file.
44
+ let stat
45
+ try {
46
+ stat = await fs.lstat(targetPath)
47
+ } catch {
48
+ response.setStatus(404)
49
+ return false
50
+ }
51
+
52
+ if (!stat.isFile() || stat.isSymbolicLink()) {
53
+ response.setStatus(404)
54
+ return false
55
+ }
56
+
57
+ const data = await fs.readFile(targetPath)
58
+
59
+ response.setBody(data)
60
+ response.setHeader("Content-Type", contentType || "application/octet-stream")
61
+ response.setHeader("Cache-Control", cacheControl || "no-store")
62
+
63
+ return true
64
+ }