mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
Update tests to use HTTPS to properly exercise TLS handshake race condition
- Changed all servers to use TLS via Bun.serve({ tls })
- Changed clients to use node:https instead of node:http
- Added rejectUnauthorized: false for self-signed certs
- Added strictSSL: false for request-promise
This properly exercises the original issue which only occurs during
new HTTPS connections due to the TLS handshake timing gap.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { bunEnv, bunExe, tempDir } from "harness";
|
||||
import { bunEnv, bunExe, tempDir, tls } from "harness";
|
||||
|
||||
// Test for GitHub issue #26638
|
||||
// First multipart upload over HTTPS corrupts the body when using request-promise + fs.createReadStream()
|
||||
@@ -8,12 +8,13 @@ import { bunEnv, bunExe, tempDir } from "harness";
|
||||
describe("issue #26638", () => {
|
||||
test("node:https streaming body yields all chunks even when end() is called quickly", async () => {
|
||||
// This test simulates the race condition where:
|
||||
// 1. Multiple chunks are written quickly to the ClientRequest
|
||||
// 1. Multiple chunks are written quickly to the ClientRequest over HTTPS
|
||||
// 2. The request is ended before all chunks have been yielded by the async generator
|
||||
// The fix ensures that all buffered chunks are yielded after the finished flag is set.
|
||||
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
tls,
|
||||
async fetch(req) {
|
||||
const text = await req.text();
|
||||
return new Response(
|
||||
@@ -28,7 +29,7 @@ describe("issue #26638", () => {
|
||||
|
||||
const dashes = Buffer.alloc(100, "-").toString();
|
||||
const clientScript = `
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
const chunks = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
@@ -36,12 +37,13 @@ for (let i = 0; i < 100; i++) {
|
||||
}
|
||||
const expectedContent = chunks.join('');
|
||||
|
||||
const req = http.request('http://localhost:${server.port}/', {
|
||||
const req = https.request('https://localhost:${server.port}/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
},
|
||||
rejectUnauthorized: false,
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
@@ -95,71 +97,76 @@ req.end();
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
// This test requires a longer timeout because it installs npm packages (request, request-promise)
|
||||
test("request-promise with form-data and fs.createReadStream works correctly", { timeout: 60_000 }, async () => {
|
||||
// This test specifically reproduces the original issue:
|
||||
// Using request-promise with form-data piping an fs.createReadStream
|
||||
// This test installs npm packages which may take longer than the default timeout
|
||||
test(
|
||||
"request-promise with form-data and fs.createReadStream works correctly over HTTPS",
|
||||
{ timeout: 60_000 },
|
||||
async () => {
|
||||
// This test specifically reproduces the original issue:
|
||||
// Using request-promise with form-data piping an fs.createReadStream over HTTPS
|
||||
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("sourceFile");
|
||||
if (!(file instanceof Blob)) {
|
||||
return new Response(JSON.stringify({ success: false, error: "No file found" }), {
|
||||
status: 400,
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
tls,
|
||||
async fetch(req) {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("sourceFile");
|
||||
if (!(file instanceof Blob)) {
|
||||
return new Response(JSON.stringify({ success: false, error: "No file found" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
const content = await file.arrayBuffer();
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
bytesReceived: file.size,
|
||||
// Verify content is correct (should be all 'A's)
|
||||
contentValid: new Uint8Array(content).every(b => b === 65), // 65 is 'A'
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
let errorMessage: string;
|
||||
if (e instanceof Error) {
|
||||
errorMessage = e.message;
|
||||
} else if (typeof e === "object" && e !== null && "message" in e) {
|
||||
errorMessage = String((e as { message: unknown }).message);
|
||||
} else {
|
||||
errorMessage = String(e);
|
||||
}
|
||||
return new Response(JSON.stringify({ success: false, error: errorMessage }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
const content = await file.arrayBuffer();
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
bytesReceived: file.size,
|
||||
// Verify content is correct (should be all 'A's)
|
||||
contentValid: new Uint8Array(content).every(b => b === 65), // 65 is 'A'
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
let errorMessage: string;
|
||||
if (e instanceof Error) {
|
||||
errorMessage = e.message;
|
||||
} else if (typeof e === "object" && e !== null && "message" in e) {
|
||||
errorMessage = String((e as { message: unknown }).message);
|
||||
} else {
|
||||
errorMessage = String(e);
|
||||
}
|
||||
return new Response(JSON.stringify({ success: false, error: errorMessage }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
using dir = tempDir("test-26638-form", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "test-26638",
|
||||
dependencies: {
|
||||
request: "^2.88.2",
|
||||
"request-promise": "^4.2.6",
|
||||
},
|
||||
}),
|
||||
// Create a test file with known content (100KB)
|
||||
"testfile.txt": Buffer.alloc(1024 * 100, "A").toString(),
|
||||
"client.js": `
|
||||
});
|
||||
|
||||
using dir = tempDir("test-26638-form", {
|
||||
"package.json": JSON.stringify({
|
||||
name: "test-26638",
|
||||
dependencies: {
|
||||
request: "^2.88.2",
|
||||
"request-promise": "^4.2.6",
|
||||
},
|
||||
}),
|
||||
// Create a test file with known content (100KB)
|
||||
"testfile.txt": Buffer.alloc(1024 * 100, "A").toString(),
|
||||
"client.js": `
|
||||
const fs = require('fs');
|
||||
const request = require('request-promise');
|
||||
|
||||
async function upload() {
|
||||
try {
|
||||
const result = await request.post('http://localhost:${server.port}/', {
|
||||
const result = await request.post('https://localhost:${server.port}/', {
|
||||
formData: {
|
||||
sourceFile: fs.createReadStream('./testfile.txt'),
|
||||
},
|
||||
json: true,
|
||||
strictSSL: false,
|
||||
});
|
||||
console.log(JSON.stringify(result));
|
||||
} catch (e) {
|
||||
@@ -170,57 +177,59 @@ async function upload() {
|
||||
|
||||
upload();
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
// Install dependencies
|
||||
await using installProc = Bun.spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const [installStdout, installStderr, installExitCode] = await Promise.all([
|
||||
installProc.stdout.text(),
|
||||
installProc.stderr.text(),
|
||||
installProc.exited,
|
||||
]);
|
||||
if (installExitCode !== 0) {
|
||||
console.error("Install stdout:", installStdout);
|
||||
console.error("Install stderr:", installStderr);
|
||||
}
|
||||
expect(installExitCode).toBe(0);
|
||||
// Install dependencies
|
||||
await using installProc = Bun.spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const [installStdout, installStderr, installExitCode] = await Promise.all([
|
||||
installProc.stdout.text(),
|
||||
installProc.stderr.text(),
|
||||
installProc.exited,
|
||||
]);
|
||||
if (installExitCode !== 0) {
|
||||
console.error("Install stdout:", installStdout);
|
||||
console.error("Install stderr:", installStderr);
|
||||
}
|
||||
expect(installExitCode).toBe(0);
|
||||
|
||||
// Run the client
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "client.js"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
// Run the client
|
||||
await using proc = Bun.spawn({
|
||||
cmd: [bunExe(), "client.js"],
|
||||
cwd: String(dir),
|
||||
env: bunEnv,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
||||
|
||||
if (stderr) {
|
||||
console.error("stderr:", stderr);
|
||||
}
|
||||
if (stderr) {
|
||||
console.error("stderr:", stderr);
|
||||
}
|
||||
|
||||
// Check stdout before exitCode for better error messages on test failure
|
||||
expect(stdout.trim()).not.toBe("");
|
||||
const result = JSON.parse(stdout.trim());
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.bytesReceived).toBe(1024 * 100);
|
||||
expect(result.contentValid).toBe(true);
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
// Check stdout before exitCode for better error messages on test failure
|
||||
expect(stdout.trim()).not.toBe("");
|
||||
const result = JSON.parse(stdout.trim());
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.bytesReceived).toBe(1024 * 100);
|
||||
expect(result.contentValid).toBe(true);
|
||||
expect(exitCode).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
test("multiple rapid writes followed by immediate end() yields all data", async () => {
|
||||
test("multiple rapid writes followed by immediate end() yields all data over HTTPS", async () => {
|
||||
// This test ensures that when many writes happen in quick succession
|
||||
// followed by an immediate end(), no data is lost.
|
||||
// followed by an immediate end() over HTTPS, no data is lost.
|
||||
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
tls,
|
||||
async fetch(req) {
|
||||
const text = await req.text();
|
||||
return new Response(
|
||||
@@ -235,18 +244,19 @@ upload();
|
||||
|
||||
const chunkContent = Buffer.alloc(100, "X").toString();
|
||||
const clientScript = `
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
const numChunks = 1000;
|
||||
const chunkSize = 100;
|
||||
const expectedLength = numChunks * chunkSize;
|
||||
|
||||
const req = http.request('http://localhost:${server.port}/', {
|
||||
const req = https.request('https://localhost:${server.port}/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
},
|
||||
rejectUnauthorized: false,
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
|
||||
Reference in New Issue
Block a user