mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 08:07:15 +00:00
* 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>
207 lines
8.0 KiB
Rust
207 lines
8.0 KiB
Rust
//! End-to-end coverage for the EE HTTP-tracing path on nativets.
|
|
//!
|
|
//! Pairs with `otel_init.rs` (which pins only the `OTEL_GLOBALS`
|
|
//! population contract). This test exercises the full chain that
|
|
//! actually delivers a span to a collector:
|
|
//!
|
|
//! `deno_telemetry::init` with EE config (Rust)
|
|
//! → `globalThis.__bootstrapOtel()` (JS, flips TRACING_ENABLED)
|
|
//! → user `fetch()` → deno_fetch's `builtinTracer().startSpan`
|
|
//! → `BatchSpanProcessor` → `HttpExporter` (OTLP/HTTP-binary)
|
|
//! → our mock OTLP listener captures the request bytes
|
|
//!
|
|
//! Without the v1.702.0 fix (#573 EE / #9163 OSS), the third arrow
|
|
//! panics in a tokio worker. With the fix in place, the span is
|
|
//! emitted and shows up at the listener — which is what the customer
|
|
//! is paying for when they enable HTTP tracing on nativets.
|
|
//!
|
|
//! `#[ignore]`'d: spins a V8 isolate (~seconds) and binds two TCP
|
|
//! listeners. Run with `cargo test -p windmill-runtime-nativets
|
|
//! --test otel_e2e -- --ignored`.
|
|
//!
|
|
//! Owns its own test binary so `OTEL_GLOBALS`'s `OnceCell` doesn't
|
|
//! race with `otel_init.rs`.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpListener;
|
|
use tokio::sync::Mutex;
|
|
|
|
use windmill_runtime_nativets::{deno_telemetry, transpile_ts, NativeAnnotation, PrewarmedIsolate};
|
|
|
|
/// Bind 127.0.0.1:0 and spawn an accept loop. Each connection is
|
|
/// read until idle/EOF, the body is appended to `captured`, then we
|
|
/// respond with HTTP 200. Returns the bound port.
|
|
async fn spawn_capturing_http(captured: Arc<Mutex<Vec<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 {
|
|
loop {
|
|
let Ok((mut sock, _)) = listener.accept().await else {
|
|
break;
|
|
};
|
|
let captured = captured.clone();
|
|
tokio::spawn(async move {
|
|
let mut buf = Vec::new();
|
|
let mut tmp = [0u8; 8192];
|
|
let _ = tokio::time::timeout(Duration::from_millis(300), async {
|
|
loop {
|
|
match sock.read(&mut tmp).await {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(n) => buf.extend_from_slice(&tmp[..n]),
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
let _ = sock
|
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
|
|
.await;
|
|
captured.lock().await.push(buf);
|
|
});
|
|
}
|
|
});
|
|
port
|
|
}
|
|
|
|
/// Initialize `deno_telemetry` with the exact `OtelConfig` shape that
|
|
/// the EE `load_internal_otel_exporter` ships in production. Keeps
|
|
/// this test in lockstep with the actual call site: if production
|
|
/// drifts away from `tracing_enabled + Capture`, this test breaks
|
|
/// before the customer-facing panic does.
|
|
fn init_with_ee_otel_config() {
|
|
deno_telemetry::init(
|
|
deno_telemetry::OtelRuntimeConfig {
|
|
runtime_name: "windmill-nativets".into(),
|
|
runtime_version: "0".into(),
|
|
},
|
|
deno_telemetry::OtelConfig {
|
|
tracing_enabled: true,
|
|
console: deno_telemetry::OtelConsoleConfig::Capture,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.expect("deno_telemetry init failed");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
#[ignore = "spins V8 + tcp listeners; run with --ignored"]
|
|
async fn fetch_after_init_otel_emits_span_to_collector() {
|
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
|
|
// 1. Stand up two listeners: one that pretends to be the user's
|
|
// fetch target, one that pretends to be the OTLP collector.
|
|
let fetch_hits: Arc<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let otlp_hits: Arc<Mutex<Vec<Vec<u8>>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let fetch_port = spawn_capturing_http(fetch_hits.clone()).await;
|
|
let otlp_port = spawn_capturing_http(otlp_hits.clone()).await;
|
|
|
|
// 2. Point the deno_telemetry exporter at the mock collector and
|
|
// initialize. Mirrors `load_internal_otel_exporter` in EE.
|
|
//
|
|
// SAFETY: test runs in its own process binary; no other thread
|
|
// reads OTEL_EXPORTER_OTLP_ENDPOINT before init returns.
|
|
unsafe {
|
|
std::env::set_var(
|
|
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
format!("http://127.0.0.1:{otlp_port}"),
|
|
);
|
|
}
|
|
init_with_ee_otel_config();
|
|
assert!(
|
|
deno_telemetry::OTEL_GLOBALS.get().is_some(),
|
|
"OTEL_GLOBALS missing — load_internal_otel_exporter's config regressed?"
|
|
);
|
|
|
|
// 3. Run user TS that bootstraps OTel and then issues a fetch
|
|
// at the mock target. `__bootstrapOtel` is fire-and-forget
|
|
// (resolves a dynamic import on the microtask queue); the
|
|
// `setTimeout` loop yields a few times so the import resolves
|
|
// and the `TRACING_ENABLED` flag is set before fetch runs.
|
|
let ts = format!(
|
|
r#"
|
|
declare const globalThis: any;
|
|
export async function main(): Promise<number> {{
|
|
globalThis.__bootstrapOtel();
|
|
// Yield multiple microtask + timer turns so the dynamic import
|
|
// in __bootstrapOtel resolves and TRACING_ENABLED flips before
|
|
// fetch runs (otherwise deno_fetch skips the span entirely).
|
|
for (let i = 0; i < 5; i++) {{
|
|
await new Promise<void>(r => setTimeout(r, 10));
|
|
}}
|
|
const resp = await fetch("http://127.0.0.1:{fetch_port}/probe");
|
|
return resp.status;
|
|
}}
|
|
"#
|
|
);
|
|
let js = transpile_ts(ts).expect("transpile failed");
|
|
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");
|
|
let res = iso
|
|
.start_execution("{}".to_string())
|
|
.wait()
|
|
.await
|
|
.expect("isolate panicked");
|
|
|
|
// The exact bug: pre-fix, fetch panics this isolate. Post-fix,
|
|
// we get back the mock target's 200.
|
|
let raw = res.result.expect("user script returned an error");
|
|
assert_eq!(raw.get(), "200", "fetch should return mock target status");
|
|
|
|
assert_eq!(
|
|
fetch_hits.lock().await.len(),
|
|
1,
|
|
"fetch target should have been hit exactly once"
|
|
);
|
|
|
|
// 4. Force the BatchSpanProcessor to flush so the exporter posts
|
|
// to our mock collector synchronously (default flush interval
|
|
// is ~5s; tests can't wait that long).
|
|
deno_telemetry::flush();
|
|
// Exporter is async over the OTel runtime; give it a beat to
|
|
// actually send the HTTP request.
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
|
|
let otlp_captured = otlp_hits.lock().await;
|
|
assert!(
|
|
!otlp_captured.is_empty(),
|
|
"OTLP collector should have received at least one export — \
|
|
spans aren't reaching the collector after init"
|
|
);
|
|
|
|
// Verify the export carries our fetch span. OTLP is protobuf, so
|
|
// grep the raw bytes for OTel HTTP semantic-convention markers
|
|
// that deno_fetch's auto-instrumentation attaches:
|
|
// - the target URL ("url.full" attribute)
|
|
// - "http.request.method" attribute
|
|
let combined: Vec<u8> = otlp_captured.iter().flatten().copied().collect();
|
|
let bytes_contain = |needle: &[u8]| combined.windows(needle.len()).any(|w| w == needle);
|
|
|
|
let url_marker = format!("http://127.0.0.1:{fetch_port}/probe");
|
|
let has_url = bytes_contain(url_marker.as_bytes());
|
|
let has_method_attr = bytes_contain(b"http.request.method");
|
|
|
|
if !(has_url && has_method_attr) {
|
|
eprintln!(
|
|
"OTLP bytes ({}): {:?}",
|
|
combined.len(),
|
|
String::from_utf8_lossy(&combined)
|
|
);
|
|
}
|
|
|
|
assert!(
|
|
has_url,
|
|
"exported OTLP body should reference the fetched URL ({}); got {} bytes",
|
|
url_marker,
|
|
combined.len()
|
|
);
|
|
assert!(
|
|
has_method_attr,
|
|
"exported OTLP body should carry HTTP semconv attributes (http.request.method); got {} bytes",
|
|
combined.len()
|
|
);
|
|
}
|