From b11126eae6dad431ce9e84d1c471bfca2e53bdf8 Mon Sep 17 00:00:00 2001 From: Lorky Date: Thu, 5 Nov 2026 03:36:52 +0100 Subject: [PATCH] fix(http): release resources for null-body responses (#3756) --- .changes/http-null-body-resource-cleanup.md | 6 ++++ examples/api/src-tauri/src/cmd.rs | 12 ++++++- examples/api/src-tauri/src/lib.rs | 1 + packages/api-e2e/test/helpers/server.ts | 9 +++++ packages/api-e2e/test/specs/http.spec.ts | 40 +++++++++++++++++++++ plugins/http/api-iife.js | 2 +- plugins/http/guest-js/index.ts | 5 +++ 7 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 .changes/http-null-body-resource-cleanup.md diff --git a/.changes/http-null-body-resource-cleanup.md b/.changes/http-null-body-resource-cleanup.md new file mode 100644 index 000000000..ee61a5399 --- /dev/null +++ b/.changes/http-null-body-resource-cleanup.md @@ -0,0 +1,6 @@ +--- +http: patch +http-js: patch +--- + +Release native HTTP response resources for responses with no body, such as `204 No Content`, even when the response is never read. diff --git a/examples/api/src-tauri/src/cmd.rs b/examples/api/src-tauri/src/cmd.rs index 734552c63..59c183992 100644 --- a/examples/api/src-tauri/src/cmd.rs +++ b/examples/api/src-tauri/src/cmd.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT use serde::Deserialize; -use tauri::command; +use tauri::{Manager, command}; #[derive(Debug, Deserialize)] #[allow(unused)] @@ -22,3 +22,13 @@ pub fn perform_request(endpoint: String, body: RequestBody) -> String { println!("{} {:?}", endpoint, body); "message response".into() } + +// Used by e2e tests to check that plugin operations release their native resources. +#[command] +pub fn webview_resource_names(webview: tauri::Webview) -> Vec { + webview + .resources_table() + .names() + .map(|(_, name)| name.into_owned()) + .collect() +} diff --git a/examples/api/src-tauri/src/lib.rs b/examples/api/src-tauri/src/lib.rs index 9b0831618..29b94383d 100644 --- a/examples/api/src-tauri/src/lib.rs +++ b/examples/api/src-tauri/src/lib.rs @@ -196,6 +196,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ cmd::log_operation, cmd::perform_request, + cmd::webview_resource_names, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/packages/api-e2e/test/helpers/server.ts b/packages/api-e2e/test/helpers/server.ts index 1e76ee1a9..b67c78c43 100644 --- a/packages/api-e2e/test/helpers/server.ts +++ b/packages/api-e2e/test/helpers/server.ts @@ -55,6 +55,7 @@ export interface FixtureServer { * per byte, so `headers` shows the exact bytes the client sent. * - `GET /raw-header` — an empty response whose `content-disposition` names * {@link RAW_HEADER_FILENAME} in raw UTF-8 bytes. + * - `GET /empty/{204,205,304}` — a response with no body and the requested status. * - `ws /ws` — a WebSocket echo endpoint: text and binary messages are sent * back as-is, and {@link WEBSOCKET_CLOSE_REQUEST} makes the server close the * connection with {@link WEBSOCKET_CLOSE_CODE}. On `/ws/headers` the server @@ -71,6 +72,14 @@ export function startFixtureServer(): Promise { const url = new URL(req.url ?? '/', FIXTURE_SERVER_URL) const [, route, ...rest] = url.pathname.split('/') + if (route === 'empty' && req.method === 'GET') { + const status = Number(rest[0]) + if ([204, 205, 304].includes(status)) { + res.writeHead(status, { 'x-e2e-empty': 'present' }).end() + return + } + } + if (route === 'updater' && req.method === 'GET') { const [target] = rest if (target === UPDATER_TARGET_NO_UPDATE) { diff --git a/packages/api-e2e/test/specs/http.spec.ts b/packages/api-e2e/test/specs/http.spec.ts index 5506227ca..b0d4476f2 100644 --- a/packages/api-e2e/test/specs/http.spec.ts +++ b/packages/api-e2e/test/specs/http.spec.ts @@ -13,6 +13,46 @@ import { FIXTURE_SERVER_URL, RAW_HEADER_FILENAME } from '../helpers/server.js' const echoServer = 'http://localhost:3003' describePlugin('http', () => { + for (const status of [204, 205, 304]) { + for (const readBody of [false, true]) { + it(`fetch releases a ${status} response ${readBody ? 'after reading it' : 'without reading it'}`, async () => { + const result = await tauri( + async (api, url, status, readBody) => { + const resourceNames = () => + api.core.invoke('webview_resource_names') + const before = await resourceNames() + const response = await api.http.fetch(`${url}/empty/${status}`) + const bytes = readBody + ? (await response.arrayBuffer()).byteLength + : null + const after = await resourceNames() + const responseCount = (names: string[]) => + names.filter((name) => name.endsWith('::ReqwestResponse')).length + return { + status: response.status, + bodyIsNull: response.body === null, + bytes, + url: response.url, + header: response.headers.get('x-e2e-empty'), + // Reading an empty JS body succeeds even if its Rust resource leaked. + // Inspect the real native resource table instead. + retained: responseCount(after) - responseCount(before) + } + }, + FIXTURE_SERVER_URL, + status, + readBody + ) + expect(result.status).toBe(status) + expect(result.bodyIsNull).toBe(true) + expect(result.bytes).toBe(readBody ? 0 : null) + expect(result.url).toBe(`${FIXTURE_SERVER_URL}/empty/${status}`) + expect(result.header).toBe('present') + expect(result.retained).toBe(0) + }) + } + } + it('fetch performs a GET and exposes status, url and headers', async () => { const response = await tauri(async (api, url) => { const response = await api.http.fetch(url, { diff --git a/plugins/http/api-iife.js b/plugins/http/api-iife.js index 63965f740..aced8b3c8 100644 --- a/plugins/http/api-iife.js +++ b/plugins/http/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_HTTP__=function(e,t){"use strict";const r="Request cancelled";return e.fetch=async function(e,n){const a=n?.signal;if(a?.aborted)throw new Error(r);const o=n?.maxRedirections,i=n?.connectTimeout,s=n?.proxy,c=n?.danger;n&&(delete n.maxRedirections,delete n.connectTimeout,delete n.proxy,delete n.danger);const d=n?.headers?n.headers instanceof Headers?n.headers:new Headers(n.headers):new Headers,l=new Request(e,n),u=await l.arrayBuffer();for(const[e,t]of l.headers)d.get(e)||d.set(e,t);const h=(d instanceof Headers?Array.from(d.entries()):Array.isArray(d)?d:Object.entries(d)).map(([e,t])=>[e,"string"==typeof t?t:t.toString()]);if(a?.aborted)throw new Error(r);const _={method:l.method,url:l.url,headers:h,maxRedirections:o,connectTimeout:i,proxy:s,danger:c},w=await t.invoke("plugin:http|fetch",new Uint8Array(u),{headers:{"client-config":encodeURIComponent(JSON.stringify(_))}}),f=()=>t.invoke("plugin:http|fetch_cancel",{rid:w}).catch(()=>{});if(a?.aborted)throw f(),new Error(r);a?.addEventListener("abort",()=>{f()});const{status:p,statusText:y,url:b,headers:g,rid:v}=await t.invoke("plugin:http|fetch_send",{rid:w});let T=!1;const m=()=>T?Promise.resolve():(T=!0,t.invoke("plugin:http|fetch_cancel_body",{rid:v}).catch(()=>{})),R=[101,103,204,205,304].includes(p)?null:new ReadableStream({start:e=>{a?.addEventListener("abort",()=>{e.error(r),m()})},pull:e=>(async e=>{let r;try{r=await t.invoke("plugin:http|fetch_read_body",{rid:v})}catch(t){return e.error(t),void m()}const n=new Uint8Array(r),a=n[n.byteLength-1],o=n.slice(0,n.byteLength-1);1!==a?e.enqueue(o):e.close()})(e),cancel:()=>{m()}}),P=new Response(R,{status:p,statusText:y});Object.defineProperty(P,"url",{value:b,writable:!1}),Object.defineProperty(P,"headers",{value:new Headers(g),writable:!1});const A=P.clone.bind(P);return Object.defineProperty(P,"clone",{value:()=>{const e=A();return Object.defineProperty(e,"url",{value:b,writable:!1}),Object.defineProperty(e,"headers",{value:new Headers(g),writable:!1}),e}}),P},e}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"http",{value:__TAURI_PLUGIN_HTTP__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_HTTP__=function(e,t){"use strict";const r="Request cancelled";return e.fetch=async function(e,n){const a=n?.signal;if(a?.aborted)throw new Error(r);const o=n?.maxRedirections,i=n?.connectTimeout,s=n?.proxy,c=n?.danger;n&&(delete n.maxRedirections,delete n.connectTimeout,delete n.proxy,delete n.danger);const d=n?.headers?n.headers instanceof Headers?n.headers:new Headers(n.headers):new Headers,l=new Request(e,n),u=await l.arrayBuffer();for(const[e,t]of l.headers)d.get(e)||d.set(e,t);const h=(d instanceof Headers?Array.from(d.entries()):Array.isArray(d)?d:Object.entries(d)).map(([e,t])=>[e,"string"==typeof t?t:t.toString()]);if(a?.aborted)throw new Error(r);const w={method:l.method,url:l.url,headers:h,maxRedirections:o,connectTimeout:i,proxy:s,danger:c},_=await t.invoke("plugin:http|fetch",new Uint8Array(u),{headers:{"client-config":encodeURIComponent(JSON.stringify(w))}}),f=()=>t.invoke("plugin:http|fetch_cancel",{rid:_}).catch(()=>{});if(a?.aborted)throw f(),new Error(r);a?.addEventListener("abort",()=>{f()});const{status:p,statusText:y,url:b,headers:g,rid:v}=await t.invoke("plugin:http|fetch_send",{rid:_});let T=!1;const m=()=>T?Promise.resolve():(T=!0,t.invoke("plugin:http|fetch_cancel_body",{rid:v}).catch(()=>{})),R=[101,103,204,205,304].includes(p)?null:new ReadableStream({start:e=>{a?.addEventListener("abort",()=>{e.error(r),m()})},pull:e=>(async e=>{let r;try{r=await t.invoke("plugin:http|fetch_read_body",{rid:v})}catch(t){return e.error(t),void m()}const n=new Uint8Array(r),a=n[n.byteLength-1],o=n.slice(0,n.byteLength-1);1!==a?e.enqueue(o):e.close()})(e),cancel:()=>{m()}});null===R&&await m();const P=new Response(R,{status:p,statusText:y});Object.defineProperty(P,"url",{value:b,writable:!1}),Object.defineProperty(P,"headers",{value:new Headers(g),writable:!1});const A=P.clone.bind(P);return Object.defineProperty(P,"clone",{value:()=>{const e=A();return Object.defineProperty(e,"url",{value:b,writable:!1}),Object.defineProperty(e,"headers",{value:new Headers(g),writable:!1}),e}}),P},e}({},window.__TAURI__.core);Object.defineProperty(window.__TAURI__,"http",{value:__TAURI_PLUGIN_HTTP__})} diff --git a/plugins/http/guest-js/index.ts b/plugins/http/guest-js/index.ts index af2f2f656..c6e560731 100644 --- a/plugins/http/guest-js/index.ts +++ b/plugins/http/guest-js/index.ts @@ -317,6 +317,11 @@ export async function fetch( } }) + if (body === null) { + // No stream will read or cancel this response's Rust resource. + await dropBody() + } + const res = new Response(body, { status, statusText