diff --git a/.github/workflows/native-runtime-packages.yml b/.github/workflows/native-runtime-packages.yml
index 797da280a..e6dcca2ca 100644
--- a/.github/workflows/native-runtime-packages.yml
+++ b/.github/workflows/native-runtime-packages.yml
@@ -333,6 +333,15 @@ jobs:
--upstream-v8 \
--partition-alloc \
--output "$GITHUB_WORKSPACE/artifacts/nuget-packages"
+ - name: Upload exact macOS native symbols
+ if: matrix.rid == 'osx-arm64'
+ uses: actions/upload-artifact@v4
+ with:
+ name: native-symbols-${{ matrix.rid }}-${{ needs.metadata.outputs.package-version }}
+ path: artifacts/native-engine-runtime-build/**/*.dSYM/**
+ if-no-files-found: error
+ compression-level: 9
+ retention-days: 3
- name: Build, pack, and test Linux runtime
if: matrix.rid == 'linux-x64'
shell: bash
diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc
index 18a27a22f..58582475a 100644
--- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc
+++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc
@@ -149,6 +149,12 @@
pending_programmatic_scroll_events.clear();
pending_interop_promises.clear();
pending_callback_promises.clear();
+ // Native host and file completions can still be pending when a window
+ // closes. Their persistent context/resolver handles must be released
+ // while the isolate is alive; member destruction happens after this
+ // body and therefore after isolate disposal below.
+ host_promise_targets.clear();
+ file_targets.clear();
pending_fetches.clear();
interop_handles.clear();
pending_promise_rejections.clear();
diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc
index aa38c37a7..979d953d8 100644
--- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc
+++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc
@@ -3180,6 +3180,127 @@ void test_native_clipboard_shortcut_events(webscene_engine*)
webscene_engine_destroy(engine);
}
+void test_native_legacy_clipboard_completion_stress(webscene_engine*)
+{
+ auto* engine = webscene_engine_create(0);
+ require(engine != nullptr,
+ "legacy clipboard completion stress engine creation failed");
+ execute_and_wait(engine, R"JS(
+ document.body.innerHTML = '';
+ const editor = document.getElementById('legacy-copy-stress');
+ globalThis.__legacyCopyStressEvents = 0;
+ globalThis.__legacyCopyStressResults = [];
+ editor.addEventListener('keydown', event => {
+ if (event.metaKey && event.key.toLowerCase() === 'c') {
+ event.preventDefault();
+ __legacyCopyStressResults.push(document.execCommand('copy'));
+ }
+ });
+ editor.addEventListener('copy', event => {
+ event.clipboardData.setData('text/plain', 'legacy stress');
+ event.preventDefault();
+ ++__legacyCopyStressEvents;
+ });
+ editor.focus();
+ )JS", "native-legacy-clipboard-stress-setup.js");
+
+ constexpr unsigned operation_count = 10000U;
+ constexpr unsigned batch_size = 16U;
+ constexpr auto modifier = WEBSCENE_INPUT_MODIFIER_META;
+ uint64_t sequence = 20000U;
+ const auto started = std::chrono::steady_clock::now();
+ for (unsigned offset = 0; offset < operation_count; offset += batch_size) {
+ for (unsigned index = 0; index < batch_size; ++index) {
+ keyboard_input(
+ engine, WEBSCENE_INPUT_KEY_DOWN, 'C', sequence++, modifier);
+ }
+ webscene_engine_metrics metrics{};
+ webscene_engine_get_metrics(engine, &metrics);
+ wait_for_consumed_inputs(
+ engine, metrics.enqueued_inputs,
+ "legacy clipboard stress input was not consumed");
+ for (unsigned index = 0; index < batch_size; ++index) {
+ const auto request = take_typed_host_request(engine);
+ require(
+ request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1
+ && request.flags
+ == WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1
+ && request.content_type == "text/plain"
+ && std::string(request.bytes.begin(), request.bytes.end())
+ == "legacy stress",
+ "legacy clipboard stress request changed");
+ require(webscene_engine_complete_host_request_v1(
+ engine, request.id, 0U, "text/plain",
+ nullptr, 0U, nullptr) != 0,
+ "legacy clipboard stress completion was rejected");
+ }
+ const auto expected = offset + batch_size;
+ require(evaluate(
+ engine,
+ "__legacyCopyStressEvents === " + std::to_string(expected)
+ + " && __legacyCopyStressResults.length === "
+ + std::to_string(expected)
+ + " && __legacyCopyStressResults.every(Boolean)",
+ "native-legacy-clipboard-stress-barrier.js") == "true",
+ "legacy clipboard stress did not drain completions in order");
+ }
+ require(!take_typed_host_request(engine),
+ "legacy clipboard stress retained a host request");
+ const auto elapsed = std::chrono::duration(
+ std::chrono::steady_clock::now() - started).count();
+ require(elapsed < 15.0,
+ "10,000 legacy clipboard completions exceeded fifteen seconds");
+ std::cout << "Legacy clipboard completion gate: operations="
+ << operation_count << " pendingHighWater=" << batch_size
+ << " elapsed=" << elapsed << "s\n";
+ // Reproduce the packaged close boundary: the worker destroys all resolved
+ // promise/microtask state immediately after the last public-ABI barrier.
+ webscene_engine_destroy(engine);
+}
+
+void test_native_pending_legacy_clipboard_shutdown(webscene_engine*)
+{
+ auto* engine = webscene_engine_create(0);
+ require(engine != nullptr,
+ "pending legacy clipboard shutdown engine creation failed");
+ execute_and_wait(engine, R"JS(
+ document.body.innerHTML = '';
+ const editor = document.getElementById('pending-copy');
+ editor.addEventListener('keydown', event => {
+ if (event.metaKey && event.key.toLowerCase() === 'c') {
+ event.preventDefault();
+ document.execCommand('copy');
+ }
+ });
+ editor.addEventListener('copy', event => {
+ event.clipboardData.setData('text/plain', 'pending close');
+ event.preventDefault();
+ });
+ editor.focus();
+ )JS", "native-pending-legacy-clipboard-shutdown-setup.js");
+
+ keyboard_input(
+ engine, WEBSCENE_INPUT_KEY_DOWN, 'C', 31001U,
+ WEBSCENE_INPUT_MODIFIER_META);
+ webscene_engine_metrics metrics{};
+ webscene_engine_get_metrics(engine, &metrics);
+ wait_for_consumed_inputs(
+ engine, metrics.enqueued_inputs,
+ "pending legacy clipboard shutdown input was not consumed");
+ const auto request = take_typed_host_request(engine);
+ require(
+ request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1
+ && request.content_type == "text/plain"
+ && std::string(request.bytes.begin(), request.bytes.end())
+ == "pending close",
+ "pending legacy clipboard shutdown did not retain its host promise");
+
+ // A native window may close before the operating-system host completes a
+ // request. Destruction must release the pending persistent resolver while
+ // its V8 isolate is still alive.
+ webscene_engine_destroy(engine);
+}
+
void test_clipboard_maximum_payload_gate(webscene_engine* engine)
{
constexpr size_t maximum_bytes = 16U * 1024U * 1024U;
diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp
index e4313e059..5ddcc21ef 100644
--- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp
+++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp
@@ -286,6 +286,12 @@ int main()
webscene_engine_destroy(focused_engine);
return 0;
}
+ if (selected == "input-diagnostics-pointer-focus") {
+ test_document_direction_and_visibility_are_native_properties();
+ test_native_legacy_clipboard_completion_stress(nullptr);
+ test_native_pending_legacy_clipboard_shutdown(nullptr);
+ return 0;
+ }
if (selected == "media-query-list") {
auto* focused_engine = webscene_engine_create(0);
require(focused_engine != nullptr, "focused engine creation failed");
@@ -925,6 +931,8 @@ int main()
test_clipboard_write_text_host_handoff(engine);
test_clipboard_read_host_completion(engine);
test_native_clipboard_shortcut_events(engine);
+ test_native_legacy_clipboard_completion_stress(engine);
+ test_native_pending_legacy_clipboard_shutdown(engine);
test_clipboard_maximum_payload_gate(engine);
test_clipboard_small_round_trip_performance(engine);
test_fullscreen_host_completion(engine);
diff --git a/scripts/build-native-engine-runtime.sh b/scripts/build-native-engine-runtime.sh
index c7c0a742c..08eeee851 100755
--- a/scripts/build-native-engine-runtime.sh
+++ b/scripts/build-native-engine-runtime.sh
@@ -383,6 +383,12 @@ elif [[ "$expected_kernel" == Linux ]]; then
-DCMAKE_SHARED_LINKER_FLAGS=-fuse-ld=lld
)
fi
+if [[ "$expected_kernel" == Darwin && "$cmake_build_type" == Release ]]; then
+ # Keep line tables only until dsymutil has emitted the exact shipped
+ # binary's external symbols. strip removes them from the runtime before
+ # packaging, so diagnostics do not increase the installed footprint.
+ cmake_args+=("-DCMAKE_CXX_FLAGS_RELEASE=-O3 -DNDEBUG -gline-tables-only")
+fi
cmake "${cmake_args[@]}"
cmake --build "$build_dir" --config "$cmake_build_type" --parallel
cmake -E copy_if_different "$icu_data" "$build_dir/icudtl.dat"
@@ -408,8 +414,6 @@ if [[ "$expected_kernel" == Darwin ]]; then
echo "Native engine deployment target is '$actual_macos_deployment_target'; expected '$macos_deployment_target'." >&2
exit 1
fi
-fi
-if [[ "$expected_kernel" == Darwin && "$cmake_build_type" == RelWithDebInfo ]]; then
native_dsym_path="$native_path.dSYM"
cmake -E remove_directory "$native_dsym_path"
dsymutil "$native_path" -o "$native_dsym_path"
@@ -417,6 +421,9 @@ if [[ "$expected_kernel" == Darwin && "$cmake_build_type" == RelWithDebInfo ]];
echo "Native engine build did not produce '$native_dsym_path'." >&2
exit 1
fi
+ if [[ "$cmake_build_type" == Release ]]; then
+ strip -S "$native_path"
+ fi
fi
snapshot_path="$build_dir/webscene_bootstrap_snapshot.bin"
snapshot_metadata_path="$build_dir/webscene_bootstrap_snapshot.meta"
diff --git a/tests/WebScene.Architecture.Tests/ReleaseCompatibilityGateTests.cs b/tests/WebScene.Architecture.Tests/ReleaseCompatibilityGateTests.cs
index 334a7eea3..df5281908 100644
--- a/tests/WebScene.Architecture.Tests/ReleaseCompatibilityGateTests.cs
+++ b/tests/WebScene.Architecture.Tests/ReleaseCompatibilityGateTests.cs
@@ -14,6 +14,40 @@ public void RuntimeBuildersRunTheCompleteRequiredCompatibilityProfile()
AssertBuilderRunsCompleteRequiredProfile("build-native-engine-runtime.ps1");
}
+ [Fact]
+ public void MacosReleaseRuntimePublishesExactBoundedNativeSymbols()
+ {
+ var builder = File.ReadAllText(Path.Combine(
+ s_repositoryRoot,
+ "scripts",
+ "build-native-engine-runtime.sh"));
+ Assert.Contains("-gline-tables-only", builder, StringComparison.Ordinal);
+ Assert.Contains("dsymutil \"$native_path\"", builder, StringComparison.Ordinal);
+ Assert.Contains("strip -S \"$native_path\"", builder, StringComparison.Ordinal);
+ var releaseOnlyGuard = builder.IndexOf(
+ "if [[ \"$expected_kernel\" == Darwin && \"$cmake_build_type\" == Release ]]; then",
+ StringComparison.Ordinal);
+ var lineTables = builder.IndexOf("-gline-tables-only", StringComparison.Ordinal);
+ var configure = builder.IndexOf("cmake \"${cmake_args[@]}\"", StringComparison.Ordinal);
+ Assert.True(
+ releaseOnlyGuard >= 0 && releaseOnlyGuard < lineTables && lineTables < configure,
+ "External symbol generation must be limited to the macOS Release package build.");
+ Assert.True(
+ builder.IndexOf("dsymutil \"$native_path\"", StringComparison.Ordinal)
+ < builder.IndexOf("strip -S \"$native_path\"", StringComparison.Ordinal),
+ "The exact runtime must be symbolized before its package copy is stripped, leaving no normal-path runtime cost.");
+
+ var workflow = File.ReadAllText(Path.Combine(
+ s_repositoryRoot,
+ ".github",
+ "workflows",
+ "native-runtime-packages.yml"));
+ Assert.Contains("name: Upload exact macOS native symbols", workflow, StringComparison.Ordinal);
+ Assert.Contains("path: artifacts/native-engine-runtime-build/**/*.dSYM/**", workflow, StringComparison.Ordinal);
+ Assert.Contains("compression-level: 9", workflow, StringComparison.Ordinal);
+ Assert.Contains("retention-days: 3", workflow, StringComparison.Ordinal);
+ }
+
[Fact]
public void RequiredProfileContainsTheEstablishedReleaseDenominator()
{