Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions NativeScript/runtime/HttpLoader.h
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,19 @@ 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
// thread and carried, because the fetch and its completion run on background
// threads that cannot read per-isolate vocabulary.
void FetchModuleBodyAsync(
const std::string& url, const std::string& canonicalKey,
double fetchDeadline,
std::function<void(ModuleFetchResult result)> completion);

// Mark a URL set (canonicalized internally) so that the NEXT network
Expand Down
78 changes: 69 additions & 9 deletions NativeScript/runtime/HttpLoader.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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<void(ModuleFetchResult result)>* 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) {
Expand All @@ -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;
Expand Down Expand Up @@ -857,17 +909,24 @@ static void PerformModuleFetchAsyncAttempt(
static_cast<size_t>([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 ?: @"<no description>").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
Expand Down Expand Up @@ -919,6 +978,7 @@ static void PerformModuleFetchAsyncAttempt(
}

void FetchModuleBodyAsync(const std::string& url, const std::string& canonicalKey,
CFAbsoluteTime fetchDeadline,
std::function<void(ModuleFetchResult result)> completion) {
// Security gate: single point of enforcement, same as HttpFetchModule.
if (!IsRemoteUrlAllowed(url)) {
Expand All @@ -931,7 +991,7 @@ void FetchModuleBodyAsync(const std::string& url, const std::string& canonicalKe
}

auto* completionHeap = new std::function<void(ModuleFetchResult)>(std::move(completion));
PerformModuleFetchAsyncAttempt(url, canonicalKey, 0, completionHeap);
PerformModuleFetchAsyncAttempt(url, canonicalKey, 0, fetchDeadline, completionHeap);
}

// Cold-boot JS-thread runloop pump.
Expand Down
8 changes: 7 additions & 1 deletion NativeScript/runtime/ModuleInternalCallbacks.h
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ v8::MaybeLocal<v8::Module> 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
Expand All @@ -152,7 +158,7 @@ v8::MaybeLocal<v8::Module> LoadHttpModuleForUrl(
// modes and steals no error text.
void StartModuleGraphLoad(
v8::Isolate* isolate, v8::Local<v8::Context> context,
const std::string& root,
const std::string& root, double fetchDeadline,
std::function<void(bool ok, const std::string& errorMessage,
v8::Local<v8::Context> context)>
onComplete);
Expand Down
18 changes: 14 additions & 4 deletions NativeScript/runtime/ModuleInternalCallbacks.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> dead{false}; // set by isolate teardown (any thread)
std::function<void(bool ok, const std::string& errorMessage, v8::Local<v8::Context> context)>
onComplete;
Expand Down Expand Up @@ -1648,7 +1652,7 @@ static void AsyncGraphEnqueue(const std::shared_ptr<AsyncGraphLoad>& 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,
Expand Down Expand Up @@ -1694,13 +1698,15 @@ static ModuleResolution ResolutionForRoot(const std::string& root) {

void StartModuleGraphLoad(
v8::Isolate* isolate, v8::Local<v8::Context> context, const std::string& root,
double fetchDeadline,
std::function<void(bool ok, const std::string& errorMessage, v8::Local<v8::Context> context)>
onComplete) {
auto load = std::make_shared<AsyncGraphLoad>();
load->isolate = isolate;
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);
Expand All @@ -1722,8 +1728,13 @@ bool RunModuleGraphLoadPumped(v8::Isolate* isolate, v8::Local<v8::Context> 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<bool>(false);
StartModuleGraphLoad(isolate, context, root,
StartModuleGraphLoad(isolate, context, root, deadline,
[done](bool /*ok*/, const std::string& /*errorMessage*/,
v8::Local<v8::Context>) { *done = true; });

Expand All @@ -1735,7 +1746,6 @@ bool RunModuleGraphLoadPumped(v8::Isolate* isolate, v8::Local<v8::Context> conte
// runloop services the remainder instead.
Runtime* runtime = Runtime::GetRuntime(isolate);
std::shared_ptr<EventLoop> 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
Expand Down Expand Up @@ -3097,7 +3107,7 @@ static void FinishHttpDynamicImport(v8::Isolate* isolate, v8::Local<v8::Context>
// 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<v8::Context> completionContext) {
// JS thread; isolate entered and context scoped by the pipeline.
Expand Down
Loading