From 537d2fb9c0ab7553ec27b028dc236dd893b53f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Wed, 16 Sep 2026 22:42:45 +0200 Subject: [PATCH] Bound native input diagnostics and teardown --- .github/workflows/native-runtime-packages.yml | 9 ++ .../native/webscene_v8_runtime_lifecycle.inc | 6 + .../native_v8_runtime_browser_dom_tests.inc | 121 ++++++++++++++++++ .../tests/native_v8_runtime_tests.cpp | 8 ++ scripts/build-native-engine-runtime.sh | 11 +- .../ReleaseCompatibilityGateTests.cs | 34 +++++ 6 files changed, 187 insertions(+), 2 deletions(-) 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() {