From f2ddaee8d194e3fb40c34c3714dd99d16f824472 Mon Sep 17 00:00:00 2001
From: arimu1 <19286898+arimu1@users.noreply.github.com>
Date: Sat, 25 Jul 2026 09:11:17 +0700
Subject: [PATCH 1/4] fix(net): buffer closed ReadableStream bodies for fetch
POST
BodyInit.extract returned an empty body for ReadableStream request
bodies, so fetch POST with duplex:half silently sent Content-Length: 0.
Drain closed streams synchronously via collectBodyBytes; reject open,
locked, or errored streams with TypeError instead of sending nothing.
Fixes lightpanda-io/browser#3052
---
src/browser/tests/net/fetch.html | 27 +++++++++++++++++++
src/browser/webapi/net/body_init.zig | 14 +++++-----
src/browser/webapi/streams/ReadableStream.zig | 23 ++++++++++++++++
src/testing.zig | 13 +++++++++
4 files changed, 69 insertions(+), 8 deletions(-)
diff --git a/src/browser/tests/net/fetch.html b/src/browser/tests/net/fetch.html
index f624d8ac8b..c41dd81334 100644
--- a/src/browser/tests/net/fetch.html
+++ b/src/browser/tests/net/fetch.html
@@ -348,6 +348,33 @@
}
+
+
+
+
diff --git a/src/browser/webapi/net/body_init.zig b/src/browser/webapi/net/body_init.zig
index 9a32e380c8..d47ecad31c 100644
--- a/src/browser/webapi/net/body_init.zig
+++ b/src/browser/webapi/net/body_init.zig
@@ -106,8 +106,9 @@ pub const BodyInit = union(enum) {
},
.stream => |stream| {
// Response special-cases `.stream` before extract. Request/XHR
- // paths buffer a closed stream synchronously; open streams and
- // async-only bodies reject rather than send Content-Length: 0.
+ // paths buffer a closed stream synchronously; a stream that
+ // can't be drained here - still open, or already used as a
+ // body - rejects rather than send Content-Length: 0.
const bytes = try stream.collectBodyBytes(arena);
return .{ .bytes = bytes, .content_type = null };
},
diff --git a/src/browser/webapi/streams/ReadableStream.zig b/src/browser/webapi/streams/ReadableStream.zig
index 7610749660..b1d63a2d01 100644
--- a/src/browser/webapi/streams/ReadableStream.zig
+++ b/src/browser/webapi/streams/ReadableStream.zig
@@ -53,6 +53,7 @@ _pull_fn: ?js.Function.Global = null,
_pulling: bool = false,
_pull_again: bool = false,
_cancel: ?Cancel = null,
+_collected: bool = false,
const UnderlyingSource = struct {
start: ?js.Function = null,
@@ -132,39 +133,44 @@ pub fn getLocked(self: *const ReadableStream) bool {
}
/// Drain a closed stream's internal queue into bytes for outbound HTTP
-/// request bodies. Open, errored, or locked streams, and chunks that cannot
-/// be converted synchronously, return `error.TypeError`.
+/// request bodies. A stream that is locked, already used as a body, still
+/// readable, or errored - or that holds a chunk which isn't string or binary
+/// data - is `error.TypeError`. The queue is only consumed once every chunk
+/// has converted, so a rejected body leaves the stream as it was.
pub fn collectBodyBytes(self: *ReadableStream, arena: std.mem.Allocator) ![]const u8 {
- if (self.getLocked()) return error.TypeError;
+ if (self._collected or self.getLocked()) {
+ return error.TypeError;
+ }
switch (self._state) {
.errored, .readable => return error.TypeError,
.closed => {},
}
- const exec = self._execution;
- const local = exec.js.local orelse return error.TypeError;
+ const local = self._execution.js.local.?;
var buf = std.Io.Writer.Allocating.init(arena);
- const controller = self._controller;
- while (controller.dequeue()) |chunk| {
+ const queue = &self._controller._queue;
+ for (queue.items) |chunk| {
const bytes: []const u8 = switch (chunk) {
.string => |s| s,
.uint8array => |arr| arr.values,
.js_value => |global| blk: {
const value = local.toLocal(global);
- if (value.isTypedArray() or value.isArrayBufferView() or value.isArrayBuffer()) {
- const typed = try local.jsValueToZig([]u8, value);
- break :blk try arena.dupe(u8, typed);
- } else if (value.isString()) |str| {
- const slice = try str.toSlice();
- break :blk try arena.dupe(u8, slice);
- } else {
+ // toStringSmart falls back to toString(), which would turn a
+ // stray number or object into "42" / "[object Object]" bytes.
+ if (value.isString() == null and !value.isTypedArray() and
+ !value.isArrayBufferView() and !value.isArrayBuffer())
+ {
return error.TypeError;
}
+ break :blk try value.toStringSmart();
},
};
try buf.writer.writeAll(bytes);
}
+
+ self._collected = true;
+ queue.clearRetainingCapacity();
return buf.written();
}
diff --git a/src/testing.zig b/src/testing.zig
index 4daac138a8..05ff606072 100644
--- a/src/testing.zig
+++ b/src/testing.zig
@@ -970,10 +970,12 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void {
});
}
- if (std.mem.eql(u8, path, "/xhr/echo_post_body")) {
+ if (std.mem.eql(u8, path, "/echo_body")) {
+ // Echo the request body back verbatim, so tests can assert on the bytes
+ // a request actually sent rather than just on its status.
var body_buf: [4096]u8 = undefined;
const body = if (req.head.method.requestHasBody())
- req.readerExpectNone(&body_buf).allocRemaining(arena_allocator, .limited(body_buf.len)) catch ""
+ try req.readerExpectNone(&body_buf).allocRemaining(arena_allocator, .limited(body_buf.len))
else
"";
return req.respond(body, .{