From 66b0c9eeb461a0fefcaafa76e6576506de18aaa7 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 14 Sep 2026 12:46:48 -0700 Subject: [PATCH 1/2] fix(server): end range responses when the file stream errors A Range request used createReadStream without an error handler. If the file became unreadable after stat, the unhandled error could crash the process. End the HTTP response instead. Signed-off-by: Sebastien Tardif --- packages/utils/httpServer.ts | 4 ++ tests/library/http-server.spec.ts | 76 +++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 tests/library/http-server.spec.ts diff --git a/packages/utils/httpServer.ts b/packages/utils/httpServer.ts index e85b30d4af686..60aaec679ccea 100644 --- a/packages/utils/httpServer.ts +++ b/packages/utils/httpServer.ts @@ -263,6 +263,10 @@ export class HttpServer { }); const readable = fs.createReadStream(absoluteFilePath, { start, end }); + readable.on('error', () => { + if (!response.writableEnded) + response.end(); + }); readable.pipe(response); } diff --git a/tests/library/http-server.spec.ts b/tests/library/http-server.spec.ts new file mode 100644 index 0000000000000..1007848f537ef --- /dev/null +++ b/tests/library/http-server.spec.ts @@ -0,0 +1,76 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import http from 'http'; +import os from 'os'; +import path from 'path'; +import { test, expect } from '@playwright/test'; +import { HttpServer } from '../../packages/utils/httpServer'; + +async function startServer(dir: string) { + const server = new HttpServer(dir); + server.routePrefix('/', (request, response) => { + const url = new URL(request.url!, 'http://localhost'); + const filePath = path.join(dir, path.basename(url.pathname)); + return server.serveFile(request, response, filePath); + }); + await server.start({ port: 0, host: '127.0.0.1' }); + return server; +} + +test('range request returns partial content', async ({}, testInfo) => { + const dir = testInfo.outputPath(); + const file = path.join(dir, 'data.bin'); + fs.writeFileSync(file, Buffer.from('abcdefghij')); + const server = await startServer(dir); + try { + const url = server.urlPrefix('precise') + '/data.bin'; + const res = await fetch(url, { headers: { Range: 'bytes=2-5' } }); + expect(res.status).toBe(206); + expect(await res.text()).toBe('cdef'); + } finally { + await server.stop(); + } +}); + +test('unreadable file range request does not crash the server', async ({}, testInfo) => { + test.skip(process.platform === 'win32', 'chmod is not a reliable ACL on Windows'); + test.skip(os.userInfo().uid === 0, 'root can read chmod 0 files'); + const dir = testInfo.outputPath(); + const file = path.join(dir, 'data.bin'); + fs.writeFileSync(file, Buffer.alloc(1024, 1)); + fs.chmodSync(file, 0); + const server = await startServer(dir); + try { + const url = server.urlPrefix('precise') + '/data.bin'; + const status = await new Promise((resolve, reject) => { + const req = http.get(url, { headers: { Range: 'bytes=0-10' } }, res => { + res.resume(); + res.on('end', () => resolve(res.statusCode || 0)); + }); + req.on('error', reject); + req.setTimeout(5000, () => { + req.destroy(); + reject(new Error('range request hung')); + }); + }); + expect(status).toBeGreaterThan(0); + } finally { + fs.chmodSync(file, 0o644); + await server.stop(); + } +}); From a0646d6016fb905f6fb4234d905a32ddbd8e4977 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 14 Sep 2026 12:57:29 -0700 Subject: [PATCH 2/2] fix(server): send 500 if a range stream fails before headers Do not write 206 until the file is open. An early read error now ends with 500 instead of a hung client. Signed-off-by: Sebastien Tardif --- packages/utils/httpServer.ts | 29 +++++++++++++++++------------ tests/library/http-server.spec.ts | 2 +- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/utils/httpServer.ts b/packages/utils/httpServer.ts index 60aaec679ccea..2af198eb2cfcd 100644 --- a/packages/utils/httpServer.ts +++ b/packages/utils/httpServer.ts @@ -254,20 +254,25 @@ export class HttpServer { return response.end(); } - // Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1 - response.writeHead(206, { - 'Content-Range': `bytes ${start}-${end}/${size}`, - 'Accept-Ranges': 'bytes', - 'Content-Length': end - start + 1, - 'Content-Type': mime.getType(path.extname(absoluteFilePath))!, - }); - const readable = fs.createReadStream(absoluteFilePath, { start, end }); - readable.on('error', () => { - if (!response.writableEnded) - response.end(); + readable.on('error', error => { + if (response.headersSent) { + response.destroy(error); + return; + } + response.writeHead(500); + response.end(); + }); + readable.on('open', () => { + // Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1 + response.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': end - start + 1, + 'Content-Type': mime.getType(path.extname(absoluteFilePath))!, + }); + readable.pipe(response); }); - readable.pipe(response); } private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) { diff --git a/tests/library/http-server.spec.ts b/tests/library/http-server.spec.ts index 1007848f537ef..70945bf46135d 100644 --- a/tests/library/http-server.spec.ts +++ b/tests/library/http-server.spec.ts @@ -68,7 +68,7 @@ test('unreadable file range request does not crash the server', async ({}, testI reject(new Error('range request hung')); }); }); - expect(status).toBeGreaterThan(0); + expect(status).toBe(500); } finally { fs.chmodSync(file, 0o644); await server.stop();