diff options
author | 2023-07-30 23:51:43 -0700 | |
---|---|---|
committer | 2023-07-30 23:51:43 -0700 | |
commit | 9ecae59bbb1a302977afa94fd879a0c6f8d6195f (patch) | |
tree | 7bfff6d9c3264b8d1fce85f63891a1f3e5833a52 | |
parent | 2ea7290172da21f4a9cd58232bf9c1ea0997d6e2 (diff) | |
download | bun-9ecae59bbb1a302977afa94fd879a0c6f8d6195f.tar.gz bun-9ecae59bbb1a302977afa94fd879a0c6f8d6195f.tar.zst bun-9ecae59bbb1a302977afa94fd879a0c6f8d6195f.zip |
Fix memory leak in response.clone(), further reduce memory usage of Request & Response (#3902)
* Atomize respsone.url & response.statusText
* Fix warning
* Atomize Request & Response URLs when possible
* Fix memory leak in response.clone()
bun/bench/snippets on jarred/atomize
❯ mem bun --smol request-response-clone.mjs
cpu: Apple M1 Max
runtime: bun 0.7.2 (arm64-darwin)
benchmark time (avg) (min … max) p75 p99 p995
-------------------------------------------------------- -----------------------------
req.clone().url 77.3 ns/iter (40.35 ns … 222.64 ns) 91.53 ns 128.11 ns 172.78 ns
resp.clone().url 162.43 ns/iter (116 ns … 337.77 ns) 177.4 ns 232.38 ns 262.65 ns
Peak memory usage: 60 MB
bun/bench/snippets on jarred/atomize
❯ mem bun-0.7.1 --smol request-response-clone.mjs
cpu: Apple M1 Max
runtime: bun 0.7.1 (arm64-darwin)
benchmark time (avg) (min … max) p75 p99 p995
-------------------------------------------------------- -----------------------------
req.clone().url 115.85 ns/iter (80.35 ns … 247.39 ns) 128.19 ns 181.93 ns 207.23 ns
resp.clone().url 252.32 ns/iter (202.6 ns … 351.07 ns) 266.56 ns 325.88 ns 334.73 ns
Peak memory usage: 1179 MB
* Update tests
* Update js_ast.zig
* Update test
---------
Co-authored-by: Jarred Sumner <709451+Jarred-Sumner@users.noreply.github.com>
-rw-r--r-- | bench/snippets/request-response-clone.mjs | 15 | ||||
-rw-r--r-- | bench/snippets/resposne-constructor.mjs | 1 | ||||
-rw-r--r-- | src/bun.js/api/filesystem_router.zig | 9 | ||||
-rw-r--r-- | src/bun.js/api/html_rewriter.zig | 4 | ||||
-rw-r--r-- | src/bun.js/api/server.zig | 47 | ||||
-rw-r--r-- | src/bun.js/bindings/BunString.cpp | 25 | ||||
-rw-r--r-- | src/bun.js/bindings/bindings.cpp | 2 | ||||
-rw-r--r-- | src/bun.js/webcore/encoding.zig | 3 | ||||
-rw-r--r-- | src/bun.js/webcore/request.zig | 126 | ||||
-rw-r--r-- | src/bun.js/webcore/response.zig | 50 | ||||
-rw-r--r-- | src/js_ast.zig | 1 | ||||
-rw-r--r-- | src/string.zig | 52 | ||||
-rw-r--r-- | test/js/bun/http/bun-server.test.ts | 4 | ||||
-rw-r--r-- | test/js/bun/util/inspect.test.js | 2 | ||||
-rw-r--r-- | test/js/web/fetch/body-stream.test.ts | 5 | ||||
-rw-r--r-- | test/js/web/fetch/fetch.test.ts | 40 |
16 files changed, 256 insertions, 130 deletions
diff --git a/bench/snippets/request-response-clone.mjs b/bench/snippets/request-response-clone.mjs new file mode 100644 index 000000000..05a980656 --- /dev/null +++ b/bench/snippets/request-response-clone.mjs @@ -0,0 +1,15 @@ +// This mostly exists to check for a memory leak in response.clone() +import { bench, run } from "./runner.mjs"; + +const req = new Request("http://localhost:3000/"); +const resp = await fetch("http://example.com"); + +bench("req.clone().url", () => { + return req.clone().url; +}); + +bench("resp.clone().url", () => { + return resp.clone().url; +}); + +await run(); diff --git a/bench/snippets/resposne-constructor.mjs b/bench/snippets/resposne-constructor.mjs new file mode 100644 index 000000000..a15892804 --- /dev/null +++ b/bench/snippets/resposne-constructor.mjs @@ -0,0 +1 @@ +for (let i = 0; i < 9999999; i++) new Request("http://aaaaaaaaaaaaaaaaaaaaa"); diff --git a/src/bun.js/api/filesystem_router.zig b/src/bun.js/api/filesystem_router.zig index 216f66b7f..edb666040 100644 --- a/src/bun.js/api/filesystem_router.zig +++ b/src/bun.js/api/filesystem_router.zig @@ -75,6 +75,8 @@ const DeprecatedGlobalRouter = struct { }; var path_: ?ZigString.Slice = null; + var req_url_slice: ZigString.Slice = .{}; + defer req_url_slice.deinit(); var pathname: string = ""; defer { if (path_) |path| { @@ -88,7 +90,8 @@ const DeprecatedGlobalRouter = struct { var url = URL.parse(path_.?.slice()); pathname = url.pathname; } else if (arg.as(Request)) |req| { - var url = URL.parse(req.url); + req_url_slice = req.url.toUTF8(bun.default_allocator); + var url = URL.parse(req_url_slice.slice()); pathname = url.pathname; } @@ -389,11 +392,11 @@ pub const FileSystemRouter = struct { if (argument.isCell()) { if (argument.as(JSC.WebCore.Request)) |req| { req.ensureURL() catch unreachable; - break :brk ZigString.Slice.fromUTF8NeverFree(req.url).clone(globalThis.allocator()) catch unreachable; + break :brk req.url.toUTF8(globalThis.allocator()); } if (argument.as(JSC.WebCore.Response)) |resp| { - break :brk ZigString.Slice.fromUTF8NeverFree(resp.url).clone(globalThis.allocator()) catch unreachable; + break :brk resp.url.toUTF8(globalThis.allocator()); } } diff --git a/src/bun.js/api/html_rewriter.zig b/src/bun.js/api/html_rewriter.zig index 92e9790c6..f754e3e23 100644 --- a/src/bun.js/api/html_rewriter.zig +++ b/src/bun.js/api/html_rewriter.zig @@ -454,8 +454,8 @@ pub const HTMLRewriter = struct { result.body.init.headers = headers.cloneThis(global); } - result.url = bun.default_allocator.dupe(u8, original.url) catch unreachable; - result.status_text = bun.default_allocator.dupe(u8, original.status_text) catch unreachable; + result.url = original.url.clone(); + result.status_text = original.status_text.clone(); var input = original.body.value.useAsAnyBlob(); sink.input = input; diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig index 801745aee..10afa4ac1 100644 --- a/src/bun.js/api/server.zig +++ b/src/bun.js/api/server.zig @@ -1069,7 +1069,7 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp byte_stream: ?*JSC.WebCore.ByteStream = null, /// Used in errors - pathname: []const u8 = "", + pathname: bun.String = bun.String.empty, /// Used either for temporary blob data or fallback /// When the response body is a temporary value @@ -1550,10 +1550,9 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp stream.unpipe(); } - if (this.pathname.len > 0) { - ctxLog("finalizeWithoutDeinit: this.pathname.len > 0 null", .{}); - this.allocator.free(bun.constStrToU8(this.pathname)); - this.pathname = ""; + if (!this.pathname.isEmpty()) { + this.pathname.deref(); + this.pathname = bun.String.empty; } // if we are waiting for the body yet and the request was not aborted we can safely clear the onData callback @@ -2235,7 +2234,7 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp request_object.uws_request = req; request_object.ensureURL() catch { - request_object.url = ""; + request_object.url = bun.String.empty; }; // we have to clone the request headers here since they will soon belong to a different request @@ -2244,7 +2243,7 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp } if (comptime debug_mode) { - ctx.pathname = bun.default_allocator.dupe(u8, request_object.url) catch unreachable; + ctx.pathname = request_object.url.clone(); } // This object dies after the stack frame is popped @@ -2596,15 +2595,28 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp runErrorHandlerWithStatusCode(this, value, 500); } - fn ensurePathname(this: *RequestContext) []const u8 { - if (this.pathname.len > 0) - return this.pathname; + const PathnameFormatter = struct { + ctx: *RequestContext, + + pub fn format(formatter: @This(), comptime fmt: []const u8, opts: std.fmt.FormatOptions, writer: anytype) !void { + var this = formatter.ctx; + + if (!this.pathname.isEmpty()) { + try this.pathname.format(fmt, opts, writer); + return; + } - if (!this.flags.has_abort_handler) { - return this.req.url(); + if (!this.flags.has_abort_handler) { + try writer.writeAll(this.req.url()); + return; + } + + try writer.writeAll("/"); } + }; - return "/"; + fn ensurePathname(this: *RequestContext) PathnameFormatter { + return .{ .ctx = this }; } pub inline fn shouldCloseConnection(this: *const RequestContext) bool { @@ -2625,7 +2637,7 @@ fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool, comp vm.log, error.ExceptionOcurred, exception_list.toOwnedSlice() catch @panic("TODO"), - "<r><red>{s}<r> - <b>{s}<r> failed", + "<r><red>{s}<r> - <b>{}<r> failed", .{ @as(string, @tagName(this.method)), this.ensurePathname() }, ); } else { @@ -4900,8 +4912,7 @@ pub fn NewServer(comptime ssl_enabled_: bool, comptime debug_mode_: bool) type { } existing_request = Request{ - .url = url.href, - .url_was_allocated = true, + .url = bun.String.create(url.href), .headers = headers, .body = JSC.WebCore.InitRequestBodyValue(body) catch unreachable, .method = method, @@ -4943,7 +4954,7 @@ pub fn NewServer(comptime ssl_enabled_: bool, comptime debug_mode_: bool) type { } if (response_value.as(JSC.WebCore.Response)) |resp| { - resp.url = this.allocator.dupe(u8, existing_request.url) catch unreachable; + resp.url = existing_request.url.clone(); } return JSC.JSPromise.resolvedPromiseValue(ctx, response_value).asObjectRef(); } @@ -5295,7 +5306,6 @@ pub fn NewServer(comptime ssl_enabled_: bool, comptime debug_mode_: bool) type { } request_object.* = .{ - .url = "", .method = ctx.method, .uws_request = req, .https = ssl_enabled, @@ -5398,7 +5408,6 @@ pub fn NewServer(comptime ssl_enabled_: bool, comptime debug_mode_: bool) type { } request_object.* = .{ - .url = "", .method = ctx.method, .uws_request = req, .upgrader = ctx, diff --git a/src/bun.js/bindings/BunString.cpp b/src/bun.js/bindings/BunString.cpp index 5eb5f9f9b..7864ee94e 100644 --- a/src/bun.js/bindings/BunString.cpp +++ b/src/bun.js/bindings/BunString.cpp @@ -6,6 +6,7 @@ #include "wtf/text/ExternalStringImpl.h" #include "GCDefferalContext.h" #include <JavaScriptCore/JSONObject.h> +#include <wtf/text/AtomString.h> using namespace JSC; @@ -25,11 +26,23 @@ extern "C" void Bun__WTFStringImpl__ref(WTF::StringImpl* impl) extern "C" bool BunString__fromJS(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue encodedValue, BunString* bunString) { + JSC::JSValue value = JSC::JSValue::decode(encodedValue); *bunString = Bun::toString(globalObject, value); return bunString->tag != BunStringTag::Dead; } +extern "C" BunString BunString__createAtom(const char* bytes, size_t length) +{ + if (simdutf::validate_ascii(bytes, length)) { + auto atom = makeAtomString(String(StringImpl::createWithoutCopying(bytes, length))); + atom.impl()->ref(); + return { BunStringTag::WTFStringImpl, { .wtf = atom.impl() } }; + } + + return { BunStringTag::Dead, {} }; +} + namespace Bun { JSC::JSValue toJS(JSC::JSGlobalObject* globalObject, BunString bunString) { @@ -179,7 +192,6 @@ extern "C" BunString BunString__fromUTF16Unitialized(size_t length) if (UNLIKELY(!ptr)) return { BunStringTag::Dead }; - impl->ref(); return { BunStringTag::WTFStringImpl, { .wtf = &impl.leakRef() } }; } @@ -190,7 +202,6 @@ extern "C" BunString BunString__fromLatin1Unitialized(size_t length) auto impl = WTF::StringImpl::createUninitialized(latin1Length, ptr); if (UNLIKELY(!ptr)) return { BunStringTag::Dead }; - impl->ref(); return { BunStringTag::WTFStringImpl, { .wtf = &impl.leakRef() } }; } @@ -302,10 +313,10 @@ extern "C" EncodedJSValue BunString__createArray( extern "C" void BunString__toWTFString(BunString* bunString) { if (bunString->tag == BunStringTag::ZigString) { - if (Zig::isTaggedUTF8Ptr(bunString->impl.zig.ptr)) { - bunString->impl.wtf = Zig::toStringCopy(bunString->impl.zig).impl(); - } else { + if (Zig::isTaggedExternalPtr(bunString->impl.zig.ptr)) { bunString->impl.wtf = Zig::toString(bunString->impl.zig).impl(); + } else { + bunString->impl.wtf = Zig::toStringCopy(bunString->impl.zig).impl(); } bunString->tag = BunStringTag::WTFStringImpl; @@ -356,7 +367,7 @@ extern "C" BunString URL__getHrefFromJS(EncodedJSValue encodedValue, JSC::JSGlob extern "C" BunString URL__getHref(BunString* input) { - auto str = Bun::toWTFString(*input); + auto&& str = Bun::toWTFString(*input); auto url = WTF::URL(str); if (!url.isValid() || url.isEmpty()) return { BunStringTag::Dead }; @@ -366,7 +377,7 @@ extern "C" BunString URL__getHref(BunString* input) extern "C" WTF::URL* URL__fromString(BunString* input) { - auto str = Bun::toWTFString(*input); + auto&& str = Bun::toWTFString(*input); auto url = WTF::URL(str); if (!url.isValid()) return nullptr; diff --git a/src/bun.js/bindings/bindings.cpp b/src/bun.js/bindings/bindings.cpp index 981a475a3..b0a291c2e 100644 --- a/src/bun.js/bindings/bindings.cpp +++ b/src/bun.js/bindings/bindings.cpp @@ -1404,7 +1404,7 @@ double JSC__JSValue__getLengthIfPropertyExistsInternal(JSC__JSValue value, JSC__ return 0; } - case JSC::JSType(JSDOMWrapperType): { + case WebCore::JSDOMWrapperType: { if (auto* headers = jsDynamicCast<WebCore::JSFetchHeaders*>(cell)) return static_cast<double>(jsCast<WebCore::JSFetchHeaders*>(cell)->wrapped().size()); diff --git a/src/bun.js/webcore/encoding.zig b/src/bun.js/webcore/encoding.zig index 7a0188aab..4fa0f4ca2 100644 --- a/src/bun.js/webcore/encoding.zig +++ b/src/bun.js/webcore/encoding.zig @@ -866,13 +866,11 @@ pub const Encoder = struct { } var str = bun.String.createUninitialized(.latin1, len) orelse return ZigString.init("Out of memory").toErrorInstance(global); - defer str.deref(); strings.copyLatin1IntoASCII(@constCast(str.latin1()), input); return str.toJS(global); }, .latin1 => { var str = bun.String.createUninitialized(.latin1, len) orelse return ZigString.init("Out of memory").toErrorInstance(global); - defer str.deref(); @memcpy(@constCast(str.latin1()), input_ptr[0..len]); @@ -903,7 +901,6 @@ pub const Encoder = struct { .hex => { var str = bun.String.createUninitialized(.latin1, len * 2) orelse return ZigString.init("Out of memory").toErrorInstance(global); - defer str.deref(); var output = @constCast(str.latin1()); const wrote = strings.encodeBytesToHex(output, input); std.debug.assert(wrote == output.len); diff --git a/src/bun.js/webcore/request.zig b/src/bun.js/webcore/request.zig index 75d4e63cd..af212e4cf 100644 --- a/src/bun.js/webcore/request.zig +++ b/src/bun.js/webcore/request.zig @@ -63,8 +63,7 @@ pub fn InitRequestBodyValue(value: Body.Value) !*BodyValueRef { } // https://developer.mozilla.org/en-US/docs/Web/API/Request pub const Request = struct { - url: []const u8 = "", - url_was_allocated: bool = false, + url: bun.String = bun.String.empty, headers: ?*FetchHeaders = null, signal: ?*AbortSignal = null, @@ -143,7 +142,7 @@ pub const Request = struct { try formatter.writeIndent(Writer, writer); try writer.writeAll(comptime Output.prettyFmt("<r>url<d>:<r> ", enable_ansi_colors)); try this.ensureURL(); - try writer.print(comptime Output.prettyFmt("\"<b>{s}<r>\"", enable_ansi_colors), .{this.url}); + try writer.print(comptime Output.prettyFmt("\"<b>{}<r>\"", enable_ansi_colors), .{this.url}); formatter.printComma(Writer, writer, enable_ansi_colors) catch unreachable; try writer.writeAll("\n"); @@ -179,11 +178,10 @@ pub const Request = struct { pub fn fromRequestContext(ctx: *RequestContext) !Request { var req = Request{ - .url = bun.asByteSlice(ctx.getFullURL()), + .url = bun.String.create(ctx.full_url), .body = try InitRequestBodyValue(.{ .Null = {} }), .method = ctx.method, .headers = FetchHeaders.createFromPicoHeaders(ctx.request.headers), - .url_was_allocated = true, }; return req; } @@ -271,9 +269,8 @@ pub const Request = struct { this.headers = null; } - if (this.url_was_allocated) { - bun.default_allocator.free(bun.constStrToU8(this.url)); - } + this.url.deref(); + this.url = bun.String.empty; if (this.signal) |signal| { _ = signal.unref(); @@ -320,12 +317,12 @@ pub const Request = struct { return .zero; }; - return ZigString.init(this.url).withEncoding().toValueGC(globalObject); + return this.url.toJS(globalObject); } pub fn sizeOfURL(this: *const Request) usize { - if (this.url.len > 0) - return this.url.len; + if (this.url.length() > 0) + return this.url.length(); if (this.uws_request) |req| { const req_url = req.url(); @@ -352,7 +349,7 @@ pub const Request = struct { } pub fn ensureURL(this: *Request) !void { - if (this.url.len > 0) return; + if (!this.url.isEmpty()) return; if (this.uws_request) |req| { const req_url = req.url(); @@ -362,16 +359,53 @@ pub const Request = struct { .is_https = this.https, .host = host, }; - const url = try std.fmt.allocPrint(bun.default_allocator, "{s}{any}{s}", .{ + const url_bytelength = std.fmt.count("{s}{any}{s}", .{ this.getProtocol(), fmt, req_url, }); + if (comptime Environment.allow_assert) { - std.debug.assert(this.sizeOfURL() == url.len); + std.debug.assert(this.sizeOfURL() == url_bytelength); + } + + if (url_bytelength < 64) { + var buffer: [64]u8 = undefined; + const url = std.fmt.bufPrint(&buffer, "{s}{any}{s}", .{ + this.getProtocol(), + fmt, + req_url, + }) catch @panic("Unexpected error while printing URL"); + + if (comptime Environment.allow_assert) { + std.debug.assert(this.sizeOfURL() == url.len); + } + + if (bun.String.tryCreateAtom(url)) |atomized| { + this.url = atomized; + return; + } + } + + if (strings.isAllASCII(host) and strings.isAllASCII(req_url)) { + this.url = bun.String.createUninitializedLatin1(url_bytelength); + var bytes = @constCast(this.url.byteSlice()); + const url = std.fmt.bufPrint(bytes, "{s}{any}{s}", .{ + this.getProtocol(), + fmt, + req_url, + }) catch @panic("Unexpected error while printing URL"); + _ = url; + } else { + // slow path + var temp_url = std.fmt.allocPrint(bun.default_allocator, "{s}{any}{s}", .{ + this.getProtocol(), + fmt, + req_url, + }) catch unreachable; + defer bun.default_allocator.free(temp_url); + this.url = bun.String.create(temp_url); } - this.url = url; - this.url_was_allocated = true; return; } } @@ -379,8 +413,7 @@ pub const Request = struct { if (comptime Environment.allow_assert) { std.debug.assert(this.sizeOfURL() == req_url.len); } - this.url = try bun.default_allocator.dupe(u8, req_url); - this.url_was_allocated = true; + this.url = bun.String.create(req_url); } } @@ -432,18 +465,13 @@ pub const Request = struct { url_or_object.as(JSC.DOMURL) != null; if (is_first_argument_a_url) { - const slice = arguments[0].toSliceOrNull(globalThis) orelse { + const str = bun.String.tryFromJS(arguments[0], globalThis) orelse { req.finalizeWithoutDeinit(); _ = req.body.unref(); return null; }; - req.url = (slice.cloneIfNeeded(globalThis.allocator()) catch { - req.finalizeWithoutDeinit(); - _ = req.body.unref(); - return null; - }).slice(); - req.url_was_allocated = req.url.len > 0; - if (req.url.len > 0) + req.url = str; + if (!req.url.isEmpty()) fields.insert(.url); } else if (!url_or_object_type.isObject()) { globalThis.throw("Failed to construct 'Request': expected non-empty string or object", .{}); @@ -510,9 +538,8 @@ pub const Request = struct { } if (!fields.contains(.url)) { - if (response.url.len > 0) { - req.url = globalThis.allocator().dupe(u8, response.url) catch unreachable; - req.url_was_allocated = true; + if (!response.url.isEmpty()) { + req.url = response.url; fields.insert(.url); } } @@ -544,29 +571,21 @@ pub const Request = struct { if (!fields.contains(.url)) { if (value.fastGet(globalThis, .url)) |url| { - req.url = (url.toSlice(globalThis, bun.default_allocator).cloneIfNeeded(bun.default_allocator) catch { - return null; - }).slice(); - req.url_was_allocated = req.url.len > 0; - if (req.url.len > 0) + req.url = bun.String.fromJS(url, globalThis); + if (!req.url.isEmpty()) fields.insert(.url); // first value } else if (@intFromEnum(value) == @intFromEnum(values_to_try[values_to_try.len - 1]) and !is_first_argument_a_url and value.implementsToString(globalThis)) { - const slice = value.toSliceOrNull(globalThis) orelse { + const str = bun.String.tryFromJS(value, globalThis) orelse { req.finalizeWithoutDeinit(); _ = req.body.unref(); return null; }; - req.url = (slice.cloneIfNeeded(globalThis.allocator()) catch { - req.finalizeWithoutDeinit(); - _ = req.body.unref(); - return null; - }).slice(); - req.url_was_allocated = req.url.len > 0; - if (req.url.len > 0) + req.url = str; + if (!req.url.isEmpty()) fields.insert(.url); } } @@ -607,20 +626,24 @@ pub const Request = struct { } } - if (req.url.len == 0) { + if (req.url.isEmpty()) { globalThis.throw("Failed to construct 'Request': url is required.", .{}); req.finalizeWithoutDeinit(); _ = req.body.unref(); return null; } - const parsed_url = ZigURL.parse(req.url); - if (parsed_url.hostname.len == 0) { - globalThis.throw("Failed to construct 'Request': Invalid URL (missing a hostname)", .{}); + // Note that the string is going to be ref'd here, so we don't need to ref it above. + const href = JSC.URL.hrefFromString(req.url); + if (href.isEmpty()) { + globalThis.throw("Failed to construct 'Request': Invalid URL \"{}\"", .{ + req.url, + }); req.finalizeWithoutDeinit(); _ = req.body.unref(); return null; } + req.url = href; if (req.body.value == .Blob and req.headers != null and @@ -717,23 +740,18 @@ pub const Request = struct { globalThis: *JSGlobalObject, preserve_url: bool, ) void { + _ = allocator; this.ensureURL() catch {}; var body = InitRequestBodyValue(this.body.value.clone(globalThis)) catch { globalThis.throw("Failed to clone request", .{}); return; }; - - const original_url = req.url; + var original_url = req.url; req.* = Request{ .body = body, - .url = if (preserve_url) original_url else allocator.dupe(u8, this.url) catch { - _ = body.unref(); - globalThis.throw("Failed to clone request", .{}); - return; - }, - .url_was_allocated = if (preserve_url) req.url_was_allocated else true, + .url = if (preserve_url) original_url else this.url.dupeRef(), .method = this.method, .headers = this.cloneHeaders(globalThis), }; diff --git a/src/bun.js/webcore/response.zig b/src/bun.js/webcore/response.zig index e9ba24fc5..3341c6eaf 100644 --- a/src/bun.js/webcore/response.zig +++ b/src/bun.js/webcore/response.zig @@ -59,8 +59,8 @@ pub const Response = struct { allocator: std.mem.Allocator, body: Body, - url: string = "", - status_text: string = "", + url: bun.String = bun.String.empty, + status_text: bun.String = bun.String.empty, redirected: bool = false, // We must report a consistent value for this @@ -85,7 +85,7 @@ pub const Response = struct { return this.reported_estimated_size orelse brk: { this.reported_estimated_size = @as( u63, - @intCast(this.body.value.estimatedSize() + this.url.len + this.status_text.len + @sizeOf(Response)), + @intCast(this.body.value.estimatedSize() + this.url.byteSlice().len + this.status_text.byteSlice().len + @sizeOf(Response)), ); break :brk this.reported_estimated_size.?; }; @@ -135,7 +135,7 @@ pub const Response = struct { try formatter.writeIndent(Writer, writer); try writer.writeAll(comptime Output.prettyFmt("<r>url<d>:<r> \"", enable_ansi_colors)); - try writer.print(comptime Output.prettyFmt("<r><b>{s}<r>", enable_ansi_colors), .{this.url}); + try writer.print(comptime Output.prettyFmt("<r><b>{}<r>", enable_ansi_colors), .{this.url}); try writer.writeAll("\""); formatter.printComma(Writer, writer, enable_ansi_colors) catch unreachable; try writer.writeAll("\n"); @@ -148,7 +148,7 @@ pub const Response = struct { try formatter.writeIndent(Writer, writer); try writer.writeAll(comptime Output.prettyFmt("<r>statusText<d>:<r> ", enable_ansi_colors)); - try JSPrinter.writeJSONString(this.status_text, Writer, writer, .ascii); + try writer.print(comptime Output.prettyFmt("<r>\"<b>{}<r>\"", enable_ansi_colors), .{this.status_text}); formatter.printComma(Writer, writer, enable_ansi_colors) catch unreachable; try writer.writeAll("\n"); @@ -175,7 +175,7 @@ pub const Response = struct { globalThis: *JSC.JSGlobalObject, ) callconv(.C) JSC.JSValue { // https://developer.mozilla.org/en-US/docs/Web/API/Response/url - return ZigString.init(this.url).toValueGC(globalThis); + return this.url.toJS(globalThis); } pub fn getResponseType( @@ -194,7 +194,7 @@ pub const Response = struct { globalThis: *JSC.JSGlobalObject, ) callconv(.C) JSC.JSValue { // https://developer.mozilla.org/en-US/docs/Web/API/Response/statusText - return ZigString.init(this.status_text).withEncoding().toValueGC(globalThis); + return this.status_text.toJS(globalThis); } pub fn getRedirected( @@ -241,12 +241,7 @@ pub const Response = struct { _: *JSC.CallFrame, ) callconv(.C) JSValue { var cloned = this.clone(getAllocator(globalThis), globalThis); - const val = Response.makeMaybePooled(globalThis, cloned); - if (this.body.init.headers) |headers| { - cloned.body.init.headers = headers.cloneThis(globalThis); - } - - return val; + return Response.makeMaybePooled(globalThis, cloned); } pub fn makeMaybePooled(globalObject: *JSC.JSGlobalObject, ptr: *Response) JSValue { @@ -262,8 +257,8 @@ pub const Response = struct { new_response.* = Response{ .allocator = allocator, .body = this.body.clone(globalThis), - .url = allocator.dupe(u8, this.url) catch unreachable, - .status_text = allocator.dupe(u8, this.status_text) catch unreachable, + .url = this.url.clone(), + .status_text = this.status_text.clone(), .redirected = this.redirected, }; } @@ -289,13 +284,8 @@ pub const Response = struct { var allocator = this.allocator; - if (this.status_text.len > 0) { - allocator.free(this.status_text); - } - - if (this.url.len > 0) { - allocator.free(this.url); - } + this.status_text.deref(); + this.url.deref(); allocator.destroy(this); } @@ -381,7 +371,7 @@ pub const Response = struct { .value = .{ .Empty = {} }, }, .allocator = getAllocator(globalThis), - .url = "", + .url = bun.String.empty, }; const json_value = args.nextEat() orelse JSC.JSValue.zero; @@ -443,7 +433,7 @@ pub const Response = struct { .value = .{ .Empty = {} }, }, .allocator = getAllocator(globalThis), - .url = "", + .url = bun.String.empty, }; const url_string_value = args.nextEat() orelse JSC.JSValue.zero; @@ -487,7 +477,6 @@ pub const Response = struct { .value = .{ .Empty = {} }, }, .allocator = getAllocator(globalThis), - .url = "", }; return response.toJS(globalThis); @@ -534,7 +523,6 @@ pub const Response = struct { response.* = Response{ .body = body, .allocator = getAllocator(globalThis), - .url = "", }; if (response.body.value == .Blob and @@ -821,8 +809,8 @@ pub const Fetch = struct { const http_response = this.result.response; return Response{ .allocator = allocator, - .url = allocator.dupe(u8, this.result.href) catch unreachable, - .status_text = allocator.dupe(u8, http_response.status) catch unreachable, + .url = bun.String.createAtomIfPossible(this.result.href), + .status_text = bun.String.createAtomIfPossible(http_response.status), .redirected = this.result.redirected, .body = .{ .init = .{ @@ -1041,7 +1029,7 @@ pub const Fetch = struct { if (first_arg.as(Request)) |request| { request.ensureURL() catch unreachable; - if (request.url.len == 0) { + if (request.url.isEmpty()) { const err = JSC.toTypeError(.ERR_INVALID_ARG_VALUE, fetch_error_blank_url, .{}, ctx); // clean hostname if any if (hostname) |host| { @@ -1050,7 +1038,7 @@ pub const Fetch = struct { return JSPromise.rejectedPromiseValue(globalThis, err); } - url = ZigURL.fromUTF8(bun.default_allocator, request.url) catch { + url = ZigURL.fromString(bun.default_allocator, request.url) catch { const err = JSC.toTypeError(.ERR_INVALID_ARG_VALUE, "fetch() URL is invalid", .{}, ctx); // clean hostname if any if (hostname) |host| { @@ -1377,7 +1365,7 @@ pub const Fetch = struct { .value = .{ .Blob = bun_file }, }, .allocator = bun.default_allocator, - .url = file_url_string.toOwnedSlice(bun.default_allocator) catch @panic("out of memory"), + .url = file_url_string.clone(), }; return JSPromise.resolvedPromiseValue(globalThis, response.toJS(globalThis)); diff --git a/src/js_ast.zig b/src/js_ast.zig index 358fe3197..d25c494fa 100644 --- a/src/js_ast.zig +++ b/src/js_ast.zig @@ -2367,7 +2367,6 @@ pub const E = struct { if (s.is_utf16) { var out = bun.String.createUninitializedUTF16(s.len()); - defer out.deref(); @memcpy(@constCast(out.utf16()), s.slice16()); return out.toJS(globalObject); } diff --git a/src/string.zig b/src/string.zig index 1e2ee752e..4fb6e001a 100644 --- a/src/string.zig +++ b/src/string.zig @@ -276,6 +276,10 @@ pub const String = extern struct { extern fn BunString__fromLatin1Unitialized(len: usize) String; extern fn BunString__fromUTF16Unitialized(len: usize) String; + pub fn isGlobal(this: String) bool { + return this.tag == Tag.ZigString and this.value.ZigString.isGloballyAllocated(); + } + pub fn toOwnedSlice(this: String, allocator: std.mem.Allocator) ![]u8 { switch (this.tag) { .ZigString => return try this.value.ZigString.toOwnedSlice(allocator), @@ -339,6 +343,54 @@ pub const String = extern struct { return this; } + pub fn clone(this: String) String { + if (this.tag == .WTFStringImpl) { + return this.dupeRef(); + } + + if (this.isEmpty()) { + return this; + } + + if (this.isUTF16()) { + var new = createUninitializedUTF16(this.length()); + @memcpy(@constCast(new.byteSlice()), this.byteSlice()); + return new; + } + + return create(this.byteSlice()); + } + + extern fn BunString__createAtom(bytes: [*]const u8, len: usize) String; + + /// May return .Dead if the string is too long or non-ascii. + pub fn createAtom(bytes: []const u8) String { + JSC.markBinding(@src()); + return BunString__createAtom(bytes.ptr, bytes.len); + } + + pub fn tryCreateAtom(bytes: []const u8) ?String { + const atom = createAtom(bytes); + if (atom.isEmpty()) { + return null; + } + + return atom; + } + + /// Atomized strings are interned strings + /// They're de-duplicated in a threadlocal hash table + /// They cannot be used from other threads. + pub fn createAtomIfPossible(bytes: []const u8) String { + if (bytes.len < 64) { + if (tryCreateAtom(bytes)) |atom| { + return atom; + } + } + + return create(bytes); + } + pub fn utf8ByteLength(this: String) usize { return switch (this.tag) { .WTFStringImpl => this.value.WTFStringImpl.utf8ByteLength(), diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 37675c25d..9fd97e3cb 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -205,7 +205,7 @@ describe("Server", () => { }, }); try { - const url = `http://${server.hostname}:${server.port}`; + const url = `http://${server.hostname}:${server.port}/`; const response = await server.fetch(url); expect(await response.text()).toBe("Hello World!"); expect(response.status).toBe(200); @@ -222,7 +222,7 @@ describe("Server", () => { }, }); try { - const url = `http://${server.hostname}:${server.port}`; + const url = `http://${server.hostname}:${server.port}/`; const response = await server.fetch(new Request(url)); expect(await response.text()).toBe("Hello World!"); expect(response.status).toBe(200); diff --git a/test/js/bun/util/inspect.test.js b/test/js/bun/util/inspect.test.js index e91204c69..4d5165543 100644 --- a/test/js/bun/util/inspect.test.js +++ b/test/js/bun/util/inspect.test.js @@ -107,7 +107,7 @@ it("Request object", () => { ` Request (0 KB) { method: "GET", - url: "https://example.com", + url: "https://example.com/", headers: Headers {} }`.trim(), ); diff --git a/test/js/web/fetch/body-stream.test.ts b/test/js/web/fetch/body-stream.test.ts index 64b3434e1..8e2baf92a 100644 --- a/test/js/web/fetch/body-stream.test.ts +++ b/test/js/web/fetch/body-stream.test.ts @@ -1,7 +1,6 @@ // @ts-nocheck -import { file, gc, serve, ServeOptions } from "bun"; -import { afterAll, afterEach, describe, expect, it, test } from "bun:test"; -import { readFileSync } from "fs"; +import { gc, ServeOptions } from "bun"; +import { afterAll, describe, expect, it, test } from "bun:test"; var port = 0; diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index d7fc87ade..ec73069de 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -30,6 +30,14 @@ afterAll(() => { const payload = new Uint8Array(1024 * 1024 * 2); crypto.getRandomValues(payload); +it("new Request(invalid url) throws", () => { + expect(() => new Request("http")).toThrow(); + expect(() => new Request("")).toThrow(); + expect(() => new Request("http://[::1")).toThrow(); + expect(() => new Request("https://[::1")).toThrow(); + expect(() => new Request("!")).toThrow(); +}); + describe("AbortSignal", () => { beforeEach(() => { startServer({ @@ -1165,9 +1173,9 @@ it("should not be able to parse json from empty body", () => { }); it("#874", () => { - expect(new Request(new Request("https://example.com"), {}).url).toBe("https://example.com"); - expect(new Request(new Request("https://example.com")).url).toBe("https://example.com"); - expect(new Request({ url: "https://example.com" }).url).toBe("https://example.com"); + expect(new Request(new Request("https://example.com"), {}).url).toBe("https://example.com/"); + expect(new Request(new Request("https://example.com")).url).toBe("https://example.com/"); + expect(new Request({ url: "https://example.com" }).url).toBe("https://example.com/"); }); it("#2794", () => { @@ -1213,3 +1221,29 @@ it("fetch() file:// works", async () => { await Bun.file(Bun.fileURLToPath(new URL("file with space in the name.txt", import.meta.url))).text(), ); }); + +it("cloned response headers are independent before accessing", () => { + const response = new Response("hello", { + headers: { + "content-type": "text/html; charset=utf-8", + }, + }); + const cloned = response.clone(); + cloned.headers.set("content-type", "text/plain"); + expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); +}); + +it("cloned response headers are independent after accessing", () => { + const response = new Response("hello", { + headers: { + "content-type": "text/html; charset=utf-8", + }, + }); + + // create the headers + response.headers; + + const cloned = response.clone(); + cloned.headers.set("content-type", "text/plain"); + expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); +}); |