fix(http): release resources for null-body responses (#3756)

This commit is contained in:
Lorky authored and GitHub committed 2026-11-05 04:36:52 +02:00
1 parent 2fbd563427
commit b11126eae6
7 files changed
+73 -2

No files matched your search

@@ -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.
+11 -1
View File
@@ -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<String> {
webview
.resources_table()
.names()
.map(|(_, name)| name.into_owned())
.collect()
}
+1
View File
@@ -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");
+9
View File
@@ -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<FixtureServer> {
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) {
+40
View File
@@ -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<string[]>('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, {
+1 -1
View File
@@ -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__})}
+5
View File
@@ -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