diff --git a/NativeScript/runtime/HttpLoader.h b/NativeScript/runtime/HttpLoader.h index 51898980..7c9a9d5f 100644 --- a/NativeScript/runtime/HttpLoader.h +++ b/NativeScript/runtime/HttpLoader.h @@ -105,6 +105,11 @@ bool HttpFetchModule(const std::string& url, const std::string& canonicalKey, // - an NSURLSession GET on a background queue with the same request shape // as the sync path (cache-bust nonce, zero-cache headers, no cookies) and // one retry on transport error. +// `fetchDeadline` is the enclosing graph load's fetch budget as an absolute +// CFAbsoluteTime (double; 0 = unbounded): attempts clamp their request +// timeout to the time remaining and the transport-error retry is skipped +// once no budget is left, so a bounded fetch can never outlive the wait +// that is pumping for it. // `completion(result)` is invoked exactly once, on an arbitrary thread — // callers must hop to their JS thread before touching V8. // `canonicalKey` as for HttpFetchModule: computed on the calling isolate's @@ -112,6 +117,7 @@ bool HttpFetchModule(const std::string& url, const std::string& canonicalKey, // threads that cannot read per-isolate vocabulary. void FetchModuleBodyAsync( const std::string& url, const std::string& canonicalKey, + double fetchDeadline, std::function completion); // Mark a URL set (canonicalized internally) so that the NEXT network diff --git a/NativeScript/runtime/HttpLoader.mm b/NativeScript/runtime/HttpLoader.mm index de965863..533da2a9 100644 --- a/NativeScript/runtime/HttpLoader.mm +++ b/NativeScript/runtime/HttpLoader.mm @@ -346,6 +346,31 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& std::string& out, std::string& contentType, int& status, uint64_t* outBustGeneration = nullptr); +// ── The module-fetch timeout policy ────────────────────────────────────────── +// +// One request timeout for every module-fetch attempt, read by every transport: +// the synchronous NSURLConnection path, each NSURLSession attempt, and the +// async session's own default. It MUST live in exactly one place — the async +// session once said 10s while every NSMutableURLRequest overrode it to 5s, so +// raising the session alone would have changed nothing. +// +// Debug runtimes get 30s: dev servers transform cold modules on demand, and +// real toolchain responses have been measured at 5–9s — comfortably past the +// old flat 5s, which expired mid-transform. A timed-out dependency fetch left +// the module absent from the registry, hid its subtree from the graph walk, +// and dumped it onto the blocking synchronous fallback (the "module graph +// walk missed" warnings). Release keeps 5s: remote module loading there is +// opt-in through the security gate, and nothing measured justifies relaxing +// its budget. +static NSTimeInterval ModuleFetchTimeoutInterval() { + return RuntimeConfig.IsDebug ? 30.0 : 5.0; +} + +// Transport-error retry delay, shared by the sync and async paths. Long enough +// to let a transient hiccup pass, short enough not to matter against the +// request timeout. +static constexpr double kModuleFetchRetryDelaySeconds = 0.12; + // synchronous-fetch timing histogram. // // The histogram is intentionally coarse — @@ -523,7 +548,7 @@ bool HttpFetchModule(const std::string& url, const std::string& canonicalKey, // One retry, and only for a transport error: an HTTP status is an answer, // not a failure to communicate, so asking again would just repeat it. TNS_DEBUG(Esm, "[http-loader] retrying %s after initial fetch error", url.c_str()); - usleep(120 * 1000); + usleep((useconds_t)(kModuleFetchRetryDelaySeconds * 1000.0 * 1000.0)); // The retry re-peeks, so the generation it carries — the one whose nonce is // on the wire for the body we end up with — replaces the first attempt's. transportOk = @@ -652,7 +677,7 @@ bool HttpFetchModule(const std::string& url, const std::string& canonicalKey, [request setValue:@"application/javascript, text/javascript, */*;q=0.1" forHTTPHeaderField:@"Accept"]; [request setValue:@"identity" forHTTPHeaderField:@"Accept-Encoding"]; - [request setTimeoutInterval:5.0]; + [request setTimeoutInterval:ModuleFetchTimeoutInterval()]; // CRITICAL for HMR: layered defense to bypass CFNetwork's URL cache. // `setCachePolicy:` alone is insufficient on iOS 18+/26+ Simulator — // CFNetwork still serves a previous save's body from fsCachedData. @@ -796,7 +821,10 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& config.URLCache = nil; config.requestCachePolicy = NSURLRequestReloadIgnoringLocalAndRemoteCacheData; config.HTTPShouldSetCookies = NO; - config.timeoutIntervalForRequest = 10.0; + // Every module request overrides this (BuildModuleFetchRequest sets the + // same policy value); the session default exists so the two settings can + // never disagree about the budget again. + config.timeoutIntervalForRequest = ModuleFetchTimeoutInterval(); config.HTTPMaximumConnectionsPerHost = 16; // ARC is disabled in this file: sessionWithConfiguration: returns an // autoreleased object; retain it for the process-lifetime singleton. @@ -810,10 +838,30 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& // std::function across threads without ARC) and guarantees exactly one // invocation + delete. Retries once on transport error, mirroring the // single-retry policy of the synchronous path. +// +// `fetchDeadline` (absolute CFAbsoluteTime, 0 = unbounded) is the enclosing +// graph load's fetch budget. Two full policy attempts plus the retry delay +// can outlast the pumped wait's deadline (e.g. 30s + 0.12s + 30s vs a 60s +// graph budget), so a bounded attempt clamps its request timeout to the time +// remaining and the retry fires only while some budget is left. A fetch that +// lands late is still reported — a dep failure stays on the designed path — +// but no request is issued that could only complete past the deadline. static void PerformModuleFetchAsyncAttempt( const std::string& url, const std::string& canonicalKey, int attempt, + CFAbsoluteTime fetchDeadline, std::function* completionHeap) { @autoreleasepool { + const double remaining = + fetchDeadline > 0 ? fetchDeadline - CFAbsoluteTimeGetCurrent() : 0; + if (fetchDeadline > 0 && remaining <= 0) { + ModuleFetchResult failed; + failed.failureReason = + "HTTP import failed: " + url + " (module graph fetch deadline exceeded)"; + (*completionHeap)(std::move(failed)); + delete completionHeap; + return; + } + uint64_t bustGeneration = 0; NSMutableURLRequest* request = BuildModuleFetchRequest(url, canonicalKey, &bustGeneration); if (!request) { @@ -824,6 +872,10 @@ static void PerformModuleFetchAsyncAttempt( return; } + if (fetchDeadline > 0 && remaining < request.timeoutInterval) { + [request setTimeoutInterval:remaining]; + } + const std::string urlCopy = url; const std::string keyCopy = canonicalKey; const uint64_t bust = bustGeneration; @@ -857,17 +909,24 @@ static void PerformModuleFetchAsyncAttempt( static_cast([data length])); } - // Transport error → one retry, the same single-retry policy the - // sync path applies, without blocking any thread. - if (error != nil && attempt == 0) { + // Transport error → one retry while the enclosing load's budget + // can still fit the delay — the same single-retry policy the + // sync path applies, without blocking any thread. The retry's + // own request clamps to whatever remains when it fires. + const bool retryFitsDeadline = + fetchDeadline <= 0 || + fetchDeadline - CFAbsoluteTimeGetCurrent() > kModuleFetchRetryDelaySeconds; + if (error != nil && attempt == 0 && retryFitsDeadline) { TNS_DEBUG(Esm, "[http-loader][fetch-async] retrying %s after transport error: %s", urlCopy.c_str(), (error.localizedDescription ?: @"").UTF8String); - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(120 * NSEC_PER_MSEC)), + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(kModuleFetchRetryDelaySeconds * + NSEC_PER_SEC)), dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ // Background thread: the carried key is the // only way this attempt can consult the bust set. - PerformModuleFetchAsyncAttempt(urlCopy, keyCopy, 1, + PerformModuleFetchAsyncAttempt(urlCopy, keyCopy, 1, fetchDeadline, completionHeap); }); // Set only once the retry is actually queued: it now owns the @@ -919,6 +978,7 @@ static void PerformModuleFetchAsyncAttempt( } void FetchModuleBodyAsync(const std::string& url, const std::string& canonicalKey, + CFAbsoluteTime fetchDeadline, std::function completion) { // Security gate: single point of enforcement, same as HttpFetchModule. if (!IsRemoteUrlAllowed(url)) { @@ -931,7 +991,7 @@ void FetchModuleBodyAsync(const std::string& url, const std::string& canonicalKe } auto* completionHeap = new std::function(std::move(completion)); - PerformModuleFetchAsyncAttempt(url, canonicalKey, 0, completionHeap); + PerformModuleFetchAsyncAttempt(url, canonicalKey, 0, fetchDeadline, completionHeap); } // Cold-boot JS-thread runloop pump. diff --git a/NativeScript/runtime/ModuleInternalCallbacks.h b/NativeScript/runtime/ModuleInternalCallbacks.h index b9d7a2ca..f0211dd0 100644 --- a/NativeScript/runtime/ModuleInternalCallbacks.h +++ b/NativeScript/runtime/ModuleInternalCallbacks.h @@ -143,6 +143,12 @@ v8::MaybeLocal LoadHttpModuleForUrl( // Compilation runs no user code, so pre-compiling the closure cannot change // evaluation order: V8 still evaluates in spec order from the root. // +// `fetchDeadline` bounds the walk's HTTP fetches as an absolute CFAbsoluteTime +// (double; 0 = unbounded): each attempt clamps its request timeout to the +// time remaining and transport-error retries stop once no budget is left. +// Callers that pump for the load pass their own wait deadline so a fetch can +// never outlive the wait; callers that simply queue work pass 0. +// // `onComplete(ok, errorMessage, context)` runs exactly once on the isolate's // JS thread with the isolate entered and `context` (the context captured at // start) already scoped. `ok` is false only when an HTTP ROOT fetch/compile @@ -152,7 +158,7 @@ v8::MaybeLocal LoadHttpModuleForUrl( // modes and steals no error text. void StartModuleGraphLoad( v8::Isolate* isolate, v8::Local context, - const std::string& root, + const std::string& root, double fetchDeadline, std::function context)> onComplete); diff --git a/NativeScript/runtime/ModuleInternalCallbacks.mm b/NativeScript/runtime/ModuleInternalCallbacks.mm index 4835902e..51f41ebe 100644 --- a/NativeScript/runtime/ModuleInternalCallbacks.mm +++ b/NativeScript/runtime/ModuleInternalCallbacks.mm @@ -1015,6 +1015,10 @@ void SetCanonicalizationConfig(CanonicalizationConfig config) { size_t fetchedCount = 0; size_t compiledCount = 0; uint64_t startUs = 0; + // Absolute CFAbsoluteTime bounding this load's fetch attempts and their + // retries — the same deadline the pumped wait enforces, so a bounded fetch + // can never outlive the wait pumping for it. 0 = unbounded. + CFAbsoluteTime fetchDeadline = 0; std::atomic dead{false}; // set by isolate teardown (any thread) std::function context)> onComplete; @@ -1648,7 +1652,7 @@ static void AsyncGraphEnqueue(const std::shared_ptr& load, const std::string url = target; // The canonical key is computed here, on the isolate's thread; the fetch // carries it so background threads never touch per-isolate vocabulary. - FetchModuleBodyAsync(url, key, [loadRef, url](ModuleFetchResult result) { + FetchModuleBodyAsync(url, key, load->fetchDeadline, [loadRef, url](ModuleFetchResult result) { // Arbitrary thread. Hop to the isolate's home thread as a nestable v8 // foreground task — thread-independent delivery, so an import() issued // from a background thread still lands on the isolate's own event loop, @@ -1694,6 +1698,7 @@ static ModuleResolution ResolutionForRoot(const std::string& root) { void StartModuleGraphLoad( v8::Isolate* isolate, v8::Local context, const std::string& root, + double fetchDeadline, std::function context)> onComplete) { auto load = std::make_shared(); @@ -1701,6 +1706,7 @@ void StartModuleGraphLoad( load->context.Reset(isolate, context); load->rootKey = CanonicalizeRegistryKey(root); load->startUs = (uint64_t)(CFAbsoluteTimeGetCurrent() * 1000.0 * 1000.0); + load->fetchDeadline = fetchDeadline; load->onComplete = std::move(onComplete); AsyncGraphLoad::g_asyncGraphLoadsInFlightCounter().fetch_add(1, std::memory_order_acq_rel); @@ -1722,8 +1728,13 @@ bool RunModuleGraphLoadPumped(v8::Isolate* isolate, v8::Local conte if (timeoutSeconds <= 0.0) { timeoutSeconds = 60.0; } + // One deadline shared by the wait below and by every fetch attempt the walk + // issues: attempts clamp to the time remaining and retries stop once no + // budget is left, so a fetch can never still be running after this wait + // has given up on it. + const CFAbsoluteTime deadline = CFAbsoluteTimeGetCurrent() + timeoutSeconds; auto done = std::make_shared(false); - StartModuleGraphLoad(isolate, context, root, + StartModuleGraphLoad(isolate, context, root, deadline, [done](bool /*ok*/, const std::string& /*errorMessage*/, v8::Local) { *done = true; }); @@ -1735,7 +1746,6 @@ bool RunModuleGraphLoadPumped(v8::Isolate* isolate, v8::Local conte // runloop services the remainder instead. Runtime* runtime = Runtime::GetRuntime(isolate); std::shared_ptr eventLoop = runtime != nullptr ? runtime->GetEventLoop() : nullptr; - const CFAbsoluteTime deadline = CFAbsoluteTimeGetCurrent() + timeoutSeconds; for (;;) { // Termination outranks completion, which is why this precedes the `*done` // test rather than sharing a loop condition with it. A walk that finished @@ -3097,7 +3107,7 @@ static void FinishHttpDynamicImport(v8::Isolate* isolate, v8::Local // immediately — no synchronous fetch, no runloop pump. const std::string requestUrl = normalizedSpec; StartModuleGraphLoad( - isolate, context, requestUrl, + isolate, context, requestUrl, /*fetchDeadline=*/0, [key, requestUrl, isolate](bool ok, const std::string& errorMessage, v8::Local completionContext) { // JS thread; isolate entered and context scoped by the pipeline. diff --git a/TestRunner/app/tests/HttpEsmLoaderTests.js b/TestRunner/app/tests/HttpEsmLoaderTests.js index 08fab4cb..8f398dea 100644 --- a/TestRunner/app/tests/HttpEsmLoaderTests.js +++ b/TestRunner/app/tests/HttpEsmLoaderTests.js @@ -227,6 +227,142 @@ describe("HTTP ESM Loader", function() { }); }); + // The module-fetch budget is build-dependent: 30s per request attempt + // in debug runtimes, 5s in release (HttpLoader.mm). A module whose + // response lands between the two — the measured 5–9s dev-server + // cold-transform latency — resolves in debug and still rejects in + // release. `canonicalizeHttpUrlKey` is a debug-only diagnostic export, + // so its presence identifies the build. + describe("fetch budget", function () { + var isDebugBuild = + typeof require("ns:module").canonicalizeHttpUrlKey === "function"; + var originalTimeout; + beforeEach(function () { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; + }); + afterEach(function () { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + + function statsCount(origin, target) { + return import(origin + "/esm/request-stats.json?target=" + + encodeURIComponent(target)) + .then(function (m) { + return m.default.count; + }); + } + + it("resolves a slow HTTP module inside the budget on its first request", + function (done) { + var origin = getHostOrigin(); + if (!origin) { + pending("REPORT_BASEURL not set; skipping host HTTP tests"); + done(); + return; + } + var target = "/esm/slow/leaf.mjs?k=direct&delayMs=7000"; + import(origin + target).then(function (mod) { + if (!isDebugBuild) { + fail("a 7s response must not resolve within the 5s release budget"); + done(); + return; + } + expect(mod.value).toBe(42); + statsCount(origin, target).then(function (count) { + // One request: no transport retry and no synchronous + // fallback fetch — both issue a second request. + expect(count).toBe(1); + done(); + }).catch(function (error) { + expect("stats rejected: " + formatError(error)).toBe("stats resolved"); + done(); + }); + }).catch(function (error) { + // Release: the 5s budget (and, on this allowlist, the + // security gate) reject a 7s response — the budget must + // not silently relax there. + expect(isDebugBuild ? "rejected: " + formatError(error) : "rejected") + .toBe("rejected"); + done(); + }); + }); + + it("resolves a slow HTTP re-export chain on one request per module", + function (done) { + var origin = getHostOrigin(); + if (!origin) { + pending("REPORT_BASEURL not set; skipping host HTTP tests"); + done(); + return; + } + if (!isDebugBuild) { + pending("release budget rejects the 7s parent — covered by the direct spec"); + done(); + return; + } + // The parent spends 7s transforming, then the walk discovers + // its re-exported child. Under the old 5s budget the parent's + // subtree was never discovered and every edge fell back to + // the blocking sync loader. + var parentTarget = "/esm/slow/parent.mjs?k=chain&delayMs=7000"; + var childTarget = "/esm/slow/child.mjs?from=chain"; + import(origin + parentTarget).then(function (mod) { + expect(mod.value).toBe(42); + expect(mod.parent).toBe("chain"); + Promise.all([statsCount(origin, parentTarget), + statsCount(origin, childTarget)]).then(function (counts) { + expect(counts).toEqual([1, 1]); + done(); + }).catch(function (error) { + expect("stats rejected: " + formatError(error)).toBe("stats resolved"); + done(); + }); + }).catch(function (error) { + expect("rejected: " + formatError(error)).toBe("resolved"); + done(); + }); + }); + + it("waits for a slow HTTP leaf during a pumped require()", function (done) { + var origin = getHostOrigin(); + if (!origin) { + pending("REPORT_BASEURL not set; skipping host HTTP tests"); + done(); + return; + } + if (!isDebugBuild) { + pending("release budget rejects the 7s leaf — covered by the direct spec"); + done(); + return; + } + var leafTarget = "/esm/slow/req-leaf.mjs?k=req&delayMs=7000"; + var nsModule = require("ns:module"); + nsModule.configureLoader({ + importMap: { + imports: { "ns-slow-leaf": origin + leafTarget }, + }, + }); + try { + var req = nsModule.createRequire(__dirname + "/anything.js"); + // Blocks the JS thread for the walk's ~7s fetch; the + // re-export boundary at slow-mid.mjs keeps the leaf's + // transitive edge inside the pumped graph. + var mod = req("./esm/mixed/slow-entry.mjs"); + expect(mod.leaf).toBe(42); + } finally { + nsModule.configureLoader({ importMap: { imports: {} } }); + } + statsCount(origin, leafTarget).then(function (count) { + expect(count).toBe(1); + done(); + }).catch(function (error) { + expect("stats rejected: " + formatError(error)).toBe("stats resolved"); + done(); + }); + }); + }); + // The import map is process-wide, so every spec here installs its own // and restores the empty map afterwards. // Each present configureLoader section replaces its state wholesale, so @@ -854,14 +990,18 @@ describe("HTTP ESM Loader", function() { }); describe("network timeouts", function() { - // The async loader's NSURLSession request timeout is 10s, so the - // rejection lands ~10s after the import — beyond jasmine's default - // 5s spec timeout. Jasmine 2.0 has no per-spec timeout argument; - // widen the global interval for just this describe. + // The loader's per-request timeout is 30s in debug builds, 5s in + // release, and a transport error is retried once — so the + // rejection lands ~60s (debug) or ~10s (release) after the import, + // beyond jasmine's default 5s spec timeout. Jasmine 2.0 has no + // per-spec timeout argument; widen the global interval for just + // this describe. + var isDebugBuild = + typeof require("ns:module").canonicalizeHttpUrlKey === "function"; var originalTimeout; beforeEach(function() { originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; - jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; + jasmine.DEFAULT_TIMEOUT_INTERVAL = isDebugBuild ? 80000 : 15000; }); afterEach(function() { jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; @@ -874,9 +1014,12 @@ describe("HTTP ESM Loader", function() { // Prefer the local XCTest server's delayed endpoint (deterministic, hermetic). // The fallback is a closed local port (fast connection-refused), never a live // external/TEST-NET host whose connect timeout would stall the JS thread on CI. - // delayMs must exceed the async loader's 10s request timeout so the - // fetch rejects (~10s) instead of completing late but successfully. - var spec = origin ? (origin + "/esm/timeout.mjs?delayMs=12000") : "http://127.0.0.1:59999/timeout-test.js"; + // delayMs must exceed the per-request timeout (30s debug / + // 5s release) so the fetch rejects instead of completing + // late but successfully. + var spec = origin + ? (origin + "/esm/timeout.mjs?delayMs=" + (isDebugBuild ? 32000 : 12000)) + : "http://127.0.0.1:59999/timeout-test.js"; import(spec).then(function(module) { fail("Should not have succeeded for unreachable server"); diff --git a/TestRunner/app/tests/esm/mixed/slow-entry.mjs b/TestRunner/app/tests/esm/mixed/slow-entry.mjs new file mode 100644 index 00000000..a17c8df5 --- /dev/null +++ b/TestRunner/app/tests/esm/mixed/slow-entry.mjs @@ -0,0 +1,7 @@ +// Entry of a local->local->http graph whose HTTP leaf answers only after a +// multi-second transform delay (ModuleTestServer /esm/slow/req-leaf.mjs). +// Exercises the pumped graph walk's fetch budget through require(): the leaf +// must arrive within the walk's deadline on its first request. +import { leaf as leafValue } from "./slow-mid.mjs"; + +export const leaf = leafValue; diff --git a/TestRunner/app/tests/esm/mixed/slow-mid.mjs b/TestRunner/app/tests/esm/mixed/slow-mid.mjs new file mode 100644 index 00000000..71448c55 --- /dev/null +++ b/TestRunner/app/tests/esm/mixed/slow-mid.mjs @@ -0,0 +1,4 @@ +// Named re-export through the local->HTTP boundary: "ns-slow-leaf" is a bare +// specifier the spec's import map points at a delayed ModuleTestServer URL, +// so this file needs no baked-in origin. +export { value as leaf } from "ns-slow-leaf"; diff --git a/TestRunnerTests/TestRunnerTests.swift b/TestRunnerTests/TestRunnerTests.swift index 6da9dc1f..24593e0c 100644 --- a/TestRunnerTests/TestRunnerTests.swift +++ b/TestRunnerTests/TestRunnerTests.swift @@ -11,6 +11,12 @@ class TestRunnerTests: XCTestCase { private let progressLock = NSLock() private var lastSpecSeen = "(no spec reported yet)" + // Requests received per target (PATH_INFO plus query), readable back by + // specs through /esm/request-stats.json so a spec can prove a module was + // fetched exactly once — no retry, no synchronous-fallback round trip. + // Only ever touched on the server's serial connection queue. + private var requestCounts = [String: Int]() + override func setUpWithError() throws { continueAfterFailure = false @@ -49,6 +55,61 @@ class TestRunnerTests: XCTestCase { // Serve tiny ESM modules for runtime HTTP loader tests. if method == "GET" { + if path.hasPrefix("/esm/") && path != "/esm/request-stats.json" { + let target = query.isEmpty ? path : "\(path)?\(query)" + self.requestCounts[target] = (self.requestCounts[target] ?? 0) + 1 + } + + if path == "/esm/request-stats.json" { + // `target` is the percent-encoded request target to count. + var wanted = "" + if let pair = query + .split(separator: "&") + .first(where: { $0.hasPrefix("target=") }), + let decoded = String(pair.dropFirst("target=".count)) + .removingPercentEncoding { + wanted = decoded + } + let body = "{\"target\":\"\(wanted)\",\"count\":\(self.requestCounts[wanted] ?? 0)}" + startResponse("200 OK", [("Content-Type", "application/json; charset=utf-8")]) + sendBody(body.data(using: .utf8) ?? Data()) + return + } + + // ── Slow-transform routes (fetch-budget coverage) ── + // Simulate a dev server answering a cold module transform in + // `delayMs` — the 5–9s real-world latency that used to expire + // mid-flight under the flat 5s request timeout. The parent + // re-exports the child so a spec covers a transitive HTTP + // edge the graph walk discovers after the slow fetch lands. + if path.hasPrefix("/esm/slow/") { + var delayMs = path == "/esm/slow/child.mjs" ? 0 : 7000 + var key = "slow" + for pair in query.split(separator: "&") { + if pair.hasPrefix("delayMs="), + let v = Int(pair.dropFirst("delayMs=".count)) { + delayMs = v + } else if pair.hasPrefix("k=") { + key = String(pair.dropFirst(2)) + } + } + let body: String + if path == "/esm/slow/parent.mjs" { + body = "export { value } from \"./child.mjs?from=\(key)\";\n" + + "export const parent = \"\(key)\";\n" + } else { + body = "export const value = 42; export const name = \"\(key)\";\n" + } + DispatchQueue.global(qos: .utility).asyncAfter( + deadline: .now() + Double(delayMs) / 1000.0 + ) { + startResponse("200 OK", + [("Content-Type", "application/javascript; charset=utf-8")]) + sendBody(body.data(using: .utf8) ?? Data()) + } + return + } + if path == "/esm/query.mjs" || path == "/ns/m/query.mjs" { func jsStringLiteral(_ s: String) -> String { return s @@ -132,9 +193,10 @@ class TestRunnerTests: XCTestCase { if path == "/esm/timeout.mjs" { // Delay the response without blocking request handling. // The loader still hits its client-side timeout when - // delayMs exceeds the async loader's request timeout - // (HttpEsmLoaderTests passes delayMs=12000), and the - // server keeps serving other connections meanwhile. + // delayMs exceeds the request timeout (HttpEsmLoaderTests + // passes 12000 in release or 32000 in debug — the request + // budget is 5s and 30s respectively), and the server + // keeps serving other connections meanwhile. var delayMs = 12000 if let pair = query .split(separator: "&")