From 78db183018f5e83bdc025e2033440333578a2350 Mon Sep 17 00:00:00 2001 From: Jeremy Bornstein Date: Tue, 13 Jan 2026 05:53:53 +1100 Subject: [PATCH 1/2] Allow Node 25 in runner version check --- scripts/lib/lactoserv/dev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/lib/lactoserv/dev b/scripts/lib/lactoserv/dev index 836f2377b..7ae6b366a 100755 --- a/scripts/lib/lactoserv/dev +++ b/scripts/lib/lactoserv/dev @@ -65,7 +65,7 @@ function target-build { && env-minimize \ && lib node-project build-main-module \ --out="${outDir}" --modules-dirs="${srcDir}" \ - --runner-script=run --runner-versions[]='20 21 22 23 24' \ + --runner-script=run --runner-versions[]='20 21 22 23 24 25' \ lactoserv ) } From 4135caade348a153a9e4c50fd65160258529c0f9 Mon Sep 17 00:00:00 2001 From: Jeremy Bornstein Date: Tue, 13 Jan 2026 07:33:48 +1100 Subject: [PATCH 2/2] Handle backpressure when streaming file responses. Check the return value of res.write() and wait for the 'drain' event before continuing when the socket buffer is full. This prevents unbounded memory accumulation when serving large files to slow clients. --- src/net-util/export/FullResponse.js | 60 ++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/src/net-util/export/FullResponse.js b/src/net-util/export/FullResponse.js index 1cb212708..a722c832b 100644 --- a/src/net-util/export/FullResponse.js +++ b/src/net-util/export/FullResponse.js @@ -775,8 +775,12 @@ export class FullResponse extends BaseResponse { throw new Error(`File changed length during response processing: ${path}`); } - // TODO: Handle `drain` requests (based on return value of `write()`). - res.write(bytesRead === buffer.length ? buffer : buffer.subarray(0, bytesRead)); + const chunk = bytesRead === buffer.length ? buffer : buffer.subarray(0, bytesRead); + const canContinue = res.write(chunk); + + if (!canContinue) { + await FullResponse.#waitForDrain(res); + } at += bytesRead; remaining -= bytesRead; @@ -1030,6 +1034,58 @@ export class FullResponse extends BaseResponse { return buffer; } + /** + * Waits for the 'drain' event on a response, or returns immediately if the + * response is already closed/destroyed. + * + * @param {TypeNodeResponse} res The low-level response object. + * @returns {boolean} `true` if drained successfully, `false` if the response + * was closed/destroyed. + * @throws {Error} Any error reported by the response. + */ + static async #waitForDrain(res) { + if (res.closed || res.destroyed) { + const error = res.errored; + if (error) { + throw (error instanceof Error) ? error : new Error(`Response error: ${error}`); + } + return false; + } + + const resultMp = new ManualPromise(); + + const onDrain = () => { + cleanup(); + resultMp.resolve(true); + }; + + const onError = (error) => { + cleanup(); + if (error) { + resultMp.reject((error instanceof Error) ? error : new Error(`Response error: ${error}`)); + } else { + resultMp.resolve(false); + } + }; + + const onClose = () => { + cleanup(); + resultMp.resolve(false); + }; + + const cleanup = () => { + res.removeListener('drain', onDrain); + res.removeListener('error', onError); + res.removeListener('close', onClose); + }; + + res.once('drain', onDrain); + res.once('error', onError); + res.once('close', onClose); + + return resultMp.promise; + } + /** * Cleans up response headers for logging. *