Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Dynamic import must consult the same scope as a static import from this
// file: the host names the referrer by script origin, and that origin must
// canonicalize to the registry key the scope prefixes match against.
export function load() {
return import("ns-scoped-leaf");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// An ES module worker entry that never finishes evaluating. The parent is told
// the entry body started before the park, so its terminate() lands inside the
// entry's bounded evaluation pump rather than after it.
postMessage("never-settles:started");

await new Promise(function () {});

globalThis.onmessage = function () {
postMessage("never-settles:unreachable");
};
23 changes: 23 additions & 0 deletions test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,29 @@ describe("HTTP ESM Loader", function () {
reportRejection(error, done);
});
});

it("applies the referrer's scope to dynamic import()", function (done) {
// The host names a dynamic import's referrer by script origin
// (a file:// URL), not by registry key; the origin must land on
// the same canonical key the scope prefixes match, or scoped
// lookups silently fall through to top-level imports.
var insideScope = appRoot + "/esm/scoped/inside/";
var scopes = {};
scopes[insideScope] = { "ns-scoped-leaf": origin + "/esm/graph-leaf.mjs?k=in" };
setMap({
imports: { "ns-scoped-leaf": origin + "/esm/graph-leaf.mjs?k=top" },
scopes: scopes,
});

import("~/esm/scoped/inside/dynamic.mjs").then(function (mod) {
return mod.load();
}).then(function (leafMod) {
expect(leafMod.name).toBe("in");
done();
}).catch(function (error) {
reportRejection(error, done);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});

// An imperative API rejects bad input loudly, the way WebIDL does on the
Expand Down
66 changes: 66 additions & 0 deletions test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ describe("worker ES module entries", function () {
worker.postMessage("ping");
});

// An http(s) worker specifier bypasses the filesystem check entirely: the
// entry is fetched, compiled and registered under its canonical URL key on
// the worker's own thread, which is also the key the settle gate probes.
it("runs a worker whose entry is an http URL", function (done) {
var origin = "http://127.0.0.1:" + com.tns.tests.ModuleTestServer.ensureStarted();
var worker = new Worker(origin + "/esm/worker-entry.mjs");
worker.onmessage = function (msg) {
expect(msg.data).toBe("http-worker-entry:ping");
worker.terminate();
done();
};
worker.postMessage("ping");
});

// Extension resolution tries `.js` before `.mjs`, and no `.js` sibling
// exists, so the ES module entry is what answers. Its top-level await also
// parks past the yield window, so the message posted here proves the
Expand All @@ -76,6 +90,58 @@ describe("worker ES module entries", function () {
worker.postMessage("ping");
});

// The worker isolate is published before its entry runs, so terminate()
// can interrupt an entry that is still evaluating - here one parked on a
// promise that never settles, inside the pump that waits for it.
it("survives terminate() while an entry is parked in top-level await", function (done) {
var ITERATIONS = 3;
var FALLBACK_TERMINATE = 700;
var SETTLE_AFTER = 700;
var errors = [];

function iteration(remaining) {
if (remaining === 0) {
expect(errors).toEqual([]);
// A worker spawned after the terminated ones still works.
var next = new Worker("~/tests/esmEntrySyncWorker.mjs");
next.onmessage = function (msg) {
expect(msg.data).toBe("esm-entry:ping");
next.terminate();
done();
};
next.postMessage("ping");
return;
}

var worker = new Worker("./esmEntryNeverSettlesWorker.mjs");
var terminated = false;

function terminateOnce() {
if (terminated) {
return;
}
terminated = true;
worker.terminate();
setTimeout(function () {
iteration(remaining - 1);
}, SETTLE_AFTER);
}

worker.onerror = function (e) {
errors.push(String((e && e.message) || e));
};
worker.onmessage = function (msg) {
expect(msg.data).toBe("never-settles:started");
terminateOnce();
};
// The evaluation pump is bounded, so a start message that never
// arrives must not push the terminate past the window it targets.
setTimeout(terminateOnce, FALLBACK_TERMINATE);
}

iteration(ITERATIONS);
});

// WHATWG parity: the worker's message queue is enabled when its entry
// script finishes evaluating, and from then on messages dispatch whether
// or not a handler exists. A handler registered later (from a timer)
Expand Down
17 changes: 17 additions & 0 deletions test-app/app/src/main/java/com/tns/tests/ModuleTestServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,23 @@ private static void route(Socket socket, String path, String query) throws IOExc
return;
}

if ("/esm/worker-entry.mjs".equals(path)) {
// A worker entry served over HTTP, importing one relative dependency
// so the entry exercises the graph walk and not just the root fetch.
String body = "import { WORKER_TAG } from \"./worker-entry-dep.mjs\";\n"
+ "globalThis.onmessage = function (msg) {\n"
+ " postMessage(WORKER_TAG + \":\" + msg.data);\n"
+ "};\n";
respond(socket, "200 OK", JS_MIME, body.getBytes(UTF8));
return;
}

if ("/esm/worker-entry-dep.mjs".equals(path)) {
String body = "export const WORKER_TAG = \"http-worker-entry\";\n";
respond(socket, "200 OK", JS_MIME, body.getBytes(UTF8));
return;
}

if ("/esm/syntax-error.mjs".equals(path)) {
// Deliberately unparseable: pins that the loader surfaces V8's real
// compile error instead of a generic failure.
Expand Down
52 changes: 35 additions & 17 deletions test-app/runtime/src/main/cpp/CallbackHandlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
#include <fstream>
#include <cstdio>
#include <chrono>
#include "HttpLoader.h"
#include "MethodCache.h"
#include "ModuleInternal.h"
#include "SimpleProfiler.h"
#include "Runtime.h"
#include "WorkerMessage.h"
Expand Down Expand Up @@ -1215,11 +1217,17 @@ void CallbackHandlers::NewThreadCallback(const v8::FunctionCallbackInfo<v8::Valu

int priority = GetWorkerThreadPriority(isolate, context, args);

// TODO: Validate worker path and call worker.onerror if the script does not exist
// An http(s) entry has no filesystem form to validate or to resolve
// against the caller's directory: it is already absolute, and the
// module loader's HTTP branch fetches it on the worker's own thread
// under the same security gate every other remote load passes. The
// URL is what the worker registers its entry under, so it is also what
// the settle gate probes — it must reach the wrapper unrewritten.
const bool isHttpEntry = ModuleInternal::IsHttpModulePath(workerPath);

// Resolve tilde paths before creating the worker
std::string resolvedPath = workerPath;
if (!workerPath.empty() && workerPath[0] == '~') {
if (!isHttpEntry && !workerPath.empty() && workerPath[0] == '~') {
// Convert ~/path to ApplicationPath/path
std::string tail = workerPath.size() >= 2 && workerPath[1] == '/' ? workerPath.substr(2) : workerPath.substr(1);
resolvedPath = Constants::APP_ROOT_FOLDER_PATH + tail;
Expand All @@ -1232,7 +1240,9 @@ void CallbackHandlers::NewThreadCallback(const v8::FunctionCallbackInfo<v8::Valu
* app-root-relative resolution, mirroring the iOS runtime.
*/
std::string currentDir = Constants::APP_ROOT_FOLDER_PATH;
auto stack = StackTrace::CurrentStackTrace(isolate, 1, StackTrace::kScriptName);
auto stack = isHttpEntry
? Local<StackTrace>()
: StackTrace::CurrentStackTrace(isolate, 1, StackTrace::kScriptName);
if (!stack.IsEmpty() && stack->GetFrameCount() > 0) {
auto currentExecutingScriptName = stack->GetFrame(isolate, 0)->GetScriptName();
auto currentExecutingScriptNameStr = ArgConverter::ConvertToString(
Expand All @@ -1248,22 +1258,30 @@ void CallbackHandlers::NewThreadCallback(const v8::FunctionCallbackInfo<v8::Valu
}
}

// Will throw if the path is invalid or the file doesn't exist. The
// worker runs on its own thread, with its own working directory and
// module registry, so it gets the canonical path resolved here rather
// than the spec: nothing on the other side can redo this resolution,
// and the entry's registry key must be the file that was validated.
// The worker runs on its own thread, with its own working directory
// and module registry, so it gets the entry resolved here rather than
// the spec: nothing on the other side can redo this resolution, and
// the entry's registry key must be what was resolved here.
std::string entryPath;
try {
entryPath = ModuleInternal::CheckFileExists(isolate, resolvedPath, currentDir);
} catch (NativeScriptException& e) {
if (currentDir == Constants::APP_ROOT_FOLDER_PATH) {
throw;
if (isHttpEntry) {
// Repaired, not canonicalized: the canonical key depends on the
// worker's own canonicalization vocabulary, which is installed on
// its isolate, and both the loader and the settle gate derive it
// there from this URL.
entryPath = NormalizeHttpModuleUrl(resolvedPath);
} else {
// Throws if the path is invalid or the file doesn't exist.
try {
entryPath = ModuleInternal::CheckFileExists(isolate, resolvedPath, currentDir);
} catch (NativeScriptException& e) {
if (currentDir == Constants::APP_ROOT_FOLDER_PATH) {
throw;
}
// not found next to the caller - retry against the app root
entryPath = ModuleInternal::CheckFileExists(isolate, resolvedPath,
Constants::APP_ROOT_FOLDER_PATH);
currentDir = Constants::APP_ROOT_FOLDER_PATH;
}
// not found next to the caller - retry against the app root
entryPath = ModuleInternal::CheckFileExists(isolate, resolvedPath,
Constants::APP_ROOT_FOLDER_PATH);
currentDir = Constants::APP_ROOT_FOLDER_PATH;
}

auto workerId = WorkerWrapper::NextWorkerId();
Expand Down
16 changes: 12 additions & 4 deletions test-app/runtime/src/main/cpp/EventLoop.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -736,12 +736,18 @@ EventLoop::PumpResult EventLoop::PumpUntil(double deadlineSeconds,
const auto deadline = std::chrono::steady_clock::now() +
std::chrono::duration<double>(deadlineSeconds);
for (;;) {
// Both probes: IsExecutionTerminating is true only while JS frames
// unwind with the termination exception active, so a pump parked with
// nothing queued would never observe TerminateExecution through it.
// Termination outranks settlement: consuming a settled result means
// running more JS, which a terminating isolate must not do.
if (terminationRequested_.load(std::memory_order_acquire) ||
isolate_->IsExecutionTerminating()) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return PumpResult::kTerminated;
}
if (settled()) {
return PumpResult::kSettled;
}
if (isolate_->IsExecutionTerminating()) {
return PumpResult::kTerminated;
}
if (IsStopped()) {
// a stopped loop drops every post, so nothing can settle anymore
return PumpResult::kTerminated;
Expand All @@ -767,7 +773,9 @@ EventLoop::PumpResult EventLoop::PumpUntil(double deadlineSeconds,
}
}
if (settled()) {
return PumpResult::kSettled;
// back through the loop head, where termination outranks the
// settlement this drain produced
continue;
}
if (ranLooperWork == 0) {
WaitForInternalWork(10, /*pumpDeliverable=*/drainLooperWork);
Expand Down
14 changes: 14 additions & 0 deletions test-app/runtime/src/main/cpp/EventLoop.h
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,18 @@ class EventLoop {
*/
static bool IsInLooperCallback();

/**
* Marks this loop's isolate as termination-requested. Callable from any
* thread. Pumps consult it alongside Isolate::IsExecutionTerminating,
* which per its contract is true only while JS frames are unwinding with
* the termination exception active - a pump parked with nothing queued
* never runs JS, so TerminateExecution alone cannot end it before the
* deadline.
*/
void NoteTerminationRequested() {
terminationRequested_.store(true, std::memory_order_release);
}

/**
* Blocks the calling thread until this loop's internal lane has work (the
* eventfd or timerfd is readable) or `timeoutMs` elapses, whichever comes
Expand Down Expand Up @@ -400,6 +412,8 @@ class EventLoop {
Lane internal_;
Lane ordered_;
std::atomic<uint64_t> claimCells_[kClaimCells] = {};
// Set by NoteTerminationRequested (any thread), read by PumpUntil.
std::atomic_bool terminationRequested_{false};
// ordered-lane source with its own bookkeeping (Timers); home-thread only
OrderedTaskSource* timerSource_ = nullptr;
// bare ordered tokens posted before the bind; flushed by Bind
Expand Down
Loading