diff --git a/docs/validation/accessibility-semantic-live-regions-20260919.md b/docs/validation/accessibility-semantic-live-regions-20260919.md new file mode 100644 index 00000000..76b5d8ab --- /dev/null +++ b/docs/validation/accessibility-semantic-live-regions-20260919.md @@ -0,0 +1,79 @@ +# Accessibility semantic live-region publication boundary + +WebScene issue #546 adds a bounded, one-way semantic live-region provider for +the native peers tracked by AppScene issue #185. WebScene publishes immutable +UTF-8 event leases. It does not create platform accessibility objects or call +AppKit, UI Automation, AT-SPI, screen-reader or announcement APIs. + +## Contract + +`webscene_engine_take_semantic_live_events_v1` removes the next completed +worker publication and returns an immutable lease which may outlive its engine. +Each event contains a monotonic sequence, top-document and nested-frame +generations, semantic identity, live-region DOM identity, role, politeness, +coalescing flags and one UTF-8 text slice. AppScene can compare those numeric +generations with its current semantic peer before delivering an announcement. + +The supported DOM subset recognizes explicit `aria-live="polite"` and +`aria-live="assertive"`, plus the implicit semantics of `status`, `alert` and +`log`. Explicit `aria-live="off"` disables the region. `status` and `alert` +default to atomic; `log` defaults to additions; other live regions default to +additions and text. Valid `aria-atomic` and `aria-relevant` tokens override +those defaults. An `aria-busy="true"` region or ancestor retains the pre-busy +baseline and emits one coalesced event after busy clears. A newly connected +alert may publish its initial content; other new regions establish a baseline. + +Worker checkpoints compare final composed-tree state. Regions publish in +composed-tree order. Within a non-atomic region, additions and text changes use +current preorder, followed by relevant removals in prior preorder. Replacing a +text node at the same position is classified as text. Exact repeated state is +suppressed by semantic identity, changed-node identity, cause flags and text. +Nested live regions own their own descendants, so a parent does not duplicate +their text. + +Hidden, `aria-hidden`, `display:none`, visibility/content-visibility hidden, +inert, detached and stale-document regions never enter an event. Navigation +and low-memory requests synchronously retire queued events. The worker checks +the iteration-start document epoch before publication, closing a race with +host-side retirement. Each complete checkpoint also prunes queued events whose +document generation or live-region semantic identity is no longer current. +Navigation resets runtime baselines for the new document; +low memory rebuilds the current document baseline without replaying alerts. +Teardown clears both pending actions and live-region events. Existing leases +remain valid because they own their event and string storage. + +## Bounds and loss behavior + +- 256 queued events, with deterministic oldest-first eviction; +- 64 events per take lease; +- 64 KiB of valid UTF-8 per event; +- 1 MiB of aggregate queued UTF-8; +- 256 live regions, 4,096 text fragments per region and 16,384 bounded DOM + visits per worker checkpoint; +- explicit batch loss flag and saturated dropped-event count; +- one event per changed region per worker checkpoint. + +If a checkpoint exceeds its scan, region or fragment budget, WebScene retains +the preceding baseline, retires queued events and publishes nothing from the +partial scan. Queue +eviction preserves the newest events and reports the exact bounded loss on the +next take. + +## Authored qualification sources + +- `semantic_live_region_header_tests.c` pins the C11 event, lease and cap ABI. +- `native_v8_runtime_semantic_live_region_tests.inc` covers terminal, Problems, + Tasks and Chat ordering; polite/assertive/status/alert/log behavior; + atomic/relevant/busy coalescing; duplicate, hidden and detached suppression; + sequence order; navigation, low-memory and teardown retirement; queue/lease + caps and a bounded worker-time gate. +- `accessibility-semantic-live-regions-source.html` and its dedicated profile + retain the Chromium-shaped browser DOM oracle. +- `native-binary-interop.test.mjs` pins take/release export visibility. + +Per the implementation-only instruction, these sources were authored but not +executed. Only `git diff --check` is used for this slice. + +Incremental semantic tree deltas, platform peer mapping, announcement API +delivery, interruption policy, user preference integration and packaged +VoiceOver, Narrator and Orca acceptance remain in later WebScene/AppScene work. diff --git a/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt b/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt index 4c9379d9..6a8062ef 100644 --- a/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt +++ b/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt @@ -5,6 +5,14 @@ include(CTest) include(FetchContent) if(BUILD_TESTING) + add_executable(webscene_semantic_live_region_header_tests + tests/semantic_live_region_header_tests.c) + target_compile_features(webscene_semantic_live_region_header_tests PRIVATE c_std_11) + target_include_directories(webscene_semantic_live_region_header_tests PRIVATE native) + add_test(NAME webscene_semantic_live_region_header_tests + COMMAND webscene_semantic_live_region_header_tests) + set_tests_properties(webscene_semantic_live_region_header_tests PROPERTIES + LABELS "native;accessibility;live-region;abi;c11" TIMEOUT 10) add_executable(webscene_semantic_action_header_tests tests/semantic_action_header_tests.c) target_compile_features(webscene_semantic_action_header_tests PRIVATE c_std_11) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp index 48036596..2f7ecd39 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp @@ -493,6 +493,104 @@ struct webscene_engine final { semantic_action_payload_bytes_ = 0U; semantic_actions_pending_.store(false, std::memory_order_release); } + void publish_semantic_live_events_v1( + webscene_native::semantic_live_capture_data_v1 capture, + uint64_t document_epoch) { + auto notify = false; + { + std::lock_guard lock(semantic_live_mutex_); + if (document_epoch != semantic_document_epoch_.load( + std::memory_order_acquire)) return; + if (!capture.complete) { + semantic_live_dropped_events_ = std::min( + UINT32_MAX, + semantic_live_dropped_events_ + semantic_live_events_.size()); + semantic_live_events_.clear(); + semantic_live_text_bytes_ = 0U; + return; + } + std::erase_if(semantic_live_events_, [&](const auto& event) { + const auto stale = event.top_document_generation + != capture.top_document_generation + || !std::binary_search( + capture.live_region_semantic_ids.begin(), + capture.live_region_semantic_ids.end(), + event.semantic_id); + if (stale) { + semantic_live_text_bytes_ -= event.text.size(); + semantic_live_dropped_events_ = std::min( + UINT32_MAX, semantic_live_dropped_events_ + 1U); + } + return stale; + }); + for (auto& event : capture.events) { + if (event.text.empty()) continue; + if (event.text.size() + > WEBSCENE_SEMANTIC_LIVE_MAXIMUM_TEXT_BYTES_V1) { + auto copied = static_cast( + WEBSCENE_SEMANTIC_LIVE_MAXIMUM_TEXT_BYTES_V1); + while (copied > 0U + && (static_cast(event.text[copied]) + & 0xc0U) == 0x80U) --copied; + event.text.resize(copied); + event.flags |= WEBSCENE_SEMANTIC_LIVE_TEXT_TRUNCATED_V1; + } + while (!semantic_live_events_.empty() + && (semantic_live_events_.size() + >= WEBSCENE_SEMANTIC_LIVE_MAXIMUM_PENDING_EVENTS_V1 + || event.text.size() + > WEBSCENE_SEMANTIC_LIVE_MAXIMUM_QUEUED_TEXT_BYTES_V1 + - semantic_live_text_bytes_)) { + semantic_live_text_bytes_ -= + semantic_live_events_.front().text.size(); + semantic_live_events_.pop_front(); + semantic_live_dropped_events_ = std::min( + UINT32_MAX, semantic_live_dropped_events_ + 1U); + } + event.sequence = next_semantic_live_sequence_++; + semantic_live_text_bytes_ += event.text.size(); + semantic_live_events_.push_back(std::move(event)); + notify = true; + } + } + if (notify) notify_host_work(); + } + std::shared_ptr + take_semantic_live_events_v1() { + std::lock_guard lock(semantic_live_mutex_); + if (semantic_live_events_.empty() + && semantic_live_dropped_events_ == 0U) return {}; + auto batch = std::make_shared< + webscene_native::semantic_live_batch_data_v1>(); + batch->batch_generation = next_semantic_live_batch_generation_++; + batch->dropped_event_count = static_cast( + semantic_live_dropped_events_); + if (batch->dropped_event_count != 0U) { + batch->flags |= WEBSCENE_SEMANTIC_LIVE_BATCH_DROPPED_EVENTS_V1; + } + semantic_live_dropped_events_ = 0U; + const auto count = std::min( + semantic_live_events_.size(), + WEBSCENE_SEMANTIC_LIVE_MAXIMUM_EVENTS_PER_LEASE_V1); + batch->events.reserve(count); + batch->strings.reserve(std::min( + semantic_live_text_bytes_, + WEBSCENE_SEMANTIC_LIVE_MAXIMUM_QUEUED_TEXT_BYTES_V1)); + for (size_t index = 0U; index < count; ++index) { + semantic_live_text_bytes_ -= semantic_live_events_.front().text.size(); + batch->append(std::move(semantic_live_events_.front())); + semantic_live_events_.pop_front(); + } + return batch; + } + void retire_semantic_live_events_v1() { + std::lock_guard lock(semantic_live_mutex_); + semantic_live_dropped_events_ = std::min( + UINT32_MAX, + semantic_live_dropped_events_ + semantic_live_events_.size()); + semantic_live_events_.clear(); + semantic_live_text_bytes_ = 0U; + } void set_work_available_callback(webscene_work_available_callback_v1 callback, void* data) { std::lock_guard lock(host_observer_mutex_); host_observer_ = callback; @@ -821,6 +919,13 @@ struct webscene_engine final { std::mutex semantic_action_mutex_; size_t semantic_action_payload_bytes_{0U}; std::atomic semantic_actions_pending_{false}; + std::deque + semantic_live_events_; + std::mutex semantic_live_mutex_; + size_t semantic_live_text_bytes_{0U}; + uint64_t semantic_live_dropped_events_{0U}; + uint64_t next_semantic_live_sequence_{1U}; + uint64_t next_semantic_live_batch_generation_{1U}; std::atomic ordered_scene_consumer_{false}; std::atomic producer_gpu_wait_consumer_{false}; #if defined(WEBSCENE_NATIVE_ENGINE_ENABLE_GRAPHICS) @@ -2011,6 +2116,27 @@ struct semantic_snapshot_lease_v1 final { view.lease_token = this; } }; + +struct semantic_live_batch_lease_v1 final { + std::shared_ptr value; + webscene_semantic_live_batch_view_v1 view{}; + + explicit semantic_live_batch_lease_v1( + std::shared_ptr batch) + : value(std::move(batch)) + { + view.struct_size = sizeof(view); + view.version = 1U; + view.batch_generation = value->batch_generation; + view.flags = value->flags; + view.dropped_event_count = value->dropped_event_count; + view.events = value->events.empty() ? nullptr : value->events.data(); + view.event_count = static_cast(value->events.size()); + view.string_bytes = value->strings.empty() ? nullptr : value->strings.data(); + view.string_byte_count = static_cast(value->strings.size()); + view.lease_token = this; + } +}; } // namespace const webscene_semantic_snapshot_view_v1* @@ -2050,6 +2176,29 @@ uint32_t webscene_engine_request_semantic_action_v1( } } +const webscene_semantic_live_batch_view_v1* +webscene_engine_take_semantic_live_events_v1(webscene_engine* engine) +{ + if (engine == nullptr) return nullptr; + try { + auto value = engine->take_semantic_live_events_v1(); + if (!value) return nullptr; + auto* lease = new semantic_live_batch_lease_v1(std::move(value)); + return &lease->view; + } catch (...) { + return nullptr; + } +} + +void webscene_semantic_live_batch_release_v1( + const webscene_semantic_live_batch_view_v1* batch) +{ + if (batch == nullptr || batch->version != 1U + || batch->struct_size < sizeof(*batch) + || batch->lease_token == nullptr) return; + delete static_cast(batch->lease_token); +} + namespace { struct scene_lease_v3 { webscene_scene_lease cpu; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports index 4a6cb7e6..81e3fc70 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports @@ -58,6 +58,8 @@ _webscene_engine_set_accessibility_preferences_v1 _webscene_engine_acquire_semantic_snapshot_v1 _webscene_semantic_snapshot_release_v1 _webscene_engine_request_semantic_action_v1 +_webscene_engine_take_semantic_live_events_v1 +_webscene_semantic_live_batch_release_v1 _webscene_engine_set_visible _webscene_engine_set_window_focused_v1 _webscene_engine_set_window_fullscreen_v1 diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h index e189e2dd..087801f3 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -165,6 +165,33 @@ typedef enum webscene_semantic_action_admission_v1 { WEBSCENE_SEMANTIC_ACTION_PAYLOAD_TOO_LARGE_V1 = 5 } webscene_semantic_action_admission_v1; +typedef enum webscene_semantic_live_region_role_v1 { + WEBSCENE_SEMANTIC_LIVE_REGION_GENERIC_V1 = 0, + WEBSCENE_SEMANTIC_LIVE_REGION_STATUS_V1 = 1, + WEBSCENE_SEMANTIC_LIVE_REGION_ALERT_V1 = 2, + WEBSCENE_SEMANTIC_LIVE_REGION_LOG_V1 = 3 +} webscene_semantic_live_region_role_v1; + +typedef enum webscene_semantic_live_politeness_v1 { + WEBSCENE_SEMANTIC_LIVE_POLITE_V1 = 1, + WEBSCENE_SEMANTIC_LIVE_ASSERTIVE_V1 = 2 +} webscene_semantic_live_politeness_v1; + +enum { + WEBSCENE_SEMANTIC_LIVE_RELEVANT_ADDITIONS_V1 = 1U << 0U, + WEBSCENE_SEMANTIC_LIVE_RELEVANT_TEXT_V1 = 1U << 1U, + WEBSCENE_SEMANTIC_LIVE_RELEVANT_REMOVALS_V1 = 1U << 2U, + WEBSCENE_SEMANTIC_LIVE_ATOMIC_V1 = 1U << 3U, + WEBSCENE_SEMANTIC_LIVE_BUSY_COALESCED_V1 = 1U << 4U, + WEBSCENE_SEMANTIC_LIVE_INITIAL_ALERT_V1 = 1U << 5U, + WEBSCENE_SEMANTIC_LIVE_TEXT_TRUNCATED_V1 = 1U << 6U, + WEBSCENE_SEMANTIC_LIVE_BATCH_DROPPED_EVENTS_V1 = 1U << 0U, + WEBSCENE_SEMANTIC_LIVE_MAXIMUM_PENDING_EVENTS_V1 = 256U, + WEBSCENE_SEMANTIC_LIVE_MAXIMUM_EVENTS_PER_LEASE_V1 = 64U, + WEBSCENE_SEMANTIC_LIVE_MAXIMUM_TEXT_BYTES_V1 = 64U * 1024U, + WEBSCENE_SEMANTIC_LIVE_MAXIMUM_QUEUED_TEXT_BYTES_V1 = 1024U * 1024U +}; + typedef struct webscene_semantic_string_v1 { uint32_t offset; uint32_t length; @@ -228,6 +255,47 @@ typedef struct webscene_semantic_snapshot_view_v1 { const void* lease_token; } webscene_semantic_snapshot_view_v1; +/* + * One platform-neutral live-region change. text is a UTF-8 slice into its + * enclosing batch. Sequence and document/frame generations let a native peer + * preserve order and reject a publication after navigation. The semantic and + * DOM identities name the live-region root, not a platform accessibility + * object. Relevant bits describe the coalesced causes represented by text. + */ +typedef struct webscene_semantic_live_event_v1 { + uint32_t struct_size; + uint32_t version; + uint64_t sequence; + uint64_t top_document_generation; + uint64_t frame_generation; + uint64_t semantic_id; + uint32_t frame_owner_dom_node_id; + uint32_t dom_node_id; + uint32_t role; + uint32_t politeness; + uint32_t flags; + webscene_semantic_string_v1 text; +} webscene_semantic_live_event_v1; + +/* + * Immutable take lease. Taking removes at most 64 queued events. A lease may + * outlive the engine. dropped_event_count is the deterministic loss observed + * since the preceding successful take, including navigation/low-memory + * retirement and oldest-first queue eviction. + */ +typedef struct webscene_semantic_live_batch_view_v1 { + uint32_t struct_size; + uint32_t version; + uint64_t batch_generation; + uint32_t flags; + uint32_t dropped_event_count; + const webscene_semantic_live_event_v1* events; + uint32_t event_count; + const char* string_bytes; + uint32_t string_byte_count; + const void* lease_token; +} webscene_semantic_live_batch_view_v1; + /* * Host-to-DOM semantic action. The host copies snapshot_generation and * semantic_id from one acquired node. A newer publication may route the same @@ -1611,6 +1679,11 @@ WEBSCENE_API void webscene_semantic_snapshot_release_v1( WEBSCENE_API uint32_t webscene_engine_request_semantic_action_v1( webscene_engine* engine, const webscene_semantic_action_request_v1* request); +/* Takes the next bounded immutable live-region batch, or null when empty. */ +WEBSCENE_API const webscene_semantic_live_batch_view_v1* +webscene_engine_take_semantic_live_events_v1(webscene_engine* engine); +WEBSCENE_API void webscene_semantic_live_batch_release_v1( + const webscene_semantic_live_batch_view_v1* batch); /* Returns the CSS cursor resolved at the latest hit-tested pointer position. */ WEBSCENE_API uint32_t webscene_engine_get_cursor(const webscene_engine* engine); /* diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc index 43ac6fdf..4b2e6394 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc @@ -381,6 +381,7 @@ std::memory_order_release); } retire_semantic_actions_v1(); + retire_semantic_live_events_v1(); low_memory_requested_.store(true, std::memory_order_release); signal_worker(); return true; @@ -870,6 +871,7 @@ std::memory_order_release); } retire_semantic_actions_v1(); + retire_semantic_live_events_v1(); semantic_snapshot_requested_.store(true, std::memory_order_release); script_work_.emplace_back(url_request{ std::string(value, length), diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc index d0054e02..df460561 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc @@ -481,6 +481,8 @@ uint64_t frame_paced_pointer_observed_after_frame = 0; uint64_t last_pointer_dispatch_frame = 0; while (!token.stop_requested()) { + const auto semantic_epoch_for_iteration = + semantic_document_epoch_.load(std::memory_order_acquire); // Avalonia observes every compositor boundary even while no V8 RAF // or CSS animation is active. Import that clock without treating // the observation as input or waking this worker; a later input or @@ -569,6 +571,8 @@ if (low_memory_requested_.exchange(false, std::memory_order_acq_rel) && runtime_ != nullptr) { runtime_->notify_low_memory(); + runtime_->retire_semantic_live_regions_v1(); + retire_semantic_live_events_v1(); trim_scene_storage(); low_memory_notifications_.fetch_add(1, std::memory_order_relaxed); update_compilation_metrics(); @@ -578,6 +582,8 @@ >= *hidden_low_memory_deadline && runtime_ != nullptr) { runtime_->notify_low_memory(); + runtime_->retire_semantic_live_regions_v1(); + retire_semantic_live_events_v1(); trim_scene_storage(); low_memory_notifications_.fetch_add(1, std::memory_order_relaxed); hidden_low_memory_notifications_.fetch_add( @@ -1586,6 +1592,13 @@ update_component_readiness(); } } + if (runtime_ != nullptr + && (changed || document_.dirty() + || document_.scene_generation() != starting_scene_generation)) { + publish_semantic_live_events_v1( + runtime_->take_semantic_live_events_v1(), + semantic_epoch_for_iteration); + } #endif if (!semantic_actions_pending_.load(std::memory_order_acquire) && semantic_snapshot_requested_.exchange( @@ -1764,6 +1777,7 @@ retire_file_runtime(); #endif retire_semantic_actions_v1(); + retire_semantic_live_events_v1(); { std::lock_guard semantic_lock(semantic_snapshot_mutex_); semantic_document_epoch_.fetch_add(1U, std::memory_order_acq_rel); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_semantic_snapshot.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_semantic_snapshot.h index 66a2a5a1..8eee27e9 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_semantic_snapshot.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_semantic_snapshot.h @@ -15,6 +15,76 @@ inline constexpr uint32_t maximum_semantic_relationships_v1 = 64U * 1024U; inline constexpr uint32_t maximum_semantic_string_bytes_v1 = 4U * 1024U * 1024U; inline constexpr size_t maximum_semantic_text_bytes_v1 = 64U * 1024U; +struct semantic_live_event_data_v1 final { + uint64_t sequence{}; + uint64_t top_document_generation{}; + uint64_t frame_generation{}; + uint64_t semantic_id{}; + uint32_t frame_owner_dom_node_id{}; + uint32_t dom_node_id{}; + uint32_t role{}; + uint32_t politeness{}; + uint32_t flags{}; + std::string text; +}; + +struct semantic_live_capture_data_v1 final { + bool complete{}; + uint64_t top_document_generation{}; + std::vector live_region_semantic_ids; + std::vector events; +}; + +struct semantic_live_fragment_v1 final { + uint32_t dom_node_id{}; + std::string text; +}; + +struct semantic_live_region_state_v1 final { + uint64_t top_document_generation{}; + uint64_t frame_generation{}; + uint64_t semantic_id{}; + uint32_t frame_owner_dom_node_id{}; + uint32_t dom_node_id{}; + uint32_t role{}; + uint32_t politeness{}; + uint32_t relevant{}; + bool atomic{}; + bool busy{}; + bool text_truncated{}; + std::vector fragments; + std::string text; + uint64_t last_event_fingerprint{}; +}; + +struct semantic_live_batch_data_v1 final { + uint64_t batch_generation{}; + uint32_t flags{}; + uint32_t dropped_event_count{}; + std::vector events; + std::string strings; + + void append(semantic_live_event_data_v1 event) + { + webscene_semantic_live_event_v1 view{}; + view.struct_size = sizeof(view); + view.version = 1U; + view.sequence = event.sequence; + view.top_document_generation = event.top_document_generation; + view.frame_generation = event.frame_generation; + view.semantic_id = event.semantic_id; + view.frame_owner_dom_node_id = event.frame_owner_dom_node_id; + view.dom_node_id = event.dom_node_id; + view.role = event.role; + view.politeness = event.politeness; + view.flags = event.flags; + view.text.offset = static_cast(strings.size()); + view.text.length = static_cast(event.text.size()); + strings.append(event.text); + events.push_back(view); + } +}; + struct semantic_action_target_v1 final { uint64_t semantic_id{}; uint64_t top_document_generation{}; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index 846ef161..91bd24e0 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -6964,6 +6964,17 @@ v8_dom_runtime::dispatch_semantic_action_v1( return result; } +semantic_live_capture_data_v1 +v8_dom_runtime::take_semantic_live_events_v1() +{ + return impl_->take_semantic_live_events_v1(); +} + +void v8_dom_runtime::retire_semantic_live_regions_v1() +{ + impl_->retire_semantic_live_regions_v1(true); +} + void v8_dom_runtime::notify_low_memory() { if (impl_->isolate == nullptr) return; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h index b4c0f245..6d200b4c 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h @@ -468,6 +468,9 @@ class v8_dom_runtime final { uint64_t snapshot_generation); semantic_action_dispatch_result_v1 dispatch_semantic_action_v1( const semantic_action_request_data_v1& request); + semantic_live_capture_data_v1 + take_semantic_live_events_v1(); + void retire_semantic_live_regions_v1(); void notify_low_memory(); void signal_animation_frame(double timestamp_ms); bool advance_discrete_wheel_scroll(double timestamp_ms); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc index 58dea9c5..aff57af4 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc @@ -182,6 +182,7 @@ document_generation.fetch_add(1U, std::memory_order_acq_rel); frame_navigation_generations.clear(); } + retire_semantic_live_regions_v1(false); std::erase_if(retired_frame_preparations, [](const auto& future) { return !future.valid() || future.wait_for(std::chrono::seconds(0)) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_semantics.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_semantics.inc index 7215cb51..795f5f1e 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_semantics.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_semantics.inc @@ -191,6 +191,387 @@ return value == 0U ? UINT64_C(1) : value; } + struct semantic_live_configuration_v1 final { + uint32_t role{WEBSCENE_SEMANTIC_LIVE_REGION_GENERIC_V1}; + uint32_t politeness{}; + uint32_t relevant{}; + bool atomic{}; + bool enabled{}; + }; + + static semantic_live_configuration_v1 semantic_live_configuration( + const dom_node& node) + { + semantic_live_configuration_v1 result; + auto role = semantic_normalize_text(semantic_attribute(node, "role")); + const auto role_separator = role.find(' '); + if (role_separator != std::string::npos) role.resize(role_separator); + role = lower_html_name(std::move(role)); + if (role == "status") { + result.role = WEBSCENE_SEMANTIC_LIVE_REGION_STATUS_V1; + result.politeness = WEBSCENE_SEMANTIC_LIVE_POLITE_V1; + result.atomic = true; + } else if (role == "alert") { + result.role = WEBSCENE_SEMANTIC_LIVE_REGION_ALERT_V1; + result.politeness = WEBSCENE_SEMANTIC_LIVE_ASSERTIVE_V1; + result.atomic = true; + } else if (role == "log") { + result.role = WEBSCENE_SEMANTIC_LIVE_REGION_LOG_V1; + result.politeness = WEBSCENE_SEMANTIC_LIVE_POLITE_V1; + } + + const auto live_attribute = node.attributes.find("aria-live"); + if (live_attribute != node.attributes.end()) { + const auto live = lower_html_name(semantic_normalize_text( + live_attribute->second)); + if (live == "polite") { + result.politeness = WEBSCENE_SEMANTIC_LIVE_POLITE_V1; + } else if (live == "assertive") { + result.politeness = WEBSCENE_SEMANTIC_LIVE_ASSERTIVE_V1; + } else { + result.politeness = 0U; + } + } + result.enabled = result.politeness != 0U; + if (!result.enabled) return result; + + const auto atomic_attribute = node.attributes.find("aria-atomic"); + if (atomic_attribute != node.attributes.end()) { + result.atomic = semantic_true(atomic_attribute->second); + } + const auto default_relevant = result.role + == WEBSCENE_SEMANTIC_LIVE_REGION_LOG_V1 + ? WEBSCENE_SEMANTIC_LIVE_RELEVANT_ADDITIONS_V1 + : WEBSCENE_SEMANTIC_LIVE_RELEVANT_ADDITIONS_V1 + | WEBSCENE_SEMANTIC_LIVE_RELEVANT_TEXT_V1; + result.relevant = default_relevant; + const auto relevant_attribute = node.attributes.find("aria-relevant"); + if (relevant_attribute != node.attributes.end()) { + result.relevant = 0U; + auto tokens = lower_html_name(relevant_attribute->second); + size_t cursor = 0U; + while (cursor < tokens.size()) { + while (cursor < tokens.size() && std::isspace( + static_cast(tokens[cursor]))) ++cursor; + const auto begin = cursor; + while (cursor < tokens.size() && !std::isspace( + static_cast(tokens[cursor]))) ++cursor; + const auto token = std::string_view(tokens).substr( + begin, cursor - begin); + if (token == "all") { + result.relevant = WEBSCENE_SEMANTIC_LIVE_RELEVANT_ADDITIONS_V1 + | WEBSCENE_SEMANTIC_LIVE_RELEVANT_TEXT_V1 + | WEBSCENE_SEMANTIC_LIVE_RELEVANT_REMOVALS_V1; + } else if (token == "additions") { + result.relevant |= WEBSCENE_SEMANTIC_LIVE_RELEVANT_ADDITIONS_V1; + } else if (token == "text") { + result.relevant |= WEBSCENE_SEMANTIC_LIVE_RELEVANT_TEXT_V1; + } else if (token == "removals") { + result.relevant |= WEBSCENE_SEMANTIC_LIVE_RELEVANT_REMOVALS_V1; + } + } + if (result.relevant == 0U) result.relevant = default_relevant; + } + return result; + } + + static void semantic_live_append_text( + std::string& destination, + std::string_view value, + bool& truncated) + { + if (value.empty()) return; + auto available = static_cast( + WEBSCENE_SEMANTIC_LIVE_MAXIMUM_TEXT_BYTES_V1) - destination.size(); + if (!destination.empty()) { + if (available == 0U) { + truncated = true; + return; + } + destination.push_back(' '); + --available; + } + auto copied = std::min(available, value.size()); + if (copied < value.size()) { + while (copied > 0U + && (static_cast(value[copied]) & 0xc0U) == 0x80U) { + --copied; + } + } + destination.append(value.substr(0U, copied)); + truncated = truncated || copied != value.size(); + } + + static void semantic_live_trim_incomplete_utf8(std::string& value) + { + if (value.empty()) return; + auto lead = value.size() - 1U; + while (lead > 0U + && (static_cast(value[lead]) & 0xc0U) == 0x80U) { + --lead; + } + const auto first = static_cast(value[lead]); + const auto expected = (first & 0x80U) == 0U ? 1U + : (first & 0xe0U) == 0xc0U ? 2U + : (first & 0xf0U) == 0xe0U ? 3U + : (first & 0xf8U) == 0xf0U ? 4U : 1U; + if (value.size() - lead < expected) value.resize(lead); + } + + semantic_live_capture_data_v1 take_semantic_live_events_v1() + { + constexpr size_t maximum_scan_nodes = 16U * 1024U; + constexpr size_t maximum_regions = 256U; + constexpr size_t maximum_fragments_per_region = 4096U; + std::vector current; + current.reserve(16U); + size_t scanned_nodes = 0U; + bool scan_truncated = false; + const auto top_generation = document_generation.load( + std::memory_order_acquire); + + std::function + collect_fragments; + collect_fragments = [&](const dom_node& node, + semantic_live_region_state_v1& region, + bool is_root) { + if (++scanned_nodes > maximum_scan_nodes + || region.fragments.size() >= maximum_fragments_per_region) { + scan_truncated = true; + return; + } + if (!is_root && semantic_hidden(document, node)) return; + if (!is_root && node.kind == dom_node_kind::element + && semantic_live_configuration(node).enabled) return; + if (node.kind == dom_node_kind::text) { + auto text = semantic_normalize_text(node.text_content); + region.text_truncated = region.text_truncated + || node.text_content.size() + > WEBSCENE_SEMANTIC_LIVE_MAXIMUM_TEXT_BYTES_V1; + semantic_live_trim_incomplete_utf8(text); + if (!text.empty()) { + region.fragments.push_back({node.id, std::move(text)}); + } + } + for (const auto* child : document.composed_children(node)) { + if (child == nullptr || scan_truncated) continue; + collect_fragments(*child, region, false); + } + }; + + std::function visit; + visit = [&](const dom_node& node, uint32_t frame_owner, + uint64_t frame_generation, bool ancestor_hidden, + bool ancestor_busy) { + if (++scanned_nodes > maximum_scan_nodes) { + scan_truncated = true; + return; + } + const auto hidden = ancestor_hidden || semantic_hidden(document, node); + if (hidden) return; + auto active_owner = frame_owner; + auto active_frame_generation = frame_generation; + if (node.tag == "body" && node.parent != nullptr + && node.parent->tag == "iframe") { + active_owner = node.parent->id; + const auto generation = frame_navigation_generations.find(active_owner); + active_frame_generation = generation == frame_navigation_generations.end() + ? 0U : generation->second; + } + const auto busy = ancestor_busy + || semantic_true(semantic_attribute(node, "aria-busy")); + const auto configuration = semantic_live_configuration(node); + if (configuration.enabled) { + if (current.size() == maximum_regions) { + scan_truncated = true; + return; + } + semantic_live_region_state_v1 region; + region.top_document_generation = top_generation; + region.frame_generation = active_frame_generation; + region.frame_owner_dom_node_id = active_owner; + region.dom_node_id = node.id; + region.semantic_id = semantic_identity( + top_generation, active_frame_generation, active_owner, node.id); + region.role = configuration.role; + region.politeness = configuration.politeness; + region.relevant = configuration.relevant; + region.atomic = configuration.atomic; + region.busy = busy; + collect_fragments(node, region, true); + for (const auto& fragment : region.fragments) { + semantic_live_append_text( + region.text, fragment.text, region.text_truncated); + } + current.push_back(std::move(region)); + } + for (const auto* child : document.composed_children(node)) { + if (child == nullptr || scan_truncated) continue; + visit(*child, active_owner, active_frame_generation, hidden, busy); + } + }; + visit(document.body(), 0U, 0U, false, false); + if (scan_truncated) return {}; + + std::vector events; + events.reserve(std::min(current.size(), static_cast(32U))); + std::unordered_set retained; + retained.reserve(current.size()); + for (auto& region : current) { + retained.insert(region.semantic_id); + auto previous = semantic_live_regions.find(region.semantic_id); + if (previous == semantic_live_regions.end()) { + if (!region.busy + && !semantic_live_suppress_initial_alerts + && region.role == WEBSCENE_SEMANTIC_LIVE_REGION_ALERT_V1 + && !region.text.empty()) { + semantic_live_event_data_v1 event; + event.top_document_generation = region.top_document_generation; + event.frame_generation = region.frame_generation; + event.semantic_id = region.semantic_id; + event.frame_owner_dom_node_id = region.frame_owner_dom_node_id; + event.dom_node_id = region.dom_node_id; + event.role = region.role; + event.politeness = region.politeness; + event.flags = WEBSCENE_SEMANTIC_LIVE_ATOMIC_V1 + | WEBSCENE_SEMANTIC_LIVE_INITIAL_ALERT_V1 + | WEBSCENE_SEMANTIC_LIVE_RELEVANT_ADDITIONS_V1 + | (region.text_truncated + ? WEBSCENE_SEMANTIC_LIVE_TEXT_TRUNCATED_V1 : 0U); + event.text = region.text; + events.push_back(std::move(event)); + } + semantic_live_regions.insert_or_assign( + region.semantic_id, std::move(region)); + continue; + } + if (region.busy) { + previous->second.busy = true; + continue; + } + + std::unordered_map old_fragments; + std::unordered_map new_fragments; + old_fragments.reserve(previous->second.fragments.size()); + new_fragments.reserve(region.fragments.size()); + for (const auto& fragment : previous->second.fragments) + old_fragments.emplace(fragment.dom_node_id, fragment.text); + for (const auto& fragment : region.fragments) + new_fragments.emplace(fragment.dom_node_id, fragment.text); + + uint32_t causes = 0U; + std::string delta; + bool text_truncated = false; + std::unordered_set replaced_old_fragments; + uint64_t fingerprint = UINT64_C(1469598103934665603); + const auto mix = [&](uint64_t value) { + fingerprint ^= value; + fingerprint *= UINT64_C(1099511628211); + }; + for (size_t index = 0U; index < region.fragments.size(); ++index) { + const auto& fragment = region.fragments[index]; + const auto old = old_fragments.find(fragment.dom_node_id); + if (old == old_fragments.end()) { + const auto positional_replacement = + index < previous->second.fragments.size() + && !new_fragments.contains( + previous->second.fragments[index].dom_node_id); + if (positional_replacement) { + replaced_old_fragments.insert( + previous->second.fragments[index].dom_node_id); + if (previous->second.fragments[index].text + == fragment.text) continue; + if ((region.relevant + & WEBSCENE_SEMANTIC_LIVE_RELEVANT_TEXT_V1) == 0U) + continue; + causes |= WEBSCENE_SEMANTIC_LIVE_RELEVANT_TEXT_V1; + mix(previous->second.fragments[index].dom_node_id); + } else { + if ((region.relevant + & WEBSCENE_SEMANTIC_LIVE_RELEVANT_ADDITIONS_V1) == 0U) + continue; + causes |= WEBSCENE_SEMANTIC_LIVE_RELEVANT_ADDITIONS_V1; + } + } else if (old->second != fragment.text) { + if ((region.relevant + & WEBSCENE_SEMANTIC_LIVE_RELEVANT_TEXT_V1) == 0U) + continue; + causes |= WEBSCENE_SEMANTIC_LIVE_RELEVANT_TEXT_V1; + } else { + continue; + } + mix(fragment.dom_node_id); + semantic_live_append_text(delta, fragment.text, text_truncated); + } + if ((region.relevant + & WEBSCENE_SEMANTIC_LIVE_RELEVANT_REMOVALS_V1) != 0U) { + for (const auto& fragment : previous->second.fragments) { + if (new_fragments.contains(fragment.dom_node_id) + || replaced_old_fragments.contains(fragment.dom_node_id)) continue; + causes |= WEBSCENE_SEMANTIC_LIVE_RELEVANT_REMOVALS_V1; + mix(fragment.dom_node_id); + semantic_live_append_text(delta, fragment.text, text_truncated); + } + } + if (causes != 0U) { + auto event_text = region.atomic && !region.text.empty() + ? region.text : std::move(delta); + for (const auto character : event_text) mix( + static_cast(character)); + mix(causes); + if (!event_text.empty() + && fingerprint != previous->second.last_event_fingerprint) { + semantic_live_event_data_v1 event; + event.top_document_generation = region.top_document_generation; + event.frame_generation = region.frame_generation; + event.semantic_id = region.semantic_id; + event.frame_owner_dom_node_id = region.frame_owner_dom_node_id; + event.dom_node_id = region.dom_node_id; + event.role = region.role; + event.politeness = region.politeness; + event.flags = causes + | (region.atomic ? WEBSCENE_SEMANTIC_LIVE_ATOMIC_V1 : 0U) + | (previous->second.busy + ? WEBSCENE_SEMANTIC_LIVE_BUSY_COALESCED_V1 : 0U) + | (text_truncated || region.text_truncated + ? WEBSCENE_SEMANTIC_LIVE_TEXT_TRUNCATED_V1 : 0U); + event.text = std::move(event_text); + events.push_back(std::move(event)); + region.last_event_fingerprint = fingerprint; + } else { + region.last_event_fingerprint = + previous->second.last_event_fingerprint; + } + } else { + region.last_event_fingerprint = previous->second.last_event_fingerprint; + } + semantic_live_regions.insert_or_assign( + region.semantic_id, std::move(region)); + } + std::erase_if(semantic_live_regions, [&](const auto& entry) { + return !retained.contains(entry.first); + }); + semantic_live_suppress_initial_alerts = false; + semantic_live_capture_data_v1 capture; + capture.complete = true; + capture.top_document_generation = top_generation; + capture.live_region_semantic_ids.reserve(retained.size()); + for (const auto identity : retained) { + capture.live_region_semantic_ids.push_back(identity); + } + std::sort( + capture.live_region_semantic_ids.begin(), + capture.live_region_semantic_ids.end()); + capture.events = std::move(events); + return capture; + } + + void retire_semantic_live_regions_v1(bool suppress_initial_alerts) + { + semantic_live_regions.clear(); + semantic_live_suppress_initial_alerts = suppress_initial_alerts; + } + std::shared_ptr build_semantic_snapshot_v1( uint64_t snapshot_generation) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc index b6076634..b0984d86 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc @@ -34,6 +34,9 @@ retired_frame_preparations; std::mutex document_generation_mutex; std::atomic document_generation{1U}; + std::unordered_map + semantic_live_regions; + bool semantic_live_suppress_initial_alerts{false}; std::shared_ptr shared_isolate; std::shared_ptr allocator; v8::Isolate* isolate{nullptr}; diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_semantic_live_region_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_semantic_live_region_tests.inc new file mode 100644 index 00000000..2f321dea --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_semantic_live_region_tests.inc @@ -0,0 +1,239 @@ +std::string_view semantic_live_text( + const webscene_semantic_live_batch_view_v1& batch, + const webscene_semantic_live_event_v1& event) +{ + require(event.text.offset <= batch.string_byte_count + && event.text.length <= batch.string_byte_count - event.text.offset, + "semantic live text slice exceeded its lease"); + return {batch.string_bytes + event.text.offset, event.text.length}; +} + +const webscene_semantic_live_batch_view_v1* wait_for_semantic_live_batch( + webscene_engine* engine) +{ + for (auto attempt = 0; attempt < 500; ++attempt) { + const auto* batch = webscene_engine_take_semantic_live_events_v1(engine); + if (batch != nullptr) return batch; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + fail("semantic live-region publication timed out"); +} + +void require_no_semantic_live_batch(webscene_engine* engine) +{ + for (auto attempt = 0; attempt < 20; ++attempt) { + const auto* batch = webscene_engine_take_semantic_live_events_v1(engine); + if (batch != nullptr) { + const auto count = batch->event_count; + webscene_semantic_live_batch_release_v1(batch); + require(count == 0U, "unexpected semantic live-region event"); + } + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } +} + +void test_semantic_live_regions_dom_order_busy_and_lifecycle() +{ + auto* engine = webscene_engine_create(0U); + require(engine != nullptr, "semantic live-region engine creation failed"); + evaluate(engine, R"JS( + document.body.innerHTML = ` +
+
Problems0
+
+ Task queuedTask stale +
+
+ `; + true; + )JS", "semantic-live-setup.js"); + const auto* baseline = wait_for_semantic_snapshot(engine); + webscene_semantic_snapshot_release_v1(baseline); + require_no_semantic_live_batch(engine); + + evaluate(engine, R"JS( + terminal.append(Object.assign(document.createElement('div'), { textContent: 'Terminal ready' })); + terminal.append(Object.assign(document.createElement('div'), { textContent: 'Process complete' })); + problems.querySelector('span').textContent = '2'; + document.getElementById('task-state').textContent = 'Task running'; + document.getElementById('stale-task').remove(); + const alert = document.createElement('section'); + alert.id = 'alert'; alert.setAttribute('role', 'alert'); alert.textContent = 'Build failed'; + document.body.append(alert); + hiddenLive = document.getElementById('hidden-live'); + hiddenLive.textContent = 'must remain silent'; + true; + )JS", "semantic-live-workbench-mutations.js"); + const auto* batch = wait_for_semantic_live_batch(engine); + require(batch->event_count == 4U, + "terminal/problems/tasks/alert changes did not coalesce per region"); + require(batch->events[0].role == WEBSCENE_SEMANTIC_LIVE_REGION_LOG_V1 + && semantic_live_text(*batch, batch->events[0]) + == "Terminal ready Process complete", + "terminal log additions lost DOM order"); + require(batch->events[1].role == WEBSCENE_SEMANTIC_LIVE_REGION_STATUS_V1 + && (batch->events[1].flags & WEBSCENE_SEMANTIC_LIVE_ATOMIC_V1) != 0U + && semantic_live_text(*batch, batch->events[1]) == "Problems 2", + "atomic Problems status did not publish its whole region"); + require(batch->events[2].politeness == WEBSCENE_SEMANTIC_LIVE_ASSERTIVE_V1 + && (batch->events[2].flags + & (WEBSCENE_SEMANTIC_LIVE_RELEVANT_TEXT_V1 + | WEBSCENE_SEMANTIC_LIVE_RELEVANT_REMOVALS_V1)) + == (WEBSCENE_SEMANTIC_LIVE_RELEVANT_TEXT_V1 + | WEBSCENE_SEMANTIC_LIVE_RELEVANT_REMOVALS_V1) + && semantic_live_text(*batch, batch->events[2]) + == "Task running Task stale", + "Tasks text/removal relevance changed"); + require(batch->events[3].role == WEBSCENE_SEMANTIC_LIVE_REGION_ALERT_V1 + && (batch->events[3].flags + & WEBSCENE_SEMANTIC_LIVE_INITIAL_ALERT_V1) != 0U, + "initial authored alert was not assertive and explicit"); + const auto first_sequence = batch->events[0].sequence; + for (uint32_t index = 1U; index < batch->event_count; ++index) { + require(batch->events[index].sequence == first_sequence + index, + "semantic live-region sequence was not contiguous"); + } + webscene_semantic_live_batch_release_v1(batch); + + evaluate(engine, R"JS( + chat.append(Object.assign(document.createElement('div'), { textContent: 'Chat one' })); + problems.querySelector('span').textContent = '2'; + true; + )JS", "semantic-live-busy-and-duplicate.js"); + require_no_semantic_live_batch(engine); + evaluate(engine, R"JS( + chat.append(Object.assign(document.createElement('div'), { textContent: 'Chat two' })); + chat.setAttribute('aria-busy', 'false'); + true; + )JS", "semantic-live-busy-clear.js"); + batch = wait_for_semantic_live_batch(engine); + require(batch->event_count == 1U + && (batch->events[0].flags + & WEBSCENE_SEMANTIC_LIVE_BUSY_COALESCED_V1) != 0U + && semantic_live_text(*batch, batch->events[0]) == "Chat one Chat two", + "busy Chat changes did not publish once after clearing"); + webscene_semantic_live_batch_release_v1(batch); + + evaluate(engine, "chat.remove(); chat.textContent = 'detached'; true;", + "semantic-live-detached.js"); + require_no_semantic_live_batch(engine); + + evaluate(engine, "terminal.textContent = 'retire on low memory'; true;", + "semantic-live-low-memory-pending.js"); + require(webscene_engine_request_low_memory(engine) != 0U, + "semantic live low-memory request failed"); + batch = webscene_engine_take_semantic_live_events_v1(engine); + if (batch != nullptr) { + require(batch->event_count == 0U, + "low memory retained semantic live-region events"); + webscene_semantic_live_batch_release_v1(batch); + } + + evaluate(engine, "terminal.textContent = 'retire on navigation'; true;", + "semantic-live-navigation-pending.js"); + constexpr std::string_view replacement{"about:blank"}; + require(webscene_engine_load_url( + engine, replacement.data(), replacement.size()) != 0U, + "semantic live navigation replacement failed"); + batch = webscene_engine_take_semantic_live_events_v1(engine); + if (batch != nullptr) { + require(batch->event_count == 0U, + "navigation retained stale semantic live-region events"); + webscene_semantic_live_batch_release_v1(batch); + } + require_no_semantic_live_batch(engine); + evaluate(engine, R"JS( + const finalAlert = document.createElement('section'); + finalAlert.setAttribute('role', 'alert'); + finalAlert.textContent = 'Lease survives teardown'; + document.body.append(finalAlert); + true; + )JS", "semantic-live-teardown-lease.js"); + batch = wait_for_semantic_live_batch(engine); + require(batch->event_count == 1U, + "teardown lease fixture was not published"); + webscene_engine_destroy(engine); + require(semantic_live_text(*batch, batch->events[0]) + == "Lease survives teardown", + "semantic live lease did not outlive its engine"); + webscene_semantic_live_batch_release_v1(batch); +} + +void test_semantic_live_region_caps_and_worker_budget() +{ + auto* engine = webscene_engine_create(0U); + require(engine != nullptr, "semantic live cap engine creation failed"); + evaluate(engine, R"JS( + document.body.innerHTML = Array.from({ length: 256 }, (_, index) => + `
`).join(''); + true; + )JS", "semantic-live-cap-setup.js"); + const auto* baseline = wait_for_semantic_snapshot(engine); + const auto baseline_generation = baseline->snapshot_generation; + webscene_semantic_snapshot_release_v1(baseline); + + const auto started = std::chrono::steady_clock::now(); + evaluate(engine, R"JS( + for (let index = 0; index < 256; ++index) { + document.getElementById(`log-${index}`).textContent = `entry-${index}`; + } + true; + )JS", "semantic-live-cap-fill.js"); + const auto* checkpoint = wait_for_semantic_action_snapshot( + engine, baseline_generation, [](const auto&) { return true; }); + const auto checkpoint_generation = checkpoint->snapshot_generation; + webscene_semantic_snapshot_release_v1(checkpoint); + evaluate(engine, R"JS( + document.getElementById('log-0').textContent = 'replacement-entry'; + true; + )JS", "semantic-live-cap-overflow.js"); + checkpoint = wait_for_semantic_action_snapshot( + engine, checkpoint_generation, [](const auto&) { return true; }); + webscene_semantic_snapshot_release_v1(checkpoint); + + const auto* batch = wait_for_semantic_live_batch(engine); + require(batch->event_count == WEBSCENE_SEMANTIC_LIVE_MAXIMUM_EVENTS_PER_LEASE_V1, + "semantic live lease exceeded or failed to reach its cap"); + require((batch->flags & WEBSCENE_SEMANTIC_LIVE_BATCH_DROPPED_EVENTS_V1) != 0U + && batch->dropped_event_count == 1U, + "semantic live oldest-first overflow was not explicit"); + require(batch->string_byte_count + <= WEBSCENE_SEMANTIC_LIVE_MAXIMUM_QUEUED_TEXT_BYTES_V1, + "semantic live lease exceeded its aggregate text cap"); + require(std::chrono::steady_clock::now() - started < std::chrono::seconds(2), + "semantic live bounded worker publication exceeded its regression budget"); + webscene_semantic_live_batch_release_v1(batch); + webscene_engine_destroy(engine); + + engine = webscene_engine_create(0U); + require(engine != nullptr, "semantic live text-cap engine creation failed"); + evaluate(engine, R"JS( + document.body.innerHTML = Array.from({ length: 17 }, (_, index) => + `
`).join(''); + true; + )JS", "semantic-live-text-cap-setup.js"); + baseline = wait_for_semantic_snapshot(engine); + webscene_semantic_snapshot_release_v1(baseline); + evaluate(engine, R"JS( + for (let index = 0; index < 17; ++index) { + document.getElementById(`large-${index}`).textContent = 'x'.repeat(70000); + } + true; + )JS", "semantic-live-text-cap-fill.js"); + batch = wait_for_semantic_live_batch(engine); + require(batch->event_count == 16U + && batch->string_byte_count + == WEBSCENE_SEMANTIC_LIVE_MAXIMUM_QUEUED_TEXT_BYTES_V1, + "semantic live aggregate UTF-8 queue cap changed"); + require(batch->dropped_event_count == 1U, + "semantic live aggregate cap loss was not explicit"); + for (uint32_t index = 0U; index < batch->event_count; ++index) { + require(batch->events[index].text.length + == WEBSCENE_SEMANTIC_LIVE_MAXIMUM_TEXT_BYTES_V1 + && (batch->events[index].flags + & WEBSCENE_SEMANTIC_LIVE_TEXT_TRUNCATED_V1) != 0U, + "semantic live per-event text cap was not explicit"); + } + webscene_semantic_live_batch_release_v1(batch); + webscene_engine_destroy(engine); +} 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 66e041c1..c5989943 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -88,6 +88,7 @@ uint8_t measure_baseline_fixture_text( #include "native_v8_runtime_test_support.inc" #include "native_v8_runtime_semantic_snapshot_tests.inc" #include "native_v8_runtime_semantic_action_tests.inc" +#include "native_v8_runtime_semantic_live_region_tests.inc" #include "native_v8_runtime_lifecycle_tests.inc" #include "native_v8_runtime_indexeddb_tests.inc" #if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8_INSPECTOR) @@ -1574,6 +1575,7 @@ int main() test_dom_listener_callback_retirement(); test_semantic_snapshot_nested_state_lifecycle_and_bounds(); test_semantic_action_dom_contract_and_retirement(); + test_semantic_live_regions_dom_order_busy_and_lifecycle(); test_low_memory_reclaims_small_detached_dom_batches(); test_detached_dom_wrappers_do_not_permanently_root_nodes(engine); test_connected_style_recascade_skips_detached_wrapper_retention(engine); @@ -1616,5 +1618,6 @@ int main() test_binary_interop_result_outlives_engine(); test_semantic_snapshot_caps_performance_and_engine_retirement(); test_semantic_action_queue_caps_and_worker_budget(); + test_semantic_live_region_caps_and_worker_budget(); return 0; } diff --git a/experiments/WebScene.NativeEngine.Probe/tests/semantic_live_region_header_tests.c b/experiments/WebScene.NativeEngine.Probe/tests/semantic_live_region_header_tests.c new file mode 100644 index 00000000..b40cf9ea --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/tests/semantic_live_region_header_tests.c @@ -0,0 +1,36 @@ +#include "webscene_native_engine.h" + +#include +#include + +_Static_assert(WEBSCENE_SEMANTIC_LIVE_REGION_STATUS_V1 == 1, + "semantic live-region role ABI changed"); +_Static_assert(WEBSCENE_SEMANTIC_LIVE_REGION_LOG_V1 == 3, + "semantic live-region role ABI changed"); +_Static_assert(WEBSCENE_SEMANTIC_LIVE_ASSERTIVE_V1 == 2, + "semantic live politeness ABI changed"); +_Static_assert(WEBSCENE_SEMANTIC_LIVE_MAXIMUM_PENDING_EVENTS_V1 == 256U, + "semantic live queue cap changed"); +_Static_assert(WEBSCENE_SEMANTIC_LIVE_MAXIMUM_EVENTS_PER_LEASE_V1 == 64U, + "semantic live lease cap changed"); +_Static_assert(WEBSCENE_SEMANTIC_LIVE_MAXIMUM_TEXT_BYTES_V1 == 65536U, + "semantic live text cap changed"); +_Static_assert(WEBSCENE_SEMANTIC_LIVE_MAXIMUM_QUEUED_TEXT_BYTES_V1 == 1048576U, + "semantic live aggregate text cap changed"); +_Static_assert(offsetof(webscene_semantic_live_event_v1, semantic_id) + > offsetof(webscene_semantic_live_event_v1, top_document_generation), + "semantic live identity fields changed order"); +_Static_assert(offsetof(webscene_semantic_live_batch_view_v1, lease_token) + > offsetof(webscene_semantic_live_batch_view_v1, string_bytes), + "semantic live lease fields changed order"); + +int main(void) +{ + webscene_semantic_live_event_v1 event = {0}; + webscene_semantic_live_batch_view_v1 batch = {0}; + event.struct_size = sizeof(event); + event.version = 1U; + batch.struct_size = sizeof(batch); + batch.version = 1U; + return event.text.length == 0U && batch.lease_token == NULL ? 0 : 1; +} diff --git a/tests/WebPlatformSubset/contracts/accessibility-semantic-live-regions-source.html b/tests/WebPlatformSubset/contracts/accessibility-semantic-live-regions-source.html new file mode 100644 index 00000000..8313957f --- /dev/null +++ b/tests/WebPlatformSubset/contracts/accessibility-semantic-live-regions-source.html @@ -0,0 +1,86 @@ + + +Accessibility semantic live-region DOM source + +
+
Problems0
+
+ Task queuedTask stale +
+
+ + diff --git a/tests/WebPlatformSubset/webscene-accessibility-semantic-live-regions-profile.json b/tests/WebPlatformSubset/webscene-accessibility-semantic-live-regions-profile.json new file mode 100644 index 00000000..97f53618 --- /dev/null +++ b/tests/WebPlatformSubset/webscene-accessibility-semantic-live-regions-profile.json @@ -0,0 +1,30 @@ +{ + "profile": "webscene-accessibility-semantic-live-regions-1", + "wptRevision": "2c705104a295c48053eeddf7fe0170d790a4e853", + "runtime": "v8", + "viewport": { + "width": 800, + "height": 600, + "deviceScaleFactor": 1 + }, + "required": [ + { + "path": "contracts/accessibility-semantic-live-regions-source.html", + "type": "contract", + "capabilities": [ + "semantic-live-polite-and-assertive-source", + "semantic-live-status-alert-log-source", + "semantic-live-atomic-relevant-busy-source", + "semantic-live-terminal-problems-tasks-chat-source", + "semantic-live-hidden-and-detached-retirement" + ], + "evidence": [ + "webscene-generation-aware-semantic-live-region-abi", + "appscene-native-live-announcement-boundary" + ], + "reason": "Browser/native source contract for bounded semantic live-region events consumed by AppScene native peers. Platform announcement objects and delivery remain in AppScene #185." + } + ], + "harnessBlocked": [], + "excluded": [] +} diff --git a/tooling/webscene/tests/native-binary-interop.test.mjs b/tooling/webscene/tests/native-binary-interop.test.mjs index 5ca7bf82..ec980be8 100644 --- a/tooling/webscene/tests/native-binary-interop.test.mjs +++ b/tooling/webscene/tests/native-binary-interop.test.mjs @@ -92,6 +92,8 @@ test('native engine publishes only the versioned leased interop surface', async 'webscene_engine_acquire_semantic_snapshot_v1', 'webscene_semantic_snapshot_release_v1', 'webscene_engine_request_semantic_action_v1', + 'webscene_engine_take_semantic_live_events_v1', + 'webscene_semantic_live_batch_release_v1', 'webscene_engine_request_window_close_v1', 'webscene_engine_take_typed_host_request_v1', 'webscene_host_request_release_v1',