From 14b9e90127dacb2c6e582dae39a20a620815732c Mon Sep 17 00:00:00 2001 From: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:52:35 -0500 Subject: [PATCH 1/2] fix(runtime): give debug HTTP module fetches a 30s request budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every module request carried a flat 5s timeout. Dev servers transform cold modules on demand and real responses have been measured at 5-9s, so valid fetches expired mid-transform: the async walk left the dependency unregistered, its subtree stayed undiscovered, and instantiation fell back to the blocking synchronous loader ("module graph walk missed"). The timeout is now selected once, from RuntimeConfig.IsDebug, and shared by the synchronous fetch, each async attempt, and the async session default (whose stale 10s never applied to requests anyway): 30s in debug runtimes, the original 5s in release. To keep fetch behavior coherent with the pumped wait, the enclosing graph load's deadline now bounds each async attempt — request timeouts clamp to the time remaining and the transport-error retry fires only while budget is left, so a fetch can never outlive the 60s wait pumping for it. Unpumped (dynamic-import) loads keep unbounded fetches. Regression coverage: new ModuleTestServer slow-transform routes plus a request-count introspection route let specs prove a delayed module resolves on a single request — a direct import(), an HTTP re-export chain, and a pumped require() through a local->HTTP boundary. The network-timeout spec now waits out the debug budget. --- NativeScript/runtime/HttpLoader.h | 6 + NativeScript/runtime/HttpLoader.mm | 78 ++++++++- .../runtime/ModuleInternalCallbacks.h | 8 +- .../runtime/ModuleInternalCallbacks.mm | 18 +- TestRunner/app/tests/HttpEsmLoaderTests.js | 159 +++++++++++++++++- TestRunner/app/tests/esm/mixed/slow-entry.mjs | 7 + TestRunner/app/tests/esm/mixed/slow-mid.mjs | 4 + TestRunnerTests/TestRunnerTests.swift | 69 +++++++- 8 files changed, 324 insertions(+), 25 deletions(-) create mode 100644 TestRunner/app/tests/esm/mixed/slow-entry.mjs create mode 100644 TestRunner/app/tests/esm/mixed/slow-mid.mjs 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 783efaef..d886fa2a 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..3f044451 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,62 @@ 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 == "/esm/slow/parent.mjs" || path == "/esm/slow/child.mjs" || + path == "/esm/slow/leaf.mjs" { + 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 +194,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: "&") From 412c11737e2675fe5de54037d4b1d92ae5bccc93 Mon Sep 17 00:00:00 2001 From: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:19:52 -0500 Subject: [PATCH 2/2] test(http-loader): cover req-leaf route in slow-transform branch The pumped require() spec imports /esm/slow/req-leaf.mjs, which the route condition missed and answered 404 text/plain. Prefix-match the slow routes so every /esm/slow/* path gets the delayed module body. --- TestRunnerTests/TestRunnerTests.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/TestRunnerTests/TestRunnerTests.swift b/TestRunnerTests/TestRunnerTests.swift index 3f044451..24593e0c 100644 --- a/TestRunnerTests/TestRunnerTests.swift +++ b/TestRunnerTests/TestRunnerTests.swift @@ -82,8 +82,7 @@ class TestRunnerTests: XCTestCase { // 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 == "/esm/slow/parent.mjs" || path == "/esm/slow/child.mjs" || - path == "/esm/slow/leaf.mjs" { + if path.hasPrefix("/esm/slow/") { var delayMs = path == "/esm/slow/child.mjs" ? 0 : 7000 var key = "slow" for pair in query.split(separator: "&") {