Skip to content
Draft
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
37 changes: 37 additions & 0 deletions .github/scripts/sample-hung-app.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Background hang sampler for CI.
#
# The runtime unit-test suite has intermittently DEADLOCKED on the constrained
# CI runner (a worker-teardown lock cycle that does not reproduce on
# many-core dev machines). When that happens the app stays alive but the JS
# thread is wedged, so nothing is POSTed and the watchdog fails at 600s — with
# no native stack to explain WHY. NativeScript's console.log goes to stdout (not
# os_log), so the unified-log archive doesn't capture it either.
#
# This script periodically `sample`s the TestRunner *app* process while the test
# runs, leaving per-thread native backtraces behind. If the suite hangs, the
# later snapshots show every thread's stack (main blocked on a lock + whatever
# the worker threads are doing) — i.e. the actual lock cycle. The files are
# written into the diagnostics dir that the workflow uploads as `test-diagnostics`.
#
# Best-effort: it never fails the build, and it exits on its own (the Xcode test
# step also kills it via a trap when xcodebuild returns). `sample` needs no sudo.
set -u

DIAG="${1:?usage: sample-hung-app.sh <diagnostics-dir>}"
mkdir -p "$DIAG"

# ~25 minutes of coverage: build (~6m) + the test phase including a full 600s
# hang + teardown. pgrep finds nothing during the build, so those ticks no-op.
for i in $(seq 1 25); do
sleep 60
# Exact-name match: the app is "TestRunner"; the XCUITest host is
# "TestRunnerTests-Runner" — we want the app, where the JS thread lives.
pid="$(pgrep -x TestRunner 2>/dev/null | head -1 || true)"
[ -z "${pid:-}" ] && continue
out="$DIAG/sample-app-$(printf '%02d' "$i").txt"
# 4s sample of all threads. -mayDie tolerates the process exiting mid-sample.
sample "$pid" 4 -mayDie -fullPaths -file "$out" >/dev/null 2>&1 || true
done

exit 0
69 changes: 65 additions & 4 deletions .github/workflows/npm_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,12 @@ jobs:

test:
name: Test
runs-on: macos-14
# Runtime suite runs on Xcode 26 / iOS 26 — the combo verified passing
# locally. iOS 17.x is intentionally NOT used: a worker-teardown stress spec
# deadlocks the JS thread there (hangs the whole suite), a bug we don't chase
# on an OS we don't ship-test. Pinned (not latest-stable) so the runtime suite
# is deterministic; bump to "27" when Xcode 27 ships.
runs-on: macos-15
needs: build
steps:
- name: Harden the runner (Audit all outbound calls)
Expand All @@ -177,7 +182,10 @@ jobs:
egress-policy: audit
- uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0
with:
xcode-version: ${{env.XCODE_VERSION}}
# Pin to Xcode 26 (latest 26.x on the runner). NOT the ^15.0 release-build
# pin — Sequoia/Tahoe ship no Xcode 15. OS=latest below then resolves to
# the iOS 26 runtime this Xcode bundles.
xcode-version: "26"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: true
Expand Down Expand Up @@ -224,9 +232,62 @@ jobs:
# TestRunnerTests.swift) need more than 20m headroom per attempt.
timeout_minutes: 40
max_attempts: 2
command: set -o pipefail && xcodebuild -project v8ios.xcodeproj -scheme TestRunner -resultBundlePath $TEST_FOLDER/test_results -destination platform\=iOS\ Simulator,OS\=17.2,name\=iPhone\ 15\ Pro\ Max build test | xcpretty
command: |
set -o pipefail
# Background native-stack sampler: if the runtime suite deadlocks, the
# test-diagnostics artifact gets per-thread backtraces of the hung app
# (the JS console never reaches os_log, so this is our only window into
# the lock cycle). Killed via trap when xcodebuild returns.
bash .github/scripts/sample-hung-app.sh "$TEST_FOLDER/diagnostics" &
SAMPLER_PID=$!
trap 'kill "$SAMPLER_PID" 2>/dev/null || true' EXIT
xcodebuild -project v8ios.xcodeproj -scheme TestRunner -resultBundlePath $TEST_FOLDER/test_results -destination platform\=iOS\ Simulator,OS\=latest,name\=iPhone\ 16\ Pro build test | xcpretty
on_retry_command: rm -rf $TEST_FOLDER/test_results* && xcrun simctl shutdown all
new_command_on_retry: xcodebuild -project v8ios.xcodeproj -scheme TestRunner -resultBundlePath $TEST_FOLDER/test_results -destination platform\=iOS\ Simulator,OS\=17.2,name\=iPhone\ 15\ Pro\ Max build test
new_command_on_retry: |
bash .github/scripts/sample-hung-app.sh "$TEST_FOLDER/diagnostics" &
SAMPLER_PID=$!
trap 'kill "$SAMPLER_PID" 2>/dev/null || true' EXIT
xcodebuild -project v8ios.xcodeproj -scheme TestRunner -resultBundlePath $TEST_FOLDER/test_results -destination platform\=iOS\ Simulator,OS\=latest,name\=iPhone\ 16\ Pro build test
# When the runtime suite fails it is almost always because the in-app
# Jasmine run died before POSTing results (crash or hang). The xcresult is
# black-box and captures nothing from inside the app, so collect the two
# things that actually explain it: the native crash report (.ips) and the
# simulator's unified log (the app's console.log / last spec before a stall).
# The watchdog in TestRunnerTests.swift prints which artifact to look at.
- name: Collect crash reports & simulator log (on failure)
if: ${{ failure() }}
run: |
DIAG="$TEST_FOLDER/diagnostics"
mkdir -p "$DIAG"
# Simulator app crashes land in the host's DiagnosticReports.
cp -R ~/Library/Logs/DiagnosticReports/. "$DIAG/DiagnosticReports/" 2>/dev/null || true
cp -R ~/Library/Logs/CoreSimulator/. "$DIAG/CoreSimulator/" 2>/dev/null || true
# Unified log = the app's console output (so the last spec before a hang
# is visible even when nothing was POSTed). `log collect` needs a booted
# device; don't rely on the `booted` alias (the prior collect failed
# because the sim wasn't booted at that moment). Resolve a concrete UDID
# — prefer one already booted from the test run, else the test device,
# booting it so the persisted log store can be collected.
UDID="$(xcrun simctl list devices booted | grep -oE '[0-9A-Fa-f-]{36}' | head -1)"
if [ -z "$UDID" ]; then
UDID="$(xcrun simctl list devices 'iPhone 16 Pro' | grep -oE '[0-9A-Fa-f-]{36}' | head -1)"
[ -n "$UDID" ] && xcrun simctl boot "$UDID" 2>/dev/null || true
[ -n "$UDID" ] && xcrun simctl bootstatus "$UDID" 2>/dev/null || true
fi
if [ -n "$UDID" ]; then
echo "Collecting unified log from simulator $UDID"
xcrun simctl spawn "$UDID" log collect --output "$DIAG/simulator.logarchive" 2>/dev/null || true
else
echo "No simulator UDID resolved; skipping logarchive collection."
fi
echo "Collected diagnostics:"; ls -laR "$DIAG" 2>/dev/null || true
- name: Upload test diagnostics (on failure)
if: ${{ failure() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: test-diagnostics
path: ${{ env.TEST_FOLDER }}/diagnostics
if-no-files-found: ignore
- name: Validate Test Results
run: |
xcparse attachments $TEST_FOLDER/test_results.xcresult $TEST_FOLDER/test-out
Expand Down
14 changes: 14 additions & 0 deletions NativeScript/runtime/DevFlags.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ namespace tns {
// Controlled by package.json setting: "logScriptLoading": true|false
bool IsScriptLoadingLogEnabled();

// HTTP module loader flags
//
// Returns true when speculative HTTP module prefetching (the dep-graph BFS
// kicked off after each successful HttpFetchText) should be enabled. Default
// OFF so cold-boot behaviour is unchanged for users who have not opted in.
// Controlled by package.json / nativescript.config: "httpModulePrefetch": true|false
bool IsHttpModulePrefetchEnabled();

// Returns true when one log line should be emitted per HTTP fetch URL.
// Default OFF because the volume is high (one line per fetch, hundreds per
// cold boot, hundreds per HMR refresh). Opt in via package.json /
// nativescript.config: "httpFetchUrlLog": true|false
bool IsHttpFetchUrlLogEnabled();

// Security config

// In debug mode (RuntimeConfig.IsDebug): always returns true.
Expand Down
92 changes: 85 additions & 7 deletions NativeScript/runtime/DevFlags.mm
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#import <Foundation/Foundation.h>

#include "DevFlags.h"
#include "Helpers.h"
#include "Runtime.h"
#include "RuntimeConfig.h"
#include <vector>
Expand All @@ -13,16 +14,93 @@ bool IsScriptLoadingLogEnabled() {
return value ? [value boolValue] : false;
}

// HTTP module loader flags

// Reads `httpModulePrefetch` from app config (default: DISABLED).
//
// Apps that want to opt in for testing can set:
//
// // nativescript.config.ts
// export default {
// httpModulePrefetch: true,
// } as NativeScriptConfig;
//
// Returning false here short-circuits both the cache lookup and the prefetch
// wave in HttpFetchText, restoring the pre-prefetcher behavior bit-for-bit.
bool IsHttpModulePrefetchEnabled() {
static std::once_flag s_initFlag;
static bool s_enabled = false;
std::call_once(s_initFlag, []() {
@autoreleasepool {
id value = Runtime::GetAppConfigValue("httpModulePrefetch");
if (value && [value respondsToSelector:@selector(boolValue)]) {
s_enabled = [value boolValue];
}
}
// Startup banner. Gated on the logScriptLoading flag so it stays silent
// by default — flip the flag in nativescript.config.ts when diagnosing
// why prefetch is or isn't engaging.
//
// [http-loader] prefetch=disabled ← expected default
// [http-loader] prefetch=enabled ← only if config opt-in
if (IsScriptLoadingLogEnabled()) {
Log(@"[http-loader] prefetch=%s shared-session=on hmr-kickstart=on",
s_enabled ? "enabled" : "disabled");
}
});
return s_enabled;
}

// Default OFF because the volume is high (one line per fetch, hundreds per
// cold boot, hundreds per HMR refresh). Opt in via `nativescript.config.ts`:
//
// export default {
// httpFetchUrlLog: true, // turn on for diagnosis only
// …
// };
bool IsHttpFetchUrlLogEnabled() {
static std::once_flag s_initFlag;
static bool s_enabled = false;
std::call_once(s_initFlag, []() {
@autoreleasepool {
id value = Runtime::GetAppConfigValue("httpFetchUrlLog");
if (value && [value respondsToSelector:@selector(boolValue)]) {
s_enabled = [value boolValue];
}
}
if (IsScriptLoadingLogEnabled()) {
Log(@"[http-loader] fetch-url-log=%s",
s_enabled ? "enabled" : "disabled");
}
});
return s_enabled;
}

// Security config

static std::once_flag s_securityConfigInitFlag;
static bool s_allowRemoteModules = false;
static std::vector<std::string> s_remoteModuleAllowlist;

// Helper to check if a URL starts with a given prefix
static bool UrlStartsWith(const std::string& url, const std::string& prefix) {
if (prefix.size() > url.size()) return false;
return url.compare(0, prefix.size(), prefix) == 0;
// Returns true when `url` is authorized by allowlist `entry`.
//
// This is intentionally stricter than a raw string-prefix test: after the
// matched entry text, the next character in `url` must be a URL-component
// boundary ('/', '?', or '#'), the URL must end exactly at the entry, or the
// entry must itself end in '/'. That refuses lookalike-host and lookalike-port
// bypasses — an entry of "https://cdn.example.com" must NOT authorize
// "https://cdn.example.com.attacker.com/x.js" or
// "https://cdn.example.com:9999/x.js". To allow a specific port, include it in
// the allowlist entry (deny-by-default for anything not explicitly listed).
static bool RemoteUrlMatchesAllowlistEntry(const std::string& url,
const std::string& entry) {
if (entry.empty()) return false;
if (url.size() < entry.size()) return false;
if (url.compare(0, entry.size(), entry) != 0) return false;
if (url.size() == entry.size()) return true; // exact match
if (entry.back() == '/') return true; // entry ended at a boundary
const char next = url[entry.size()];
return next == '/' || next == '?' || next == '#';
}

void InitializeSecurityConfig() {
Expand Down Expand Up @@ -84,9 +162,9 @@ bool IsRemoteUrlAllowed(const std::string& url) {
return true;
}

// Check if URL matches any allowlist prefix
for (const std::string& prefix : s_remoteModuleAllowlist) {
if (UrlStartsWith(url, prefix)) {
// Check if URL matches any allowlist entry on a URL-component boundary.
for (const std::string& entry : s_remoteModuleAllowlist) {
if (RemoteUrlMatchesAllowlistEntry(url, entry)) {
return true;
}
}
Expand Down
Loading