From 87aba16dcf0a345e53dc6b9f2b37b215fafeddfd Mon Sep 17 00:00:00 2001 From: Joris Jansen Date: Thu, 30 Jul 2026 16:25:38 +0200 Subject: [PATCH] AE13b3: capture canonical parser trees and patch streams --- crates/html/src/conformance/execution.rs | 971 +++++++- crates/html/src/conformance/mod.rs | 15 +- crates/html/src/conformance/model.rs | 201 +- crates/html/src/conformance/projection.rs | 1963 +++++++++++++++++ crates/html/src/dom_patch.rs | 9 +- crates/html/src/html5/bridge/adapters.rs | 608 ++++- crates/html/src/html5/bridge/mod.rs | 4 + crates/html/src/html5/mod.rs | 2 + crates/html/src/html5/session/api.rs | 87 +- crates/html/src/html5/session/driver.rs | 148 +- crates/html/src/html5/session/tests/smoke.rs | 20 +- crates/html/src/html5/shared/error.rs | 10 + crates/html/src/html5/shared/observation.rs | 4 + crates/html/src/parser/session.rs | 108 +- crates/html/src/parser/tests.rs | 2 +- crates/html/src/types.rs | 30 + crates/html_test_support/src/wpt_tokenizer.rs | 2 + docs/adr/001-html5-parsing-architecture.md | 10 +- docs/engine-feature-gap-tracker.md | 16 +- .../ae1-html-parser-dom-ownership-contract.md | 4 +- ...3-parser-conformance-regression-harness.md | 87 +- docs/html5/dompatch-contract.md | 5 + docs/html5/invariants.md | 5 + docs/html5/node-identity-contract.md | 14 +- docs/html5/parser-fixture-format-v1.md | 7 +- 25 files changed, 4018 insertions(+), 314 deletions(-) create mode 100644 crates/html/src/conformance/projection.rs diff --git a/crates/html/src/conformance/execution.rs b/crates/html/src/conformance/execution.rs index 53ef3db6..4d080acf 100644 --- a/crates/html/src/conformance/execution.rs +++ b/crates/html/src/conformance/execution.rs @@ -3,7 +3,11 @@ //! This is the only canonical observation request boundary. It deliberately //! does not participate in the stable parser facade. +use super::projection::{ObservationAllocationController, project_patches, project_tree}; +#[cfg(test)] +use super::projection::{ObservationAllocationStep, ObservationFailureInjection}; use super::{CanonicalParserResult, IncompleteObservationReason, ObservationState}; +use crate::html5::PatchHistoryObservationConfig; use crate::html5::shared::{ CapturedSurface, DocumentParseContext, ErrorPolicy, ObservationOccurrenceSequence, ObservationSurface, ParserObservationCapture, ParserObservationConfig, @@ -50,6 +54,13 @@ pub struct ParserObservationRequest<'a> { pub parse_errors: ObservationRequest, pub implementation_diagnostics: ObservationRequest, pub document_mode: ScalarObservationRequest, + /// Maximum canonical structural units: document, document type, element or + /// HTML template host, text, comment, processing instruction, and typed + /// template-contents boundary. Attributes and the outer `ObservedTree` + /// wrapper do not consume units. This is not a byte budget. + pub tree: ObservationRequest, + /// Maximum semantic `DomPatch` operations. This is not a byte budget. + pub patches: ObservationRequest, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -59,9 +70,48 @@ pub enum ParserObservationExecutionError { TokenizerInvariant(ParserTokenizerInvariantError), TokenCanonicalizationInvariant, ObservationRecorderMissing, + PatchHistoryCaptureMissing, ObservationInvariant(ParserObservationInvariantError), + ResourceExhaustion(ObservationResourceExhaustion), +} + +/// Fallible allocation boundary owned by post-parse canonical observation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ObservationReservationSite { + CanonicalTreeProjection, + CanonicalPatchProjection, + SnapshotLabelStorage, } +/// Allocation or representable-capacity failure while constructing a +/// canonical result after successful production parsing and materialization. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ObservationResourceExhaustion { + site: ObservationReservationSite, +} + +impl ObservationResourceExhaustion { + pub const fn site(self) -> ObservationReservationSite { + self.site + } + + pub(super) const fn at(site: ObservationReservationSite) -> Self { + Self { site } + } +} + +impl std::fmt::Display for ObservationResourceExhaustion { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "canonical parser observation allocation failed at {:?}", + self.site + ) + } +} + +impl std::error::Error for ObservationResourceExhaustion {} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ParserTokenizerInvariantError { SelfClosingFlagMissingSolidusPosition, @@ -105,6 +155,18 @@ pub enum ParserObservationInvariantError { NormalizedPositionIndexDiscontinuity, NormalizedPositionIndexMissing, InvalidNormalizedPositionOffset, + PatchDroppedCountOverflow, + CanonicalTreeUnitCountOverflow, + CanonicalTreeRootNotDocument, + UnexpectedLegacyDocumentDoctypeMetadata, + MissingHtmlTemplateContents, + InvalidTemplateContentsKind, + CanonicalTreeTraversalContradiction, + CanonicalTreePreflightProjectionMismatch, + InvalidPatchKey, + DuplicatePatchCreation, + MissingPatchCreationHistory, + SnapshotLabelSequenceOverflow, } impl std::fmt::Display for ParserObservationExecutionError { @@ -124,12 +186,22 @@ impl std::fmt::Display for ParserObservationExecutionError { Self::ObservationRecorderMissing => formatter.write_str( "parser observation was requested but the production recorder was missing", ), + Self::PatchHistoryCaptureMissing => formatter.write_str( + "patch history was requested but the parser-session capture was missing", + ), Self::ObservationInvariant(invariant) => { write!( formatter, "parser observation invariant failed: {invariant:?}" ) } + Self::ResourceExhaustion(exhaustion) => { + write!( + formatter, + "parser observation allocation failed at {:?}", + exhaustion.site() + ) + } } } } @@ -146,7 +218,13 @@ pub fn execute_parser_observation( parse_errors: internal_request(request.parse_errors), implementation_diagnostics: internal_request(request.implementation_diagnostics), }; - let (capture, document_mode) = match request.target { + let patch_config = match request.patches { + ObservationRequest::NotRequested => PatchHistoryObservationConfig::default(), + ObservationRequest::Capture { capacity } => { + PatchHistoryObservationConfig::capture(capacity) + } + }; + let (capture, document_mode, tree, patches) = match request.target { ParserObservationTarget::StandaloneTokenizer => { let capture = execute_standalone_tokenizer(request.input, config)?; let mode = match request.document_mode { @@ -155,27 +233,59 @@ pub fn execute_parser_observation( reason: super::NotApplicableReason::StandaloneTokenizerRun, }, }; - (capture, mode) + let tree = not_applicable_or_not_requested(request.tree); + let patches = not_applicable_or_not_requested(request.patches); + (capture, mode, tree, patches) } ParserObservationTarget::DocumentParser => { - let (capture, production_mode) = execute_document_parser(request.input, config)?; + let (capture, production_mode, tree, patches) = execute_document_parser( + request.input, + config, + patch_config, + request.tree, + request.patches, + )?; let mode = match request.document_mode { ScalarObservationRequest::NotRequested => ObservationState::NotRequested, ScalarObservationRequest::Capture => ObservationState::Captured(production_mode), }; - (capture, mode) + (capture, mode, tree, patches) } }; - canonical_result(capture, document_mode) + canonical_result(capture, document_mode, tree, patches) +} + +fn not_applicable_or_not_requested(request: ObservationRequest) -> ObservationState { + match request { + ObservationRequest::NotRequested => ObservationState::NotRequested, + ObservationRequest::Capture { .. } => ObservationState::NotApplicable { + reason: super::NotApplicableReason::StandaloneTokenizerRun, + }, + } } fn execute_document_parser( input: ParserObservationInput<'_>, config: ParserObservationConfig, -) -> Result<(ParserObservationCapture, crate::DocumentMode), ParserObservationExecutionError> { + patch_config: PatchHistoryObservationConfig, + tree_request: ObservationRequest, + patch_request: ObservationRequest, +) -> Result< + ( + ParserObservationCapture, + crate::DocumentMode, + ObservationState, + ObservationState, + ), + ParserObservationExecutionError, +> { let observation_requested = config.is_requested(); - let mut parser = HtmlParser::new_with_observations(HtmlParseOptions::default(), config) - .map_err(parser_error_without_live_parser)?; + let mut parser = HtmlParser::new_with_conformance_observations( + HtmlParseOptions::default(), + config, + patch_config, + ) + .map_err(parser_error_without_live_parser)?; match input { ParserObservationInput::Utf8(text) => { push_document_text(&mut parser, text)?; @@ -197,21 +307,75 @@ fn execute_document_parser( if let Err(error) = parser.finish() { return Err(document_parser_operation_error(&parser, error)); } - finalize_document_parser(parser, observation_requested) + finalize_document_parser(parser, observation_requested, tree_request, patch_request) } fn finalize_document_parser( - mut parser: HtmlParser, + parser: HtmlParser, + observation_requested: bool, + tree_request: ObservationRequest, + patch_request: ObservationRequest, +) -> Result< + ( + ParserObservationCapture, + crate::DocumentMode, + ObservationState, + ObservationState, + ), + ParserObservationExecutionError, +> { + finalize_document_parser_with_allocations( + parser, + observation_requested, + tree_request, + patch_request, + &mut ObservationAllocationController::default(), + ) +} + +fn finalize_document_parser_with_allocations( + parser: HtmlParser, observation_requested: bool, -) -> Result<(ParserObservationCapture, crate::DocumentMode), ParserObservationExecutionError> { - let document_mode = parser.document_mode_for_conformance(); - let capture = take_document_capture(&mut parser, observation_requested)?; - // Run the same final materialization path as the stable facade before - // exposing observations, even though AE13b1 does not capture the tree. - let _ = parser - .into_output() + tree_request: ObservationRequest, + patch_request: ObservationRequest, + allocations: &mut ObservationAllocationController, +) -> Result< + ( + ParserObservationCapture, + crate::DocumentMode, + ObservationState, + ObservationState, + ), + ParserObservationExecutionError, +> { + let document_mode = parser + .document_mode_for_conformance() + .map_err(|error| document_parser_operation_error(&parser, error))?; + let (output, capture, patch_history) = parser + .into_output_with_observations() .map_err(parser_error_without_live_parser)?; - Ok((capture, document_mode)) + let capture = require_capture(capture, observation_requested)?; + validate_capture(&capture)?; + let tree = match tree_request { + ObservationRequest::NotRequested => ObservationState::NotRequested, + ObservationRequest::Capture { capacity } => { + project_tree(&output.document, capacity, allocations)? + } + }; + let patches = match patch_request { + ObservationRequest::NotRequested => { + if patch_history.is_some() { + return Err(ParserObservationExecutionError::ParserInvariant); + } + ObservationState::NotRequested + } + ObservationRequest::Capture { .. } => { + let history = + patch_history.ok_or(ParserObservationExecutionError::PatchHistoryCaptureMissing)?; + project_patches(history, allocations)? + } + }; + Ok((capture, document_mode, tree, patches)) } fn push_document_text( @@ -239,13 +403,21 @@ fn document_parser_operation_error( error: crate::HtmlParseError, ) -> ParserObservationExecutionError { match error { - crate::HtmlParseError::Fatal(crate::ParserFatalError::EngineInvariant) => parser - .tokenizer_invariant_for_conformance() - .map(public_tokenizer_invariant) - .map(ParserObservationExecutionError::TokenizerInvariant) - .unwrap_or(ParserObservationExecutionError::ParserFatal( - crate::ParserFatalError::EngineInvariant, - )), + crate::HtmlParseError::Fatal(crate::ParserFatalError::EngineInvariant) => { + if let Some(invariant) = parser.patch_history_invariant_for_conformance() { + ParserObservationExecutionError::ObservationInvariant(public_observation_invariant( + invariant, + )) + } else { + parser + .tokenizer_invariant_for_conformance() + .map(public_tokenizer_invariant) + .map(ParserObservationExecutionError::TokenizerInvariant) + .unwrap_or(ParserObservationExecutionError::ParserFatal( + crate::ParserFatalError::EngineInvariant, + )) + } + } crate::HtmlParseError::Fatal(error) => ParserObservationExecutionError::ParserFatal(error), crate::HtmlParseError::Decode | crate::HtmlParseError::PatchValidation(_) => { ParserObservationExecutionError::ParserInvariant @@ -280,7 +452,11 @@ fn execute_standalone_tokenizer( config: ParserObservationConfig, ) -> Result { let observation_requested = config.is_requested(); - let mut ctx = DocumentParseContext::with_observations(ErrorPolicy::default(), config); + let mut ctx = if observation_requested { + DocumentParseContext::with_observations(ErrorPolicy::default(), config) + } else { + DocumentParseContext::with_error_policy(ErrorPolicy::default()) + }; let mut tokenizer = Html5Tokenizer::new(TokenizerConfig::default(), &mut ctx); let mut input = Input::new(); let mut decoder = ByteStreamDecoder::new(); @@ -309,9 +485,16 @@ fn execute_standalone_tokenizer( }; if byte_input { - let _ = decoder.finish_with_context(&mut input, &mut ctx); - } else { + if ctx.observation_enabled() { + let _ = decoder.finish_with_context(&mut input, &mut ctx); + } else { + let (_, replacements) = decoder.finish_counted(&mut input); + ctx.record_decode_replacements(replacements); + } + } else if ctx.observation_enabled() { let _ = input.finish_preprocessing_observed(ctx.observation_position_index_mut()); + } else { + let _ = input.finish_preprocessing(); } pump_standalone(&mut tokenizer, &mut input, &mut ctx)?; let _ = tokenizer.finish_with_context(&input, &mut ctx); @@ -320,7 +503,7 @@ fn execute_standalone_tokenizer( public_tokenizer_invariant(invariant), )); } - drop(tokenizer.next_batch_observed(&mut input, &mut ctx)); + drain_standalone_batch(&mut tokenizer, &mut input, &mut ctx); take_standalone_capture(&mut ctx, observation_requested) } @@ -331,7 +514,11 @@ fn push_standalone_text( ctx: &mut DocumentParseContext, text: &str, ) -> Result<(), ParserObservationExecutionError> { - input.push_str_observed(text, ctx.observation_position_index_mut()); + if ctx.observation_enabled() { + input.push_str_observed(text, ctx.observation_position_index_mut()); + } else { + input.push_str(text); + } pump_standalone(tokenizer, input, ctx) } @@ -342,7 +529,12 @@ fn push_standalone_bytes( ctx: &mut DocumentParseContext, bytes: &[u8], ) -> Result<(), ParserObservationExecutionError> { - let _ = decoder.push_bytes_with_context(bytes, input, ctx); + if ctx.observation_enabled() { + let _ = decoder.push_bytes_with_context(bytes, input, ctx); + } else { + let (_, replacements) = decoder.push_bytes_counted(bytes, input); + ctx.record_decode_replacements(replacements); + } pump_standalone(tokenizer, input, ctx) } @@ -358,7 +550,7 @@ fn pump_standalone( public_tokenizer_invariant(invariant), )); } - drop(tokenizer.next_batch_observed(input, ctx)); + drain_standalone_batch(tokenizer, input, ctx); if result == TokenizeResult::NeedMoreInput { return Ok(()); } @@ -368,6 +560,18 @@ fn pump_standalone( } } +fn drain_standalone_batch( + tokenizer: &mut Html5Tokenizer, + input: &mut Input, + ctx: &mut DocumentParseContext, +) { + if ctx.observation_enabled() { + drop(tokenizer.next_batch_observed(input, ctx)); + } else { + drop(tokenizer.next_batch(input)); + } +} + fn public_tokenizer_invariant( invariant: crate::html5::tokenizer::TokenizerInvariantKind, ) -> ParserTokenizerInvariantError { @@ -501,6 +705,7 @@ fn require_capture( } } +#[cfg(test)] fn take_document_capture( parser: &mut HtmlParser, observation_requested: bool, @@ -521,28 +726,37 @@ fn take_standalone_capture( fn canonical_result( capture: ParserObservationCapture, document_mode: ObservationState, + tree: ObservationState, + patches: ObservationState, ) -> Result { - if capture.token_capture_failed { - return Err(ParserObservationExecutionError::TokenCanonicalizationInvariant); - } - if let Some(invariant) = capture.invariant { - return Err(ParserObservationExecutionError::ObservationInvariant( - public_observation_invariant(invariant), - )); - } + validate_capture(&capture)?; Ok(CanonicalParserResult { tokens: finish_surface(capture.tokens), parse_errors: finish_surface(capture.parse_errors), implementation_diagnostics: finish_surface(capture.implementation_diagnostics), document_mode, - tree: ObservationState::NotRequested, - patches: ObservationState::NotRequested, + tree, + patches, transitions: ObservationState::NotRequested, unsupported_features: ObservationState::NotRequested, final_invariants: ObservationState::NotRequested, }) } +fn validate_capture( + capture: &ParserObservationCapture, +) -> Result<(), ParserObservationExecutionError> { + if capture.token_capture_failed { + return Err(ParserObservationExecutionError::TokenCanonicalizationInvariant); + } + if let Some(invariant) = capture.invariant { + return Err(ParserObservationExecutionError::ObservationInvariant( + public_observation_invariant(invariant), + )); + } + Ok(()) +} + fn public_observation_invariant( invariant: ParserObservationInvariant, ) -> ParserObservationInvariantError { @@ -574,6 +788,9 @@ fn public_observation_invariant( ParserObservationInvariant::InvalidNormalizedPositionOffset => { ParserObservationInvariantError::InvalidNormalizedPositionOffset } + ParserObservationInvariant::PatchDroppedCountOverflow => { + ParserObservationInvariantError::PatchDroppedCountOverflow + } } } @@ -597,7 +814,7 @@ fn finish_surface(capture: CapturedSurface) -> ObservationState> { #[cfg(test)] mod tests { use super::*; - use crate::conformance::NotApplicableReason; + use crate::conformance::{NotApplicableReason, ObservedPatchStream, ObservedTreeNode}; use crate::html5::shared::{ EventPosition, ImplementationDiagnosticCode, InputCoordinateSpace, ParseErrorCode, SourceBytePosition, SourcePositionUnavailableReason, Utf8ReplacementReason, @@ -640,6 +857,8 @@ mod tests { capacity: DIAGNOSTIC_CAPACITY, }, document_mode: ScalarObservationRequest::NotRequested, + tree: ObservationRequest::NotRequested, + patches: ObservationRequest::NotRequested, }) .expect("production tokenizer observation should succeed") } @@ -742,6 +961,8 @@ mod tests { parse_errors: ObservationRequest::NotRequested, implementation_diagnostics: ObservationRequest::NotRequested, document_mode: ScalarObservationRequest::NotRequested, + tree: ObservationRequest::NotRequested, + patches: ObservationRequest::NotRequested, }) .expect("unobserved conformance execution still runs production parsing"); assert!(matches!(result.tokens, ObservationState::NotRequested)); @@ -831,17 +1052,209 @@ mod tests { .expect("observed parser"); parser.push_str("

x

").expect("document input"); parser.finish().expect("document finish"); - parser.inject_patch_for_conformance_test(crate::DomPatch::AppendChild { - parent: crate::PatchKey(u32::MAX - 1), - child: crate::PatchKey(u32::MAX), - }); + parser + .inject_patch_for_conformance_test(crate::DomPatch::AppendChild { + parent: crate::PatchKey(u32::MAX - 1), + child: crate::PatchKey(u32::MAX), + }) + .expect("unobserved injected patch"); assert_eq!( - finalize_document_parser(parser, true), + finalize_document_parser( + parser, + true, + ObservationRequest::NotRequested, + ObservationRequest::NotRequested, + ), Err(ParserObservationExecutionError::ParserInvariant) ); } + #[test] + fn materialization_failure_returns_execution_failure_without_canonical_output() { + let mut parser = HtmlParser::new_with_conformance_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + PatchHistoryObservationConfig::capture(64), + ) + .unwrap(); + parser.push_str("

x

").unwrap(); + parser.finish().unwrap(); + let _ = parser.take_patches().unwrap(); + parser.force_materialization_failure_for_test(); + let error = parser.into_output_with_observations().unwrap_err(); + assert_eq!( + parser_error_without_live_parser(error), + ParserObservationExecutionError::ParserInvariant + ); + } + + #[test] + fn requested_patch_observation_without_session_capture_is_an_execution_failure() { + let mut parser = HtmlParser::new_with_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + ) + .unwrap(); + parser.push_str("

x

").unwrap(); + parser.finish().unwrap(); + assert_eq!( + finalize_document_parser( + parser, + false, + ObservationRequest::NotRequested, + ObservationRequest::Capture { capacity: 64 }, + ), + Err(ParserObservationExecutionError::PatchHistoryCaptureMissing) + ); + } + + #[test] + fn post_parse_projection_allocation_failure_suppresses_canonical_result() { + let mut parser = HtmlParser::new_with_conformance_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + PatchHistoryObservationConfig::default(), + ) + .unwrap(); + parser.push_str("

x

").unwrap(); + parser.finish().unwrap(); + let mut allocations = + ObservationAllocationController::with_failure(ObservationFailureInjection { + step: ObservationAllocationStep::CanonicalTreeChildStorage, + occurrence: NonZeroU64::MIN, + }); + assert_eq!( + finalize_document_parser_with_allocations( + parser, + false, + ObservationRequest::Capture { capacity: 16 }, + ObservationRequest::NotRequested, + &mut allocations, + ), + Err(ParserObservationExecutionError::ResourceExhaustion( + ObservationResourceExhaustion::at( + ObservationReservationSite::CanonicalTreeProjection + ) + )) + ); + } + + #[test] + fn live_patch_history_invariant_is_stable_fatal_but_exact_for_conformance() { + let mut parser = HtmlParser::new_with_conformance_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + PatchHistoryObservationConfig::capture(0), + ) + .expect("parser"); + parser.force_patch_history_dropped_for_test(u64::MAX); + let stable = parser + .inject_patch_for_conformance_test(crate::DomPatch::Clear) + .unwrap_err(); + assert_eq!( + stable, + crate::HtmlParseError::Fatal(crate::ParserFatalError::EngineInvariant) + ); + assert_eq!( + document_parser_operation_error(&parser, stable), + ParserObservationExecutionError::ObservationInvariant( + ParserObservationInvariantError::PatchDroppedCountOverflow + ) + ); + assert_eq!( + parser.take_patches(), + Err(crate::HtmlParseError::Fatal( + crate::ParserFatalError::EngineInvariant + )) + ); + assert_eq!( + parser.document_mode_for_conformance(), + Err(crate::HtmlParseError::Fatal( + crate::ParserFatalError::EngineInvariant + )) + ); + assert_eq!( + parser.take_observations_for_conformance(), + Err(crate::HtmlParseError::Fatal( + crate::ParserFatalError::EngineInvariant + )) + ); + } + + #[cfg(feature = "parser-failure-injection")] + #[test] + fn live_patch_history_resource_failure_keeps_exact_parser_fatal_identity() { + use crate::html5::shared::ParserFailureInjection; + + let mut parser = HtmlParser::new_with_conformance_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + PatchHistoryObservationConfig::capture(8), + ) + .expect("parser"); + parser.set_patch_history_failure_injection_for_test(ParserFailureInjection::new( + crate::ParserReservationSite::PatchHistoryObservationStorage, + NonZeroU64::MIN, + )); + let exhaustion = crate::ParserResourceExhaustion::at( + crate::ParserReservationSite::PatchHistoryObservationStorage, + ); + let fatal = crate::ParserFatalError::ResourceExhaustion(exhaustion); + assert_eq!( + parser.inject_patch_for_conformance_test(crate::DomPatch::Clear), + Err(crate::HtmlParseError::Fatal(fatal)) + ); + assert_eq!( + parser.take_patches(), + Err(crate::HtmlParseError::Fatal(fatal)) + ); + assert_eq!( + document_parser_operation_error(&parser, crate::HtmlParseError::Fatal(fatal)), + ParserObservationExecutionError::ParserFatal(fatal) + ); + } + + #[cfg(feature = "parser-failure-injection")] + #[test] + fn live_capture_failure_stops_before_next_token_and_blocks_all_output() { + use crate::html5::shared::ParserFailureInjection; + + let mut parser = HtmlParser::new_with_conformance_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + PatchHistoryObservationConfig::capture(128), + ) + .unwrap(); + parser.set_patch_history_failure_injection_for_test(ParserFailureInjection::new( + crate::ParserReservationSite::PatchHistoryObservationStorage, + NonZeroU64::MIN, + )); + parser.push_str("
later
").unwrap(); + let exhaustion = crate::ParserResourceExhaustion::at( + crate::ParserReservationSite::PatchHistoryObservationStorage, + ); + let fatal = crate::ParserFatalError::ResourceExhaustion(exhaustion); + assert_eq!(parser.pump(), Err(crate::HtmlParseError::Fatal(fatal))); + assert_eq!( + parser.tokens_processed(), + 1, + "the token after the synchronously failed emission must not run" + ); + assert_eq!( + parser.push_str("

never

"), + Err(crate::HtmlParseError::Fatal(fatal)) + ); + assert_eq!( + parser.take_patch_batch(), + Err(crate::HtmlParseError::Fatal(fatal)) + ); + assert!(matches!( + parser.into_output_with_observations(), + Err(crate::HtmlParseError::Fatal(error)) if error == fatal + )); + } + #[test] fn observation_invariants_are_typed_execution_failures() { let mut capture = empty_capture(); @@ -849,7 +1262,12 @@ mod tests { ObservationOccurrenceSequence::ParseErrors, )); assert_eq!( - canonical_result(capture, ObservationState::NotRequested), + canonical_result( + capture, + ObservationState::NotRequested, + ObservationState::NotRequested, + ObservationState::NotRequested, + ), Err(ParserObservationExecutionError::ObservationInvariant( ParserObservationInvariantError::ParseErrorOccurrenceOverflow )) @@ -858,7 +1276,12 @@ mod tests { let mut capture = empty_capture(); capture.invariant = Some(ParserObservationInvariant::InvalidNormalizedPositionOffset); assert_eq!( - canonical_result(capture, ObservationState::NotRequested), + canonical_result( + capture, + ObservationState::NotRequested, + ObservationState::NotRequested, + ObservationState::NotRequested, + ), Err(ParserObservationExecutionError::ObservationInvariant( ParserObservationInvariantError::InvalidNormalizedPositionOffset )) @@ -867,7 +1290,12 @@ mod tests { let mut capture = empty_capture(); capture.invariant = Some(ParserObservationInvariant::NormalizedPositionIndexMissing); assert_eq!( - canonical_result(capture, ObservationState::NotRequested), + canonical_result( + capture, + ObservationState::NotRequested, + ObservationState::NotRequested, + ObservationState::NotRequested, + ), Err(ParserObservationExecutionError::ObservationInvariant( ParserObservationInvariantError::NormalizedPositionIndexMissing )) @@ -898,7 +1326,12 @@ mod tests { "an invalid normalized offset must not retain a false unavailable-position event" ); assert_eq!( - canonical_result(capture, ObservationState::NotRequested), + canonical_result( + capture, + ObservationState::NotRequested, + ObservationState::NotRequested, + ObservationState::NotRequested, + ), Err(ParserObservationExecutionError::ObservationInvariant( ParserObservationInvariantError::InvalidNormalizedPositionOffset )) @@ -930,7 +1363,12 @@ mod tests { "missing-index corruption must not retain a false unavailable event" ); assert_eq!( - canonical_result(capture, ObservationState::NotRequested), + canonical_result( + capture, + ObservationState::NotRequested, + ObservationState::NotRequested, + ObservationState::NotRequested, + ), Err(ParserObservationExecutionError::ObservationInvariant( ParserObservationInvariantError::NormalizedPositionIndexMissing )) @@ -1043,6 +1481,8 @@ mod tests { parse_errors: ObservationRequest::NotRequested, implementation_diagnostics: ObservationRequest::Capture { capacity: 1 }, document_mode: ScalarObservationRequest::NotRequested, + tree: ObservationRequest::NotRequested, + patches: ObservationRequest::NotRequested, }) .unwrap(); let ObservationState::Incomplete { partial, reason } = result.implementation_diagnostics @@ -1449,6 +1889,8 @@ mod tests { parse_errors: ObservationRequest::Capture { capacity: 8 }, implementation_diagnostics: ObservationRequest::NotRequested, document_mode: ScalarObservationRequest::NotRequested, + tree: ObservationRequest::NotRequested, + patches: ObservationRequest::NotRequested, }) .expect("document parser observation"); assert!(captured(&text_mode.parse_errors).iter().any(|event| { @@ -2860,6 +3302,8 @@ mod tests { capacity: diagnostic_capacity, }, document_mode, + tree: ObservationRequest::NotRequested, + patches: ObservationRequest::NotRequested, }) .expect("document observation should complete") } @@ -3153,6 +3597,8 @@ mod tests { parse_errors: ObservationRequest::NotRequested, implementation_diagnostics: ObservationRequest::NotRequested, document_mode: ScalarObservationRequest::Capture, + tree: ObservationRequest::NotRequested, + patches: ObservationRequest::NotRequested, }) .expect("scalar-only production execution"); assert_eq!(scalar_only.document_mode, result.document_mode); @@ -3168,6 +3614,8 @@ mod tests { parse_errors: ObservationRequest::NotRequested, implementation_diagnostics: ObservationRequest::NotRequested, document_mode: ScalarObservationRequest::Capture, + tree: ObservationRequest::NotRequested, + patches: ObservationRequest::NotRequested, }) .expect("standalone tokenizer execution"); assert_eq!( @@ -3177,4 +3625,421 @@ mod tests { } ); } + + #[test] + fn canonical_document_tree_preserves_production_payloads_and_namespaces() { + let result = execute_parser_observation(ParserObservationRequest { + target: ParserObservationTarget::DocumentParser, + input: ParserObservationInput::Utf8( + "\ + s\ + m", + ), + tokens: ObservationRequest::NotRequested, + parse_errors: ObservationRequest::NotRequested, + implementation_diagnostics: ObservationRequest::NotRequested, + document_mode: ScalarObservationRequest::NotRequested, + tree: ObservationRequest::Capture { capacity: 256 }, + patches: ObservationRequest::Capture { capacity: 512 }, + }) + .expect("canonical document observation"); + assert!(matches!(result.tokens, ObservationState::NotRequested)); + assert!(matches!( + result.parse_errors, + ObservationState::NotRequested + )); + assert!(matches!( + result.implementation_diagnostics, + ObservationState::NotRequested + )); + let ObservationState::Captured(tree) = result.tree else { + panic!("tree must be complete"); + }; + let [ObservedTreeNode::Document { children }] = tree.roots.as_slice() else { + panic!("document must remain the sole canonical root"); + }; + assert!(matches!( + &children[0], + ObservedTreeNode::DocumentType { + name: Some(name), + public_id: Some(public_id), + system_id: Some(system_id), + } if name == "html" && public_id == "pub" && system_id == "sys" + )); + assert!(children.iter().any(|node| matches!( + node, + ObservedTreeNode::ProcessingInstruction { target, data } + if target == "pi" && data == "data" + ))); + + let html = children + .iter() + .find(|node| { + matches!( + node, + ObservedTreeNode::Element { + namespace: crate::ElementNamespace::Html, + local_name, + .. + } if local_name == "html" + ) + }) + .expect("html element"); + let mut stack = vec![html]; + let mut saw_comment = false; + let mut saw_svg = false; + let mut saw_math = false; + let mut template_depths = Vec::new(); + while let Some(node) = stack.pop() { + match node { + ObservedTreeNode::Comment { data } => saw_comment |= data == "comment", + ObservedTreeNode::Text { .. } + | ObservedTreeNode::DocumentType { .. } + | ObservedTreeNode::ProcessingInstruction { .. } => {} + ObservedTreeNode::Document { children } + | ObservedTreeNode::Element { children, .. } => { + if let ObservedTreeNode::Element { + namespace, + local_name, + attributes, + .. + } = node + { + if *namespace == crate::ElementNamespace::Svg && local_name == "svg" { + saw_svg = attributes.iter().any(|attribute| { + attribute.namespace == crate::AttributeNamespace::XLink + && attribute.prefix.as_deref() == Some("xlink") + && attribute.local_name == "href" + && attribute.value == "#x" + }) && attributes + .first() + .is_some_and(|attribute| attribute.local_name == "viewBox"); + } + saw_math |= + *namespace == crate::ElementNamespace::MathMl && local_name == "math"; + } + stack.extend(children.iter().rev()); + } + ObservedTreeNode::HtmlTemplateElement { + ordinary_children, + contents, + .. + } => { + template_depths.push(contents.children.len()); + stack.extend(ordinary_children.iter().rev()); + stack.extend(contents.children.iter().rev()); + } + } + } + assert!(saw_comment && saw_svg && saw_math); + assert_eq!( + template_depths.len(), + 2, + "nested template contents retained" + ); + + let ObservationState::Captured(patches) = result.patches else { + panic!("patches must be complete"); + }; + assert!(!patches.operations.is_empty()); + assert!( + patches + .operations + .iter() + .all(|operation| { !format!("{operation:?}").contains("PatchKey") }) + ); + } + + #[test] + fn tree_and_patch_only_sessions_do_not_enable_diagnostic_observation() { + for patch_config in [ + PatchHistoryObservationConfig::default(), + PatchHistoryObservationConfig::capture(128), + ] { + let mut parser = HtmlParser::new_with_conformance_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + patch_config, + ) + .expect("parser"); + assert!(!parser.diagnostic_observation_enabled_for_test()); + assert_eq!(parser.take_observations_for_conformance().unwrap(), None); + parser.push_str("

x

").unwrap(); + parser.finish().unwrap(); + assert!(!parser.diagnostic_observation_enabled_for_test()); + let (output, diagnostics, _) = parser.into_output_with_observations().unwrap(); + assert_eq!(diagnostics, None); + + let ordinary = crate::parse_document("

x

", HtmlParseOptions::default()).unwrap(); + assert_eq!(output.patches, ordinary.patches); + assert_eq!(output.counters, ordinary.counters); + } + } + + #[derive(Clone, Copy)] + enum PatchDrainSchedule { + WholeInput, + Chunked, + TakePatches, + TakePatchBatch, + } + + fn captured_raw_patches(schedule: PatchDrainSchedule) -> crate::html5::RawPatchHistoryCapture { + let mut parser = HtmlParser::new_with_conformance_observations( + HtmlParseOptions::default(), + ParserObservationConfig::default(), + PatchHistoryObservationConfig::capture(512), + ) + .unwrap(); + let chunks: &[&str] = match schedule { + PatchDrainSchedule::WholeInput => &["
ab
"], + PatchDrainSchedule::Chunked + | PatchDrainSchedule::TakePatches + | PatchDrainSchedule::TakePatchBatch => { + &["
", "ab", "
"] + } + }; + for chunk in chunks { + parser.push_str(chunk).unwrap(); + parser.pump().unwrap(); + match schedule { + PatchDrainSchedule::WholeInput | PatchDrainSchedule::Chunked => {} + PatchDrainSchedule::TakePatches => { + let _ = parser.take_patches().unwrap(); + } + PatchDrainSchedule::TakePatchBatch => { + while parser.take_patch_batch().unwrap().is_some() {} + } + } + } + parser.finish().unwrap(); + if matches!(schedule, PatchDrainSchedule::TakePatches) { + let _ = parser.take_patches().unwrap(); + } + if matches!(schedule, PatchDrainSchedule::TakePatchBatch) { + while parser.take_patch_batch().unwrap().is_some() {} + } + let (_, diagnostics, history) = parser.into_output_with_observations().unwrap(); + assert_eq!(diagnostics, None); + history.expect("requested complete raw history") + } + + #[test] + fn canonical_patch_history_is_independent_of_transport_drain_schedule() { + let whole = captured_raw_patches(PatchDrainSchedule::WholeInput); + let chunked = captured_raw_patches(PatchDrainSchedule::Chunked); + let by_vector = captured_raw_patches(PatchDrainSchedule::TakePatches); + let by_batch = captured_raw_patches(PatchDrainSchedule::TakePatchBatch); + assert_eq!(whole, chunked); + assert_eq!(whole, by_vector); + assert_eq!(whole, by_batch); + let whole = project_patches(whole, &mut ObservationAllocationController::default()) + .expect("whole canonicalization"); + let by_batch = project_patches(by_batch, &mut ObservationAllocationController::default()) + .expect("batch canonicalization"); + assert_eq!(whole, by_batch); + } + + fn observe_patch_capacity(capacity: usize) -> ObservationState { + execute_parser_observation(ParserObservationRequest { + target: ParserObservationTarget::DocumentParser, + input: ParserObservationInput::Utf8("

x

"), + tokens: ObservationRequest::NotRequested, + parse_errors: ObservationRequest::NotRequested, + implementation_diagnostics: ObservationRequest::NotRequested, + document_mode: ScalarObservationRequest::NotRequested, + tree: ObservationRequest::NotRequested, + patches: ObservationRequest::Capture { capacity }, + }) + .unwrap() + .patches + } + + #[test] + fn patch_capacity_zero_exact_and_one_below_keep_semantic_prefixes() { + let ObservationState::Captured(complete) = observe_patch_capacity(256) else { + panic!("large capacity"); + }; + let required = complete.operations.len(); + assert!(required > 1); + assert_eq!( + observe_patch_capacity(required), + ObservationState::Captured(complete.clone()) + ); + + let ObservationState::Incomplete { partial, reason } = observe_patch_capacity(required - 1) + else { + panic!("one below must be incomplete"); + }; + assert_eq!(partial.operations, complete.operations[..required - 1]); + assert_eq!( + reason, + IncompleteObservationReason::StorageLimitExceeded { + retained: required - 1, + dropped: 1, + } + ); + + let ObservationState::Incomplete { partial, reason } = observe_patch_capacity(0) else { + panic!("zero capacity must be incomplete"); + }; + assert!(partial.operations.is_empty()); + assert_eq!( + reason, + IncompleteObservationReason::StorageLimitExceeded { + retained: 0, + dropped: required as u64, + } + ); + } + + #[test] + fn standalone_tree_and_patch_requests_are_not_applicable_without_diagnostics() { + let result = execute_parser_observation(ParserObservationRequest { + target: ParserObservationTarget::StandaloneTokenizer, + input: ParserObservationInput::Utf8("

x"), + tokens: ObservationRequest::NotRequested, + parse_errors: ObservationRequest::NotRequested, + implementation_diagnostics: ObservationRequest::NotRequested, + document_mode: ScalarObservationRequest::NotRequested, + tree: ObservationRequest::Capture { capacity: 32 }, + patches: ObservationRequest::Capture { capacity: 32 }, + }) + .unwrap(); + assert!(matches!(result.tokens, ObservationState::NotRequested)); + assert!(matches!( + result.tree, + ObservationState::NotApplicable { + reason: NotApplicableReason::StandaloneTokenizerRun + } + )); + assert!(matches!( + result.patches, + ObservationState::NotApplicable { + reason: NotApplicableReason::StandaloneTokenizerRun + } + )); + } + + #[test] + fn integrated_parser_depth_within_materialization_limit_projects_successfully() { + let depth = 900; + let mut source = String::new(); + source.try_reserve(depth * 11).unwrap(); + for _ in 0..depth { + source.push_str("

"); + } + source.push('x'); + for _ in 0..depth { + source.push_str("
"); + } + let result = execute_parser_observation(ParserObservationRequest { + target: ParserObservationTarget::DocumentParser, + input: ParserObservationInput::Utf8(&source), + tokens: ObservationRequest::NotRequested, + parse_errors: ObservationRequest::NotRequested, + implementation_diagnostics: ObservationRequest::NotRequested, + document_mode: ScalarObservationRequest::NotRequested, + tree: ObservationRequest::Capture { + capacity: depth + 5, + }, + patches: ObservationRequest::NotRequested, + }) + .unwrap(); + let ObservationState::Captured(tree) = result.tree else { + panic!("integrated deep tree must be complete"); + }; + { + let [ObservedTreeNode::Document { children }] = tree.roots.as_slice() else { + panic!("exactly one document root"); + }; + let [ + ObservedTreeNode::Element { + namespace: crate::ElementNamespace::Html, + local_name, + children: html_children, + .. + }, + ] = children.as_slice() + else { + panic!("document must contain exactly one HTML root"); + }; + assert_eq!(local_name, "html"); + let [ + ObservedTreeNode::Element { + local_name: head_name, + children: head_children, + .. + }, + ObservedTreeNode::Element { + local_name: body_name, + children: body_children, + .. + }, + ] = html_children.as_slice() + else { + panic!("HTML children must preserve head-before-body source order"); + }; + assert_eq!(head_name, "head"); + assert!(head_children.is_empty()); + assert_eq!(body_name, "body"); + + let mut current = body_children.as_slice(); + let mut div_count = 0usize; + let mut maximum_depth = 2usize; + let mut structural_units = 4usize; // document, html, head, body + while let [ + ObservedTreeNode::Element { + namespace: crate::ElementNamespace::Html, + local_name, + children, + .. + }, + ] = current + { + assert_eq!(local_name, "div"); + div_count += 1; + maximum_depth = 2 + div_count; + structural_units += 1; + current = children; + } + assert!(matches!( + current, + [ObservedTreeNode::Text { data }] if data == "x" + )); + structural_units += 1; + maximum_depth += 1; + assert_eq!(div_count, depth); + let element_count = div_count.checked_add(3).unwrap(); + assert_eq!(element_count, depth + 3); + assert_eq!(maximum_depth, depth + 3); + assert_eq!(structural_units, depth + 5); + } + drop_observed_tree_iteratively(tree); + } + + fn drop_observed_tree_iteratively(tree: crate::conformance::ObservedTree) { + let mut stack = tree.roots; + while let Some(mut node) = stack.pop() { + match &mut node { + ObservedTreeNode::Document { children } + | ObservedTreeNode::Element { children, .. } => { + stack.extend(std::mem::take(children)); + } + ObservedTreeNode::HtmlTemplateElement { + ordinary_children, + contents, + .. + } => { + stack.extend(std::mem::take(ordinary_children)); + stack.extend(std::mem::take(&mut contents.children)); + } + ObservedTreeNode::DocumentType { .. } + | ObservedTreeNode::Comment { .. } + | ObservedTreeNode::Text { .. } + | ObservedTreeNode::ProcessingInstruction { .. } => {} + } + } + } } diff --git a/crates/html/src/conformance/mod.rs b/crates/html/src/conformance/mod.rs index afbfee88..a8b782ec 100644 --- a/crates/html/src/conformance/mod.rs +++ b/crates/html/src/conformance/mod.rs @@ -1,11 +1,13 @@ //! Typed, parser-owned semantic models for HTML conformance observations. //! -//! AE13a defines these passive result shapes without wiring observation hooks -//! into the tokenizer or tree builder. Snapshot serialization and integrated -//! parser capture belong to later AE13 slices. +//! Diagnostic capture is owned by production parser context, complete semantic +//! patch history by the parser-session adapter, and final tree projection by +//! conformance execution after successful materialization. Snapshot +//! serialization remains outside this module. mod execution; mod model; +mod projection; pub use crate::html5::shared::{ DiagnosticEventMetadata, EventPosition, ImplementationDiagnosticCode, @@ -19,8 +21,9 @@ pub use crate::html5::shared::{ Utf8ReplacementPayload, Utf8ReplacementReason, WhatwgParseErrorCode, }; pub use execution::{ - ObservationRequest, ParserObservationExecutionError, ParserObservationInput, - ParserObservationInvariantError, ParserObservationRequest, ParserObservationTarget, - ParserTokenizerInvariantError, ScalarObservationRequest, execute_parser_observation, + ObservationRequest, ObservationReservationSite, ObservationResourceExhaustion, + ParserObservationExecutionError, ParserObservationInput, ParserObservationInvariantError, + ParserObservationRequest, ParserObservationTarget, ParserTokenizerInvariantError, + ScalarObservationRequest, execute_parser_observation, }; pub use model::*; diff --git a/crates/html/src/conformance/model.rs b/crates/html/src/conformance/model.rs index 92da6c06..717acf3f 100644 --- a/crates/html/src/conformance/model.rs +++ b/crates/html/src/conformance/model.rs @@ -7,9 +7,7 @@ use crate::html5::shared::{ ImplementationDiagnosticEvent, ObservedInsertionMode, ObservedToken, ParseErrorEvent, ParserContextSummary, }; -use crate::{ - AttributeNamespace, DocumentMode, DomPatch, ElementNamespace, ParserCreatedAttribute, PatchKey, -}; +use crate::{AttributeNamespace, DocumentMode, ElementNamespace}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum ObservationState { @@ -201,102 +199,6 @@ pub enum ObservedPatchOperation { }, } -/// Convert one production patch without exposing its raw `PatchKey` values. -/// -/// The caller owns snapshot-local label assignment. AE13a deliberately does -/// not define stream labeling or a patch-v3 serializer. -pub fn canonicalize_dom_patch( - patch: &DomPatch, - mut label_for: impl FnMut(PatchKey) -> PatchNodeLabel, -) -> ObservedPatchOperation { - match patch { - DomPatch::Clear => ObservedPatchOperation::Clear, - DomPatch::CreateDocument { key, doctype } => ObservedPatchOperation::CreateDocument { - node: label_for(*key), - legacy_doctype: doctype.clone(), - }, - DomPatch::CreateDocumentType { - key, - name, - public_id, - system_id, - } => ObservedPatchOperation::CreateDocumentType { - node: label_for(*key), - name: name.clone(), - public_id: public_id.clone(), - system_id: system_id.clone(), - }, - DomPatch::CreateElement { - key, - name, - attributes, - } => ObservedPatchOperation::CreateElement { - node: label_for(*key), - namespace: name.namespace(), - local_name: name.local_name_str().to_string(), - attributes: attributes.iter().map(canonicalize_dom_attribute).collect(), - }, - DomPatch::CreateTemplateContents { host, contents } => { - ObservedPatchOperation::CreateTemplateContents { - host: label_for(*host), - contents: label_for(*contents), - } - } - DomPatch::CreateText { key, text } => ObservedPatchOperation::CreateText { - node: label_for(*key), - text: text.clone(), - }, - DomPatch::CreateComment { key, text } => ObservedPatchOperation::CreateComment { - node: label_for(*key), - data: text.clone(), - }, - DomPatch::CreateProcessingInstruction { key, target, data } => { - ObservedPatchOperation::CreateProcessingInstruction { - node: label_for(*key), - target: target.clone(), - data: data.clone(), - } - } - DomPatch::AppendChild { parent, child } => ObservedPatchOperation::AppendChild { - parent: label_for(*parent), - child: label_for(*child), - }, - DomPatch::InsertBefore { - parent, - child, - before, - } => ObservedPatchOperation::InsertBefore { - parent: label_for(*parent), - child: label_for(*child), - before: label_for(*before), - }, - DomPatch::RemoveNode { key } => ObservedPatchOperation::RemoveNode { - node: label_for(*key), - }, - DomPatch::SetAttributes { key, attributes } => ObservedPatchOperation::SetAttributes { - node: label_for(*key), - attributes: attributes.iter().map(canonicalize_dom_attribute).collect(), - }, - DomPatch::SetText { key, text } => ObservedPatchOperation::SetText { - node: label_for(*key), - text: text.clone(), - }, - DomPatch::AppendText { key, text } => ObservedPatchOperation::AppendText { - node: label_for(*key), - text: text.clone(), - }, - } -} - -fn canonicalize_dom_attribute(attribute: &ParserCreatedAttribute) -> ObservedDomAttribute { - ObservedDomAttribute { - namespace: attribute.namespace(), - prefix: attribute.prefix().map(str::to_string), - local_name: attribute.local_name().to_string(), - value: attribute.value().to_string(), - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TreeDispatchPath { HtmlInsertionMode(ObservedInsertionMode), @@ -592,8 +494,6 @@ mod tests { use crate::html5::shared::{ InputCoordinateSpace, NormalizedInputPosition, NormalizedLineNumber, NormalizedScalarColumn, }; - use crate::names::NameInterner; - use crate::{ExpandedElementName, QualifiedAttributeName}; #[test] fn normalized_line_and_scalar_column_coordinates_are_one_based() { @@ -746,103 +646,4 @@ mod tests { && attributes[3].prefix.as_deref() == Some("xmlns") )); } - - #[test] - fn canonical_patch_conversion_acknowledges_every_current_production_variant() { - let mut names = NameInterner::new(); - let div = names.intern_exact("div").unwrap(); - let lang = names.intern_exact("lang").unwrap(); - let element_name = ExpandedElementName::new( - ElementNamespace::Html, - names.resolve_local_name(div).unwrap(), - ); - let xml_lang = ParserCreatedAttribute::new( - QualifiedAttributeName::xml(names.resolve_local_name(lang).unwrap()), - "en".to_string(), - ); - let patches = vec![ - DomPatch::Clear, - DomPatch::CreateDocument { - key: PatchKey(1), - doctype: Some("html".to_string()), - }, - DomPatch::CreateDocumentType { - key: PatchKey(2), - name: Some("html".to_string()), - public_id: Some("public".to_string()), - system_id: Some("system".to_string()), - }, - DomPatch::CreateElement { - key: PatchKey(3), - name: element_name, - attributes: vec![xml_lang.clone()], - }, - DomPatch::CreateTemplateContents { - host: PatchKey(3), - contents: PatchKey(4), - }, - DomPatch::CreateText { - key: PatchKey(5), - text: "text".to_string(), - }, - DomPatch::CreateComment { - key: PatchKey(6), - text: "comment".to_string(), - }, - DomPatch::CreateProcessingInstruction { - key: PatchKey(7), - target: "xml".to_string(), - data: "value".to_string(), - }, - DomPatch::AppendChild { - parent: PatchKey(1), - child: PatchKey(3), - }, - DomPatch::InsertBefore { - parent: PatchKey(1), - child: PatchKey(5), - before: PatchKey(3), - }, - DomPatch::RemoveNode { key: PatchKey(6) }, - DomPatch::SetAttributes { - key: PatchKey(3), - attributes: vec![xml_lang], - }, - DomPatch::SetText { - key: PatchKey(5), - text: "replacement".to_string(), - }, - DomPatch::AppendText { - key: PatchKey(5), - text: " suffix".to_string(), - }, - ]; - - let observed = patches - .iter() - .map(|patch| { - canonicalize_dom_patch(patch, |key| PatchNodeLabel(format!("node-{}", key.0))) - }) - .collect::>(); - assert_eq!(observed.len(), 14); - assert!(matches!(observed[0], ObservedPatchOperation::Clear)); - assert!(matches!( - &observed[2], - ObservedPatchOperation::CreateDocumentType { - public_id: Some(public_id), - system_id: Some(system_id), - .. - } if public_id == "public" && system_id == "system" - )); - assert!(matches!( - &observed[3], - ObservedPatchOperation::CreateElement { attributes, .. } - if attributes[0].namespace == AttributeNamespace::Xml - && attributes[0].prefix.as_deref() == Some("xml") - )); - assert!(matches!( - &observed[13], - ObservedPatchOperation::AppendText { text, .. } if text == " suffix" - )); - } } diff --git a/crates/html/src/conformance/projection.rs b/crates/html/src/conformance/projection.rs new file mode 100644 index 00000000..a6608e2b --- /dev/null +++ b/crates/html/src/conformance/projection.rs @@ -0,0 +1,1963 @@ +//! Fallible canonical projection from production-owned parser results. + +use super::execution::{ + ObservationReservationSite, ObservationResourceExhaustion, ParserObservationExecutionError, + ParserObservationInvariantError, +}; +use super::model::{ + IncompleteObservationReason, ObservationState, ObservedDomAttribute, ObservedPatchOperation, + ObservedPatchStream, ObservedTemplateContents, ObservedTree, ObservedTreeNode, PatchNodeLabel, +}; +use crate::html5::RawPatchHistoryCapture; +use crate::types::{DocumentFragmentNode, ParserCreatedFragmentKind}; +use crate::{DomPatch, Node, ParserCreatedAttribute, PatchKey}; +use std::collections::{HashMap, HashSet}; + +#[derive(Debug, Default)] +pub(super) struct ObservationAllocationController { + #[cfg(test)] + selected: Option, + #[cfg(test)] + matching_occurrences: u64, +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug)] +pub(super) struct ObservationFailureInjection { + pub(super) step: ObservationAllocationStep, + pub(super) occurrence: std::num::NonZeroU64, +} + +/// Private semantic identity consumed only by the test failure selector. +/// +/// Production failures continue to expose only `ObservationReservationSite`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ObservationAllocationStep { + CanonicalTreeTraversalStack, + CanonicalTreeFrameStack, + CanonicalTreeChildStorage, + CanonicalTreeString, + CanonicalTreeAttributeStorage, + CanonicalTreeAttributeValue, + CanonicalPatchOperationStorage, + CanonicalPatchPayload, + CanonicalPatchAttributeStorage, + CanonicalPatchAttributeValue, + PatchCreationHistoryStorage, + SnapshotLabelMapStorage, + SnapshotLabelStringStorage, +} + +#[cfg(test)] +impl ObservationAllocationController { + pub(super) const fn with_failure(injection: ObservationFailureInjection) -> Self { + Self { + selected: Some(injection), + matching_occurrences: 0, + } + } +} + +impl ObservationAllocationController { + fn before_reservation( + &mut self, + site: ObservationReservationSite, + step: ObservationAllocationStep, + ) -> Result<(), ObservationResourceExhaustion> { + #[cfg(test)] + if let Some(selected) = self.selected + && selected.step == step + { + self.matching_occurrences += 1; + if self.matching_occurrences == selected.occurrence.get() { + self.selected = None; + return Err(ObservationResourceExhaustion::at(site)); + } + } + let _ = (site, step); + Ok(()) + } +} + +pub(super) fn project_tree( + document: &Node, + capacity: usize, + allocations: &mut ObservationAllocationController, +) -> Result, ParserObservationExecutionError> { + let required = count_tree_units(document, allocations)?; + if required > capacity { + let dropped = u64::try_from(required).map_err(|_| tree_unit_overflow())?; + return Ok(ObservationState::Incomplete { + partial: ObservedTree::default(), + reason: IncompleteObservationReason::StorageLimitExceeded { + retained: 0, + dropped, + }, + }); + } + + let (tree, emitted) = build_tree(document, allocations)?; + validate_projected_unit_count(required, emitted)?; + Ok(ObservationState::Captured(tree)) +} + +fn count_tree_units( + document: &Node, + allocations: &mut ObservationAllocationController, +) -> Result { + let mut walker = TreeWalker::new(document, allocations)?; + let mut count = 0usize; + while let Some(event) = walker.next(allocations)? { + let additional = match event { + TreeWalkEvent::EnterNode(_) => 1, + TreeWalkEvent::EnterTemplateContents(_) => 1, + TreeWalkEvent::ExitContainer => 0, + }; + count = count + .checked_add(additional) + .ok_or_else(tree_unit_overflow)?; + } + Ok(count) +} + +fn build_tree( + document: &Node, + allocations: &mut ObservationAllocationController, +) -> Result<(ObservedTree, usize), ParserObservationExecutionError> { + let site = ObservationReservationSite::CanonicalTreeProjection; + allocations.before_reservation(site, ObservationAllocationStep::CanonicalTreeFrameStack)?; + let mut frames = Vec::new(); + frames + .try_reserve(2) + .map_err(|_| ObservationResourceExhaustion::at(site))?; + let mut roots = try_node_vec(1, allocations)?; + frames.push(TreeProjectionFrame::Root); + + let mut walker = TreeWalker::new(document, allocations)?; + let mut emitted = 0usize; + while let Some(event) = walker.next(allocations)? { + match event { + TreeWalkEvent::EnterNode(node) => { + emitted = emitted.checked_add(1).ok_or_else(tree_unit_overflow)?; + enter_node(node, &mut frames, &mut roots, allocations)?; + } + TreeWalkEvent::EnterTemplateContents(contents) => { + emitted = emitted.checked_add(1).ok_or_else(tree_unit_overflow)?; + if contents.kind() != ParserCreatedFragmentKind::TemplateContents + || !matches!(frames.last(), Some(TreeProjectionFrame::Template { .. })) + { + return Err(tree_traversal_contradiction()); + } + reserve_frame(&mut frames, allocations)?; + frames.push(TreeProjectionFrame::TemplateContents { + children: try_node_vec(contents.children().len(), allocations)?, + }); + } + TreeWalkEvent::ExitContainer => { + exit_frame(&mut frames, &mut roots)?; + } + } + } + if frames.len() != 1 || !matches!(frames[0], TreeProjectionFrame::Root) { + return Err(tree_traversal_contradiction()); + } + Ok((ObservedTree { roots }, emitted)) +} + +fn enter_node( + node: &Node, + frames: &mut Vec, + roots: &mut Vec, + allocations: &mut ObservationAllocationController, +) -> Result<(), ParserObservationExecutionError> { + match node { + Node::Document { children, .. } => { + reserve_frame(frames, allocations)?; + frames.push(TreeProjectionFrame::Document { + children: try_node_vec(children.len(), allocations)?, + }); + } + Node::DocumentType { + name, + public_id, + system_id, + .. + } => append_projected_node( + frames, + roots, + ObservedTreeNode::DocumentType { + name: try_copy_optional_string( + name.as_deref(), + ObservationReservationSite::CanonicalTreeProjection, + ObservationAllocationStep::CanonicalTreeString, + allocations, + )?, + public_id: try_copy_optional_string( + public_id.as_deref(), + ObservationReservationSite::CanonicalTreeProjection, + ObservationAllocationStep::CanonicalTreeString, + allocations, + )?, + system_id: try_copy_optional_string( + system_id.as_deref(), + ObservationReservationSite::CanonicalTreeProjection, + ObservationAllocationStep::CanonicalTreeString, + allocations, + )?, + }, + )?, + Node::Text { text, .. } => append_projected_node( + frames, + roots, + ObservedTreeNode::Text { + data: try_copy_string( + text, + ObservationReservationSite::CanonicalTreeProjection, + ObservationAllocationStep::CanonicalTreeString, + allocations, + )?, + }, + )?, + Node::Comment { text, .. } => append_projected_node( + frames, + roots, + ObservedTreeNode::Comment { + data: try_copy_string( + text, + ObservationReservationSite::CanonicalTreeProjection, + ObservationAllocationStep::CanonicalTreeString, + allocations, + )?, + }, + )?, + Node::ProcessingInstruction { + processing_instruction, + } => append_projected_node( + frames, + roots, + ObservedTreeNode::ProcessingInstruction { + target: try_copy_string( + processing_instruction.target(), + ObservationReservationSite::CanonicalTreeProjection, + ObservationAllocationStep::CanonicalTreeString, + allocations, + )?, + data: try_copy_string( + processing_instruction.data(), + ObservationReservationSite::CanonicalTreeProjection, + ObservationAllocationStep::CanonicalTreeString, + allocations, + )?, + }, + )?, + Node::Element { element } => { + let attributes = try_observed_attributes( + element.attributes(), + ObservationReservationSite::CanonicalTreeProjection, + ObservationAllocationStep::CanonicalTreeAttributeStorage, + ObservationAllocationStep::CanonicalTreeString, + ObservationAllocationStep::CanonicalTreeAttributeValue, + allocations, + )?; + if element.expanded_name().is_html("template") { + if element.template_contents().is_none() { + return Err(tree_traversal_contradiction()); + } + reserve_frame(frames, allocations)?; + frames.push(TreeProjectionFrame::Template { + attributes, + ordinary_children: try_node_vec(element.children().len(), allocations)?, + contents: None, + }); + } else { + reserve_frame(frames, allocations)?; + frames.push(TreeProjectionFrame::Element { + namespace: element.namespace(), + local_name: try_copy_string( + element.name(), + ObservationReservationSite::CanonicalTreeProjection, + ObservationAllocationStep::CanonicalTreeString, + allocations, + )?, + attributes, + children: try_node_vec(element.children().len(), allocations)?, + }); + } + } + } + Ok(()) +} + +fn exit_frame( + frames: &mut Vec, + roots: &mut Vec, +) -> Result<(), ParserObservationExecutionError> { + let Some(frame) = frames.pop() else { + return Err(tree_traversal_contradiction()); + }; + let node = match frame { + TreeProjectionFrame::Root => return Err(tree_traversal_contradiction()), + TreeProjectionFrame::Document { children } => ObservedTreeNode::Document { children }, + TreeProjectionFrame::Element { + namespace, + local_name, + attributes, + children, + } => ObservedTreeNode::Element { + namespace, + local_name, + attributes, + children, + }, + TreeProjectionFrame::Template { + attributes, + ordinary_children, + contents: Some(contents), + } => ObservedTreeNode::HtmlTemplateElement { + attributes, + ordinary_children, + contents, + }, + TreeProjectionFrame::Template { contents: None, .. } => { + return Err(tree_traversal_contradiction()); + } + TreeProjectionFrame::TemplateContents { children } => { + let Some(TreeProjectionFrame::Template { contents, .. }) = frames.last_mut() else { + return Err(tree_traversal_contradiction()); + }; + if contents.is_some() { + return Err(tree_traversal_contradiction()); + } + *contents = Some(ObservedTemplateContents { children }); + return Ok(()); + } + }; + append_projected_node(frames, roots, node) +} + +fn append_projected_node( + frames: &mut [TreeProjectionFrame], + roots: &mut Vec, + node: ObservedTreeNode, +) -> Result<(), ParserObservationExecutionError> { + match frames.last_mut() { + Some(TreeProjectionFrame::Root) => roots.push(node), + Some(TreeProjectionFrame::Document { children }) + | Some(TreeProjectionFrame::Element { children, .. }) + | Some(TreeProjectionFrame::TemplateContents { children }) => children.push(node), + Some(TreeProjectionFrame::Template { + ordinary_children, .. + }) => ordinary_children.push(node), + None => return Err(tree_traversal_contradiction()), + } + Ok(()) +} + +fn reserve_frame( + frames: &mut Vec, + allocations: &mut ObservationAllocationController, +) -> Result<(), ParserObservationExecutionError> { + let site = ObservationReservationSite::CanonicalTreeProjection; + allocations.before_reservation(site, ObservationAllocationStep::CanonicalTreeFrameStack)?; + frames + .try_reserve(1) + .map_err(|_| ObservationResourceExhaustion::at(site))?; + Ok(()) +} + +fn try_node_vec( + capacity: usize, + allocations: &mut ObservationAllocationController, +) -> Result, ObservationResourceExhaustion> { + let site = ObservationReservationSite::CanonicalTreeProjection; + allocations.before_reservation(site, ObservationAllocationStep::CanonicalTreeChildStorage)?; + let mut nodes = Vec::new(); + nodes + .try_reserve_exact(capacity) + .map_err(|_| ObservationResourceExhaustion::at(site))?; + Ok(nodes) +} + +enum TreeProjectionFrame { + Root, + Document { + children: Vec, + }, + Element { + namespace: crate::ElementNamespace, + local_name: String, + attributes: Vec, + children: Vec, + }, + Template { + attributes: Vec, + ordinary_children: Vec, + contents: Option, + }, + TemplateContents { + children: Vec, + }, +} + +enum TreeWalkWork<'a> { + EnterNode(&'a Node), + EnterTemplateContents(&'a DocumentFragmentNode), + ExitContainer, +} + +enum TreeWalkEvent<'a> { + EnterNode(&'a Node), + EnterTemplateContents(&'a DocumentFragmentNode), + ExitContainer, +} + +struct TreeWalker<'a> { + work: Vec>, + document_seen: bool, +} + +impl<'a> TreeWalker<'a> { + fn new( + document: &'a Node, + allocations: &mut ObservationAllocationController, + ) -> Result { + if !matches!(document, Node::Document { .. }) { + return Err(observation_invariant( + ParserObservationInvariantError::CanonicalTreeRootNotDocument, + )); + } + let mut work = Vec::new(); + reserve_tree_work(&mut work, 1, allocations)?; + work.push(TreeWalkWork::EnterNode(document)); + Ok(Self { + work, + document_seen: false, + }) + } + + fn next( + &mut self, + allocations: &mut ObservationAllocationController, + ) -> Result>, ParserObservationExecutionError> { + let Some(work) = self.work.pop() else { + return Ok(None); + }; + Ok(Some(match work { + TreeWalkWork::ExitContainer => TreeWalkEvent::ExitContainer, + TreeWalkWork::EnterTemplateContents(contents) => { + if contents.kind() != ParserCreatedFragmentKind::TemplateContents { + return Err(observation_invariant( + ParserObservationInvariantError::InvalidTemplateContentsKind, + )); + } + let additional = contents + .children() + .len() + .checked_add(1) + .ok_or_else(tree_unit_overflow)?; + reserve_tree_work(&mut self.work, additional, allocations)?; + self.work.push(TreeWalkWork::ExitContainer); + for child in contents.children().iter().rev() { + self.work.push(TreeWalkWork::EnterNode(child)); + } + TreeWalkEvent::EnterTemplateContents(contents) + } + TreeWalkWork::EnterNode(node) => { + match node { + Node::Document { + doctype, children, .. + } => { + if self.document_seen { + return Err(observation_invariant( + ParserObservationInvariantError::CanonicalTreeRootNotDocument, + )); + } + self.document_seen = true; + if doctype.is_some() { + return Err(observation_invariant( + ParserObservationInvariantError::UnexpectedLegacyDocumentDoctypeMetadata, + )); + } + schedule_node_children(&mut self.work, children, allocations)?; + } + Node::Element { element } => { + if element.expanded_name().is_html("template") { + let Some(contents) = element.template_contents() else { + return Err(observation_invariant( + ParserObservationInvariantError::MissingHtmlTemplateContents, + )); + }; + if contents.kind() != ParserCreatedFragmentKind::TemplateContents { + return Err(observation_invariant( + ParserObservationInvariantError::InvalidTemplateContentsKind, + )); + } + let additional = element + .children() + .len() + .checked_add(2) + .ok_or_else(tree_unit_overflow)?; + reserve_tree_work(&mut self.work, additional, allocations)?; + self.work.push(TreeWalkWork::ExitContainer); + self.work + .push(TreeWalkWork::EnterTemplateContents(contents)); + for child in element.children().iter().rev() { + self.work.push(TreeWalkWork::EnterNode(child)); + } + } else { + schedule_node_children( + &mut self.work, + element.children(), + allocations, + )?; + } + } + Node::DocumentType { .. } + | Node::Text { .. } + | Node::Comment { .. } + | Node::ProcessingInstruction { .. } => {} + } + TreeWalkEvent::EnterNode(node) + } + })) + } +} + +fn schedule_node_children<'a>( + work: &mut Vec>, + children: &'a [Node], + allocations: &mut ObservationAllocationController, +) -> Result<(), ParserObservationExecutionError> { + let additional = children + .len() + .checked_add(1) + .ok_or_else(tree_unit_overflow)?; + reserve_tree_work(work, additional, allocations)?; + work.push(TreeWalkWork::ExitContainer); + for child in children.iter().rev() { + work.push(TreeWalkWork::EnterNode(child)); + } + Ok(()) +} + +fn reserve_tree_work<'a>( + work: &mut Vec>, + additional: usize, + allocations: &mut ObservationAllocationController, +) -> Result<(), ParserObservationExecutionError> { + let site = ObservationReservationSite::CanonicalTreeProjection; + allocations.before_reservation(site, ObservationAllocationStep::CanonicalTreeTraversalStack)?; + work.try_reserve(additional) + .map_err(|_| ObservationResourceExhaustion::at(site))?; + Ok(()) +} + +pub(super) fn project_patches( + capture: RawPatchHistoryCapture, + allocations: &mut ObservationAllocationController, +) -> Result, ParserObservationExecutionError> { + let site = ObservationReservationSite::CanonicalPatchProjection; + allocations.before_reservation( + site, + ObservationAllocationStep::CanonicalPatchOperationStorage, + )?; + let mut operations = Vec::new(); + operations + .try_reserve_exact(capture.operations.len()) + .map_err(|_| ObservationResourceExhaustion::at(site))?; + let mut labels = SnapshotLabels::default(); + let mut created = HashSet::new(); + for patch in &capture.operations { + validate_patch_history(patch, &mut created, allocations)?; + operations.push(canonicalize_patch(patch, &mut labels, allocations)?); + } + let stream = ObservedPatchStream { operations }; + if capture.dropped == 0 { + Ok(ObservationState::Captured(stream)) + } else { + Ok(ObservationState::Incomplete { + partial: stream, + reason: IncompleteObservationReason::StorageLimitExceeded { + retained: capture.operations.len(), + dropped: capture.dropped, + }, + }) + } +} + +fn validate_patch_history( + patch: &DomPatch, + created: &mut HashSet, + allocations: &mut ObservationAllocationController, +) -> Result<(), ParserObservationExecutionError> { + match patch { + DomPatch::Clear => Ok(()), + DomPatch::CreateDocument { key, .. } + | DomPatch::CreateDocumentType { key, .. } + | DomPatch::CreateElement { key, .. } + | DomPatch::CreateText { key, .. } + | DomPatch::CreateComment { key, .. } + | DomPatch::CreateProcessingInstruction { key, .. } => { + introduce_key(*key, created, allocations) + } + DomPatch::CreateTemplateContents { host, contents } => { + require_key(*host, created)?; + introduce_key(*contents, created, allocations) + } + DomPatch::AppendChild { parent, child } => { + require_key(*parent, created)?; + require_key(*child, created) + } + DomPatch::InsertBefore { + parent, + child, + before, + } => { + require_key(*parent, created)?; + require_key(*child, created)?; + require_key(*before, created) + } + DomPatch::RemoveNode { key } + | DomPatch::SetAttributes { key, .. } + | DomPatch::SetText { key, .. } + | DomPatch::AppendText { key, .. } => require_key(*key, created), + } +} + +fn introduce_key( + key: PatchKey, + created: &mut HashSet, + allocations: &mut ObservationAllocationController, +) -> Result<(), ParserObservationExecutionError> { + if key == PatchKey::INVALID { + return Err(observation_invariant( + ParserObservationInvariantError::InvalidPatchKey, + )); + } + if created.contains(&key) { + return Err(observation_invariant( + ParserObservationInvariantError::DuplicatePatchCreation, + )); + } + let site = ObservationReservationSite::CanonicalPatchProjection; + allocations.before_reservation(site, ObservationAllocationStep::PatchCreationHistoryStorage)?; + created + .try_reserve(1) + .map_err(|_| ObservationResourceExhaustion::at(site))?; + created.insert(key); + Ok(()) +} + +fn require_key( + key: PatchKey, + created: &HashSet, +) -> Result<(), ParserObservationExecutionError> { + if key == PatchKey::INVALID { + return Err(observation_invariant( + ParserObservationInvariantError::InvalidPatchKey, + )); + } + if !created.contains(&key) { + return Err(observation_invariant( + ParserObservationInvariantError::MissingPatchCreationHistory, + )); + } + Ok(()) +} + +#[derive(Default)] +struct SnapshotLabels { + by_key: HashMap, + next: u64, +} + +impl SnapshotLabels { + fn label( + &mut self, + key: PatchKey, + allocations: &mut ObservationAllocationController, + ) -> Result { + if key == PatchKey::INVALID { + return Err(observation_invariant( + ParserObservationInvariantError::InvalidPatchKey, + )); + } + let number = if let Some(number) = self.by_key.get(&key) { + *number + } else { + let number = self.next.checked_add(1).ok_or_else(|| { + observation_invariant( + ParserObservationInvariantError::SnapshotLabelSequenceOverflow, + ) + })?; + let site = ObservationReservationSite::SnapshotLabelStorage; + allocations + .before_reservation(site, ObservationAllocationStep::SnapshotLabelMapStorage)?; + self.by_key + .try_reserve(1) + .map_err(|_| ObservationResourceExhaustion::at(site))?; + self.by_key.insert(key, number); + self.next = number; + number + }; + Ok(PatchNodeLabel(try_label_string(number, allocations)?)) + } +} + +fn try_label_string( + number: u64, + allocations: &mut ObservationAllocationController, +) -> Result { + let mut digits = [0u8; 20]; + let mut cursor = digits.len(); + let mut remaining = number; + loop { + cursor -= 1; + digits[cursor] = b'0' + (remaining % 10) as u8; + remaining /= 10; + if remaining == 0 { + break; + } + } + let digit_text = std::str::from_utf8(&digits[cursor..]).map_err(|_| { + observation_invariant(ParserObservationInvariantError::SnapshotLabelSequenceOverflow) + })?; + let site = ObservationReservationSite::SnapshotLabelStorage; + allocations.before_reservation(site, ObservationAllocationStep::SnapshotLabelStringStorage)?; + let capacity = 5usize + .checked_add(digit_text.len()) + .ok_or_else(|| ObservationResourceExhaustion::at(site))?; + let mut label = String::new(); + label + .try_reserve_exact(capacity) + .map_err(|_| ObservationResourceExhaustion::at(site))?; + label.push_str("node-"); + label.push_str(digit_text); + Ok(label) +} + +fn canonicalize_patch( + patch: &DomPatch, + labels: &mut SnapshotLabels, + allocations: &mut ObservationAllocationController, +) -> Result { + let site = ObservationReservationSite::CanonicalPatchProjection; + Ok(match patch { + DomPatch::Clear => ObservedPatchOperation::Clear, + DomPatch::CreateDocument { key, doctype } => ObservedPatchOperation::CreateDocument { + node: labels.label(*key, allocations)?, + legacy_doctype: try_copy_optional_string( + doctype.as_deref(), + site, + ObservationAllocationStep::CanonicalPatchPayload, + allocations, + )?, + }, + DomPatch::CreateDocumentType { + key, + name, + public_id, + system_id, + } => ObservedPatchOperation::CreateDocumentType { + node: labels.label(*key, allocations)?, + name: try_copy_optional_string( + name.as_deref(), + site, + ObservationAllocationStep::CanonicalPatchPayload, + allocations, + )?, + public_id: try_copy_optional_string( + public_id.as_deref(), + site, + ObservationAllocationStep::CanonicalPatchPayload, + allocations, + )?, + system_id: try_copy_optional_string( + system_id.as_deref(), + site, + ObservationAllocationStep::CanonicalPatchPayload, + allocations, + )?, + }, + DomPatch::CreateElement { + key, + name, + attributes, + } => ObservedPatchOperation::CreateElement { + node: labels.label(*key, allocations)?, + namespace: name.namespace(), + local_name: try_copy_string( + name.local_name_str(), + site, + ObservationAllocationStep::CanonicalPatchPayload, + allocations, + )?, + attributes: try_observed_attributes( + attributes, + site, + ObservationAllocationStep::CanonicalPatchAttributeStorage, + ObservationAllocationStep::CanonicalPatchPayload, + ObservationAllocationStep::CanonicalPatchAttributeValue, + allocations, + )?, + }, + DomPatch::CreateTemplateContents { host, contents } => { + ObservedPatchOperation::CreateTemplateContents { + host: labels.label(*host, allocations)?, + contents: labels.label(*contents, allocations)?, + } + } + DomPatch::CreateText { key, text } => ObservedPatchOperation::CreateText { + node: labels.label(*key, allocations)?, + text: try_copy_string( + text, + site, + ObservationAllocationStep::CanonicalPatchPayload, + allocations, + )?, + }, + DomPatch::CreateComment { key, text } => ObservedPatchOperation::CreateComment { + node: labels.label(*key, allocations)?, + data: try_copy_string( + text, + site, + ObservationAllocationStep::CanonicalPatchPayload, + allocations, + )?, + }, + DomPatch::CreateProcessingInstruction { key, target, data } => { + ObservedPatchOperation::CreateProcessingInstruction { + node: labels.label(*key, allocations)?, + target: try_copy_string( + target, + site, + ObservationAllocationStep::CanonicalPatchPayload, + allocations, + )?, + data: try_copy_string( + data, + site, + ObservationAllocationStep::CanonicalPatchPayload, + allocations, + )?, + } + } + DomPatch::AppendChild { parent, child } => ObservedPatchOperation::AppendChild { + parent: labels.label(*parent, allocations)?, + child: labels.label(*child, allocations)?, + }, + DomPatch::InsertBefore { + parent, + child, + before, + } => ObservedPatchOperation::InsertBefore { + parent: labels.label(*parent, allocations)?, + child: labels.label(*child, allocations)?, + before: labels.label(*before, allocations)?, + }, + DomPatch::RemoveNode { key } => ObservedPatchOperation::RemoveNode { + node: labels.label(*key, allocations)?, + }, + DomPatch::SetAttributes { key, attributes } => ObservedPatchOperation::SetAttributes { + node: labels.label(*key, allocations)?, + attributes: try_observed_attributes( + attributes, + site, + ObservationAllocationStep::CanonicalPatchAttributeStorage, + ObservationAllocationStep::CanonicalPatchPayload, + ObservationAllocationStep::CanonicalPatchAttributeValue, + allocations, + )?, + }, + DomPatch::SetText { key, text } => ObservedPatchOperation::SetText { + node: labels.label(*key, allocations)?, + text: try_copy_string( + text, + site, + ObservationAllocationStep::CanonicalPatchPayload, + allocations, + )?, + }, + DomPatch::AppendText { key, text } => ObservedPatchOperation::AppendText { + node: labels.label(*key, allocations)?, + text: try_copy_string( + text, + site, + ObservationAllocationStep::CanonicalPatchPayload, + allocations, + )?, + }, + }) +} + +fn try_observed_attributes( + attributes: &[ParserCreatedAttribute], + site: ObservationReservationSite, + storage_step: ObservationAllocationStep, + string_step: ObservationAllocationStep, + value_step: ObservationAllocationStep, + allocations: &mut ObservationAllocationController, +) -> Result, ObservationResourceExhaustion> { + allocations.before_reservation(site, storage_step)?; + let mut observed = Vec::new(); + observed + .try_reserve_exact(attributes.len()) + .map_err(|_| ObservationResourceExhaustion::at(site))?; + for attribute in attributes { + observed.push(ObservedDomAttribute { + namespace: attribute.namespace(), + prefix: try_copy_optional_string(attribute.prefix(), site, string_step, allocations)?, + local_name: try_copy_string(attribute.local_name(), site, string_step, allocations)?, + value: try_copy_string(attribute.value(), site, value_step, allocations)?, + }); + } + Ok(observed) +} + +fn try_copy_optional_string( + value: Option<&str>, + site: ObservationReservationSite, + step: ObservationAllocationStep, + allocations: &mut ObservationAllocationController, +) -> Result, ObservationResourceExhaustion> { + value + .map(|value| try_copy_string(value, site, step, allocations)) + .transpose() +} + +fn try_copy_string( + value: &str, + site: ObservationReservationSite, + step: ObservationAllocationStep, + allocations: &mut ObservationAllocationController, +) -> Result { + allocations.before_reservation(site, step)?; + let mut copy = String::new(); + copy.try_reserve_exact(value.len()) + .map_err(|_| ObservationResourceExhaustion::at(site))?; + copy.push_str(value); + Ok(copy) +} + +fn tree_unit_overflow() -> ParserObservationExecutionError { + observation_invariant(ParserObservationInvariantError::CanonicalTreeUnitCountOverflow) +} + +fn validate_projected_unit_count( + preflight: usize, + projected: usize, +) -> Result<(), ParserObservationExecutionError> { + if preflight == projected { + Ok(()) + } else { + Err(observation_invariant( + ParserObservationInvariantError::CanonicalTreePreflightProjectionMismatch, + )) + } +} + +fn tree_traversal_contradiction() -> ParserObservationExecutionError { + observation_invariant(ParserObservationInvariantError::CanonicalTreeTraversalContradiction) +} + +fn observation_invariant( + invariant: ParserObservationInvariantError, +) -> ParserObservationExecutionError { + ParserObservationExecutionError::ObservationInvariant(invariant) +} + +impl From for ParserObservationExecutionError { + fn from(error: ObservationResourceExhaustion) -> Self { + Self::ResourceExhaustion(error) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::attributes::QualifiedAttributeName; + use crate::names::NameInterner; + use crate::types::{DocumentFragmentNode, Id}; + use crate::{ElementNamespace, ExpandedElementName, ParserCreatedAttribute}; + use std::num::NonZeroU64; + + fn injection( + step: ObservationAllocationStep, + occurrence: u64, + ) -> ObservationAllocationController { + ObservationAllocationController::with_failure(ObservationFailureInjection { + step, + occurrence: NonZeroU64::new(occurrence).expect("non-zero occurrence"), + }) + } + + fn raw(operations: Vec, dropped: u64) -> RawPatchHistoryCapture { + let capacity = operations.len(); + RawPatchHistoryCapture { + operations, + dropped, + capacity, + } + } + + #[test] + fn patch_projection_covers_every_variant_and_fixed_operand_order() { + let mut names = NameInterner::new(); + let div = names.intern_exact("div").unwrap(); + let lang = names.intern_exact("lang").unwrap(); + let element_name = ExpandedElementName::new( + ElementNamespace::Html, + names.resolve_local_name(div).unwrap(), + ); + let xml_lang = ParserCreatedAttribute::new( + QualifiedAttributeName::xml(names.resolve_local_name(lang).unwrap()), + "en".to_owned(), + ); + let patches = vec![ + DomPatch::Clear, + DomPatch::CreateDocument { + key: PatchKey(91), + doctype: Some("legacy".to_owned()), + }, + DomPatch::CreateDocumentType { + key: PatchKey(8), + name: Some("html".to_owned()), + public_id: Some("public".to_owned()), + system_id: Some("system".to_owned()), + }, + DomPatch::CreateElement { + key: PatchKey(44), + name: element_name, + attributes: vec![xml_lang.clone()], + }, + DomPatch::CreateTemplateContents { + host: PatchKey(44), + contents: PatchKey(3), + }, + DomPatch::CreateText { + key: PatchKey(77), + text: "text".to_owned(), + }, + DomPatch::CreateComment { + key: PatchKey(12), + text: "comment".to_owned(), + }, + DomPatch::CreateProcessingInstruction { + key: PatchKey(66), + target: "xml".to_owned(), + data: "value".to_owned(), + }, + DomPatch::AppendChild { + parent: PatchKey(91), + child: PatchKey(8), + }, + DomPatch::InsertBefore { + parent: PatchKey(91), + child: PatchKey(77), + before: PatchKey(8), + }, + DomPatch::RemoveNode { key: PatchKey(12) }, + DomPatch::SetAttributes { + key: PatchKey(44), + attributes: vec![xml_lang], + }, + DomPatch::SetText { + key: PatchKey(77), + text: "replacement".to_owned(), + }, + DomPatch::AppendText { + key: PatchKey(77), + text: " suffix".to_owned(), + }, + ]; + let state = project_patches( + raw(patches, 0), + &mut ObservationAllocationController::default(), + ) + .unwrap(); + let ObservationState::Captured(stream) = state else { + panic!("expected complete patch stream"); + }; + assert_eq!(stream.operations.len(), 14); + assert!(matches!( + stream.operations[0], + ObservedPatchOperation::Clear + )); + assert!(matches!( + &stream.operations[1], + ObservedPatchOperation::CreateDocument { node, .. } if node.0 == "node-1" + )); + assert!(matches!( + &stream.operations[4], + ObservedPatchOperation::CreateTemplateContents { host, contents } + if host.0 == "node-3" && contents.0 == "node-4" + )); + assert!(matches!( + &stream.operations[9], + ObservedPatchOperation::InsertBefore { + parent, + child, + before, + } if parent.0 == "node-1" && child.0 == "node-5" && before.0 == "node-2" + )); + assert!(matches!( + &stream.operations[11], + ObservedPatchOperation::SetAttributes { attributes, .. } + if attributes[0].namespace == crate::AttributeNamespace::Xml + && attributes[0].prefix.as_deref() == Some("xml") + )); + } + + #[test] + fn clear_preserves_label_sequence_and_historical_key_reuse_is_rejected() { + let state = project_patches( + raw( + vec![ + DomPatch::CreateDocument { + key: PatchKey(1), + doctype: None, + }, + DomPatch::Clear, + DomPatch::CreateDocument { + key: PatchKey(2), + doctype: None, + }, + ], + 0, + ), + &mut ObservationAllocationController::default(), + ) + .unwrap(); + let ObservationState::Captured(stream) = state else { + panic!("captured"); + }; + assert!(matches!( + &stream.operations[2], + ObservedPatchOperation::CreateDocument { node, .. } if node.0 == "node-2" + )); + + assert_eq!( + project_patches( + raw( + vec![ + DomPatch::CreateDocument { + key: PatchKey(1), + doctype: None, + }, + DomPatch::Clear, + DomPatch::CreateText { + key: PatchKey(1), + text: "reuse".to_owned(), + }, + ], + 0, + ), + &mut ObservationAllocationController::default(), + ), + Err(observation_invariant( + ParserObservationInvariantError::DuplicatePatchCreation + )) + ); + } + + #[test] + fn retained_prefix_rejects_invalid_or_missing_creation_history() { + for (patch, expected) in [ + ( + DomPatch::CreateText { + key: PatchKey::INVALID, + text: "x".to_owned(), + }, + ParserObservationInvariantError::InvalidPatchKey, + ), + ( + DomPatch::AppendChild { + parent: PatchKey(1), + child: PatchKey(2), + }, + ParserObservationInvariantError::MissingPatchCreationHistory, + ), + ] { + assert_eq!( + project_patches( + raw(vec![patch], 0), + &mut ObservationAllocationController::default(), + ), + Err(observation_invariant(expected)) + ); + } + } + + #[test] + fn patch_overflow_keeps_exact_prefix_and_dropped_count() { + let state = project_patches( + raw( + vec![DomPatch::CreateDocument { + key: PatchKey(9), + doctype: None, + }], + 3, + ), + &mut ObservationAllocationController::default(), + ) + .unwrap(); + let ObservationState::Incomplete { partial, reason } = state else { + panic!("incomplete"); + }; + assert_eq!(partial.operations.len(), 1); + assert_eq!( + reason, + IncompleteObservationReason::StorageLimitExceeded { + retained: 1, + dropped: 3, + } + ); + } + + #[test] + fn canonical_label_storage_failures_are_typed_and_atomic() { + let patch = || { + raw( + vec![DomPatch::CreateDocument { + key: PatchKey(7), + doctype: None, + }], + 0, + ) + }; + for step in [ + ObservationAllocationStep::SnapshotLabelMapStorage, + ObservationAllocationStep::SnapshotLabelStringStorage, + ] { + let error = project_patches(patch(), &mut injection(step, 1)).unwrap_err(); + assert_eq!( + error, + ParserObservationExecutionError::ResourceExhaustion( + ObservationResourceExhaustion::at( + ObservationReservationSite::SnapshotLabelStorage + ) + ) + ); + } + } + + #[test] + fn snapshot_label_sequence_overflow_is_a_typed_invariant() { + let mut labels = SnapshotLabels { + by_key: HashMap::new(), + next: u64::MAX, + }; + assert_eq!( + labels.label(PatchKey(1), &mut ObservationAllocationController::default()), + Err(observation_invariant( + ParserObservationInvariantError::SnapshotLabelSequenceOverflow + )) + ); + } + + #[test] + fn canonical_patch_nested_payload_failures_are_typed_and_atomic() { + let text = || { + raw( + vec![DomPatch::CreateText { + key: PatchKey(1), + text: "text".to_owned(), + }], + 0, + ) + }; + let doctype = || { + raw( + vec![DomPatch::CreateDocumentType { + key: PatchKey(1), + name: Some("html".to_owned()), + public_id: Some("public".to_owned()), + system_id: Some("system".to_owned()), + }], + 0, + ) + }; + let processing_instruction = || { + raw( + vec![DomPatch::CreateProcessingInstruction { + key: PatchKey(1), + target: "target".to_owned(), + data: "data".to_owned(), + }], + 0, + ) + }; + for (capture, step, occurrence) in [ + (text(), ObservationAllocationStep::CanonicalPatchPayload, 1), + ( + doctype(), + ObservationAllocationStep::CanonicalPatchPayload, + 1, + ), + ( + doctype(), + ObservationAllocationStep::CanonicalPatchPayload, + 2, + ), + ( + doctype(), + ObservationAllocationStep::CanonicalPatchPayload, + 3, + ), + ( + processing_instruction(), + ObservationAllocationStep::CanonicalPatchPayload, + 1, + ), + ( + processing_instruction(), + ObservationAllocationStep::CanonicalPatchPayload, + 2, + ), + ] { + assert_eq!( + project_patches(capture, &mut injection(step, occurrence)), + Err(ParserObservationExecutionError::ResourceExhaustion( + ObservationResourceExhaustion::at( + ObservationReservationSite::CanonicalPatchProjection + ) + )) + ); + } + + let mut names = NameInterner::new(); + let div = names.intern_exact("div").unwrap(); + let lang = names.intern_exact("lang").unwrap(); + for step in [ + ObservationAllocationStep::CanonicalPatchAttributeStorage, + ObservationAllocationStep::CanonicalPatchAttributeValue, + ] { + let capture = raw( + vec![DomPatch::CreateElement { + key: PatchKey(1), + name: ExpandedElementName::new( + ElementNamespace::Html, + names.resolve_local_name(div).unwrap(), + ), + attributes: vec![ParserCreatedAttribute::new( + QualifiedAttributeName::xml(names.resolve_local_name(lang).unwrap()), + "en".to_owned(), + )], + }], + 0, + ); + assert!(matches!( + project_patches(capture, &mut injection(step, 1)), + Err(ParserObservationExecutionError::ResourceExhaustion(exhaustion)) + if exhaustion.site() + == ObservationReservationSite::CanonicalPatchProjection + )); + } + + for step in [ + ObservationAllocationStep::CanonicalPatchOperationStorage, + ObservationAllocationStep::PatchCreationHistoryStorage, + ] { + assert!(matches!( + project_patches(text(), &mut injection(step, 1)), + Err(ParserObservationExecutionError::ResourceExhaustion(exhaustion)) + if exhaustion.site() + == ObservationReservationSite::CanonicalPatchProjection + )); + } + } + + fn template_tree() -> Node { + let mut names = NameInterner::new(); + let template = names.intern_exact("template").unwrap(); + Node::Document { + id: Id(1), + doctype: None, + children: vec![Node::from_element_parts( + Id(2), + ExpandedElementName::new( + ElementNamespace::Html, + names.resolve_local_name(template).unwrap(), + ), + Vec::new(), + Vec::new(), + Some(Box::new(DocumentFragmentNode::new_template_contents( + Id(3), + vec![Node::Comment { + id: Id(5), + text: "contents".to_owned(), + }], + ))), + vec![Node::Text { + id: Id(4), + text: "ordinary".to_owned(), + }], + )], + } + } + + #[test] + fn template_walk_visits_ordinary_children_before_typed_contents() { + let document = template_tree(); + let state = project_tree( + &document, + usize::MAX, + &mut ObservationAllocationController::default(), + ) + .unwrap(); + let ObservationState::Captured(tree) = state else { + panic!("captured"); + }; + let ObservedTreeNode::Document { children } = &tree.roots[0] else { + panic!("document"); + }; + let ObservedTreeNode::HtmlTemplateElement { + ordinary_children, + contents, + .. + } = &children[0] + else { + panic!("template"); + }; + assert!(matches!( + &ordinary_children[0], + ObservedTreeNode::Text { data } if data == "ordinary" + )); + assert!(matches!( + &contents.children[0], + ObservedTreeNode::Comment { data } if data == "contents" + )); + + // The shared walker projects the ordinary text payload before the + // template-contents comment payload. Selecting within the semantic + // string step proves that unrelated stack reservations cannot retarget + // either failure. + for occurrence in [1, 2] { + assert_eq!( + project_tree( + &document, + usize::MAX, + &mut injection(ObservationAllocationStep::CanonicalTreeString, occurrence), + ), + Err(ParserObservationExecutionError::ResourceExhaustion( + ObservationResourceExhaustion::at( + ObservationReservationSite::CanonicalTreeProjection + ) + )) + ); + } + } + + #[test] + fn canonical_tree_leaf_payload_failures_are_typed_and_atomic() { + let leaves = [ + ( + Node::Text { + id: Id(2), + text: "text".to_owned(), + }, + vec![1], + ), + ( + Node::Comment { + id: Id(2), + text: "comment".to_owned(), + }, + vec![1], + ), + ( + Node::DocumentType { + id: Id(2), + name: Some("html".to_owned()), + public_id: Some("public".to_owned()), + system_id: Some("system".to_owned()), + }, + vec![1, 2, 3], + ), + ( + Node::ProcessingInstruction { + processing_instruction: + crate::ProcessingInstructionNode::try_from_parser_created_parts( + Id(2), + "target".to_owned(), + "data".to_owned(), + ) + .unwrap(), + }, + vec![1, 2], + ), + ]; + for (leaf, occurrences) in leaves { + let document = Node::Document { + id: Id(1), + doctype: None, + children: vec![leaf], + }; + for occurrence in occurrences { + assert!(matches!( + project_tree( + &document, + usize::MAX, + &mut injection( + ObservationAllocationStep::CanonicalTreeString, + occurrence, + ), + ), + Err(ParserObservationExecutionError::ResourceExhaustion(exhaustion)) + if exhaustion.site() + == ObservationReservationSite::CanonicalTreeProjection + )); + } + } + } + + #[test] + fn canonical_tree_attribute_vector_and_value_failures_are_typed() { + let mut names = NameInterner::new(); + let div = names.intern_exact("div").unwrap(); + let lang = names.intern_exact("lang").unwrap(); + let document = Node::Document { + id: Id(1), + doctype: None, + children: vec![Node::from_element_parts( + Id(2), + ExpandedElementName::new( + ElementNamespace::Html, + names.resolve_local_name(div).unwrap(), + ), + vec![ParserCreatedAttribute::new( + QualifiedAttributeName::xml(names.resolve_local_name(lang).unwrap()), + "en".to_owned(), + )], + Vec::new(), + None, + Vec::new(), + )], + }; + for step in [ + ObservationAllocationStep::CanonicalTreeAttributeStorage, + ObservationAllocationStep::CanonicalTreeAttributeValue, + ] { + assert!(matches!( + project_tree( + &document, + usize::MAX, + &mut injection(step, 1), + ), + Err(ParserObservationExecutionError::ResourceExhaustion(exhaustion)) + if exhaustion.site() + == ObservationReservationSite::CanonicalTreeProjection + )); + } + } + + #[test] + fn canonical_tree_container_allocation_steps_are_semantically_targeted() { + let document = Node::Document { + id: Id(1), + doctype: None, + children: Vec::new(), + }; + for step in [ + ObservationAllocationStep::CanonicalTreeTraversalStack, + ObservationAllocationStep::CanonicalTreeFrameStack, + ObservationAllocationStep::CanonicalTreeChildStorage, + ] { + assert!(matches!( + project_tree(&document, 1, &mut injection(step, 1)), + Err(ParserObservationExecutionError::ResourceExhaustion(exhaustion)) + if exhaustion.site() + == ObservationReservationSite::CanonicalTreeProjection + )); + } + } + + #[test] + fn tree_capacity_is_atomic_in_structural_units() { + let document = template_tree(); + let exact = 5; // document, template, ordinary text, contents boundary, comment + assert!(matches!( + project_tree( + &document, + exact, + &mut ObservationAllocationController::default() + ), + Ok(ObservationState::Captured(_)) + )); + assert_eq!( + project_tree( + &document, + exact - 1, + &mut ObservationAllocationController::default() + ), + Ok(ObservationState::Incomplete { + partial: ObservedTree::default(), + reason: IncompleteObservationReason::StorageLimitExceeded { + retained: 0, + dropped: exact as u64, + }, + }) + ); + assert!(matches!( + project_tree( + &document, + 0, + &mut ObservationAllocationController::default() + ), + Ok(ObservationState::Incomplete { + partial: ObservedTree { roots }, + .. + }) if roots.is_empty() + )); + } + + #[test] + fn qualified_attributes_do_not_consume_tree_structural_capacity() { + let mut names = NameInterner::new(); + let div = names.intern_exact("div").unwrap(); + let lang = names.intern_exact("lang").unwrap(); + let href = names.intern_exact("href").unwrap(); + let element_name = ExpandedElementName::new( + ElementNamespace::Html, + names.resolve_local_name(div).unwrap(), + ); + let attribute_sets = [ + Vec::new(), + vec![ParserCreatedAttribute::new( + QualifiedAttributeName::xml(names.resolve_local_name(lang).unwrap()), + "en".to_owned(), + )], + vec![ + ParserCreatedAttribute::new( + QualifiedAttributeName::xml(names.resolve_local_name(lang).unwrap()), + "en".to_owned(), + ), + ParserCreatedAttribute::new( + QualifiedAttributeName::xlink(names.resolve_local_name(href).unwrap()), + "#target".to_owned(), + ), + ], + ]; + for attributes in attribute_sets { + let document = Node::Document { + id: Id(1), + doctype: None, + children: vec![Node::from_element_parts( + Id(2), + element_name.clone(), + attributes, + Vec::new(), + None, + Vec::new(), + )], + }; + assert!(matches!( + project_tree( + &document, + 2, + &mut ObservationAllocationController::default() + ), + Ok(ObservationState::Captured(_)) + )); + assert_eq!( + project_tree( + &document, + 1, + &mut ObservationAllocationController::default() + ), + Ok(ObservationState::Incomplete { + partial: ObservedTree::default(), + reason: IncompleteObservationReason::StorageLimitExceeded { + retained: 0, + dropped: 2, + }, + }) + ); + } + } + + fn assert_tree_invariant_at_all_capacities( + document: &Node, + otherwise_required: usize, + expected: ParserObservationInvariantError, + ) { + for capacity in [ + 0, + otherwise_required.saturating_sub(1), + otherwise_required, + usize::MAX, + ] { + assert_eq!( + project_tree( + document, + capacity, + &mut ObservationAllocationController::default() + ), + Err(observation_invariant(expected)), + "capacity {capacity} must not conceal malformed materialized state" + ); + } + } + + #[test] + fn legacy_document_doctype_metadata_precedes_every_capacity_outcome() { + let document = Node::Document { + id: Id(1), + doctype: Some("legacy".to_owned()), + children: vec![Node::Text { + id: Id(2), + text: "x".to_owned(), + }], + }; + assert_tree_invariant_at_all_capacities( + &document, + 2, + ParserObservationInvariantError::UnexpectedLegacyDocumentDoctypeMetadata, + ); + } + + #[test] + fn canonical_tree_requires_exactly_one_document_root() { + let root = Node::Text { + id: Id(1), + text: "not a document".to_owned(), + }; + assert_tree_invariant_at_all_capacities( + &root, + 1, + ParserObservationInvariantError::CanonicalTreeRootNotDocument, + ); + + let nested_document = Node::Document { + id: Id(1), + doctype: None, + children: vec![Node::Document { + id: Id(2), + doctype: None, + children: Vec::new(), + }], + }; + assert_tree_invariant_at_all_capacities( + &nested_document, + 2, + ParserObservationInvariantError::CanonicalTreeRootNotDocument, + ); + } + + #[test] + fn missing_html_template_contents_precedes_every_capacity_outcome() { + let mut names = NameInterner::new(); + let template = names.intern_exact("template").unwrap(); + let document = Node::Document { + id: Id(1), + doctype: None, + children: vec![Node::from_element_parts( + Id(2), + ExpandedElementName::new( + ElementNamespace::Html, + names.resolve_local_name(template).unwrap(), + ), + Vec::new(), + Vec::new(), + None, + Vec::new(), + )], + }; + assert_tree_invariant_at_all_capacities( + &document, + 3, + ParserObservationInvariantError::MissingHtmlTemplateContents, + ); + } + + #[test] + fn invalid_template_contents_kind_precedes_every_capacity_outcome() { + let mut document = template_tree(); + let Node::Document { children, .. } = &mut document else { + unreachable!(); + }; + let Node::Element { element } = &mut children[0] else { + unreachable!(); + }; + element + .template_contents_mut() + .expect("template association") + .force_unsupported_kind_for_conformance_test(); + assert_tree_invariant_at_all_capacities( + &document, + 5, + ParserObservationInvariantError::InvalidTemplateContentsKind, + ); + } + + #[test] + fn foreign_template_names_remain_ordinary_elements() { + let mut names = NameInterner::new(); + let template = names.intern_exact("template").unwrap(); + for namespace in [ElementNamespace::Svg, ElementNamespace::MathMl] { + let document = Node::Document { + id: Id(1), + doctype: None, + children: vec![Node::from_element_parts( + Id(2), + ExpandedElementName::new( + namespace, + names.resolve_local_name(template).unwrap(), + ), + Vec::new(), + Vec::new(), + None, + Vec::new(), + )], + }; + let state = project_tree( + &document, + 2, + &mut ObservationAllocationController::default(), + ) + .unwrap(); + let ObservationState::Captured(tree) = state else { + panic!("foreign template is an ordinary complete element"); + }; + let [ObservedTreeNode::Document { children }] = tree.roots.as_slice() else { + panic!("document"); + }; + assert!(matches!( + children.as_slice(), + [ObservedTreeNode::Element { + namespace: actual, + local_name, + children, + .. + }] if *actual == namespace && local_name == "template" && children.is_empty() + )); + } + } + + #[test] + fn synthetic_preflight_projection_mismatch_is_a_typed_invariant() { + assert_eq!( + validate_projected_unit_count(2, 1), + Err(observation_invariant( + ParserObservationInvariantError::CanonicalTreePreflightProjectionMismatch + )) + ); + } + + #[test] + fn deep_projection_and_teardown_do_not_depend_on_native_recursion() { + const DEPTH: usize = 12_000; + let mut names = NameInterner::new(); + let div = names.intern_exact("div").unwrap(); + let expanded = ExpandedElementName::new( + ElementNamespace::Html, + names.resolve_local_name(div).unwrap(), + ); + let mut child = Node::Text { + id: Id((DEPTH + 2) as u32), + text: "leaf".to_owned(), + }; + for index in (0..DEPTH).rev() { + child = Node::from_element_parts( + Id((index + 2) as u32), + expanded.clone(), + Vec::new(), + Vec::new(), + None, + vec![child], + ); + } + let mut document = Node::Document { + id: Id(1), + doctype: None, + children: vec![child], + }; + let state = project_tree( + &document, + DEPTH + 2, + &mut ObservationAllocationController::default(), + ) + .unwrap(); + let ObservationState::Captured(tree) = state else { + panic!("captured"); + }; + assert_pure_deep_tree_shape(&tree, DEPTH); + drop_observed_tree_iteratively(tree); + drop_node_iteratively(&mut document); + } + + fn assert_pure_deep_tree_shape(tree: &ObservedTree, expected_elements: usize) { + let [ObservedTreeNode::Document { children }] = tree.roots.as_slice() else { + panic!("exactly one document root"); + }; + let mut current = children.as_slice(); + let mut element_count = 0usize; + let mut maximum_depth = 0usize; + let mut structural_units = 1usize; + loop { + match current { + [ + ObservedTreeNode::Element { + namespace: ElementNamespace::Html, + local_name, + children, + .. + }, + ] if local_name == "div" => { + element_count += 1; + maximum_depth = element_count; + structural_units += 1; + current = children; + } + [ObservedTreeNode::Text { data }] if data == "leaf" => { + maximum_depth += 1; + structural_units += 1; + break; + } + _ => panic!("deep chain must preserve one source-ordered child at every depth"), + } + } + assert_eq!(element_count, expected_elements); + assert_eq!(maximum_depth, expected_elements + 1); + assert_eq!(structural_units, expected_elements + 2); + } + + fn drop_observed_tree_iteratively(tree: ObservedTree) { + let mut stack = tree.roots; + while let Some(mut node) = stack.pop() { + match &mut node { + ObservedTreeNode::Document { children } + | ObservedTreeNode::Element { children, .. } => { + stack.extend(std::mem::take(children)); + } + ObservedTreeNode::HtmlTemplateElement { + ordinary_children, + contents, + .. + } => { + stack.extend(std::mem::take(ordinary_children)); + stack.extend(std::mem::take(&mut contents.children)); + } + ObservedTreeNode::DocumentType { .. } + | ObservedTreeNode::Comment { .. } + | ObservedTreeNode::Text { .. } + | ObservedTreeNode::ProcessingInstruction { .. } => {} + } + } + } + + fn drop_node_iteratively(document: &mut Node) { + let (ordinary, template) = document.take_child_groups_for_iterative_drop(); + let mut stack = ordinary; + if let Some(template) = template { + stack.extend(template); + } + while let Some(mut node) = stack.pop() { + let (ordinary, template) = node.take_child_groups_for_iterative_drop(); + stack.extend(ordinary); + if let Some(template) = template { + stack.extend(template); + } + } + } +} diff --git a/crates/html/src/dom_patch.rs b/crates/html/src/dom_patch.rs index aabba70b..cd33497a 100644 --- a/crates/html/src/dom_patch.rs +++ b/crates/html/src/dom_patch.rs @@ -20,6 +20,8 @@ //! - Every element carries an explicit namespace and exact canonical local name. //! - All `PatchKey` values used in patches must be non-zero (`PatchKey::INVALID` //! is never valid in a patch stream). +//! - Patch keys are session-lifetime identities. Clearing the live document or +//! draining a runtime batch does not release historical keys for reuse. //! - Attribute vectors are applied exactly as emitted. HTML5 parser-created //! output canonicalizes attributes before emission; appliers must not dedupe //! or reorder attributes downstream. @@ -85,8 +87,11 @@ impl DomPatchBatch { pub enum DomPatch { /// Clear all existing nodes for the document before applying subsequent patches. /// - /// This must be the first patch in a batch when used, and resets all key allocation state. - /// Implementations MUST treat mid-stream `Clear` as a protocol violation. + /// This must be the first patch in a runtime batch when used and resets the + /// live document structure. It does not reset session-lifetime key + /// allocation: every previously allocated `PatchKey` remains historical and + /// cannot be reused later in the same parser session. Implementations MUST + /// treat mid-batch `Clear` as a protocol violation. Clear, /// Create a document root node. CreateDocument { diff --git a/crates/html/src/html5/bridge/adapters.rs b/crates/html/src/html5/bridge/adapters.rs index 23e3ebf8..148c6983 100644 --- a/crates/html/src/html5/bridge/adapters.rs +++ b/crates/html/src/html5/bridge/adapters.rs @@ -4,7 +4,14 @@ //! It is intentionally minimal and does not change patch semantics; it only //! validates lightweight invariants and buffers patches for the runtime. -use crate::dom_patch::{DomPatch, PatchKey}; +use crate::dom_patch::DomPatch; +#[cfg(debug_assertions)] +use crate::dom_patch::PatchKey; +#[cfg(any(test, feature = "parser-conformance"))] +use crate::html5::shared::{ + ParserObservationInvariant, ParserReservationController, ParserReservationSite, + ParserResourceExhaustion, +}; use crate::html5::tree_builder::PatchSink; #[cfg(debug_assertions)] use std::collections::HashSet; @@ -23,18 +30,43 @@ use std::collections::HashSet; /// a batch). /// - Versioning and flush boundaries are owned by the runtime (e.g. runtime_parse); /// this adapter only buffers patches emitted during a pump. -#[derive(Debug, Default)] +#[derive(Debug)] pub(crate) struct PatchEmitterAdapter { patches: Vec, saw_clear: bool, invariant_violation: bool, + #[cfg(any(test, feature = "parser-conformance"))] + patch_history: Option, + #[cfg(any(test, feature = "parser-conformance"))] + patch_history_failure: Option, + #[cfg(any(test, feature = "parser-conformance"))] + patch_history_allocations: RawPatchObservationAllocationController, #[cfg(debug_assertions)] created_keys: HashSet, } impl PatchEmitterAdapter { pub(crate) fn new() -> Self { - Self::default() + Self { + patches: Vec::new(), + saw_clear: false, + invariant_violation: false, + #[cfg(any(test, feature = "parser-conformance"))] + patch_history: None, + #[cfg(any(test, feature = "parser-conformance"))] + patch_history_failure: None, + #[cfg(any(test, feature = "parser-conformance"))] + patch_history_allocations: RawPatchObservationAllocationController::default(), + #[cfg(debug_assertions)] + created_keys: HashSet::new(), + } + } + + #[cfg(any(test, feature = "parser-conformance"))] + pub(crate) fn new_with_patch_history(config: PatchHistoryObservationConfig) -> Self { + let mut adapter = Self::new(); + adapter.patch_history = config.capacity.map(RawPatchHistoryCapture::new); + adapter } pub(crate) fn take_patches(&mut self) -> Vec { @@ -56,10 +88,88 @@ impl PatchEmitterAdapter { } had } + + #[cfg(any(test, feature = "parser-conformance"))] + pub(crate) fn take_patch_history_failure(&mut self) -> Option { + self.patch_history_failure.take() + } + + #[cfg(feature = "parser-conformance")] + pub(crate) fn take_patch_history(&mut self) -> Option { + self.patch_history.take() + } + + #[cfg(any(test, feature = "parser-conformance"))] + fn retain_patch_for_history(&mut self, patch: &DomPatch) { + if self.patch_history_failure.is_some() { + return; + } + let Some(history) = self.patch_history.as_mut() else { + return; + }; + if history.operations.len() >= history.capacity { + let Some(dropped) = history.dropped.checked_add(1) else { + self.patch_history_failure = Some(PatchHistoryCaptureFailure::Invariant( + ParserObservationInvariant::PatchDroppedCountOverflow, + )); + return; + }; + history.dropped = dropped; + return; + } + + let site = ParserReservationSite::PatchHistoryObservationStorage; + let result = self + .patch_history_allocations + .before_reservation(RawPatchAllocationStep::RawPatchOperationStorage) + .and_then(|()| { + history + .operations + .try_reserve(1) + .map_err(|_| ParserResourceExhaustion::at(site)) + }) + .and_then(|()| { + try_clone_dom_patch_for_observation(patch, &mut self.patch_history_allocations) + }); + match result { + Ok(observed) => history.operations.push(observed), + Err(error) => { + self.patch_history_failure = + Some(PatchHistoryCaptureFailure::ResourceExhaustion(error)); + } + } + } + + #[cfg(all(test, feature = "parser-conformance"))] + pub(crate) fn force_patch_history_dropped_for_test(&mut self, dropped: u64) { + if let Some(history) = self.patch_history.as_mut() { + history.dropped = dropped; + } + } + + #[cfg(all(test, feature = "parser-failure-injection"))] + pub(crate) fn set_patch_history_failure_injection_for_test( + &mut self, + injection: crate::html5::shared::ParserFailureInjection, + ) { + self.patch_history_allocations.parser_reservations = + ParserReservationController::with_failure(injection); + } + + #[cfg(all(test, feature = "parser-failure-injection"))] + fn set_raw_patch_semantic_failure_for_test( + &mut self, + injection: RawPatchObservationFailureInjection, + ) { + self.patch_history_allocations + .select_semantic_failure(injection); + } } impl PatchSink for PatchEmitterAdapter { fn push(&mut self, patch: DomPatch) { + #[cfg(any(test, feature = "parser-conformance"))] + self.retain_patch_for_history(&patch); if matches!(patch, DomPatch::Clear) { if !self.patches.is_empty() || self.saw_clear { self.invariant_violation = true; @@ -78,6 +188,288 @@ impl PatchSink for PatchEmitterAdapter { } } +#[cfg(any(test, feature = "parser-conformance"))] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct PatchHistoryObservationConfig { + capacity: Option, +} + +#[cfg(any(test, feature = "parser-conformance"))] +impl PatchHistoryObservationConfig { + pub(crate) const fn capture(capacity: usize) -> Self { + Self { + capacity: Some(capacity), + } + } +} + +#[cfg(any(test, feature = "parser-conformance"))] +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct RawPatchHistoryCapture { + pub(crate) operations: Vec, + pub(crate) dropped: u64, + pub(crate) capacity: usize, +} + +#[cfg(any(test, feature = "parser-conformance"))] +impl RawPatchHistoryCapture { + fn new(capacity: usize) -> Self { + Self { + operations: Vec::new(), + dropped: 0, + capacity, + } + } +} + +#[cfg(any(test, feature = "parser-conformance"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PatchHistoryCaptureFailure { + ResourceExhaustion(ParserResourceExhaustion), + Invariant(ParserObservationInvariant), +} + +/// Private semantic identity consumed only by deterministic allocation tests. +/// +/// Production failures remain `PatchHistoryObservationStorage`. +#[cfg(any(test, feature = "parser-conformance"))] +#[allow( + clippy::enum_variant_names, + reason = "the Raw prefix distinguishes approved raw-history test identities from canonical allocation steps" +)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RawPatchAllocationStep { + RawPatchOperationStorage, + RawTextOrCommentData, + RawDoctypeString, + RawProcessingInstructionTarget, + RawProcessingInstructionData, + RawAttributeVector, + RawAttributeValue, +} + +#[cfg(all(test, feature = "parser-failure-injection"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RawPatchObservationFailureInjection { + step: RawPatchAllocationStep, + occurrence: std::num::NonZeroU64, +} + +#[derive(Debug, Default)] +#[cfg(any(test, feature = "parser-conformance"))] +struct RawPatchObservationAllocationController { + parser_reservations: ParserReservationController, + #[cfg(all(test, feature = "parser-failure-injection"))] + selected: Option, + #[cfg(all(test, feature = "parser-failure-injection"))] + matching_occurrences: u64, +} + +#[cfg(any(test, feature = "parser-conformance"))] +impl RawPatchObservationAllocationController { + fn before_reservation( + &mut self, + step: RawPatchAllocationStep, + ) -> Result<(), ParserResourceExhaustion> { + let site = ParserReservationSite::PatchHistoryObservationStorage; + #[cfg(all(test, feature = "parser-failure-injection"))] + if let Some(selected) = self.selected + && selected.step == step + { + self.matching_occurrences += 1; + if self.matching_occurrences == selected.occurrence.get() { + self.selected = None; + return Err(ParserResourceExhaustion::at(site)); + } + } + let _ = step; + self.parser_reservations.before_reservation(site) + } + + #[cfg(all(test, feature = "parser-failure-injection"))] + fn select_semantic_failure(&mut self, injection: RawPatchObservationFailureInjection) { + self.selected = Some(injection); + self.matching_occurrences = 0; + } +} + +/// Fallibly duplicate every owned payload in a semantic patch. +/// +/// `PatchKey`, namespace enums, and interned element/attribute name handles are +/// copied normally: they are scalar values or `Arc`-backed handles and do not +/// allocate. Every newly owned string and vector reserves at the parser-owned +/// observation site before it is populated. +#[cfg(any(test, feature = "parser-conformance"))] +fn try_clone_dom_patch_for_observation( + patch: &DomPatch, + allocations: &mut RawPatchObservationAllocationController, +) -> Result { + use DomPatch::{ + AppendChild, AppendText, Clear, CreateComment, CreateDocument, CreateDocumentType, + CreateElement, CreateProcessingInstruction, CreateTemplateContents, CreateText, + InsertBefore, RemoveNode, SetAttributes, SetText, + }; + Ok(match patch { + Clear => Clear, + CreateDocument { key, doctype } => CreateDocument { + key: *key, + doctype: try_copy_optional_string( + doctype.as_deref(), + RawPatchAllocationStep::RawDoctypeString, + allocations, + )?, + }, + CreateDocumentType { + key, + name, + public_id, + system_id, + } => CreateDocumentType { + key: *key, + name: try_copy_optional_string( + name.as_deref(), + RawPatchAllocationStep::RawDoctypeString, + allocations, + )?, + public_id: try_copy_optional_string( + public_id.as_deref(), + RawPatchAllocationStep::RawDoctypeString, + allocations, + )?, + system_id: try_copy_optional_string( + system_id.as_deref(), + RawPatchAllocationStep::RawDoctypeString, + allocations, + )?, + }, + CreateElement { + key, + name, + attributes, + } => CreateElement { + key: *key, + name: name.clone(), + attributes: try_copy_attributes(attributes, allocations)?, + }, + CreateTemplateContents { host, contents } => CreateTemplateContents { + host: *host, + contents: *contents, + }, + CreateText { key, text } => CreateText { + key: *key, + text: try_copy_string( + text, + RawPatchAllocationStep::RawTextOrCommentData, + allocations, + )?, + }, + CreateComment { key, text } => CreateComment { + key: *key, + text: try_copy_string( + text, + RawPatchAllocationStep::RawTextOrCommentData, + allocations, + )?, + }, + CreateProcessingInstruction { key, target, data } => CreateProcessingInstruction { + key: *key, + target: try_copy_string( + target, + RawPatchAllocationStep::RawProcessingInstructionTarget, + allocations, + )?, + data: try_copy_string( + data, + RawPatchAllocationStep::RawProcessingInstructionData, + allocations, + )?, + }, + AppendChild { parent, child } => AppendChild { + parent: *parent, + child: *child, + }, + InsertBefore { + parent, + child, + before, + } => InsertBefore { + parent: *parent, + child: *child, + before: *before, + }, + RemoveNode { key } => RemoveNode { key: *key }, + SetAttributes { key, attributes } => SetAttributes { + key: *key, + attributes: try_copy_attributes(attributes, allocations)?, + }, + SetText { key, text } => SetText { + key: *key, + text: try_copy_string( + text, + RawPatchAllocationStep::RawTextOrCommentData, + allocations, + )?, + }, + AppendText { key, text } => AppendText { + key: *key, + text: try_copy_string( + text, + RawPatchAllocationStep::RawTextOrCommentData, + allocations, + )?, + }, + }) +} + +#[cfg(any(test, feature = "parser-conformance"))] +fn try_copy_optional_string( + value: Option<&str>, + step: RawPatchAllocationStep, + allocations: &mut RawPatchObservationAllocationController, +) -> Result, ParserResourceExhaustion> { + value + .map(|value| try_copy_string(value, step, allocations)) + .transpose() +} + +#[cfg(any(test, feature = "parser-conformance"))] +fn try_copy_string( + value: &str, + step: RawPatchAllocationStep, + allocations: &mut RawPatchObservationAllocationController, +) -> Result { + let site = ParserReservationSite::PatchHistoryObservationStorage; + allocations.before_reservation(step)?; + let mut copy = String::new(); + copy.try_reserve_exact(value.len()) + .map_err(|_| ParserResourceExhaustion::at(site))?; + copy.push_str(value); + Ok(copy) +} + +#[cfg(any(test, feature = "parser-conformance"))] +fn try_copy_attributes( + attributes: &[crate::ParserCreatedAttribute], + allocations: &mut RawPatchObservationAllocationController, +) -> Result, ParserResourceExhaustion> { + let site = ParserReservationSite::PatchHistoryObservationStorage; + allocations.before_reservation(RawPatchAllocationStep::RawAttributeVector)?; + let mut copy = Vec::new(); + copy.try_reserve_exact(attributes.len()) + .map_err(|_| ParserResourceExhaustion::at(site))?; + for attribute in attributes { + copy.push(crate::ParserCreatedAttribute::new( + attribute.name().clone(), + try_copy_string( + attribute.value(), + RawPatchAllocationStep::RawAttributeValue, + allocations, + )?, + )); + } + Ok(copy) +} + #[cfg(debug_assertions)] fn created_key(patch: &DomPatch) -> Option { match patch { @@ -93,8 +485,13 @@ fn created_key(patch: &DomPatch) -> Option { #[cfg(test)] mod tests { - use super::PatchEmitterAdapter; + use super::{PatchEmitterAdapter, PatchHistoryCaptureFailure, PatchHistoryObservationConfig}; + #[cfg(feature = "parser-failure-injection")] + use super::{RawPatchAllocationStep, RawPatchObservationFailureInjection}; use crate::dom_patch::{DomPatch, PatchKey}; + use crate::html5::shared::ParserObservationInvariant; + #[cfg(all(feature = "parser-conformance", feature = "parser-failure-injection"))] + use crate::html5::shared::ParserReservationSite; use crate::html5::tree_builder::PatchSink; #[test] @@ -121,4 +518,207 @@ mod tests { }); assert!(adapter.take_invariant_violation()); } + + #[cfg(feature = "parser-conformance")] + #[test] + fn patch_history_capacity_is_an_exact_semantic_prefix() { + let mut adapter = + PatchEmitterAdapter::new_with_patch_history(PatchHistoryObservationConfig::capture(1)); + adapter.push(DomPatch::CreateDocument { + key: PatchKey(1), + doctype: None, + }); + adapter.push(DomPatch::CreateText { + key: PatchKey(2), + text: "not retained".to_owned(), + }); + let history = adapter.take_patch_history().expect("requested history"); + assert_eq!(history.operations.len(), 1); + assert_eq!(history.dropped, 1); + assert_eq!( + adapter.take_patches().len(), + 2, + "history capacity must not alter transport" + ); + } + + #[cfg(all(feature = "parser-conformance", feature = "parser-failure-injection"))] + #[test] + fn nested_patch_payload_reservations_fail_with_live_parser_identity() { + use std::num::NonZeroU64; + + let cases = [ + ( + DomPatch::CreateText { + key: PatchKey(1), + text: "text".to_owned(), + }, + RawPatchAllocationStep::RawTextOrCommentData, + 1, + ), + ( + DomPatch::CreateComment { + key: PatchKey(1), + text: "comment".to_owned(), + }, + RawPatchAllocationStep::RawTextOrCommentData, + 1, + ), + ( + DomPatch::CreateDocumentType { + key: PatchKey(1), + name: Some("html".to_owned()), + public_id: Some("public".to_owned()), + system_id: Some("system".to_owned()), + }, + RawPatchAllocationStep::RawDoctypeString, + 1, + ), + ( + DomPatch::CreateDocumentType { + key: PatchKey(1), + name: Some("html".to_owned()), + public_id: Some("public".to_owned()), + system_id: Some("system".to_owned()), + }, + RawPatchAllocationStep::RawDoctypeString, + 2, + ), + ( + DomPatch::CreateDocumentType { + key: PatchKey(1), + name: Some("html".to_owned()), + public_id: Some("public".to_owned()), + system_id: Some("system".to_owned()), + }, + RawPatchAllocationStep::RawDoctypeString, + 3, + ), + ( + DomPatch::CreateProcessingInstruction { + key: PatchKey(1), + target: "target".to_owned(), + data: "data".to_owned(), + }, + RawPatchAllocationStep::RawProcessingInstructionTarget, + 1, + ), + ( + DomPatch::CreateProcessingInstruction { + key: PatchKey(1), + target: "target".to_owned(), + data: "data".to_owned(), + }, + RawPatchAllocationStep::RawProcessingInstructionData, + 1, + ), + ]; + for (patch, step, occurrence) in cases { + let mut adapter = PatchEmitterAdapter::new_with_patch_history( + PatchHistoryObservationConfig::capture(8), + ); + adapter.set_raw_patch_semantic_failure_for_test(RawPatchObservationFailureInjection { + step, + occurrence: NonZeroU64::new(occurrence).unwrap(), + }); + adapter.push(patch); + assert!(matches!( + adapter.take_patch_history_failure(), + Some(PatchHistoryCaptureFailure::ResourceExhaustion(error)) + if error.site() == ParserReservationSite::PatchHistoryObservationStorage + )); + assert_eq!( + adapter.take_patches().len(), + 1, + "the original transport patch must survive observation failure" + ); + } + } + + #[cfg(all(feature = "parser-conformance", feature = "parser-failure-injection"))] + #[test] + fn attribute_vector_and_value_reservations_are_independently_fallible() { + use crate::names::{ElementNamespace, ExpandedElementName, NameInterner}; + use crate::{ParserCreatedAttribute, QualifiedAttributeName}; + use std::num::NonZeroU64; + + for step in [ + RawPatchAllocationStep::RawAttributeVector, + RawPatchAllocationStep::RawAttributeValue, + ] { + let mut names = NameInterner::new(); + let div = names.intern_exact("div").unwrap(); + let title = names.intern_exact("title").unwrap(); + let patch = DomPatch::CreateElement { + key: PatchKey(1), + name: ExpandedElementName::new( + ElementNamespace::Html, + names.resolve_local_name(div).unwrap(), + ), + attributes: vec![ParserCreatedAttribute::new( + QualifiedAttributeName::unqualified(names.resolve_local_name(title).unwrap()), + "value".to_owned(), + )], + }; + let mut adapter = PatchEmitterAdapter::new_with_patch_history( + PatchHistoryObservationConfig::capture(8), + ); + adapter.set_raw_patch_semantic_failure_for_test(RawPatchObservationFailureInjection { + step, + occurrence: NonZeroU64::MIN, + }); + adapter.push(patch); + assert!(matches!( + adapter.take_patch_history_failure(), + Some(PatchHistoryCaptureFailure::ResourceExhaustion(_)) + )); + } + } + + #[cfg(all(feature = "parser-conformance", feature = "parser-failure-injection"))] + #[test] + fn first_capture_failure_latches_while_all_same_token_transport_survives() { + use std::num::NonZeroU64; + + let mut adapter = + PatchEmitterAdapter::new_with_patch_history(PatchHistoryObservationConfig::capture(8)); + adapter.set_raw_patch_semantic_failure_for_test(RawPatchObservationFailureInjection { + step: RawPatchAllocationStep::RawPatchOperationStorage, + occurrence: NonZeroU64::MIN, + }); + // This is the narrow `PatchSink` seam used by one tree-builder token: + // both owned patches reach transport before the session performs its + // mandatory post-`push_token` failure check. + adapter.push(DomPatch::CreateText { + key: PatchKey(1), + text: "first".to_owned(), + }); + adapter.push(DomPatch::AppendText { + key: PatchKey(1), + text: "second".to_owned(), + }); + assert!(matches!( + adapter.take_patch_history_failure(), + Some(PatchHistoryCaptureFailure::ResourceExhaustion(error)) + if error.site() == ParserReservationSite::PatchHistoryObservationStorage + )); + assert_eq!(adapter.take_patches().len(), 2); + assert_eq!(adapter.take_patch_history_failure(), None); + } + + #[cfg(feature = "parser-conformance")] + #[test] + fn dropped_count_overflow_retains_exact_observation_invariant() { + let mut adapter = + PatchEmitterAdapter::new_with_patch_history(PatchHistoryObservationConfig::capture(0)); + adapter.force_patch_history_dropped_for_test(u64::MAX); + adapter.push(DomPatch::Clear); + assert_eq!( + adapter.take_patch_history_failure(), + Some(PatchHistoryCaptureFailure::Invariant( + ParserObservationInvariant::PatchDroppedCountOverflow + )) + ); + assert_eq!(adapter.take_patches(), vec![DomPatch::Clear]); + } } diff --git a/crates/html/src/html5/bridge/mod.rs b/crates/html/src/html5/bridge/mod.rs index 10579f08..8fff77e5 100644 --- a/crates/html/src/html5/bridge/mod.rs +++ b/crates/html/src/html5/bridge/mod.rs @@ -3,3 +3,7 @@ mod adapters; pub(crate) use adapters::PatchEmitterAdapter; +#[cfg(any(test, feature = "parser-conformance"))] +pub(crate) use adapters::{ + PatchHistoryCaptureFailure, PatchHistoryObservationConfig, RawPatchHistoryCapture, +}; diff --git a/crates/html/src/html5/mod.rs b/crates/html/src/html5/mod.rs index 726d46c4..5413c229 100644 --- a/crates/html/src/html5/mod.rs +++ b/crates/html/src/html5/mod.rs @@ -14,6 +14,8 @@ pub mod tokenizer; pub mod tree_builder; // Public re-exports: consumers should import from `html::html5::*` rather than `shared::*`. +#[cfg(any(test, feature = "parser-conformance"))] +pub(crate) use bridge::{PatchHistoryObservationConfig, RawPatchHistoryCapture}; #[cfg(any(test, feature = "html5-fuzzing"))] pub use fuzz::{ Html5PipelineFuzzConfig, Html5PipelineFuzzError, Html5PipelineFuzzSummary, diff --git a/crates/html/src/html5/session/api.rs b/crates/html/src/html5/session/api.rs index 6cddb769..3c5fc6b0 100644 --- a/crates/html/src/html5/session/api.rs +++ b/crates/html/src/html5/session/api.rs @@ -1,5 +1,7 @@ use crate::dom_patch::{DomPatch, DomPatchBatch}; use crate::html5::bridge::PatchEmitterAdapter; +#[cfg(any(test, feature = "parser-conformance"))] +use crate::html5::bridge::{PatchHistoryObservationConfig, RawPatchHistoryCapture}; #[cfg(feature = "parser-conformance")] use crate::html5::shared::ParserObservationCapture; use crate::html5::shared::{ @@ -23,6 +25,8 @@ pub struct Html5ParseSession { pub(super) patch_emitter: PatchEmitterAdapter, pub(super) next_patch_batch_version: u64, pub(super) state: Html5ParseSessionState, + #[cfg(any(test, feature = "parser-conformance"))] + pub(super) patch_history_invariant: Option, #[cfg(all(test, feature = "parser-conformance"))] pub(super) applied_tokenizer_controls_for_test: Vec, } @@ -58,9 +62,38 @@ pub(super) enum DrainOutcome { impl Html5ParseSession { pub fn new( + tokenizer_config: TokenizerConfig, + builder_config: TreeBuilderConfig, + ctx: DocumentParseContext, + ) -> Result { + Self::new_with_patch_emitter( + tokenizer_config, + builder_config, + ctx, + PatchEmitterAdapter::new(), + ) + } + + #[cfg(any(test, feature = "parser-conformance"))] + pub(crate) fn new_with_patch_history( + tokenizer_config: TokenizerConfig, + builder_config: TreeBuilderConfig, + ctx: DocumentParseContext, + patch_history: PatchHistoryObservationConfig, + ) -> Result { + Self::new_with_patch_emitter( + tokenizer_config, + builder_config, + ctx, + PatchEmitterAdapter::new_with_patch_history(patch_history), + ) + } + + fn new_with_patch_emitter( tokenizer_config: TokenizerConfig, builder_config: TreeBuilderConfig, mut ctx: DocumentParseContext, + patch_emitter: PatchEmitterAdapter, ) -> Result { let tokenizer = Html5Tokenizer::new(tokenizer_config, &mut ctx); let builder = @@ -71,9 +104,11 @@ impl Html5ParseSession { input: Input::new(), tokenizer, builder, - patch_emitter: PatchEmitterAdapter::new(), + patch_emitter, next_patch_batch_version: 0, state: Html5ParseSessionState::Usable, + #[cfg(any(test, feature = "parser-conformance"))] + patch_history_invariant: None, #[cfg(all(test, feature = "parser-conformance"))] applied_tokenizer_controls_for_test: Vec::new(), }) @@ -199,8 +234,11 @@ impl Html5ParseSession { } #[cfg(feature = "parser-conformance")] - pub(crate) fn document_mode_for_conformance(&self) -> crate::DocumentMode { - self.builder.document_mode() + pub(crate) fn document_mode_for_conformance( + &self, + ) -> Result { + self.ensure_usable()?; + Ok(self.builder.document_mode()) } #[cfg(feature = "parser-conformance")] @@ -210,6 +248,21 @@ impl Html5ParseSession { self.tokenizer.invariant_failure_kind() } + #[cfg(feature = "parser-conformance")] + pub(crate) fn patch_history_invariant_for_conformance( + &self, + ) -> Option { + self.patch_history_invariant + } + + #[cfg(feature = "parser-conformance")] + pub(crate) fn take_patch_history_for_conformance( + &mut self, + ) -> Result, Html5SessionError> { + self.ensure_usable()?; + Ok(self.patch_emitter.take_patch_history()) + } + fn ensure_usable(&self) -> Result<(), Html5SessionError> { match self.state { Html5ParseSessionState::Usable => Ok(()), @@ -237,8 +290,29 @@ impl Html5ParseSession { } #[cfg(test)] - pub(crate) fn inject_patch_for_test(&mut self, patch: DomPatch) { + pub(crate) fn inject_patch_for_test( + &mut self, + patch: DomPatch, + ) -> Result<(), Html5SessionError> { + self.ensure_usable()?; self.patch_emitter.push(patch); + let result = self.resolve_patch_history_capture_failure(); + self.latch_fatal(result) + } + + #[cfg(all(test, feature = "parser-conformance"))] + pub(crate) fn force_patch_history_dropped_for_test(&mut self, dropped: u64) { + self.patch_emitter + .force_patch_history_dropped_for_test(dropped); + } + + #[cfg(all(test, feature = "parser-failure-injection"))] + pub(crate) fn set_patch_history_failure_injection_for_test( + &mut self, + injection: crate::html5::shared::ParserFailureInjection, + ) { + self.patch_emitter + .set_patch_history_failure_injection_for_test(injection); } #[cfg(test)] @@ -264,6 +338,11 @@ impl Html5ParseSession { &self.applied_tokenizer_controls_for_test } + #[cfg(test)] + pub(crate) fn diagnostic_observation_enabled_for_test(&self) -> bool { + self.ctx.observation_enabled() + } + #[cfg(test)] pub(crate) fn force_self_closing_flag_without_solidus_for_test(&mut self) { self.tokenizer diff --git a/crates/html/src/html5/session/driver.rs b/crates/html/src/html5/session/driver.rs index cd3efbd3..5637390e 100644 --- a/crates/html/src/html5/session/driver.rs +++ b/crates/html/src/html5/session/driver.rs @@ -1,5 +1,7 @@ use super::api::{DrainMode, DrainOutcome, Html5ParseSession}; use crate::html5::bridge::PatchEmitterAdapter; +#[cfg(any(test, feature = "parser-conformance"))] +use crate::html5::bridge::PatchHistoryCaptureFailure; use crate::html5::shared::{ DocumentParseContext, EngineInvariantError, Html5SessionError, ParserFatalError, Token, }; @@ -42,7 +44,7 @@ impl Html5ParseSession { } pub(super) fn drain_token_granular_batch(&mut self) -> Result { - let step = { + let processed = { let batch = if self.ctx.observation_enabled() { self.tokenizer .next_batch_observed(&mut self.input, &mut self.ctx) @@ -70,14 +72,15 @@ impl Html5ParseSession { &mut self.patch_emitter, token, &resolver, - )? + ) }; + let step = self.resolve_processed_token(processed)?; Ok(self.apply_tree_builder_step(step)) } pub(super) fn drain_all_queued_batches(&mut self) -> Result { - let steps = { + let (steps, failure) = { let batch = if self.ctx.observation_enabled() { self.tokenizer .next_batch_observed(&mut self.input, &mut self.ctx) @@ -90,18 +93,28 @@ impl Html5ParseSession { let resolver = batch.resolver(); let mut steps = Vec::with_capacity(batch.tokens().len()); + let mut failure = None; for token in batch.iter() { - let step = Self::process_token( + let processed = Self::process_token( &mut self.ctx, &mut self.builder, &mut self.patch_emitter, token, &resolver, - )?; - steps.push(step); + ); + match processed.into_outcome() { + Ok(step) => steps.push(step), + Err(error) => { + failure = Some(error); + break; + } + } } - steps + (steps, failure) }; + if let Some(failure) = failure { + return self.resolve_processed_token_failure(failure); + } for step in steps { if self.apply_tree_builder_step(step) == DrainOutcome::Suspended { @@ -134,16 +147,46 @@ impl Html5ParseSession { patch_emitter: &mut PatchEmitterAdapter, token: &Token, resolver: &dyn TextResolver, - ) -> Result { + ) -> ProcessedToken { ctx.counters.tokens_processed = ctx.counters.tokens_processed.saturating_add(1); let mut process_context = TreeBuilderProcessContext::for_integrated_parser(ctx); - match builder.push_token(token, &mut process_context, resolver, patch_emitter) { - Ok(step) => Ok(step), - Err(err) => { + let builder_result = + builder.push_token(token, &mut process_context, resolver, patch_emitter); + #[cfg(any(test, feature = "parser-conformance"))] + let patch_history_failure = patch_emitter.take_patch_history_failure(); + ProcessedToken { + builder_result, + #[cfg(any(test, feature = "parser-conformance"))] + patch_history_failure, + } + } + + fn resolve_processed_token( + &mut self, + processed: ProcessedToken, + ) -> Result { + processed + .into_outcome() + .or_else(|failure| self.resolve_processed_token_failure(failure)) + } + + fn resolve_processed_token_failure( + &mut self, + failure: ProcessedTokenFailure, + ) -> Result { + match failure { + #[cfg(any(test, feature = "parser-conformance"))] + ProcessedTokenFailure::PatchHistory(failure) => { + self.resolve_patch_history_failure(failure) + } + ProcessedTokenFailure::TreeBuilder(err) => { if matches!(err, ParserFatalError::EngineInvariant) { - ctx.counters.tree_builder_invariant_errors = - ctx.counters.tree_builder_invariant_errors.saturating_add(1); + self.ctx.counters.tree_builder_invariant_errors = self + .ctx + .counters + .tree_builder_invariant_errors + .saturating_add(1); } #[cfg(any(test, feature = "debug-stats"))] error!(target: "html5", "tree builder fatal error: {err:?}"); @@ -154,6 +197,34 @@ impl Html5ParseSession { } } + #[cfg(test)] + pub(super) fn resolve_patch_history_capture_failure( + &mut self, + ) -> Result<(), Html5SessionError> { + match self.patch_emitter.take_patch_history_failure() { + Some(failure) => self.resolve_patch_history_failure::<()>(failure), + None => Ok(()), + } + } + + #[cfg(any(test, feature = "parser-conformance"))] + fn resolve_patch_history_failure( + &mut self, + failure: PatchHistoryCaptureFailure, + ) -> Result { + match failure { + PatchHistoryCaptureFailure::ResourceExhaustion(error) => Err(Html5SessionError::Fatal( + ParserFatalError::ResourceExhaustion(error), + )), + PatchHistoryCaptureFailure::Invariant(invariant) => { + if self.patch_history_invariant.is_none() { + self.patch_history_invariant = Some(invariant); + } + Err(Html5SessionError::Fatal(ParserFatalError::EngineInvariant)) + } + } + } + pub(super) fn apply_tree_builder_step(&mut self, step: TreeBuilderStepResult) -> DrainOutcome { self.apply_tokenizer_control(step.tokenizer_control); if matches!(step.flow, TreeBuilderControlFlow::Suspend(_)) { @@ -186,3 +257,54 @@ impl Html5ParseSession { } } } + +pub(super) struct ProcessedToken { + builder_result: Result, + #[cfg(any(test, feature = "parser-conformance"))] + patch_history_failure: Option, +} + +impl ProcessedToken { + fn into_outcome(self) -> Result { + // A capture failure is emitted synchronously while `push_token` owns + // the patch sink, so it is the earliest detected failure even when the + // same call also returns a tree-builder fatal. + #[cfg(any(test, feature = "parser-conformance"))] + if let Some(failure) = self.patch_history_failure { + return Err(ProcessedTokenFailure::PatchHistory(failure)); + } + self.builder_result + .map_err(ProcessedTokenFailure::TreeBuilder) + } +} + +enum ProcessedTokenFailure { + #[cfg(any(test, feature = "parser-conformance"))] + PatchHistory(PatchHistoryCaptureFailure), + TreeBuilder(ParserFatalError), +} + +#[cfg(all(test, feature = "parser-conformance"))] +mod patch_history_precedence_tests { + use super::{ProcessedToken, ProcessedTokenFailure}; + use crate::html5::bridge::PatchHistoryCaptureFailure; + use crate::html5::shared::{ParserFatalError, ParserObservationInvariant}; + + #[test] + fn synchronously_latched_capture_failure_precedes_same_token_builder_fatal() { + let processed = ProcessedToken { + builder_result: Err(ParserFatalError::EngineInvariant), + patch_history_failure: Some(PatchHistoryCaptureFailure::Invariant( + ParserObservationInvariant::PatchDroppedCountOverflow, + )), + }; + assert!(matches!( + processed.into_outcome(), + Err(ProcessedTokenFailure::PatchHistory( + PatchHistoryCaptureFailure::Invariant( + ParserObservationInvariant::PatchDroppedCountOverflow + ) + )) + )); + } +} diff --git a/crates/html/src/html5/session/tests/smoke.rs b/crates/html/src/html5/session/tests/smoke.rs index 29e7cc03..e245eb09 100644 --- a/crates/html/src/html5/session/tests/smoke.rs +++ b/crates/html/src/html5/session/tests/smoke.rs @@ -52,10 +52,12 @@ fn session_patch_batches_are_version_monotonic_and_atomic() { .is_none() ); - session.inject_patch_for_test(DomPatch::CreateDocument { - key: PatchKey(1), - doctype: None, - }); + session + .inject_patch_for_test(DomPatch::CreateDocument { + key: PatchKey(1), + doctype: None, + }) + .unwrap(); let batch0: DomPatchBatch = session .take_patch_batch() .expect("session batch drain") @@ -77,10 +79,12 @@ fn session_patch_batches_are_version_monotonic_and_atomic() { "empty drain must not advance version" ); - session.inject_patch_for_test(DomPatch::CreateComment { - key: PatchKey(2), - text: "x".to_string(), - }); + session + .inject_patch_for_test(DomPatch::CreateComment { + key: PatchKey(2), + text: "x".to_string(), + }) + .unwrap(); let batch1: DomPatchBatch = session .take_patch_batch() .expect("session batch drain") diff --git a/crates/html/src/html5/shared/error.rs b/crates/html/src/html5/shared/error.rs index 38c67706..34cff176 100644 --- a/crates/html/src/html5/shared/error.rs +++ b/crates/html/src/html5/shared/error.rs @@ -67,6 +67,9 @@ pub enum ParserReservationSite { KnownTagAtomStorage, KnownTagLookupStorage, TemplateChildStorage, + /// Complete semantic patch-history observation retained by the live parser + /// before caller-controlled transport drains. + PatchHistoryObservationStorage, } /// Failure of an explicitly fallible parser-owned reservation boundary. @@ -102,6 +105,9 @@ impl std::fmt::Display for ParserResourceExhaustion { ParserReservationSite::TemplateChildStorage => { formatter.write_str("HTML parser-owned reservation failed at TemplateChildStorage") } + ParserReservationSite::PatchHistoryObservationStorage => formatter.write_str( + "HTML parser-owned reservation failed at PatchHistoryObservationStorage", + ), } } } @@ -194,6 +200,10 @@ mod fatal_display_tests { ParserReservationSite::TemplateChildStorage, "HTML parser-owned reservation failed at TemplateChildStorage", ), + ( + ParserReservationSite::PatchHistoryObservationStorage, + "HTML parser-owned reservation failed at PatchHistoryObservationStorage", + ), ] { assert_eq!( ParserFatalError::ResourceExhaustion(ParserResourceExhaustion::at(site)) diff --git a/crates/html/src/html5/shared/observation.rs b/crates/html/src/html5/shared/observation.rs index db783ad2..0050d472 100644 --- a/crates/html/src/html5/shared/observation.rs +++ b/crates/html/src/html5/shared/observation.rs @@ -29,6 +29,10 @@ pub(crate) enum ParserObservationInvariant { NormalizedPositionIndexDiscontinuity, NormalizedPositionIndexMissing, InvalidNormalizedPositionOffset, + /// The exact count of semantic patch operations omitted after prefix + /// capacity exhaustion could not be represented. + #[cfg(any(test, feature = "parser-conformance"))] + PatchDroppedCountOverflow, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/html/src/parser/session.rs b/crates/html/src/parser/session.rs index 44ab259b..db02f82e 100644 --- a/crates/html/src/parser/session.rs +++ b/crates/html/src/parser/session.rs @@ -9,6 +9,8 @@ use crate::html5::shared::DocumentParseContext; use crate::html5::shared::ParserFailureInjection; #[cfg(feature = "parser-conformance")] use crate::html5::shared::{ParserObservationCapture, ParserObservationConfig}; +#[cfg(feature = "parser-conformance")] +use crate::html5::{PatchHistoryObservationConfig, RawPatchHistoryCapture}; use crate::patch_validation::PatchValidationArena; use super::options::HtmlParseOptions; @@ -68,14 +70,26 @@ impl HtmlParser { Self::with_context(options, ctx) } - #[cfg(feature = "parser-conformance")] + #[cfg(all(test, feature = "parser-conformance"))] pub(crate) fn new_with_observations( options: HtmlParseOptions, observations: ParserObservationConfig, ) -> Result { - let ctx = - DocumentParseContext::with_observations(options.error_policy.into(), observations); - Self::with_context(options, ctx) + Self::new_with_conformance_observations( + options, + observations, + PatchHistoryObservationConfig::default(), + ) + } + + #[cfg(feature = "parser-conformance")] + pub(crate) fn new_with_conformance_observations( + options: HtmlParseOptions, + diagnostics: ParserObservationConfig, + patch_history: PatchHistoryObservationConfig, + ) -> Result { + let ctx = DocumentParseContext::with_observations(options.error_policy.into(), diagnostics); + Self::with_context_and_patch_history(options, ctx, patch_history) } #[cfg(all( @@ -111,6 +125,26 @@ impl HtmlParser { } #[cfg(feature = "parser-conformance")] + fn with_context_and_patch_history( + options: HtmlParseOptions, + ctx: DocumentParseContext, + patch_history: PatchHistoryObservationConfig, + ) -> Result { + let session = Html5ParseSession::new_with_patch_history( + options.tokenizer.into(), + options.tree_builder.into(), + ctx, + patch_history, + )?; + Ok(Self { + session, + arena: PatchValidationArena::default(), + patches_drained_before_output: false, + poisoned: false, + }) + } + + #[cfg(all(test, feature = "parser-conformance"))] pub(crate) fn take_observations_for_conformance( &mut self, ) -> Result, HtmlParseError> { @@ -119,8 +153,10 @@ impl HtmlParser { } #[cfg(feature = "parser-conformance")] - pub(crate) fn document_mode_for_conformance(&self) -> crate::DocumentMode { - self.session.document_mode_for_conformance() + pub(crate) fn document_mode_for_conformance( + &self, + ) -> Result { + Ok(self.session.document_mode_for_conformance()?) } #[cfg(feature = "parser-conformance")] @@ -130,9 +166,20 @@ impl HtmlParser { self.session.tokenizer_invariant_for_conformance() } + #[cfg(feature = "parser-conformance")] + pub(crate) fn patch_history_invariant_for_conformance( + &self, + ) -> Option { + self.session.patch_history_invariant_for_conformance() + } + #[cfg(all(test, feature = "parser-conformance"))] - pub(crate) fn inject_patch_for_conformance_test(&mut self, patch: DomPatch) { - self.session.inject_patch_for_test(patch); + pub(crate) fn inject_patch_for_conformance_test( + &mut self, + patch: DomPatch, + ) -> Result<(), HtmlParseError> { + self.session.inject_patch_for_test(patch)?; + Ok(()) } /// Append raw bytes to the session decoder/input buffer. @@ -224,6 +271,11 @@ impl HtmlParser { self.session.normalized_input_for_test() } + #[cfg(test)] + pub(crate) fn diagnostic_observation_enabled_for_test(&self) -> bool { + self.session.diagnostic_observation_enabled_for_test() + } + #[cfg(test)] pub(crate) fn force_self_closing_flag_without_solidus_for_test(&mut self) { self.session @@ -322,6 +374,27 @@ impl HtmlParser { /// patch batches, `ParseOutput::patches` contains only the remaining /// undrained patches and `contains_full_patch_history` is `false`. pub fn into_output(mut self) -> Result { + self.materialize_output() + } + + #[cfg(feature = "parser-conformance")] + pub(crate) fn into_output_with_observations( + mut self, + ) -> Result< + ( + ParseOutput, + Option, + Option, + ), + HtmlParseError, + > { + let output = self.materialize_output()?; + let diagnostics = self.session.take_observations_for_conformance()?; + let patch_history = self.session.take_patch_history_for_conformance()?; + Ok((output, diagnostics, patch_history)) + } + + fn materialize_output(&mut self) -> Result { let mut patches = Vec::new(); while let Some(batch) = self.take_patch_batch_internal(false)? { patches.extend(batch.patches); @@ -339,6 +412,25 @@ impl HtmlParser { }) } + #[cfg(all(test, feature = "parser-conformance"))] + pub(crate) fn force_patch_history_dropped_for_test(&mut self, dropped: u64) { + self.session.force_patch_history_dropped_for_test(dropped); + } + + #[cfg(all(test, feature = "parser-failure-injection"))] + pub(crate) fn set_patch_history_failure_injection_for_test( + &mut self, + injection: ParserFailureInjection, + ) { + self.session + .set_patch_history_failure_injection_for_test(injection); + } + + #[cfg(all(test, feature = "parser-conformance"))] + pub(crate) fn force_materialization_failure_for_test(&mut self) { + self.arena.root = Some(crate::PatchKey(u32::MAX)); + } + pub(super) fn apply_patches(&mut self, patches: &[DomPatch]) -> Result<(), HtmlParseError> { if patches.is_empty() { return Ok(()); diff --git a/crates/html/src/parser/tests.rs b/crates/html/src/parser/tests.rs index eb8bab1a..ec415a3a 100644 --- a/crates/html/src/parser/tests.rs +++ b/crates/html/src/parser/tests.rs @@ -539,7 +539,7 @@ fn document_mode_capture_is_whole_and_chunk_delivery_invariant() { parser.pump().expect("document-mode chunk pump"); } parser.finish().expect("document-mode finish"); - let document_mode = parser.document_mode_for_conformance(); + let document_mode = parser.document_mode_for_conformance().unwrap(); let capture = parser .take_observations_for_conformance() .expect("observation drain") diff --git a/crates/html/src/types.rs b/crates/html/src/types.rs index 7e41e19e..6d6a5e9a 100644 --- a/crates/html/src/types.rs +++ b/crates/html/src/types.rs @@ -39,6 +39,7 @@ impl Id { /// - Keys are stable for the lifetime of a document. /// - Keys are never reused within a document lifetime. /// - When deletion is introduced, deleted keys are never reused. +/// - Resetting live structure does not release historical keys for reuse. /// - `NodeKey(0)` is reserved as invalid. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct NodeKey(pub u32); @@ -111,6 +112,13 @@ impl DocumentFragmentNode { pub(crate) fn children_mut(&mut self) -> &mut Vec { &mut self.children } + + /// Narrow test seam for proving that canonical observation rejects a + /// fragment-kind contradiction without weakening production constructors. + #[cfg(test)] + pub(crate) fn force_unsupported_kind_for_conformance_test(&mut self) { + self.kind = ParserCreatedFragmentKind::TestOnlyUnsupported; + } } /// Internal parser-created document-fragment classification. @@ -376,6 +384,28 @@ impl Node { } } + /// Detach owned child groups so tests of iterative tree consumers can also + /// tear down adversarially deep values without recursive `Drop`. + #[cfg(test)] + pub(crate) fn take_child_groups_for_iterative_drop( + &mut self, + ) -> (Vec, Option>) { + match self { + Node::Document { children, .. } => (std::mem::take(children), None), + Node::Element { element } => { + let ordinary = std::mem::take(element.children_mut()); + let template = element + .template_contents_mut() + .map(|contents| std::mem::take(contents.children_mut())); + (ordinary, template) + } + Node::DocumentType { .. } + | Node::Text { .. } + | Node::Comment { .. } + | Node::ProcessingInstruction { .. } => (Vec::new(), None), + } + } + pub fn element(&self) -> Option<&ElementNode> { match self { Node::Element { element } => Some(element), diff --git a/crates/html_test_support/src/wpt_tokenizer.rs b/crates/html_test_support/src/wpt_tokenizer.rs index 6bc81bad..23857912 100644 --- a/crates/html_test_support/src/wpt_tokenizer.rs +++ b/crates/html_test_support/src/wpt_tokenizer.rs @@ -199,6 +199,8 @@ pub fn run_tokenizer_whole_observed( parse_errors: ObservationRequest::NotRequested, implementation_diagnostics: ObservationRequest::NotRequested, document_mode: html::conformance::ScalarObservationRequest::NotRequested, + tree: ObservationRequest::NotRequested, + patches: ObservationRequest::NotRequested, }) .map_err(|error| format!("canonical tokenizer observation failed for '{case_id}': {error}"))?; let observed_tokens = match result.tokens { diff --git a/docs/adr/001-html5-parsing-architecture.md b/docs/adr/001-html5-parsing-architecture.md index 0ae92e87..e8bf05c1 100644 --- a/docs/adr/001-html5-parsing-architecture.md +++ b/docs/adr/001-html5-parsing-architecture.md @@ -384,9 +384,13 @@ enabled. Patch transaction semantics: - A patch batch is an atomic transaction: apply all patches or none. -- A `Clear` starts a new baseline for the document handle and invalidates all prior keys for that baseline. -- Strict runtime appliers reset key-allocation domain on `Clear`; keys may be reused after `Clear`. -- Producers may still choose monotonic non-reuse within their own session allocators (for example, HTML5 tree-builder allocators). +- A `Clear` starts a new runtime baseline for the document handle, and strict + runtime appliers may reset their baseline-local duplicate-key tracking. +- Independently, one production parser session owns a session-lifetime key + allocation domain: its allocator and AE13 semantic history do not reuse a + key after `Clear`. +- A genuinely new parser session or document handle owns a fresh parser + identity domain. - On fatal parse failure, the runtime must either (a) emit a new handle and a full create stream, or (b) emit `Clear` + full create on the existing handle. The choice is explicit and consistent. - A “full create stream” is `CreateDocument` + a complete create/append stream for every node in document order, with a fresh key allocator for the new handle when a new handle is used. - `CreateDocument` is part of the patch protocol in this design; if not already present, it is introduced as a first-class patch operation. diff --git a/docs/engine-feature-gap-tracker.md b/docs/engine-feature-gap-tracker.md index f0021db4..2ac283db 100644 --- a/docs/engine-feature-gap-tracker.md +++ b/docs/engine-feature-gap-tracker.md @@ -437,10 +437,18 @@ Current supported subset: contradictory self-closing effects propagate through the fatal invariant path. The finite, fail-on-incomplete legacy DOM-golden projection is isolated in parser-conformance test support and does not merge implementation/resource - diagnostics. Canonical serializers, fixture - sidecars, trees, patches, transitions, unsupported features, and AE13c final - invariants remain deferred. Full doctype classification conformance is not - claimed. See `docs/html5/ae13-parser-conformance-regression-harness.md`. + diagnostics. AE13b3 adds feature-gated in-memory canonical projection of the + successfully materialized parser-created document and complete pre-drain + semantic patch history. Tree projection is iterative and atomic under + node-and-template-boundary structural capacity; attributes do not consume + semantic capacity but remain fallibly allocated. Patch capture retains an operation-count prefix, + assigns first-appearance labels, and validates retained creation history. + These capacities are not byte budgets, and the existing parser-owned + materializer is not claimed to be stack-independent. Canonical serializers, + fixture sidecars/corpus migration, transitions, unsupported features, and + AE13c final invariants remain deferred. Full doctype classification + conformance is not claimed. See + `docs/html5/ae13-parser-conformance-regression-harness.md`. - AE1 HTML parser ownership architecture: HTML/parser owns tokenizer input preprocessing, tokenizer states, typed tokens, parse errors, tree-construction state, insertion modes, stack of open elements, active diff --git a/docs/html5/ae1-html-parser-dom-ownership-contract.md b/docs/html5/ae1-html-parser-dom-ownership-contract.md index bda615f7..bee73ebf 100644 --- a/docs/html5/ae1-html-parser-dom-ownership-contract.md +++ b/docs/html5/ae1-html-parser-dom-ownership-contract.md @@ -203,8 +203,8 @@ AE1 keeps three identity domains separate. `PatchKey` is the parser output identity used by `DomPatch` streams. It is allocated by the HTML tree builder, is non-zero in emitted patches, and is not -reused within the active parser document baseline except where the patch -contract explicitly permits reuse after `Clear`. +reused within the parser session. `Clear` resets live structure without +releasing historical key allocation. ### `html::internal::Id` diff --git a/docs/html5/ae13-parser-conformance-regression-harness.md b/docs/html5/ae13-parser-conformance-regression-harness.md index 30709ef4..43b5d903 100644 --- a/docs/html5/ae13-parser-conformance-regression-harness.md +++ b/docs/html5/ae13-parser-conformance-regression-harness.md @@ -498,8 +498,91 @@ patch observations use a separate DOM attribute model containing namespace, prefix, local name, and value. Trees structurally retain doctype public/system identifiers and template contents beneath their host. Patch observations can faithfully represent every current `DomPatch` variant and payload; only -`PatchKey` is replaced by caller-supplied snapshot-local labels. AE13a does not -assign stream labels or implement the patch-v3 serializer. +`PatchKey` is replaced by deterministic snapshot-local labels. AE13b3 assigns +those labels in first semantic operand appearance order. Multi-key operand +order is fixed as host then contents for `CreateTemplateContents`, parent then +child for `AppendChild`, and parent then child then before for `InsertBefore`. +`Clear` neither resets label numbering nor permits parser-session key reuse. +No raw numeric `PatchKey`, runtime batch boundary, or batch version enters the +canonical patch contract. AE13b3 does not implement a patch serializer. + +### Canonical parser-created tree and complete patch capture + +AE13b3 separates three observation owners: + +- `DocumentParseContext` records only tokenizer tokens, parse errors, + implementation diagnostics, diagnostic occurrence sequences, and normalized + positions. +- `PatchEmitterAdapter` optionally retains raw semantic `DomPatch` history at + the single production boundary that receives each owned patch before + caller-controlled drains. +- conformance execution retains the tree request until successful parser + finish, patch validation, and `ParseOutput::document` materialization. + +Tree-only and patch-only requests therefore do not install the diagnostic +recorder, build the normalized-position index, use observed token drains, or +change decoder/tokenizer execution. The final canonical tree is projected only +from the successfully materialized `ParseOutput::document`. `LiveTree`, +`PatchValidationArena` internals, legacy DOM snapshot text, and +`ParseOutput::patches` are not canonical tree or complete-history sources. + +The tree projector preserves the document root, real `DocumentType` children +and all three doctype strings, text, comments, processing instructions with +separate target/data, element namespaces and exact local names, ordered +qualified attributes, ordinary children, and the typed template-contents +boundary. Template traversal order is host and attributes, ordinary children +in source order, the contents boundary, then contents children in source +order. Preflight and projection share one iterative event walker; children are +scheduled in reverse on its LIFO work stack. This removes native recursion only +from the AE13 canonical projector, not from the existing parser-owned +materializer. + +Tree capacity counts canonical structural units: document, document type, +element or HTML template host, text, comment, processing instruction, and the +typed template-contents boundary each consume one unit. Ordered attributes and +the outer `ObservedTree` wrapper consume no structural units, although their +owned storage remains checked and fallible. A tree is atomic. Insufficient +capacity returns an empty, clearly `Incomplete` tree with the exact required +unit count dropped; it never returns a recursively truncated tree. The complete +iterative preflight validates materialized tree invariants before capacity can +produce `Incomplete`. Patch capacity counts semantic operations and retains +the exact original prefix with an exact dropped count. Neither capacity is a +byte-memory budget. Individual strings and attribute payloads may be large; +every newly owned nested string, vector, attribute payload, label, map, history +set, and traversal stack uses checked fallible allocation. A byte-budget +request model is outside AE13b3. + +Preflight requires exactly one document root, rejects the legacy document-level +doctype compatibility field, and requires every HTML-namespace `template` to +own a `TemplateContents` fragment of the correct kind. Foreign SVG/MathML +elements whose local name is `template` remain ordinary elements. These +contradictions have typed observation-invariant identities and take precedence +over every capacity outcome. + +Live patch-history allocation failure terminalizes the parser through +`ParserFatalError::ResourceExhaustion(PatchHistoryObservationStorage)`. A +patch-history dropped-count contradiction terminalizes stable parsing as the +existing `EngineInvariant`; feature-gated conformance state retains and +specializes the exact `PatchDroppedCountOverflow` identity. The adapter is +checked immediately after every tree-builder token call, even when that call +also returns a fatal error, and the synchronously latched capture failure has +precedence. The original patch still enters ordinary transport unchanged, but +the failed session exposes no drain, document mode, materialized output, or +observation. + +Canonical tree/patch allocation occurs after successful production parsing and +materialization, so it reports typed observation resource exhaustion at +`CanonicalTreeProjection`, `CanonicalPatchProjection`, or +`SnapshotLabelStorage`, never a false parser failure. Arithmetic and semantic +contradictions remain typed observation invariants. Any failure suppresses the +entire `CanonicalParserResult`. + +Retained patch prefixes are checked in release builds. Create operations +introduce fresh non-zero keys; `CreateTemplateContents` requires its host and +introduces contents; every structural/content operation requires retained +creation history. Duplicate creation, `PatchKey::INVALID`, or a reference +without retained creation history is an execution invariant. `Clear` resets +live structure only and preserves historical creation identity. Transition token summaries, insertion modes, dispatch paths, and parser-context token kinds are typed semantic values. Unsupported-feature observations are diff --git a/docs/html5/dompatch-contract.md b/docs/html5/dompatch-contract.md index abe0c8be..fd22d6ef 100644 --- a/docs/html5/dompatch-contract.md +++ b/docs/html5/dompatch-contract.md @@ -90,6 +90,11 @@ For all patch streams: 3. Structural child ordering is explicit via `AppendChild` / `InsertBefore`; consumers must not reorder. 4. `Clear` may only appear as the first patch in a batch. 5. `PatchKey(0)` is invalid and must never appear in emitted patches. +6. Within one production parser session, patch keys are session-lifetime + identities. `Clear` resets live structure but does not release any + historically allocated parser key for reuse. This parser-history rule is + distinct from a runtime applier resetting baseline-local duplicate-key + tracking at a batch-leading `Clear`. HTML5 tree-builder Core v0 emission profile: diff --git a/docs/html5/invariants.md b/docs/html5/invariants.md index d7d5e907..971b85fa 100644 --- a/docs/html5/invariants.md +++ b/docs/html5/invariants.md @@ -73,6 +73,11 @@ Required invariants: - `SetText` and `AppendText` only target text nodes - the final post-batch DOM state must satisfy the DOM invariants above +The production parser additionally maintains a session-history invariant that +is intentionally stricter than this batch checker: `Clear` does not release +historical parser keys for reuse. AE13 canonical retained-prefix validation +checks that session-wide rule without changing the runtime baseline contract. + ## API Surface Current checker entrypoints: diff --git a/docs/html5/node-identity-contract.md b/docs/html5/node-identity-contract.md index fb998283..9d2b6c55 100644 --- a/docs/html5/node-identity-contract.md +++ b/docs/html5/node-identity-contract.md @@ -97,11 +97,21 @@ Related contracts: - Applies patch batches atomically: all-or-none. - Rejects unknown/missing keys deterministically. -- `Clear` resets DOM contents and key-allocation domain for that handle baseline. +- `Clear` resets DOM contents and the strict applier's baseline-local + duplicate-key tracking for that document handle. - Legal structural moves preserve the moved node's `PatchKey`. - Key reuse policy in strict applier: - keys are non-reusable until `Clear`, - - keys MAY be reused after `Clear`. + - keys MAY be reused after `Clear` in a new runtime baseline. + +The production parser has a stricter, separate session-history contract: +its allocator never reuses a `PatchKey` in the same parser session, including +after `Clear`. AE13 retained-prefix validation enforces that parser-history +rule without changing runtime baseline semantics. + +AE13 canonical patch labels are a separate snapshot-local display identity. +They are assigned by first semantic operand appearance, remain monotonic across +`Clear`, and never expose the numeric `PatchKey`. ### Legacy diff path (`runtime_parse` test diff helpers) diff --git a/docs/html5/parser-fixture-format-v1.md b/docs/html5/parser-fixture-format-v1.md index 8d176423..849a4297 100644 --- a/docs/html5/parser-fixture-format-v1.md +++ b/docs/html5/parser-fixture-format-v1.md @@ -157,8 +157,11 @@ requested and completed with zero events. Recognized default sidecars that are present but undeclared are rejected. A declared sidecar must exist even when its execution surface belongs to a later -AE13 slice. In AE13a, only `tokens.txt` with `html5-token-v1` executes; an active -fixture requesting another surface fails with typed `UnsupportedExpectation`. +AE13 slice. The feature-gated AE13b3 execution API can capture canonical trees +and complete semantic patch histories in memory. Fixture-v1 still executes +only `tokens.txt` with `html5-token-v1`: tree and patch serializers, sidecars, +and corpus migration remain deliberately absent, so an active fixture +requesting either sidecar still fails with typed `UnsupportedExpectation`. ## Dispositions and sources