feat(nativets): bound fetch on a peer that never answers (#11026)

* feat(nativets): bound fetch on a peer that never answers

deno_fetch applies no deadline of any kind. A peer that completes the TCP
handshake, accepts the request and then goes silent leaves `await fetch(...)`
pending indefinitely, holding its worker slot until the *job* timeout -- which
on self-hosted defaults to DEFAULT_SELFHOSTED_TIMEOUT, i.e. 7 days.

Nothing else catches this. Zombie-job detection keys off a stale
v2_job_runtime.ping, and a worker blocked inside a pending fetch keeps pinging
normally throughout: the worker is alive and healthy, only the work is dead.

What this bounds is the wait for a response to begin, and it stops there:

  - a peer that never answers            -> rejected after N seconds
  - a peer slow to answer, but under N   -> unaffected
  - a body that then streams for an hour,
    or is read slowly by the caller      -> unaffected, always

That last line rules out the obvious implementation: AbortSignal.timeout(N)
around every fetch would bound the hang and break every streaming response and
long download. This is a hang detector, not a latency budget.

Default 300s via WINDMILL_FETCH_RESPONSE_TIMEOUT_SECS (0 disables), with a
per-script `//fetch_response_timeout <seconds>` annotation alongside the
existing //useragent and //proxy. Both nativets paths inherit it, since
eval_fetch_timeout and the dedicated-worker path in bun_executor both funnel
through create_nativets_runtime.

The ms value is clamped to i32::MAX: deno_web's setTimeout runs its delay
through webidl.converters.long, a 32-bit conversion that *wraps*, so a setting
past ~24.8 days would come out negative and fire immediately -- turning an
over-generous timeout into an instant one on every fetch.

The window covers connect, TLS and request upload as well as server think
time, so a very slow large upload is bounded by it too; the error message says
so rather than claiming the connection went silent.

Not covered: a body that stalls midway. Reaching that needs the response's
InnerBody, which deno_fetch keeps module-private, and every way to wrap it
from outside changes observable Response semantics (locking, bodyUsed,
double-consume errors). Left for a follow-up in deno_fetch itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JshNT5XVH78ZsTMvDWfFHb

* test(nativets): cover the instance-wide response-timeout env var

A typo in WINDMILL_FETCH_RESPONSE_TIMEOUT_SECS would compile, pass every
other test, and silently hand every operator the 300s default -- the same
class of silent-default failure the timeout itself exists to prevent. Its
own test binary, since a LazyLock resolves the value once per process.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JshNT5XVH78ZsTMvDWfFHb

* fix(nativets): inherit the caller's RequestInit, and clear the long-poll ceiling

Two problems with the first cut, both found in review.

`{ ...init, signal }` copied only own enumerable properties, but RequestInit is
a WebIDL dictionary whose members deno_fetch reads with plain property gets
that walk the prototype chain. Anything inherited or non-enumerable was
dropped: `fetch(url, Object.create({method: "POST"}))` silently became a GET.
Worse, a non-object init went from a loud TypeError to a silent GET, because
spreading "POST" yields {0:"P",1:"O",...} -- a valid dictionary with ignored
keys. Now the init is inherited from rather than copied, and a non-dictionary
is handed straight back to deno_fetch for its own TypeError.

The 300s default also sat at half of TIMEOUT_WAIT_RESULT (600s), which
run_wait_result long-polls against with no response headers. A script running
another job synchronously for 300-600s would have timed out client-side while
the server was still legitimately holding the request open -- the long-poll
risk class, instantiated inside the product and reachable without writing a
raw fetch. Default raised to 900s, with the constraint recorded where someone
would break it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JshNT5XVH78ZsTMvDWfFHb

* fix(nativets): hand the caller's RequestInit to Request untouched

Carrying a WebIDL dictionary across by hand has no safe form, and both
previous attempts were wrong in opposite directions. Spreading a copy drops
inherited and non-enumerable members, and turns a non-object init from a loud
TypeError into a silent GET. Inheriting from it via Object.create fixes those
but makes the child the receiver, so an accessor on the original runs against
an object that lacks its private-field brand:

    Cannot read private member #body from an object whose class did not
    declare it

So don't carry it at all. fetch()'s own first act is `new Request(input,
init)`; doing that here hands the init to the same constructor, read exactly
as it would be without this wrapper, and our signal travels in an init we own.
`req.signal` is then deno's own resolution of init.signal over an input
Request's signal, which removes the hand-rolled version of that rule too.

The Request is built twice as a result, once here and once inside fetch. That
is cheap: cloneInnerRequest carries method, headers, redirect mode, clientRid
and blob entry, and a body is proxied rather than buffered -- a static body is
a shallow {body, consumed} copy sharing its bytes, a stream gets a one-chunk
pass-through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JshNT5XVH78ZsTMvDWfFHb

* fix(nativets): keep an aborted fetch settling in the same tick

deno_fetch keeps its outer fetch non-async on purpose: "WPT has a test that
aborted fetch is settled in the same tick. This means we cannot wrap the
promise if it is already settled" (26_fetch.js). An `async` wrapper adopts
that promise through another one, so a rejection that used to land before any
microtask queued after the call now lands after it.

Made the wrapper non-async, with an early return that hands deno's settled
rejection straight back for an already-aborted signal, and no timer armed
there since there is no response to wait for. Construction still has to reject
rather than throw, so it is caught and returned as a rejection, which is what
the `async` was buying.

The comment claiming this matched deno_fetch's own `async function fetch` was
wrong on two counts -- that function is not async, and the wrapper was not
matching it. Replaced with the constraint that actually holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JshNT5XVH78ZsTMvDWfFHb

* fix(nativets): keep fetch's observable shape and reach intrinsics safely

Three ways the wrapper was distinguishable from the fetch it replaces, all
observable from a script sharing the isolate.

`.then` was an ordinary property lookup, so `Promise.prototype.then =
undefined` broke fetch after the request had already gone out. deno's own
modules reach intrinsics through primordials, and this file already captured
setTimeout, clearTimeout and Promise.reject for exactly that reason, so the
lookup was the odd one out. Now captured alongside them.

Declaring `init` without a default made `fetch.length` 2 where the standard
says 1. And the empty-call branch forwarded two explicit `undefined`s, so
deno's required-argument check saw two arguments and raised "Invalid URL:
'undefined'" instead of "1 argument required". Forwarding through
ReflectApply preserves the count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JshNT5XVH78ZsTMvDWfFHb

* docs: clarify fetch timeout restart requirements

* fix: capture native fetch abort helpers

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-08 16:46:12 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 946756ae83
commit 785277e0bb
8 changed files with 810 additions and 12 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ mod prewarmed_isolate_tests {
use windmill_worker::{build_loader, LoaderMode, BUN_PATH};
fn default_annotation() -> NativeAnnotation {
NativeAnnotation { useragent: None, proxy: None }
NativeAnnotation::default()
}
/// Bundle a TypeScript script into JS suitable for `PrewarmedIsolate`.
+51 -6
View File
@@ -26,7 +26,7 @@ use std::{
cell::RefCell,
path::PathBuf,
rc::Rc,
sync::{Arc, Mutex},
sync::{Arc, LazyLock, Mutex},
};
// Re-export deno_telemetry for use by windmill-worker's otel proxy
@@ -182,10 +182,33 @@ struct LogString {
pub s: mpsc::UnboundedSender<String>,
}
#[derive(Clone)]
#[derive(Clone, Default)]
pub struct NativeAnnotation {
pub useragent: Option<String>,
pub proxy: Option<(String, Option<(String, String)>)>,
/// `//fetch_response_timeout <seconds>`: per-script override of
/// [`default_fetch_response_timeout_secs`]. `Some(0)` disables it for this
/// script; `None` leaves the default in force.
pub fetch_response_timeout_secs: Option<u64>,
}
/// How long `fetch()` waits for a response to begin, in seconds; `0` disables.
///
/// Covers everything up to the response headers and stops there, so a body may
/// then stream for any length of time. `src/runtime.js` holds the semantics.
///
/// Must exceed `TIMEOUT_WAIT_RESULT` (default 600), which holds synchronous job
/// calls open without headers. Raising that hot-reloaded instance setting may
/// also require raising `WINDMILL_FETCH_RESPONSE_TIMEOUT_SECS` in the deployment
/// and restarting workers: this environment value is cached for the process.
pub fn default_fetch_response_timeout_secs() -> u64 {
static SECS: LazyLock<u64> = LazyLock::new(|| {
std::env::var("WINDMILL_FETCH_RESPONSE_TIMEOUT_SECS")
.ok()
.and_then(|x| x.trim().parse::<u64>().ok())
.unwrap_or(900)
});
*SECS
}
/// Serializes V8 isolate creation as defense-in-depth against concurrent
@@ -401,7 +424,7 @@ pub fn transpile_ts(expr: String) -> anyhow::Result<String> {
}
pub fn get_annotation(inner_content: &str) -> NativeAnnotation {
let mut res = NativeAnnotation { useragent: None, proxy: None };
let mut res = NativeAnnotation::default();
let anns = inner_content
.lines()
@@ -414,6 +437,13 @@ pub fn get_annotation(inner_content: &str) -> NativeAnnotation {
res.useragent = Some(ann.trim_start_matches("useragent").trim().to_string());
} else if ann.starts_with("proxy") {
res.proxy = capture_proxy(ann.trim_start_matches("proxy").trim());
} else if ann.starts_with("fetch_response_timeout") {
// A typo falls back to the default, never to "no timeout".
res.fetch_response_timeout_secs = ann
.trim_start_matches("fetch_response_timeout")
.trim()
.parse::<u64>()
.ok();
}
}
res
@@ -554,6 +584,16 @@ pub(crate) fn create_nativets_runtime(
let ops = vec![op_get_static_args(), op_log()];
let ext = Extension { name: "windmill", ops: ops.into(), ..Default::default() };
// deno_web's setTimeout puts its delay through `webidl.converters.long`,
// which wraps at 32 bits: past i32::MAX ms (~24.8 days) the delay comes out
// negative and fires immediately, so an over-generous setting would abort
// every fetch on the spot. Cap rather than wrap.
let fetch_response_timeout_ms = ann
.fetch_response_timeout_secs
.unwrap_or_else(default_fetch_response_timeout_secs)
.saturating_mul(1000)
.min(i32::MAX as u64);
let fetch_options = deno_fetch::Options {
root_cert_store_provider: NATIVE_ROOT_CERT_STORE_PROVIDER.clone(),
user_agent: ann.useragent.unwrap_or_else(|| "windmill/beta".to_string()),
@@ -625,10 +665,15 @@ pub(crate) fn create_nativets_runtime(
}
// Per-isolate JS init that can't run in the snapshot (runtime.js executes at
// snapshot-build time): currently seeds performance.timeOrigin via
// setTimeOrigin(), which must read this isolate's wall clock.
// snapshot-build time): the wall clock behind performance.timeOrigin and the
// fetch response timeout are both per-isolate values.
js_runtime
.execute_script("<wm_init>", "globalThis.__wmInitPerIsolate()")
.execute_script(
"<wm_init>",
format!(
"globalThis.__wmInitPerIsolate({{ fetchResponseTimeoutMs: {fetch_response_timeout_ms} }})"
),
)
.map_err(windmill_common::error::to_anyhow)?;
Ok(CreatedRuntime { js_runtime, log_receiver, memory_limit_rx })
@@ -30,9 +30,115 @@ import * as performance from "ext:deno_web/15_performance.js";
import "ext:deno_web/16_image_data.js";
import "ext:deno_fetch/27_eventsource.js";
// deno_fetch applies no deadline, so a peer that accepts a request and then
// never answers leaves `await fetch(...)` pending until the job timeout, which
// self-hosted defaults to 7 days.
const ORIGINAL_FETCH = fetch.fetch;
// deno_web's timers reject any `this` other than undefined/globalThis, so
// `timers.setTimeout(...)` passes the module namespace and throws "Illegal
// invocation".
const setTimeoutUnbound = timers.setTimeout;
const clearTimeoutUnbound = timers.clearTimeout;
// Captured before user code shares the isolate and could redefine them, the
// way deno's own modules reach intrinsics through primordials.
const PromiseReject = Promise.reject.bind(Promise);
const promiseThen = Function.prototype.call.bind(Promise.prototype.then);
const abortSignalAny = abortSignal.AbortSignal.any.bind(abortSignal.AbortSignal);
const abortControllerAbort = Function.prototype.call.bind(
abortSignal.AbortController.prototype.abort,
);
const ReflectApply = Reflect.apply;
// Installed per isolate by __wmInitPerIsolate; 0 disables. Only a backstop
// for the impossible case of fetch running before that init.
let fetchResponseTimeoutMs = 900_000;
function fetchResponseTimeoutError(requestUrl, timeoutMs) {
let target;
try {
const parsed = new url.URL(requestUrl);
// Query and fragment routinely carry tokens, and this reaches a job log.
target = parsed.origin + parsed.pathname;
} catch {
target = "the request target";
}
return new domException.DOMException(
`fetch to ${target} timed out: no response headers arrived within ` +
`${Math.round(timeoutMs / 1000)}s (this covers connect, request upload ` +
`and the wait for the server to start replying; once a response begins ` +
`it is never interrupted). Change it per script with ` +
`"//fetch_response_timeout <seconds>" (0 disables), or instance-wide ` +
`with WINDMILL_FETCH_RESPONSE_TIMEOUT_SECS.`,
"TimeoutError",
);
}
globalThis.atob = base64.atob;
globalThis.btoa = base64.btoa;
globalThis.fetch = fetch.fetch;
// Not `async`, for the same reason deno_fetch's own outer fetch isn't: WPT
// pins that an aborted fetch settles in the same tick, which adopting its
// promise through another one would break. Construction still has to reject
// rather than throw, so it is caught and handed back as a rejection.
globalThis.fetch = function fetch(input, init = undefined) {
const timeoutMs = fetchResponseTimeoutMs;
// Forwarded with the original argument count, so deno still sees an empty
// call as empty and raises its own "1 argument required". The default on
// `init` is what keeps `fetch.length` at 1, as the standard has it.
if (!(timeoutMs > 0) || arguments.length < 1) {
return ReflectApply(ORIGINAL_FETCH, undefined, arguments);
}
let req;
let controller;
let signal;
try {
// RequestInit is a WebIDL dictionary: copying it drops inherited and
// non-enumerable members, and inheriting from it runs accessors against the
// wrong receiver. Hand it to the same Request constructor fetch() would,
// and carry our own signal in an init of our own.
req = new request.Request(input, init);
// `req.signal` is deno's own resolution of init.signal over an input
// Request's signal, so combining with it preserves the caller's abort and
// reason while ours only adds a ceiling.
controller = new abortSignal.AbortController();
signal = abortSignalAny([req.signal, controller.signal]);
} catch (e) {
return PromiseReject(e);
}
// Already aborted: hand back deno's own settled rejection untouched, and arm
// nothing -- there is no response to wait for.
if (signal.aborted) {
return ORIGINAL_FETCH(req, { signal });
}
let timer = setTimeoutUnbound(() => {
timer = undefined;
abortControllerAbort(controller, fetchResponseTimeoutError(req.url, timeoutMs));
}, timeoutMs);
// Disarmed on headers, never on body completion: a response that has begun
// arriving must be free to stream for as long as it needs.
const disarm = () => {
if (timer !== undefined) {
clearTimeoutUnbound(timer);
timer = undefined;
}
};
return promiseThen(
ORIGINAL_FETCH(req, { signal }),
(res) => {
disarm();
return res;
},
(e) => {
disarm();
throw e;
},
);
};
globalThis.Request = request.Request;
globalThis.Response = response.Response;
globalThis.Blob = file.Blob;
@@ -123,7 +229,11 @@ Object.assign(globalThis, {
// Per-isolate init, invoked from Rust after the snapshot is restored (this
// module body runs at snapshot-build time, not per isolate).
globalThis.__wmInitPerIsolate = () => {
globalThis.__wmInitPerIsolate = (config) => {
if (config != null && typeof config.fetchResponseTimeoutMs === "number") {
fetchResponseTimeoutMs = config.fetchResponseTimeoutMs;
}
// setTimeOrigin() seeds performance.timeOrigin from the isolate's wall clock;
// without it timeOrigin is undefined and `timeOrigin + performance.now()` is NaN.
performance.setTimeOrigin();
@@ -24,7 +24,7 @@ use crate::{transpile_ts, NativeAnnotation, PrewarmedIsolate, PrewarmedResult};
/// positional args, and return the isolate's result + captured logs.
async fn run_ts(ts: &str, arg_names: &[&str], args: serde_json::Value) -> PrewarmedResult {
let js = transpile_ts(ts.to_string()).expect("transpile_ts failed");
let ann = NativeAnnotation { useragent: None, proxy: None };
let ann = NativeAnnotation::default();
let arg_names: Vec<String> = arg_names.iter().map(|s| s.to_string()).collect();
let mut iso = PrewarmedIsolate::spawn(String::new(), js, ann, arg_names, None);
iso.wait_ready().await.expect("isolate failed to pre-warm");
@@ -232,7 +232,7 @@ export async function main(i: number): Promise<number> {
for i in 0..N {
let js = js.clone();
let h = tokio::spawn(async move {
let ann = NativeAnnotation { useragent: None, proxy: None };
let ann = NativeAnnotation::default();
let mut iso =
PrewarmedIsolate::spawn(String::new(), js, ann, vec!["i".to_string()], None);
iso.wait_ready().await.expect("pre-warm failed");
@@ -0,0 +1,37 @@
//! `//fetch_response_timeout` parsing. Cheap (no V8), so it stays out of the
//! e2e file.
use windmill_runtime_nativets::get_annotation;
#[test]
fn a_value_is_parsed_and_zero_stays_distinct_from_absent() {
// Collapsing `Some(0)` to `None` would silently reinstate the default on a
// script that explicitly asked for no limit.
assert_eq!(
get_annotation("//native\n//fetch_response_timeout 30\n").fetch_response_timeout_secs,
Some(30)
);
assert_eq!(
get_annotation("//fetch_response_timeout 0\n").fetch_response_timeout_secs,
Some(0)
);
assert_eq!(
get_annotation("//native\n").fetch_response_timeout_secs,
None
);
}
#[test]
fn a_malformed_value_falls_back_to_the_default_not_to_no_timeout() {
for src in [
"//fetch_response_timeout abc\n",
"//fetch_response_timeout\n",
"//fetch_response_timeout -5\n",
] {
assert_eq!(
get_annotation(src).fetch_response_timeout_secs,
None,
"{src:?} should leave the default in force"
);
}
}
@@ -0,0 +1,593 @@
//! The nativets `fetch()` response timeout.
//!
//! Two halves of one contract, and a fix that satisfies only the first is worse
//! than no fix:
//!
//! 1. a peer that accepts a request and never answers is given up on
//! 2. a response that has begun arriving is never cut off, however long it
//! takes in total
//!
//! (2) rules out the obvious implementation — `AbortSignal.timeout(N)` around
//! every fetch would satisfy (1) and break every streaming response and long
//! download.
//!
//! Hermetic: loopback listeners, no egress.
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use windmill_runtime_nativets::{transpile_ts, NativeAnnotation, PrewarmedIsolate};
/// The annotation is in whole seconds, so the tests scale around this.
const TIMEOUT_SECS: u64 = 2;
async fn run_with_timeout_secs(ts: &str, secs: u64) -> Result<String, String> {
let js = transpile_ts(ts.to_string()).expect("transpile_ts failed");
let ann =
NativeAnnotation { fetch_response_timeout_secs: Some(secs), ..NativeAnnotation::default() };
let mut iso = PrewarmedIsolate::spawn(String::new(), js, ann, vec![], None);
iso.wait_ready().await.expect("isolate failed to pre-warm");
let res = iso
.start_execution("{}".to_string())
.wait()
.await
.expect("isolate panicked");
res.result.map(|raw| raw.get().to_string())
}
/// A peer that reads the request and then answers nothing, closing only after
/// `close_after` so no test leaves an isolate wedged on a pending fetch.
///
/// The socket is held rather than dropped on accept: dropping it sends a FIN,
/// which surfaces as a connection error — the easy failure, not this one.
async fn spawn_silent_peer(seen: Arc<Mutex<Vec<u8>>>, close_after: Duration) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
let seen = seen.clone();
tokio::spawn(async move {
let mut buf = [0u8; 8192];
if let Ok(n) = sock.read(&mut buf).await {
seen.lock().await.extend_from_slice(&buf[..n]);
}
tokio::time::sleep(close_after).await;
drop(sock);
});
}
});
port
}
/// A peer that records the request it received and answers 200 immediately.
async fn spawn_echo_peer(seen: Arc<Mutex<Vec<u8>>>) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
let seen = seen.clone();
tokio::spawn(async move {
let mut buf = [0u8; 8192];
if let Ok(n) = sock.read(&mut buf).await {
seen.lock().await.extend_from_slice(&buf[..n]);
}
let _ = sock
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
.await;
});
}
});
port
}
/// A peer that responds after `headers_after`, then dribbles a chunked body out
/// over `chunks * chunk_every`.
async fn spawn_streaming_peer(
headers_after: Duration,
chunks: usize,
chunk_every: Duration,
) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
tokio::spawn(async move {
let mut buf = [0u8; 8192];
let _ = sock.read(&mut buf).await;
tokio::time::sleep(headers_after).await;
if sock
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\n\r\n",
)
.await
.is_err()
{
return;
}
for _ in 0..chunks {
tokio::time::sleep(chunk_every).await;
if sock.write_all(b"1\r\nx\r\n").await.is_err() {
return;
}
}
let _ = sock.write_all(b"0\r\n\r\n").await;
});
}
});
port
}
const POST_TO_SILENT_PEER: &str = r#"
export async function main(): Promise<number> {
const res = await fetch("http://127.0.0.1:{port}/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: 1 }),
});
return res.status;
}
"#;
fn post_script(port: u16) -> String {
POST_TO_SILENT_PEER.replace("{port}", &port.to_string())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_peer_that_never_answers_is_given_up_on() {
let seen = Arc::new(Mutex::new(Vec::new()));
let port = spawn_silent_peer(seen.clone(), Duration::from_secs(60)).await;
let started = Instant::now();
let err = run_with_timeout_secs(&post_script(port), TIMEOUT_SECS)
.await
.expect_err("fetch against a peer that never answers must not resolve");
let elapsed = started.elapsed();
assert!(
elapsed >= Duration::from_secs(TIMEOUT_SECS),
"gave up after {elapsed:?}, before the configured {TIMEOUT_SECS}s -- \
the timeout is firing on something other than the wait for a response",
);
assert!(
elapsed < Duration::from_secs(TIMEOUT_SECS + 15),
"took {elapsed:?} to give up",
);
// The message has to stand on its own in a job log: a hung request is
// otherwise indistinguishable from a slow one.
assert!(
err.contains("no response headers arrived within"),
"error should explain what timed out, got: {err}",
);
assert!(
err.contains("/orders") && err.contains("fetch_response_timeout"),
"error should name the target and how to change the limit, got: {err}",
);
// Without this the test would also pass if the request never went out.
let body = String::from_utf8_lossy(&seen.lock().await.clone()).to_string();
assert!(
body.contains("POST /orders"),
"peer should have received the request, got: {body}",
);
}
/// The counterfactual for the test above: with the timeout disabled, the same
/// script against the same peer is still running well past the point the
/// timeout would have fired.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn without_a_timeout_the_same_request_keeps_running() {
let seen = Arc::new(Mutex::new(Vec::new()));
// Closes eventually, so the isolate unwinds instead of pinning a blocking
// task for the rest of the test binary's life.
let port = spawn_silent_peer(seen, Duration::from_secs(TIMEOUT_SECS * 3)).await;
let still_running = tokio::time::timeout(
Duration::from_secs(TIMEOUT_SECS * 2),
run_with_timeout_secs(&post_script(port), 0),
)
.await
.is_err();
assert!(
still_running,
"with the timeout disabled the request should still have been pending \
at {}s -- if it ends on its own, the test above proves nothing",
TIMEOUT_SECS * 2,
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_streaming_body_outliving_the_timeout_is_not_cut_off() {
// Headers land fast, then the body trickles well past the timeout. A
// total-duration timeout fails here; that is the point of the test.
let port =
spawn_streaming_peer(Duration::from_millis(200), 10, Duration::from_millis(500)).await;
let ts = format!(
r#"
export async function main(): Promise<string> {{
const res = await fetch("http://127.0.0.1:{port}/stream");
return `${{res.status}}:${{(await res.text()).length}}`;
}}
"#
);
let started = Instant::now();
let out = run_with_timeout_secs(&ts, TIMEOUT_SECS)
.await
.expect("a streaming response must not be interrupted");
let elapsed = started.elapsed();
assert_eq!(out, "\"200:10\"", "full body should arrive intact");
assert!(
elapsed > Duration::from_secs(TIMEOUT_SECS),
"the transfer ({elapsed:?}) has to outlast the {TIMEOUT_SECS}s timeout \
for this to be exercising anything",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_slow_but_answering_peer_is_not_cut_off() {
// A quarter of the window rather than half: CI runs this at
// --test-threads=10 alongside other V8 isolates, and this margin is what
// absorbs executor starvation.
let port = spawn_streaming_peer(
Duration::from_millis(1_000 * TIMEOUT_SECS / 4),
1,
Duration::from_millis(10),
)
.await;
let ts = format!(
r#"
export async function main(): Promise<number> {{
const res = await fetch("http://127.0.0.1:{port}/slow");
await res.text();
return res.status;
}}
"#
);
let out = run_with_timeout_secs(&ts, TIMEOUT_SECS)
.await
.expect("a slow but answering peer must not be cut off");
assert_eq!(out, "200");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_over_large_timeout_does_not_wrap_into_an_instant_one() {
// deno_web's setTimeout puts its delay through `webidl.converters.long`,
// which wraps at 32 bits. Unclamped, this ~46-day setting wraps negative and
// aborts immediately -- asking for a longer leash would kill every fetch.
let port = spawn_streaming_peer(Duration::from_millis(50), 1, Duration::from_millis(10)).await;
let ts = format!(
r#"
export async function main(): Promise<number> {{
const res = await fetch("http://127.0.0.1:{port}/ok");
await res.text();
return res.status;
}}
"#
);
let out = run_with_timeout_secs(&ts, 4_000_000)
.await
.expect("an over-large timeout must not abort the request");
assert_eq!(out, "200");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_caller_abort_still_wins_with_its_own_reason() {
let seen = Arc::new(Mutex::new(Vec::new()));
let port = spawn_silent_peer(seen, Duration::from_secs(60)).await;
let ts = format!(
r#"
export async function main(): Promise<string> {{
const ac = new AbortController();
setTimeout(() => ac.abort(new Error("caller_abort_marker")), 200);
try {{
await fetch("http://127.0.0.1:{port}/probe", {{ signal: ac.signal }});
return "unexpectedly resolved";
}} catch (e) {{
return String((e as Error).message);
}}
}}
"#
);
let out = run_with_timeout_secs(&ts, TIMEOUT_SECS)
.await
.expect("script should catch its own abort");
assert!(
out.contains("caller_abort_marker"),
"caller's abort reason should survive being combined with ours, got: {out}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_timeout_is_catchable_as_a_timeout_error() {
// Scripts that retry on transient failures need to recognise this one;
// `TimeoutError` matches AbortSignal.timeout()'s reason.
let seen = Arc::new(Mutex::new(Vec::new()));
let port = spawn_silent_peer(seen, Duration::from_secs(60)).await;
let ts = format!(
r#"
export async function main(): Promise<string> {{
try {{
await fetch("http://127.0.0.1:{port}/probe");
return "unexpectedly resolved";
}} catch (e) {{
return (e as Error).name;
}}
}}
"#
);
let out = run_with_timeout_secs(&ts, TIMEOUT_SECS)
.await
.expect("script should catch the timeout");
assert_eq!(out, "\"TimeoutError\"");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_init_whose_members_are_inherited_is_not_flattened() {
// RequestInit is a WebIDL dictionary and deno_fetch reads its members with
// plain property gets, so they may sit on the prototype chain or be
// non-enumerable. Object spread copies neither, which would silently
// downgrade this POST to a GET and drop the header.
let seen = Arc::new(Mutex::new(Vec::new()));
let port = spawn_echo_peer(seen.clone()).await;
let ts = format!(
r#"
declare const Object: any;
export async function main(): Promise<number> {{
const base = {{ method: "POST", headers: {{ "x-probe": "yes" }} }};
const res = await fetch("http://127.0.0.1:{port}/inherited", Object.create(base));
return res.status;
}}
"#
);
let out = run_with_timeout_secs(&ts, TIMEOUT_SECS)
.await
.expect("request should succeed");
assert_eq!(out, "200");
let body = String::from_utf8_lossy(&seen.lock().await.clone()).to_string();
assert!(
body.starts_with("POST /inherited"),
"inherited `method` should survive, got: {body}",
);
assert!(
body.to_lowercase().contains("x-probe: yes"),
"inherited `headers` should survive, got: {body}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_non_dictionary_init_still_fails_loudly() {
// deno's dictionary converter throws on a non-object init. Copying members
// into a fresh object instead of inheriting would turn `"POST"` into
// {0:"P",1:"O",...} -- a valid dictionary with ignored keys, i.e. a silent
// GET where the caller used to get a TypeError.
let seen = Arc::new(Mutex::new(Vec::new()));
let port = spawn_echo_peer(seen).await;
let ts = format!(
r#"
export async function main(): Promise<string> {{
try {{
await fetch("http://127.0.0.1:{port}/x", "POST" as any);
return "unexpectedly resolved";
}} catch (e) {{
return (e as Error).name;
}}
}}
"#
);
let out = run_with_timeout_secs(&ts, TIMEOUT_SECS)
.await
.expect("script should catch the error");
assert_eq!(out, "\"TypeError\"");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_accessor_backed_init_reads_against_its_own_receiver() {
// A getter on the init must run with the object it was defined on as `this`,
// or a private field is unreachable and it throws. Carrying the init across
// by inheritance rather than by handing it to Request would break this.
let seen = Arc::new(Mutex::new(Vec::new()));
let port = spawn_echo_peer(seen.clone()).await;
let ts = format!(
r#"
class Init {{
#method = "POST";
#body = "from-private-field";
get method(): string {{ return this.#method; }}
get body(): string {{ return this.#body; }}
}}
export async function main(): Promise<number> {{
const res = await fetch("http://127.0.0.1:{port}/accessor", new Init() as any);
return res.status;
}}
"#
);
let out = run_with_timeout_secs(&ts, TIMEOUT_SECS)
.await
.expect("an accessor-backed init must not throw");
assert_eq!(out, "200");
let body = String::from_utf8_lossy(&seen.lock().await.clone()).to_string();
assert!(
body.starts_with("POST /accessor") && body.contains("from-private-field"),
"getter-provided method and body should both reach the wire, got: {body}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_request_input_keeps_its_body_and_headers() {
// The wrapper builds a Request and hands that to fetch, so the body survives
// one more construction than it used to -- deno proxies it rather than
// consuming it, and this pins that.
let seen = Arc::new(Mutex::new(Vec::new()));
let port = spawn_echo_peer(seen.clone()).await;
let ts = format!(
r#"
export async function main(): Promise<number> {{
const req = new Request("http://127.0.0.1:{port}/from-request", {{
method: "PUT",
headers: {{ "x-probe": "yes" }},
body: "payload-body",
}});
const res = await fetch(req);
return res.status;
}}
"#
);
let out = run_with_timeout_secs(&ts, TIMEOUT_SECS)
.await
.expect("a Request input must work");
assert_eq!(out, "200");
let body = String::from_utf8_lossy(&seen.lock().await.clone()).to_string();
assert!(
body.starts_with("PUT /from-request")
&& body.to_lowercase().contains("x-probe: yes")
&& body.contains("payload-body"),
"method, headers and body should all survive, got: {body}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_already_aborted_fetch_settles_in_the_same_tick() {
// deno_fetch keeps its outer fetch non-async and returns an already-settled
// rejection untouched, because WPT pins that an aborted fetch settles in the
// same tick. Adopting it through another promise pushes the rejection behind
// any microtask queued after the call.
let ts = r#"
export async function main(): Promise<string> {
const order: string[] = [];
const ac = new AbortController();
ac.abort();
const f = fetch("http://127.0.0.1:1/x", { signal: ac.signal })
.catch(() => { order.push("fetch"); });
Promise.resolve().then(() => { order.push("queued-after"); });
await f;
await new Promise<void>((r) => setTimeout(r, 0));
return order.join(",");
}
"#;
let out = run_with_timeout_secs(ts, TIMEOUT_SECS)
.await
.expect("script should run");
assert_eq!(
out, "\"fetch,queued-after\"",
"the rejection must land before a microtask queued after the call",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_wrapper_keeps_fetch_s_own_shape() {
// The wrapper is indistinguishable from deno's fetch on three counts a
// script can observe: its arity, the error for an empty call, and not
// depending on a mutable `Promise.prototype.then` the way an ordinary
// property lookup would.
let seen = Arc::new(Mutex::new(Vec::new()));
let port = spawn_echo_peer(seen).await;
let ts = format!(
r#"
declare const Promise: any;
declare const AbortSignal: any;
declare const AbortController: any;
export async function main(): Promise<string> {{
const arity = (fetch as any).length;
// The message, not just the type: forwarding two explicit `undefined`s
// would still throw a TypeError, just deno's invalid-URL one instead of
// its required-argument one.
let emptyCall = "resolved";
try {{
await (fetch as any)();
}} catch (e) {{
emptyCall = (e as Error).message.includes("1 argument required")
? "required-argument"
: `other(${{(e as Error).message}})`;
}}
// Patched only across the call: the wrapper reaches for these while
// building its return value, and awaiting under a patched Promise
// prototype would instead measure V8 treating it as a plain thenable.
const originalThen = Promise.prototype.then;
const originalAny = AbortSignal.any;
const originalAbort = AbortController.prototype.abort;
let pending: any;
try {{
Promise.prototype.then = undefined;
(AbortSignal as any).any = undefined;
(AbortController.prototype as any).abort = undefined;
pending = fetch("http://127.0.0.1:{port}/shape");
}} finally {{
Promise.prototype.then = originalThen;
(AbortSignal as any).any = originalAny;
(AbortController.prototype as any).abort = originalAbort;
}}
const status = (await pending).status;
return `${{arity}}:${{emptyCall}}:${{status}}`;
}}
"#
);
let out = run_with_timeout_secs(&ts, TIMEOUT_SECS)
.await
.expect("script should run");
assert_eq!(out, "\"1:required-argument:200\"");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_timer_aborts_through_a_captured_intrinsic() {
// The timeout has to fire for this one: `AbortController.prototype.abort`
// is patched out for the whole wait, so an ordinary lookup would throw
// inside the timer callback and leave the request pending forever.
let seen = Arc::new(Mutex::new(Vec::new()));
let port = spawn_silent_peer(seen, Duration::from_secs(TIMEOUT_SECS * 5)).await;
let ts = format!(
r#"
declare const AbortController: any;
export async function main(): Promise<string> {{
const originalAbort = AbortController.prototype.abort;
AbortController.prototype.abort = undefined;
try {{
await fetch("http://127.0.0.1:{port}/patched-abort");
return "unexpectedly resolved";
}} catch (e) {{
return (e as Error).name;
}} finally {{
AbortController.prototype.abort = originalAbort;
}}
}}
"#
);
let out = run_with_timeout_secs(&ts, TIMEOUT_SECS)
.await
.expect("the timeout must still fire");
assert_eq!(out, "\"TimeoutError\"");
}
@@ -136,7 +136,7 @@ export async function main(): Promise<number> {{
"#
);
let js = transpile_ts(ts).expect("transpile failed");
let ann = NativeAnnotation { useragent: None, proxy: None };
let ann = NativeAnnotation::default();
let mut iso = PrewarmedIsolate::spawn(String::new(), js, ann, vec![], None);
iso.wait_ready().await.expect("isolate failed to pre-warm");
@@ -0,0 +1,13 @@
//! Its own test binary: the setting is read once per process through a
//! `LazyLock`, so nothing else may resolve it first. Adding a second test to
//! this file breaks that isolation.
use windmill_runtime_nativets::default_fetch_response_timeout_secs;
#[test]
fn the_env_var_is_what_operators_actually_set() {
// A typo in the variable's name would compile, pass every other test, and
// silently hand every operator the built-in default instead.
std::env::set_var("WINDMILL_FETCH_RESPONSE_TIMEOUT_SECS", "17");
assert_eq!(default_fetch_response_timeout_secs(), 17);
}