]> git.kaiwu.me - njs.git/commitdiff
QuickJS: make Fetch finalization pool-independent
authorDmitry Volyntsev <xeioex@nginx.com>
Fri, 14 Aug 2026 02:10:28 +0000 (19:10 -0700)
committerDmitry Volyntsev <xeioexception@gmail.com>
Fri, 14 Aug 2026 23:05:54 +0000 (16:05 -0700)
QuickJS may defer finalization of a Fetch object until after its native
backing pools have been destroyed.  Context reuse is not required: a
local JavaScript cycle is sufficient.

    function content(r) {
        const response = new Response("body");

        response.self = response;
        r.return(200);
    }

With context reuse disabled, the cycle is collected while
JS_FreeContext() destroys the per-request context.  At that point the
engine pool has already been destroyed.  The Response finalizer accessed
the pool-backed native structure and attempted to destroy its response
chain, causing a use-after-free.  Request finalization had the same
lifetime dependency through its cached Headers value.

The fix introduces a QuickJS-runtime-owned wrapper for Fetch Request and
Response objects.  It owns the cached Headers value and keeps only a
non-owning pointer to the pool-backed data.  Finalizers now release only
runtime-owned state, while response chains continue to be reclaimed in
bulk with the engine pool.

nginx/ngx_qjs_fetch.c
nginx/t/js_fetch_object_lifetime.t [new file with mode: 0644]

index 2c67f630ba58e164a9078e518808b91824e9539b..7adcda4f6ecc56c7b25e93ae59addec4431fa27c 100644 (file)
@@ -28,6 +28,12 @@ typedef struct {
 } ngx_qjs_fetch_t;
 
 
+typedef struct {
+    void      *data;
+    JSValue    header_value;
+} ngx_qjs_fetch_object_t;
+
+
 static ngx_int_t ngx_qjs_method_process(JSContext *cx,
     ngx_js_request_t *request);
 static ngx_int_t ngx_qjs_headers_inherit(JSContext *cx,
@@ -89,6 +95,8 @@ static JSValue ngx_qjs_ext_fetch_request_field(JSContext *cx,
     JSValueConst this_val, int magic);
 static JSValue ngx_qjs_ext_fetch_request_mode(JSContext *cx,
     JSValueConst this_val);
+static void ngx_qjs_fetch_request_mark(JSRuntime *rt, JSValueConst val,
+    JS_MarkFunc *mark_func);
 static void ngx_qjs_fetch_request_finalizer(JSRuntime *rt, JSValue val);
 
 static JSValue ngx_qjs_fetch_response_ctor(JSContext *cx,
@@ -111,6 +119,8 @@ static JSValue ngx_qjs_ext_fetch_response_redirected(JSContext *cx,
     JSValueConst this_val);
 static JSValue ngx_qjs_ext_fetch_response_field(JSContext *cx,
     JSValueConst this_val, int magic);
+static void ngx_qjs_fetch_response_mark(JSRuntime *rt, JSValueConst val,
+    JS_MarkFunc *mark_func);
 static void ngx_qjs_fetch_response_finalizer(JSRuntime *rt, JSValue val);
 
 static JSValue ngx_qjs_fetch_flag(JSContext *cx, const ngx_qjs_entry_t *entries,
@@ -183,12 +193,14 @@ static const JSClassDef  ngx_qjs_fetch_headers_class = {
 static const JSClassDef  ngx_qjs_fetch_request_class = {
     "Request",
     .finalizer = ngx_qjs_fetch_request_finalizer,
+    .gc_mark = ngx_qjs_fetch_request_mark,
 };
 
 
 static const JSClassDef  ngx_qjs_fetch_response_class = {
     "Response",
     .finalizer = ngx_qjs_fetch_response_finalizer,
+    .gc_mark = ngx_qjs_fetch_response_mark,
 };
 
 
@@ -477,6 +489,37 @@ ngx_qjs_fetch_headers_ctor(JSContext *cx, JSValueConst new_target, int argc,
 }
 
 
+static ngx_int_t
+ngx_qjs_fetch_object_set(JSContext *cx, JSValueConst value, void *data)
+{
+    ngx_qjs_fetch_object_t  *object;
+
+    object = js_malloc(cx, sizeof(ngx_qjs_fetch_object_t));
+    if (object == NULL) {
+        return NGX_ERROR;
+    }
+
+    object->data = data;
+    object->header_value = JS_UNDEFINED;
+
+    JS_SetOpaque(value, object);
+
+    return NGX_OK;
+}
+
+
+static void *
+ngx_qjs_fetch_object_data(JSContext *cx, JSValueConst value,
+    JSClassID class_id)
+{
+    ngx_qjs_fetch_object_t  *object;
+
+    object = JS_GetOpaque2(cx, value, class_id);
+
+    return (object != NULL) ? object->data : NULL;
+}
+
+
 static JSValue
 ngx_qjs_fetch_request_ctor(JSContext *cx, JSValueConst new_target, int argc,
     JSValueConst *argv)
@@ -511,7 +554,10 @@ ngx_qjs_fetch_request_ctor(JSContext *cx, JSValueConst new_target, int argc,
         return JS_EXCEPTION;
     }
 
-    JS_SetOpaque(obj, request);
+    if (ngx_qjs_fetch_object_set(cx, obj, request) != NGX_OK) {
+        JS_FreeValue(cx, obj);
+        return JS_EXCEPTION;
+    }
 
     return obj;
 }
@@ -521,10 +567,11 @@ static ngx_int_t
 ngx_qjs_request_ctor(JSContext *cx, ngx_js_request_t *request,
     ngx_url_t *u, int argc, JSValueConst *argv)
 {
-    JSValue            input, init, value;
-    ngx_int_t          rc;
-    ngx_pool_t        *pool;
-    ngx_js_request_t  *orig;
+    JSValue                  input, init, value;
+    ngx_int_t                rc;
+    ngx_pool_t              *pool;
+    ngx_js_request_t         *orig;
+    ngx_qjs_fetch_object_t   *object;
 
     input = argv[0];
     if (JS_IsUndefined(input)) {
@@ -549,8 +596,6 @@ ngx_qjs_request_ctor(JSContext *cx, ngx_js_request_t *request,
     request->body.data = NULL;
     request->body.len = 0;
     request->headers.guard = GUARD_REQUEST;
-    ngx_qjs_arg(request->header_value) = JS_UNDEFINED;
-
     pool = ngx_qjs_external_pool(cx, JS_GetContextOpaque(cx));
 
     rc = ngx_list_init(&request->headers.header_list, pool, 4,
@@ -571,13 +616,15 @@ ngx_qjs_request_ctor(JSContext *cx, ngx_js_request_t *request,
         }
 
     } else {
-        orig = JS_GetOpaque(input, NGX_QJS_CLASS_ID_FETCH_REQUEST);
-        if (orig == NULL) {
+        object = JS_GetOpaque(input, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+        if (object == NULL) {
             JS_ThrowTypeError(cx,
                               "input is not string or a Request object");
             return NGX_ERROR;
         }
 
+        orig = object->data;
+
         request->url = orig->url;
         request->method = orig->method;
         request->body = orig->body;
@@ -781,8 +828,6 @@ ngx_qjs_fetch_response_ctor(JSContext *cx, JSValueConst new_target, int argc,
 
     response->code = 200;
     response->headers.guard = GUARD_RESPONSE;
-    ngx_qjs_arg(response->header_value) = JS_UNDEFINED;
-
     ret = ngx_list_init(&response->headers.header_list, pool, 4,
                         sizeof(ngx_js_tb_elt_t));
     if (ret != NGX_OK) {
@@ -926,7 +971,10 @@ string:
         return JS_EXCEPTION;
     }
 
-    JS_SetOpaque(obj, response);
+    if (ngx_qjs_fetch_object_set(cx, obj, response) != NGX_OK) {
+        JS_FreeValue(cx, obj);
+        return JS_EXCEPTION;
+    }
 
     return obj;
 }
@@ -1194,8 +1242,6 @@ ngx_qjs_fetch_alloc(JSContext *cx, ngx_pool_t *pool, ngx_log_t *log,
     http->keepalive = (conf->fetch_keepalive > 0
                        && !ngx_js_conf_dynamic_proxy(conf));
 
-    ngx_qjs_arg(http->response.header_value) = JS_UNDEFINED;
-
     http->append_headers = ngx_qjs_fetch_append_headers;
     http->ready_handler = ngx_qjs_fetch_process_done;
     http->error_handler = ngx_qjs_fetch_error;
@@ -1354,7 +1400,15 @@ ngx_qjs_fetch_process_done(ngx_js_http_t *http)
         return;
     }
 
-    JS_SetOpaque(fetch->response_value, &http->response);
+    if (ngx_qjs_fetch_object_set(fetch->cx, fetch->response_value,
+                                 &http->response)
+        != NGX_OK)
+    {
+        JS_FreeValue(fetch->cx, fetch->response_value);
+        fetch->response_value = JS_UNDEFINED;
+        ngx_qjs_fetch_error(http, "fetch response creation failed");
+        return;
+    }
 
     ngx_qjs_fetch_done(fetch, fetch->response_value, NGX_OK);
 }
@@ -1933,7 +1987,8 @@ ngx_qjs_ext_fetch_request_body(JSContext *cx, JSValueConst this_val,
     JSValue            result;
     ngx_js_request_t  *request;
 
-    request = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    request = ngx_qjs_fetch_object_data(cx, this_val,
+                                        NGX_QJS_CLASS_ID_FETCH_REQUEST);
     if (request == NULL) {
         return JS_UNDEFINED;
     }
@@ -1998,7 +2053,8 @@ ngx_qjs_ext_fetch_request_body_used(JSContext *cx, JSValueConst this_val)
 {
     ngx_js_request_t  *request;
 
-    request = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    request = ngx_qjs_fetch_object_data(cx, this_val,
+                                        NGX_QJS_CLASS_ID_FETCH_REQUEST);
     if (request == NULL) {
         return JS_UNDEFINED;
     }
@@ -2012,7 +2068,8 @@ ngx_qjs_ext_fetch_request_cache(JSContext *cx, JSValueConst this_val)
 {
     ngx_js_request_t  *request;
 
-    request = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    request = ngx_qjs_fetch_object_data(cx, this_val,
+                                        NGX_QJS_CLASS_ID_FETCH_REQUEST);
     if (request == NULL) {
         return JS_UNDEFINED;
     }
@@ -2027,7 +2084,8 @@ ngx_qjs_ext_fetch_request_credentials(JSContext *cx, JSValueConst this_val)
 {
     ngx_js_request_t  *request;
 
-    request = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    request = ngx_qjs_fetch_object_data(cx, this_val,
+                                        NGX_QJS_CLASS_ID_FETCH_REQUEST);
     if (request == NULL) {
         return JS_UNDEFINED;
     }
@@ -2040,15 +2098,17 @@ ngx_qjs_ext_fetch_request_credentials(JSContext *cx, JSValueConst this_val)
 static JSValue
 ngx_qjs_ext_fetch_request_headers(JSContext *cx, JSValueConst this_val)
 {
-    JSValue           header;
-    ngx_js_request_t  *request;
+    JSValue                  header;
+    ngx_js_request_t        *request;
+    ngx_qjs_fetch_object_t  *object;
 
-    request = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
-    if (request == NULL) {
+    object = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    if (object == NULL) {
         return JS_UNDEFINED;
     }
 
-    header = ngx_qjs_arg(request->header_value);
+    request = object->data;
+    header = object->header_value;
 
     if (JS_IsUndefined(header)) {
         header = JS_NewObjectClass(cx, NGX_QJS_CLASS_ID_FETCH_HEADERS);
@@ -2058,7 +2118,7 @@ ngx_qjs_ext_fetch_request_headers(JSContext *cx, JSValueConst this_val)
 
         JS_SetOpaque(header, &request->headers);
 
-        ngx_qjs_arg(request->header_value) = header;
+        object->header_value = header;
     }
 
     return JS_DupValue(cx, header);
@@ -2071,7 +2131,8 @@ ngx_qjs_ext_fetch_request_field(JSContext *cx, JSValueConst this_val, int magic)
     ngx_str_t         *field;
     ngx_js_request_t  *request;
 
-    request = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    request = ngx_qjs_fetch_object_data(cx, this_val,
+                                        NGX_QJS_CLASS_ID_FETCH_REQUEST);
     if (request == NULL) {
         return JS_UNDEFINED;
     }
@@ -2087,7 +2148,8 @@ ngx_qjs_ext_fetch_request_mode(JSContext *cx, JSValueConst this_val)
 {
     ngx_js_request_t  *request;
 
-    request = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    request = ngx_qjs_fetch_object_data(cx, this_val,
+                                        NGX_QJS_CLASS_ID_FETCH_REQUEST);
     if (request == NULL) {
         return JS_UNDEFINED;
     }
@@ -2096,14 +2158,31 @@ ngx_qjs_ext_fetch_request_mode(JSContext *cx, JSValueConst this_val)
 }
 
 
+static void
+ngx_qjs_fetch_request_mark(JSRuntime *rt, JSValueConst val,
+    JS_MarkFunc *mark_func)
+{
+    ngx_qjs_fetch_object_t  *object;
+
+    object = JS_GetOpaque(val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    if (object != NULL) {
+        JS_MarkValue(rt, object->header_value, mark_func);
+    }
+}
+
+
 static void
 ngx_qjs_fetch_request_finalizer(JSRuntime *rt, JSValue val)
 {
-    ngx_js_request_t  *request;
+    ngx_qjs_fetch_object_t  *object;
 
-    request = JS_GetOpaque(val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    object = JS_GetOpaque(val, NGX_QJS_CLASS_ID_FETCH_REQUEST);
+    if (object == NULL) {
+        return;
+    }
 
-    JS_FreeValueRT(rt, ngx_qjs_arg(request->header_value));
+    JS_FreeValueRT(rt, object->header_value);
+    js_free_rt(rt, object);
 }
 
 
@@ -2112,7 +2191,8 @@ ngx_qjs_ext_fetch_response_status(JSContext *cx, JSValueConst this_val)
 {
     ngx_js_response_t  *response;
 
-    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    response = ngx_qjs_fetch_object_data(cx, this_val,
+                                         NGX_QJS_CLASS_ID_FETCH_RESPONSE);
     if (response == NULL) {
         return JS_UNDEFINED;
     }
@@ -2126,7 +2206,8 @@ ngx_qjs_ext_fetch_response_status_text(JSContext *cx, JSValueConst this_val)
 {
     ngx_js_response_t  *response;
 
-    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    response = ngx_qjs_fetch_object_data(cx, this_val,
+                                         NGX_QJS_CLASS_ID_FETCH_RESPONSE);
     if (response == NULL) {
         return JS_UNDEFINED;
     }
@@ -2141,7 +2222,8 @@ ngx_qjs_ext_fetch_response_ok(JSContext *cx, JSValueConst this_val)
 {
     ngx_js_response_t  *response;
 
-    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    response = ngx_qjs_fetch_object_data(cx, this_val,
+                                         NGX_QJS_CLASS_ID_FETCH_RESPONSE);
     if (response == NULL) {
         return JS_UNDEFINED;
     }
@@ -2155,7 +2237,8 @@ ngx_qjs_ext_fetch_response_body_used(JSContext *cx, JSValueConst this_val)
 {
     ngx_js_response_t  *response;
 
-    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    response = ngx_qjs_fetch_object_data(cx, this_val,
+                                         NGX_QJS_CLASS_ID_FETCH_RESPONSE);
     if (response == NULL) {
         return JS_UNDEFINED;
     }
@@ -2167,15 +2250,17 @@ ngx_qjs_ext_fetch_response_body_used(JSContext *cx, JSValueConst this_val)
 static JSValue
 ngx_qjs_ext_fetch_response_headers(JSContext *cx, JSValueConst this_val)
 {
-    JSValue            header;
-    ngx_js_response_t  *response;
+    JSValue                  header;
+    ngx_js_response_t       *response;
+    ngx_qjs_fetch_object_t  *object;
 
-    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
-    if (response == NULL) {
+    object = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    if (object == NULL) {
         return JS_UNDEFINED;
     }
 
-    header = ngx_qjs_arg(response->header_value);
+    response = object->data;
+    header = object->header_value;
 
     if (JS_IsUndefined(header)) {
         header = JS_NewObjectClass(cx, NGX_QJS_CLASS_ID_FETCH_HEADERS);
@@ -2185,7 +2270,7 @@ ngx_qjs_ext_fetch_response_headers(JSContext *cx, JSValueConst this_val)
 
         JS_SetOpaque(header, &response->headers);
 
-        ngx_qjs_arg(response->header_value) = header;
+        object->header_value = header;
     }
 
     return JS_DupValue(cx, header);
@@ -2197,7 +2282,8 @@ ngx_qjs_ext_fetch_response_type(JSContext *cx, JSValueConst this_val)
 {
     ngx_js_response_t  *response;
 
-    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    response = ngx_qjs_fetch_object_data(cx, this_val,
+                                         NGX_QJS_CLASS_ID_FETCH_RESPONSE);
     if (response == NULL) {
         return JS_UNDEFINED;
     }
@@ -2215,7 +2301,8 @@ ngx_qjs_ext_fetch_response_body(JSContext *cx, JSValueConst this_val,
     njs_str_t           string;
     ngx_js_response_t  *response;
 
-    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    response = ngx_qjs_fetch_object_data(cx, this_val,
+                                         NGX_QJS_CLASS_ID_FETCH_RESPONSE);
     if (response == NULL) {
         return JS_UNDEFINED;
     }
@@ -2281,7 +2368,8 @@ ngx_qjs_ext_fetch_response_redirected(JSContext *cx, JSValueConst this_val)
 {
     ngx_js_response_t  *response;
 
-    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    response = ngx_qjs_fetch_object_data(cx, this_val,
+                                         NGX_QJS_CLASS_ID_FETCH_RESPONSE);
     if (response == NULL) {
         return JS_UNDEFINED;
     }
@@ -2296,7 +2384,8 @@ ngx_qjs_ext_fetch_response_field(JSContext *cx, JSValueConst this_val, int magic
     ngx_str_t          *field;
     ngx_js_response_t  *response;
 
-    response = JS_GetOpaque2(cx, this_val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    response = ngx_qjs_fetch_object_data(cx, this_val,
+                                         NGX_QJS_CLASS_ID_FETCH_RESPONSE);
     if (response == NULL) {
         return JS_UNDEFINED;
     }
@@ -2307,15 +2396,31 @@ ngx_qjs_ext_fetch_response_field(JSContext *cx, JSValueConst this_val, int magic
 }
 
 
+static void
+ngx_qjs_fetch_response_mark(JSRuntime *rt, JSValueConst val,
+    JS_MarkFunc *mark_func)
+{
+    ngx_qjs_fetch_object_t  *object;
+
+    object = JS_GetOpaque(val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    if (object != NULL) {
+        JS_MarkValue(rt, object->header_value, mark_func);
+    }
+}
+
+
 static void
 ngx_qjs_fetch_response_finalizer(JSRuntime *rt, JSValue val)
 {
-    ngx_js_response_t  *response;
+    ngx_qjs_fetch_object_t  *object;
 
-    response = JS_GetOpaque(val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    object = JS_GetOpaque(val, NGX_QJS_CLASS_ID_FETCH_RESPONSE);
+    if (object == NULL) {
+        return;
+    }
 
-    JS_FreeValueRT(rt, ngx_qjs_arg(response->header_value));
-    njs_chb_destroy(&response->chain);
+    JS_FreeValueRT(rt, object->header_value);
+    js_free_rt(rt, object);
 }
 
 
diff --git a/nginx/t/js_fetch_object_lifetime.t b/nginx/t/js_fetch_object_lifetime.t
new file mode 100644 (file)
index 0000000..c1d4fb2
--- /dev/null
@@ -0,0 +1,158 @@
+#!/usr/bin/perl
+
+# (C) Dmitry Volyntsev
+# (C) F5, Inc.
+
+# Tests for QuickJS Fetch object finalization after pool destruction.
+
+###############################################################################
+
+use warnings;
+use strict;
+
+use Test::More;
+
+BEGIN { use FindBin; chdir($FindBin::Bin); }
+
+use lib 'lib';
+use Test::Nginx;
+
+###############################################################################
+
+select STDERR; $| = 1;
+select STDOUT; $| = 1;
+
+my $t = Test::Nginx->new()->has(qw/http rewrite/)
+    ->write_file_expand('nginx.conf', <<'EOF');
+
+%%TEST_GLOBALS%%
+
+daemon off;
+worker_processes 1;
+
+events {
+}
+
+http {
+    %%TEST_GLOBALS_HTTP%%
+
+    js_engine qjs;
+    js_import test.js;
+
+    server {
+        listen       127.0.0.1:8080;
+        server_name  localhost;
+
+        location /reuse {
+            js_context_reuse 1;
+            js_content test.reuse;
+        }
+
+        location /no_reuse {
+            js_context_reuse 0;
+            js_content test.no_reuse;
+        }
+
+        location /target {
+            add_header X-Test preserved;
+            return 200 body;
+        }
+    }
+}
+
+EOF
+
+my $p0 = port(8080);
+
+$t->write_file('test.js', <<EOF);
+    var retained;
+
+    async function reuse(r) {
+        var result = 'ok';
+
+        switch (r.args.op) {
+        case 'request':
+            retained = new Request('http://127.0.0.1:$p0/', {
+                headers: {x: 'request'}
+            });
+            result = retained.headers.get('x');
+            break;
+
+        case 'response':
+            retained = new Response('response', {
+                headers: {x: 'response'}
+            });
+            result = retained.headers.get('x');
+            break;
+
+        case 'fetch':
+            retained = await ngx.fetch('http://127.0.0.1:$p0/target');
+            result = retained.headers.get('x-test');
+            break;
+
+        case 'release':
+            retained = null;
+            break;
+        }
+
+        r.return(200, result);
+    }
+
+    async function no_reuse(r) {
+        var object;
+
+        switch (r.args.type) {
+        case 'request':
+            object = new Request('http://127.0.0.1:$p0/');
+            break;
+
+        case 'response':
+            object = new Response('response');
+            break;
+
+        case 'fetch':
+            object = await ngx.fetch('http://127.0.0.1:$p0/target');
+            break;
+
+        case 'request_headers':
+            object = new Request('http://127.0.0.1:$p0/');
+            object.headers.owner = object;
+            r.return(200, 'ok');
+            return;
+
+        case 'response_headers':
+            object = new Response('response');
+            object.headers.owner = object;
+            r.return(200, 'ok');
+            return;
+        }
+
+        object.self = object;
+        r.return(200, 'ok');
+    }
+
+    export default {reuse, no_reuse};
+EOF
+
+$t->try_run('no QuickJS support')->plan(11);
+
+###############################################################################
+
+like(http_get('/reuse?op=request'), qr/request$/s, 'retain Request');
+like(http_get('/reuse?op=release'), qr/200 OK/, 'release Request');
+
+like(http_get('/reuse?op=response'), qr/response$/s, 'retain Response');
+like(http_get('/reuse?op=release'), qr/200 OK/, 'release Response');
+
+like(http_get('/reuse?op=fetch'), qr/preserved$/s, 'retain fetch Response');
+like(http_get('/reuse?op=release'), qr/200 OK/, 'release fetch Response');
+
+like(http_get('/no_reuse?type=request'), qr/200 OK/, 'Request cycle');
+like(http_get('/no_reuse?type=response'), qr/200 OK/, 'Response cycle');
+like(http_get('/no_reuse?type=fetch'), qr/200 OK/, 'fetch Response cycle');
+like(http_get('/no_reuse?type=request_headers'), qr/200 OK/,
+    'Request Headers cycle');
+like(http_get('/no_reuse?type=response_headers'), qr/200 OK/,
+    'Response Headers cycle');
+
+###############################################################################