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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
run: npm run build-libffi

- name: Build metadata generator
run: npm run build-metagen
run: METADATA_GENERATOR_ARCHS="$(uname -m)" npm run build-metagen

- name: Generate macOS metadata
run: npm run metagen macos
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,6 @@ packages/react-native/dist/
packages/react-native/ios/vendor/
packages/react-native/metadata/
packages/react-native/native-api/

# Rust metadata bundle analyzer build output
metadata-generator/symbol-analyzer/target/
9 changes: 9 additions & 0 deletions metadata-generator/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra -target ${METADATA_BINARY_

set(CMAKE_CXX_STANDARD 20)

option(METADATA_GENERATOR_BUILD_TESTS "Build metadata generator unit tests" OFF)

if(NOT DEFINED METADATA_BINARY_ARCH)
set(METADATA_BINARY_ARCH "${CMAKE_HOST_SYSTEM_PROCESSOR}")
endif(NOT DEFINED METADATA_BINARY_ARCH)
Expand Down Expand Up @@ -109,3 +111,10 @@ install(TARGETS ${NAME}
RUNTIME DESTINATION bin)

configure_file(build-step-metadata-generator.py ${CMAKE_CURRENT_BINARY_DIR}/bin/build-step-metadata-generator.py COPYONLY)

if(METADATA_GENERATOR_BUILD_TESTS)
enable_testing()
add_executable(metadata-filter-tests tests/MetadataFilterTests.cpp)
target_include_directories(metadata-filter-tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include)
add_test(NAME metadata-filter-tests COMMAND metadata-filter-tests)
endif()
49 changes: 49 additions & 0 deletions metadata-generator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ cmake -B build -DCMAKE_BUILD_TYPE=Release -DMETADATA_BINARY_ARCH=$METADATA_ARCH
cmake --build build
```

The repository packaging script builds both Intel and Apple Silicon host tools
by default. For local development on Xcode installations whose `libclang` only
contains the current host architecture, build just that architecture:

```bash
METADATA_GENERATOR_ARCHS="$(uname -m)" npm run build-metagen
```

## Debugging the metadata generator

To debug the metadata generator you first need to generate the xcode project for it:
Expand All @@ -46,3 +54,44 @@ Example command line arguments:
```

For a better way of generating these arguments, just run the TestRunner scheme on the v8ios-runtime project and get the arguments from the log.

## Opt-in bundle-based metadata filtering

`symbol-analyzer/` contains a Rust/Oxc analyzer that scans the emitted JavaScript
or TypeScript bundle for unresolved global symbols and writes them as a normal
NativeScript metadata whitelist. It processes multiple bundle/chunk files in
parallel and emits deterministically sorted rules. Parse or semantic-analysis
errors, dynamic global property access, and dynamic code execution fail open by
writing `*:*`, so an unsupported bundle cannot accidentally remove metadata.
Foundation and Runtime are retained by default as a conservative safety margin.

The existing `whitelist.mdg` and `blacklist.mdg` behavior remains available.
When automatic filtering is enabled, `whitelist.mdg` is merged into the generated
whitelist and `blacklist.mdg` is still applied afterwards.

Set these environment variables on the metadata-generator build phase:

```bash
NS_METADATA_AUTO_FILTER=1
# A shell-quoted list of emitted bundle files or directories.
NS_METADATA_BUNDLE_PATHS="$CONFIGURATION_BUILD_DIR/app/bundle.js"
```

The packaged analyzer next to `objc-metadata-generator` is used by default.
`NS_METADATA_SYMBOL_ANALYZER` can override its path for local development.

For repeatable full-SDK performance runs on macOS, use
`benchmarks/run-macos.sh` with a fresh output directory. Each iteration records
wall/CPU time, peak memory, and SHA-256 hashes for every generated artifact.

To validate the analyzer against the repository's real NativeScript macOS
examples as source, minified bundles, and split chunks, run:

```bash
metadata-generator/benchmarks/validate-bundles.sh \
metadata-generator/symbol-analyzer/target/release/ns-metadata-symbols
```

The script pins esbuild 0.25.8, requires identical analyzer output for all three
forms, and verifies that the intentionally dynamic/malformed TestRunner corpus
fails open.
90 changes: 90 additions & 0 deletions metadata-generator/benchmarks/RESULTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Metadata generator benchmark — 2026-08-01

Baseline commit: `15e5418cb07c01e3a567b4791325b87809668bd9`
(`origin/refactor`). Workload: the full macOS 26.5 SDK, binary metadata,
Foundation/AppKit TypeScript declarations, umbrella header, and signature
dispatch bindings. Each result below is the median of five warm-cache runs from
`run-macos.sh`.

Host: Apple M5 Pro (18 cores, 64 GiB), macOS 27.0 (26A5378n), Xcode 26.6,
Apple Clang 21.0.0, CMake 4.3.2.

| Measurement | Baseline | Optimized | Change |
| --- | ---: | ---: | ---: |
| Wall time | 6.98 s | 4.94 s | -29.2% |
| User CPU | 5.62 s | 4.35 s | -22.6% |
| System CPU | 0.80 s | 0.55 s | -31.2% |
| Retired instructions | 54.86 B | 52.66 B | -4.0% |
| Maximum RSS | 1,212,743,680 B | 1,181,007,872 B | -2.6% |
| Peak footprint | 1,125,811,544 B | 1,095,992,640 B | -2.6% |

All 207 generated artifacts were byte-for-byte identical in every baseline and
optimized run. The primary changes were parsing declarations with
`CXTranslationUnit_SkipFunctionBodies` and reserving Objective-C class member
storage before constructing members.

## Profile summary

The baseline Time Profiler trace contained 4,735 ms of sampled CPU time:

| Stage | Sampled CPU | Share |
| --- | ---: | ---: |
| Clang parse | 3,093 ms | 65.3% |
| IR construction/post-processing | 1,311 ms | 27.7% |
| Metadata serialization | 164 ms | 3.5% |
| TypeScript emission | 64 ms | 1.4% |
| Umbrella discovery | 36 ms | 0.8% |

The largest generator-level CPU sites were `MetadataFactory::process` (889 ms),
retained-return attribute detection (614 ms), class processing (421 ms), and
`MetadataFactory::postProcess` (417 ms). Token/pretty-print scans accounted for
562 ms. `open` and `stat` accounted for about 360 ms; output writing was not a
dominant cost.

A malloc-stack snapshot at an 808.7 MiB process footprint was dominated by
libclang diagnostics, `SmallVector`, `StringMap`, and source buffers. The largest
generator-owned site was Objective-C class member-vector growth (26.9 MiB),
which motivated the exact-capacity reservation.

## Opt-in filtering example

A small AppKit bundle using `NSView` produced a conservative whitelist retaining
Foundation, Runtime, the referenced symbols, and dependency closure:

| Output | Unfiltered | Filtered | Change |
| --- | ---: | ---: | ---: |
| Binary metadata | 6,426,238 B | 485,325 B | -92.4% |
| Signature bindings | 8,447,422 B | 1,322,558 B | -84.3% |
| TypeScript output | 14,864 KiB | 1,024 KiB | -93.1% |
| Wall time | 6.98 s median | 3.80 s | -45.6% |
| Maximum RSS | 1,212,743,680 B | 982,958,080 B | -18.9% |

This is an illustrative workload, not a size guarantee; the dependency closure
and default Foundation/Runtime retention intentionally prefer false positives
over missing native metadata.

## Production bundle and build integration

`validate-bundles.sh` bundles the three repository macOS NativeScript examples
with pinned esbuild 0.25.8. The original source files, separately minified ESM
bundles, and minified dynamic-import code-split output each produced the same 48
rules, with no diagnostics or fail-open marker. All three whitelist files were
byte-identical with SHA-256
`ecf7218d52356b797bb723d0594ce8b0832308137551630977ff189ff1e8af4d`.

The full TestRunner app corpus exercised 184 JavaScript files, including worker
files, intentional invalid-syntax fixtures, `eval`, and dynamic global access.
The analyzer used 18 workers, reported three diagnostics, and emitted `*:*` as
designed, preserving all metadata.

Across 100 warm-cache process invocations of that 184-file corpus, analysis
averaged 9.9 ms with one Rayon worker and 6.5 ms with 18 workers (-34.3%). The
whitelist output was identical at every worker count.

The real Xcode TestRunner build completed with automatic filtering enabled and
ran its focused Metadata test suite (2 specs, 0 failures). The generated
fail-open metadata was 6,457,719 bytes and signature dispatch output was
8,477,285 bytes. A separate end-to-end build-step run using the statically
analyzable split example bundles generated 533,247 bytes of metadata, 1,403,490
bytes of signature bindings, and 1,148 KiB of TypeScript declarations; every
referenced native symbol was present in the declarations.
4 changes: 4 additions & 0 deletions metadata-generator/benchmarks/fixtures/split-entry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import "../../../platforms/apple/examples/appkit.js";

import("../../../platforms/apple/examples/view_controller.js");
import("../../../platforms/apple/examples/webview.js");
48 changes: 48 additions & 0 deletions metadata-generator/benchmarks/run-macos.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/bin/bash
set -euo pipefail

if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
echo "Usage: $0 <objc-metadata-generator> <new-output-directory> [iterations]" >&2
exit 2
fi

generator=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")
output=$2
iterations=${3:-5}

if [ ! -x "$generator" ]; then
echo "Generator is not executable: $generator" >&2
exit 2
fi
if [ -e "$output" ]; then
echo "Output path already exists (use a new path): $output" >&2
exit 2
fi

sdk_root=$(xcrun --sdk macosx --show-sdk-path)
sdk_version=$(xcrun --sdk macosx --show-sdk-version)
mkdir -p "$output"

for iteration in $(jot "$iterations"); do
run="$output/run-$iteration"
mkdir -p "$run/types"
/usr/bin/time -l -o "$run/timing.txt" "$generator" \
"types=$run/types" \
"ts-index-mode=frameworks-list" \
"ts-index-frameworks=Foundation,AppKit" \
-output-bin "$run/metadata.bin" \
-output-umbrella "$run/umbrella.h" \
-output-signature-bindings-cpp "$run/signatures.inc" \
Xclang -isysroot "$sdk_root" -std=gnu99 \
-target "arm64-apple-macosx$sdk_version" \
>"$run/stdout.log" 2>"$run/stderr.log"

(
cd "$run"
find . -type f ! -name '*.log' ! -name timing.txt ! -name hashes.txt -print0 | \
LC_ALL=C sort -z | xargs -0 shasum -a 256
) >"$run/hashes.txt"
done

echo "Completed $iterations run(s) in $output"
echo "Compare timing.txt files and verify hashes.txt lists are identical."
66 changes: 66 additions & 0 deletions metadata-generator/benchmarks/validate-bundles.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/bin/bash
set -euo pipefail

if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
echo "Usage: $0 <ns-metadata-symbols> [work-directory]" >&2
exit 2
fi

analyzer=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")
script_dir=$(cd "$(dirname "$0")" && pwd)
repo_root=$(cd "$script_dir/../.." && pwd)
work_dir=${2:-$(mktemp -d "${TMPDIR:-/tmp}/ns-metadata-bundles.XXXXXX")}

if [ ! -x "$analyzer" ]; then
echo "Analyzer is not executable: $analyzer" >&2
exit 2
fi
if [ -e "$work_dir" ] && [ -n "$(find "$work_dir" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]; then
echo "Work directory is not empty: $work_dir" >&2
exit 2
fi

mkdir -p "$work_dir/minified" "$work_dir/split"
sources=(
"$repo_root/platforms/apple/examples/appkit.js"
"$repo_root/platforms/apple/examples/view_controller.js"
"$repo_root/platforms/apple/examples/webview.js"
)

# Pin the bundler so the emitted-bundle regression is repeatable. Dynamic
# imports exercise multiple emitted chunks while the imported code remains the
# repository's real app source.
npx --yes esbuild@0.25.8 "${sources[@]}" \
--bundle --minify --format=esm --external:@nativescript/* \
--outdir="$work_dir/minified"
npx --yes esbuild@0.25.8 "$script_dir/fixtures/split-entry.js" \
--bundle --minify --splitting --format=esm --external:@nativescript/* \
--chunk-names=chunks/[name]-[hash] --outdir="$work_dir/split"

if [ "$(find "$work_dir/split/chunks" -type f -name '*.js' | wc -l | tr -d ' ')" -lt 2 ]; then
echo "Expected esbuild to emit at least two split chunks" >&2
exit 1
fi

"$analyzer" --output "$work_dir/source.mdg" "${sources[@]}"
"$analyzer" --output "$work_dir/minified.mdg" "$work_dir/minified"
"$analyzer" --output "$work_dir/split.mdg" "$work_dir/split"

cmp "$work_dir/source.mdg" "$work_dir/minified.mdg"
cmp "$work_dir/source.mdg" "$work_dir/split.mdg"
if grep -q '^\*:\*$' "$work_dir/source.mdg"; then
echo "Representative bundles unexpectedly disabled filtering" >&2
exit 1
fi

# The runtime test corpus contains malformed fixtures, eval, and dynamic global
# access. It must conservatively disable filtering rather than under-strip.
"$analyzer" --output "$work_dir/fail-open.mdg" \
"$repo_root/platforms/apple/test/runtime/runner/app"
grep -q '^\*:\*$' "$work_dir/fail-open.mdg"

echo "Bundle validation passed. Outputs: $work_dir"
shasum -a 256 \
"$work_dir/source.mdg" \
"$work_dir/minified.mdg" \
"$work_dir/split.mdg"
27 changes: 25 additions & 2 deletions metadata-generator/build-step-metadata-generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ def is_nativescript_source_root(search_path):
default_signature_bindings_path = os.path.join(src_root, "NativeScript", "ffi", "objc", "napi", "GeneratedSignatureDispatch.inc")
if os.path.isdir(os.path.dirname(default_signature_bindings_path)):
signature_bindings_cpp_path = default_signature_bindings_path
auto_filter_enabled = env_bool("NS_METADATA_AUTO_FILTER") or env_bool("TNS_METADATA_AUTO_FILTER")
auto_filter_bundle_paths = env_or_none("NS_METADATA_BUNDLE_PATHS") or env_or_none("TNS_METADATA_BUNDLE_PATHS")
symbol_analyzer_path = env_or_none("NS_METADATA_SYMBOL_ANALYZER") or env_or_none("TNS_METADATA_SYMBOL_ANALYZER")
if symbol_analyzer_path is None:
symbol_analyzer_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ns-metadata-symbols")


def save_stream_to_file(filename, stream):
Expand All @@ -197,6 +202,7 @@ def generate_metadata(arch):
# optionally add typescript output folder
if typescript_output_folder is not None:
current_typescript_output_folder = os.path.join(typescript_output_folder, arch)
os.makedirs(current_typescript_output_folder, exist_ok=True)
generator_call.extend(["-output-typescript", current_typescript_output_folder])
print("Generating TypeScript declarations in: \"{}\"".format(current_typescript_output_folder))

Expand All @@ -212,8 +218,25 @@ def generate_metadata(arch):
print("Generating signature dispatch bindings in: \"{}\"".format(signature_bindings_cpp_path))

whitelist_file_name = os.path.join(src_root, "whitelist.mdg")
if os.path.exists(whitelist_file_name):
generator_call.extend(["--whitelist-modules-file", whitelist_file_name])
effective_whitelist_file_name = whitelist_file_name if os.path.exists(whitelist_file_name) else None
if auto_filter_enabled:
if not auto_filter_bundle_paths:
raise RuntimeError("NS_METADATA_AUTO_FILTER requires NS_METADATA_BUNDLE_PATHS")
if not os.path.isfile(symbol_analyzer_path) or not os.access(symbol_analyzer_path, os.X_OK):
raise RuntimeError("Metadata symbol analyzer is not executable: {}".format(symbol_analyzer_path))

effective_whitelist_file_name = os.path.join(
conf_build_dir, "metadata-auto-whitelist-{}.mdg".format(arch))
analyzer_call = [symbol_analyzer_path, "--output", effective_whitelist_file_name]
if os.path.exists(whitelist_file_name):
analyzer_call.extend(["--include-whitelist", whitelist_file_name])
analyzer_call.extend(shlex.split(auto_filter_bundle_paths))
print("Generating metadata whitelist from app bundle(s):")
print(" ".join(analyzer_call))
subprocess.check_call(analyzer_call)

if effective_whitelist_file_name is not None:
generator_call.extend(["--whitelist-modules-file", effective_whitelist_file_name])

blacklist_file_name = os.path.join(src_root, "blacklist.mdg")
if os.path.exists(blacklist_file_name):
Expand Down
2 changes: 2 additions & 0 deletions metadata-generator/include/IR.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <vector>

#include "Metadata.h"
#include "MetadataFilter.h"
#include "Util.h"
#include "clang-c/Index.h"

Expand Down Expand Up @@ -364,6 +365,7 @@ class MetadataFactory {
void processProtocolRefs();

std::unordered_set<std::string> includePaths;
MetadataFilter metadataFilter;

std::unordered_map<std::string, VariableDecl> variables;
std::unordered_map<std::string, EnumDecl> enums;
Expand Down
Loading
Loading