diff --git a/docs/adr/0020-keep-primitive-literals-shadow-only-after-dual-run.md b/docs/adr/0020-keep-primitive-literals-shadow-only-after-dual-run.md index 8074f06bf..c2f2653d3 100644 --- a/docs/adr/0020-keep-primitive-literals-shadow-only-after-dual-run.md +++ b/docs/adr/0020-keep-primitive-literals-shadow-only-after-dual-run.md @@ -46,18 +46,36 @@ machine-readable owner and concrete action required for reclassification. A changed code, state, diagnostic, or mapping outcome is unexplained and blocks the gate. -Runtime and output sizes are retained as two ordered measurement samples. -Artifact sizes and the controller's peak or current resident memory are -recorded with their measurement method and scope. These measurements are -characterization data: the Go lane includes a one-shot process and JSON Lines -transport, while Rust runs in-process, and controller RSS excludes the Go child -process. The measured fields are therefore not part of the byte-stability gate -and do not establish a performance advantage. - -The primitive/literal category is **not ready for a later authority decision**. -Rust remains shadow-only. Go remains both the serving semantic authority and -the production fallback. There is no authority switch, TS7 producer protocol -change, external consumer behavior change, or Palamedes change. +Issue #53 selects `one-shot-child-process-shadow` as the production-equivalent +observation boundary. For every classified corpus case, the release rollout +controller starts the real `tsfacts` command as the Go serving child and the +release `oxc-occurrence-map primitive-shadow-worker` command as the Rust shadow +child. Both receive the same ordered project selections and equivalent limits. +Only the Go child's stdout is eligible to become the served response; Rust +stdout is observation-only and uses an internal request/response shape that is +not the TS7 producer protocol. + +The serving controller's failure state machine is executable library code. A +Rust failure is observed, preserves the Go response, disables subsequent +shadow execution, and can be re-enabled only by an explicit reset. A Go +failure is returned and never masked by Rust. Unit tests and every rollout +report exercise failure, rollback, skipped-after-rollback, reset, and Go +failure paths. + +Runtime, raw producer-output size, artifact size, and peak resident memory are +retained as two ordered measurement samples. Both runtime lanes now cover the +same one-shot child-process boundary. On Unix, the controller uses `wait4` +`ru_maxrss` for each child and aggregates the maximum case-process RSS per +producer. Raw output sizes deliberately cover different internal schemas and +the sequential samples remain characterization data, not performance claims or +compatibility thresholds. + +With these production-integration and measurement gaps closed, the checked +evidence is **ready to inform a later authority decision**. This ADR still +keeps Rust shadow-only. Go remains both the serving semantic authority and the +production fallback. There is no authority switch, TS7 producer protocol +change, external consumer behavior change, or Palamedes change. A switch still +requires an explicit later proposal and ADR. ## Resolved rollout observations @@ -74,19 +92,20 @@ These are resolved, explicit limitations rather than authority-readiness blockers or hidden exclusions. Their fixture contracts remain CI-blocking when the observed behavior changes. -## Remaining authority blockers +## Remaining caveats -- Rust is not integrated into the serving path, so production fallback, - rollback, and shadow observation at that boundary have not been exercised. -- Runtime and output-size samples do not yet use one production-equivalent - process or library boundary for both implementations. -- The evidence does not isolate peak resident memory for the Go child process, - so per-producer memory parity is not established. +- The four unsupported and three recovery-file observations remain explicit, + owned limitations; evidence readiness does not make them supported. +- Raw Go and Rust output-byte counts cover different internal payload schemas + and must not be interpreted as wire-protocol parity. +- The sequential one-shot samples are not daemon throughput or latency + benchmarks. +- Peak RSS uses Unix `wait4`; a non-Unix release must supply or justify a + comparable child-process method. -A later authority proposal must close or explicitly re-scope these blockers, -rerun the supported platform matrix, preserve Go fallback, and create a new ADR -that authorizes the category-specific switch. This record does not authorize -that proposal. +A later authority proposal must assess these caveats, rerun the supported +platform matrix, preserve Go fallback, and create a new ADR that authorizes any +category-specific switch. This record does not authorize that proposal. ## Consequences @@ -95,8 +114,9 @@ that proposal. size evidence. - Host-dependent measurements remain reproducible through a documented command without weakening the exact semantic gate. -- A green rollout job means that shadow comparison is healthy. It does not mean - the category is production-ready or approved to replace Go. +- A green rollout job means that exact comparison, production-equivalent shadow + observation, rollback, and scoped measurements are healthy. It does not + approve replacing Go. ## References @@ -105,3 +125,4 @@ that proposal. - [Checked rollout evidence](../evidence/primitive-literal-rollout-2026-08-17.json) - [Issue #47](https://github.com/swernerx/typescript-semantic-kernel/issues/47) - [Issue #52](https://github.com/swernerx/typescript-semantic-kernel/issues/52) +- [Issue #53](https://github.com/swernerx/typescript-semantic-kernel/issues/53) diff --git a/docs/evidence/README.md b/docs/evidence/README.md index 6226d7c6c..7299f4fbc 100644 --- a/docs/evidence/README.md +++ b/docs/evidence/README.md @@ -45,9 +45,17 @@ record is generated by: It embeds the exact primitive/literal conformance report and requires that stable report to be byte-identical across two complete dual-runs. Runtime, resident memory, artifact size, and producer-output size are retained in a -separate measurement section because those values or their comparison boundary -vary by host. The report is a shadow rollout artifact, not an authority switch -or a general benchmark. +separate measurement section. Go serving and Rust shadow execution use the +same one-shot release-child boundary over the same per-case requests; Unix +`wait4` records each child's peak RSS. Raw output bytes still cover different +internal schemas, and sequential samples vary by host, so the report is not a +general benchmark. + +The report also exercises an executable serving controller: Rust failure is +observed while the Go response survives, rollback disables later shadow work, +explicit reset re-enables it, and a Go failure is never masked. Only Go output +is served. This makes the evidence ready for a later authority decision but is +not itself an authority switch. Conformance schema v5 reports both the exact supported-record compatibility and a CI-enforced 100% accounting denominator over every selected fact. The @@ -56,4 +64,5 @@ mapping gaps remain present as stable regression limitations with explicit owners and reclassification actions. [ADR-0020](../adr/0020-keep-primitive-literals-shadow-only-after-dual-run.md) -records why the category is not ready for a later authority decision. +records the selected boundary, measurements, remaining caveats, and continued +Go authority. diff --git a/docs/evidence/primitive-literal-rollout-2026-08-17.json b/docs/evidence/primitive-literal-rollout-2026-08-17.json index 5d40981eb..282c5e3c5 100644 --- a/docs/evidence/primitive-literal-rollout-2026-08-17.json +++ b/docs/evidence/primitive-literal-rollout-2026-08-17.json @@ -1,5 +1,5 @@ { - "schemaVersion": 2, + "schemaVersion": 3, "evidenceKind": "primitive-literal-controlled-go-rust-dual-run", "command": "./internal/oxc_reference/run-rollout.sh --output ", "environment": { @@ -17,6 +17,25 @@ "ts7ProducerProtocolChanged": false, "externalConsumerBehaviorChanged": false }, + "boundary": { + "name": "one-shot-child-process-shadow", + "controller": "release rollout controller", + "goServingProcess": "cmd/tsfacts JSON Lines serving child", + "rustShadowProcess": "oxc-occurrence-map primitive-shadow-worker child", + "requestScope": "same ordered manifest selections, capabilities, budgets, and project per classified corpus case", + "servedOutput": "Go stdout only; Rust stdout is observation-only", + "sameOneShotChildProcessBoundary": true, + "releaseArtifacts": true + }, + "failurePaths": { + "goResponseSurvivesShadowFailure": true, + "shadowFailureIsObserved": true, + "shadowFailureDetailRetained": true, + "rollbackDisablesShadow": true, + "rolledBackRequestSkipsShadow": true, + "explicitResetReenablesShadow": true, + "goFailureIsNotMasked": true + }, "determinism": { "completeRuns": 2, "conformanceReportsByteEqual": true, @@ -28,7 +47,7 @@ "candidate": "independent-primitive-literal-v2", "shadowOnly": true, "execution": { - "repositoryRevision": "20d726c587bc612b5be8311530a553f70b0ed79b", + "repositoryRevision": "5d79fe8dcc7fbe740fd0391ee3b7f1bb45e90409", "typescriptVersion": "7.1.0-dev", "typescriptRevision": "1bcfa18d79a3be41772223d5c05dfe4480e614ff", "requestSchemaVersion": 1, @@ -3975,40 +3994,48 @@ "passes": true }, "measurements": { - "scope": "one-shot Go process versus in-process Rust producer over identical ordered requests; characterization only", + "scope": "sequential one-shot release child processes over identical ordered per-case requests; characterization, not a throughput benchmark", + "outputScope": "raw successful child stdout bytes before decoding; Go JSON Lines and the internal Rust shadow payload have different schemas", + "memoryScope": "per-child peak resident set from wait4 rusage, aggregated as the maximum across case processes for each producer", "samples": [ { "ordinal": 1, "cases": 4, - "goOracleNanoseconds": 700402249, - "rustProducerNanoseconds": 1367000, - "rustDeterminismCheckNanoseconds": 236041, - "totalNanoseconds": 704086084, - "goSnapshotBytes": 36671, - "rustCandidateBytes": 22615 + "goServingWallNanoseconds": 297564333, + "rustShadowWallNanoseconds": 38245417, + "goServedOutputBytes": 36671, + "rustShadowOutputBytes": 22619, + "goPeakResidentBytes": 44023808, + "rustPeakResidentBytes": 3162112, + "residentMeasurement": "wait4-ru-maxrss-peak", + "memoryComparable": true, + "servedGoResponsesUnchanged": true, + "shadowObservationFailures": 0 }, { "ordinal": 2, "cases": 4, - "goOracleNanoseconds": 127294084, - "rustProducerNanoseconds": 789167, - "rustDeterminismCheckNanoseconds": 237917, - "totalNanoseconds": 129383625, - "goSnapshotBytes": 36671, - "rustCandidateBytes": 22615 + "goServingWallNanoseconds": 248149001, + "rustShadowWallNanoseconds": 29793292, + "goServedOutputBytes": 36671, + "rustShadowOutputBytes": 22619, + "goPeakResidentBytes": 43384832, + "rustPeakResidentBytes": 3162112, + "residentMeasurement": "wait4-ru-maxrss-peak", + "memoryComparable": true, + "servedGoResponsesUnchanged": true, + "shadowObservationFailures": 0 } ], "artifacts": { "goExecutableBytes": 25670354, - "rustExecutableBytes": 3345040, - "peakOrCurrentControllerResidentBytes": 4964352, - "residentMeasurement": "macos-getrusage-peak", - "memoryScope": "Rust rollout controller including decoded Go snapshots; excludes child Go process RSS" + "rustExecutableBytes": 3498416, + "scope": "on-disk bytes of the two release executables invoked at the selected child-process boundary" } }, "readiness": { - "readyForLaterAuthorityDecision": false, - "status": "not-ready", + "readyForLaterAuthorityDecision": true, + "status": "evidence-ready-authority-unchanged", "resolvedRolloutLimitations": [ { "case": "primitive-literal-conformance", @@ -4130,11 +4157,13 @@ "action": "Reclassify only when OXC parses the recovery file and produces an exact NodeId mapping for this recursive-budget selection." } ], - "blockers": [ - "the Rust producer is not integrated into the serving path, so production fallback and rollback have not been exercised", - "runtime and output measurements compare a one-shot Go process with an in-process Rust shadow path; a production-equivalent boundary is not selected", - "controller RSS excludes the child Go process, so per-producer peak-memory parity is not established" - ] + "remainingCaveats": [ + "the four unsupported primitive/literal selections and three recovery-file mapping gaps remain explicitly fixture-classified limitations", + "raw producer-output byte counts characterize different internal payload schemas and are not protocol-parity evidence", + "sequential one-shot process samples characterize the selected boundary; they are not daemon throughput or latency benchmarks", + "peak resident memory uses Unix wait4 rusage and requires a separately justified method on non-Unix platforms" + ], + "blockers": [] }, "passes": true } diff --git a/docs/porting/migration-contract.md b/docs/porting/migration-contract.md index 67333a948..6962804ba 100644 --- a/docs/porting/migration-contract.md +++ b/docs/porting/migration-contract.md @@ -94,16 +94,16 @@ gate compares its five roots, structured graph identity, states, mapping, and truncation against Go at an exact threshold. Passing it does not transfer semantic authority or alter production routing. Project loading, resolution, binding, symbols, inference beyond the supported contextual literals, -overloads, generic instantiation, narrowing, recovery, and all production -fallback behavior remain Go-authoritative. +overloads, generic instantiation, narrowing, and recovery remain +Go-authoritative. ADR-0020 adds the controlled rollout lane. Two complete runs must share the checked-out corpus and repository revision, TS compiler revision, request schema, project, capabilities, budgets, and ordered selections. The stable conformance reports must be byte-identical. Host-dependent runtime, resident memory, artifact size, and producer-output size remain separately scoped -characterization evidence. The lane is shadow-only and does not exercise or -authorize production routing. +characterization evidence. The lane is shadow-only and does not authorize +production routing. Issue #52 adds conformance schema v5's all-selection accounting gate. Every selected fact must be classified, the classification count must remain equal @@ -114,6 +114,26 @@ owners and concrete reclassification actions. They are neither dropped from the corpus nor counted as supported. Go remains semantic authority and the production fallback. +Issue #53 selects a production-equivalent, one-shot child-process boundary for +the rollout controller. The controller obtains the served response from the +real release `tsfacts` child and starts the release Rust +`primitive-shadow-worker` child only for observation. Both children cover the +same ordered per-case project selections and limits. The Rust worker's compact +JSON is an internal harness contract, not a change to the TS7 producer +protocol. A Rust failure preserves the Go response, records the failure, and +disables shadow execution until an explicit reset; a Go failure is never +masked. These fallback, rollback, reset, and failure-observation paths are +executable tests and structured rollout fields. + +Both runtime samples now measure the same release child-process scope. Raw +successful stdout bytes are counted before decoding, artifact sizes cover the +two invoked executables, and Unix `wait4` peak RSS is measured independently +for every child and aggregated per producer. Different internal payload +schemas and sequential one-shot execution remain explicit characterization +caveats. Passing this evidence makes the slice ready for a later authority +decision, but does not itself switch authority: Go remains the serving path and +production fallback. + ## Gate ladder 1. Focused `internal/semanticfacts` and `internal/tsfacts` tests pass, including @@ -136,9 +156,10 @@ production fallback. mapping cases. Its supported-record metric is paired with a mandatory 100% all-selection accounting denominator, so no classified case is hidden. 10. `./internal/oxc_reference/run-rollout.sh --output ` executes two - release-profile dual-runs, requires byte-identical embedded conformance, - records the controlled measurements in ADR-0020, and retains Go as serving - authority and production fallback. + release-profile dual-runs at the same one-shot child-process boundary, + requires byte-identical embedded conformance, exercises fallback and + rollback, records comparable runtime/output/artifact/child-RSS scopes in + ADR-0020, and retains Go as serving authority and production fallback. ## Upstream synchronization diff --git a/internal/oxc_reference/Cargo.lock b/internal/oxc_reference/Cargo.lock index 983b13533..b1dd3ad72 100644 --- a/internal/oxc_reference/Cargo.lock +++ b/internal/oxc_reference/Cargo.lock @@ -102,6 +102,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + [[package]] name = "memchr" version = "2.8.3" @@ -166,6 +172,7 @@ dependencies = [ name = "oxc-occurrence-consumer" version = "0.1.0" dependencies = [ + "libc", "oxc_allocator", "oxc_ast", "oxc_parser", diff --git a/internal/oxc_reference/Cargo.toml b/internal/oxc_reference/Cargo.toml index e41d8798f..52d6cf199 100644 --- a/internal/oxc_reference/Cargo.toml +++ b/internal/oxc_reference/Cargo.toml @@ -13,5 +13,6 @@ oxc_ast = "0.144.0" oxc_parser = "0.144.0" oxc_semantic = "0.144.0" oxc_span = "0.144.0" +libc = "0.2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/internal/oxc_reference/README.md b/internal/oxc_reference/README.md index ef2979035..478d0cfc4 100644 --- a/internal/oxc_reference/README.md +++ b/internal/oxc_reference/README.md @@ -91,12 +91,17 @@ Run the controlled rollout evidence path with release binaries: This command executes two complete Go/Rust dual-runs over the same ordered manifest requests and requires their embedded conformance reports to be byte-identical. The report pins the repository and TypeScript revisions, -projects, capabilities, budgets, and selection counts. It also records two -host-dependent runtime/output-size samples, release artifact sizes, and the -Rust controller's explicitly scoped resident-memory measurement. Those -measurements characterize the one-shot Go versus in-process Rust harness; they -are not compatibility thresholds or a performance claim. Go remains the -serving authority and fallback, and Rust stays shadow-only. See +projects, capabilities, budgets, and selection counts. For each measured case, +the controller invokes the real Go serving command and the internal Rust +shadow worker as equivalent one-shot release children. Only Go stdout is +served. A tested state machine observes Rust failure, preserves the Go +response, rolls the shadow back until explicit reset, and never masks a Go +failure. Two samples record wall time and raw output bytes at this boundary, +release artifact sizes, and per-producer peak child RSS from Unix `wait4`. +Different internal output schemas and sequential one-shot execution remain +explicit characterization caveats, not compatibility thresholds or a +performance claim. Go remains the serving authority and fallback, and Rust +stays shadow-only. See [ADR-0020](../../docs/adr/0020-keep-primitive-literals-shadow-only-after-dual-run.md). The first test suite applies the Rust implementation of the portable contract @@ -144,11 +149,11 @@ Occurrence identity and attachment plumbing is the first approved mechanical port category. Primitive/literal construction is now independently implemented for the narrow, tagged conformance corpus and satisfies the shadow threshold in ADR-0019. The controlled dual-run in ADR-0020 resolves the four unsupported -and three recovery observations as stable, owned, actionable limitations. The -serving-integration, measurement-boundary, and memory blockers remain. This -does not transfer semantic authority: the implementation is not wired into -production, Go remains the fallback, and broader TypeScript semantics remain -Go-authoritative. +and three recovery observations as stable, owned, actionable limitations. +Issue #53 additionally exercises production-equivalent child-process shadow, +fallback, rollback, and comparable child RSS. This evidence is ready for a +later authority decision but does not make one: Go remains the serving path and +fallback, and broader TypeScript semantics remain Go-authoritative. Adding a projection does not transfer semantic authority. New parser-boundary normalizations require a shared fixture and the versioned portable contract. diff --git a/internal/oxc_reference/crates/occurrence-consumer/Cargo.toml b/internal/oxc_reference/crates/occurrence-consumer/Cargo.toml index 26ac712dc..7743fabf5 100644 --- a/internal/oxc_reference/crates/occurrence-consumer/Cargo.toml +++ b/internal/oxc_reference/crates/occurrence-consumer/Cargo.toml @@ -7,6 +7,7 @@ rust-version.workspace = true publish = false [dependencies] +libc.workspace = true oxc_allocator.workspace = true oxc_ast.workspace = true oxc_parser.workspace = true diff --git a/internal/oxc_reference/crates/occurrence-consumer/src/conformance.rs b/internal/oxc_reference/crates/occurrence-consumer/src/conformance.rs index a744cca7d..c6d8a3bac 100644 --- a/internal/oxc_reference/crates/occurrence-consumer/src/conformance.rs +++ b/internal/oxc_reference/crates/occurrence-consumer/src/conformance.rs @@ -4,7 +4,6 @@ use std::{ io::{BufReader, Write}, path::{Path, PathBuf}, process::{Command, Stdio}, - time::Instant, }; use serde::{Deserialize, Serialize}; @@ -16,19 +15,20 @@ use crate::{ PrimitiveLiteralCandidate, }, contract::{Occurrence, Span}, - evidence::resident_memory, facts::{ EntityState, LiteralValue, OccurrenceTypeFacts, ProducerBudgetReport, SemanticSnapshot, TypeGraph, TypeId, TypeKind, TypeViewState, TypeViewStates, }, primitive_producer::{ - IndependentPrimitiveLiteralOutput, PrimitiveLiteralSelection, PrimitiveProducerLimits, - produce_primitive_literals, + INDEPENDENT_PRIMITIVE_LITERAL_PRODUCER_VERSION, IndependentPrimitiveLiteralOutput, + PrimitiveLiteralSelection, PrimitiveProducerLimits, PrimitiveShadowBudgets, + PrimitiveShadowRequest, produce_primitive_literals, }, + serving::{ProcessObservation, ServingShadowController, ShadowObservation, run_child}, }; pub const CONFORMANCE_SCHEMA_VERSION: u32 = 5; -pub const ROLLOUT_SCHEMA_VERSION: u32 = 2; +pub const ROLLOUT_SCHEMA_VERSION: u32 = 3; #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -67,6 +67,8 @@ pub struct RolloutReport { pub command: &'static str, pub environment: RolloutEnvironment, pub authority: AuthorityBoundary, + pub boundary: ProductionBoundary, + pub failure_paths: FailurePathEvidence, pub determinism: DeterminismEvidence, pub conformance: ConformanceReport, pub measurements: RolloutMeasurements, @@ -95,6 +97,43 @@ pub struct AuthorityBoundary { pub external_consumer_behavior_changed: bool, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProductionBoundary { + pub name: &'static str, + pub controller: &'static str, + pub go_serving_process: &'static str, + pub rust_shadow_process: &'static str, + pub request_scope: &'static str, + pub served_output: &'static str, + pub same_one_shot_child_process_boundary: bool, + pub release_artifacts: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FailurePathEvidence { + pub go_response_survives_shadow_failure: bool, + pub shadow_failure_is_observed: bool, + pub shadow_failure_detail_retained: bool, + pub rollback_disables_shadow: bool, + pub rolled_back_request_skips_shadow: bool, + pub explicit_reset_reenables_shadow: bool, + pub go_failure_is_not_masked: bool, +} + +impl FailurePathEvidence { + fn passes(self) -> bool { + self.go_response_survives_shadow_failure + && self.shadow_failure_is_observed + && self.shadow_failure_detail_retained + && self.rollback_disables_shadow + && self.rolled_back_request_skips_shadow + && self.explicit_reset_reenables_shadow + && self.go_failure_is_not_masked + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct DeterminismEvidence { @@ -107,6 +146,8 @@ pub struct DeterminismEvidence { #[serde(rename_all = "camelCase")] pub struct RolloutMeasurements { pub scope: &'static str, + pub output_scope: &'static str, + pub memory_scope: &'static str, pub samples: Vec, pub artifacts: RolloutArtifacts, } @@ -116,12 +157,16 @@ pub struct RolloutMeasurements { pub struct MeasurementSample { pub ordinal: usize, pub cases: usize, - pub go_oracle_nanoseconds: u64, - pub rust_producer_nanoseconds: u64, - pub rust_determinism_check_nanoseconds: u64, - pub total_nanoseconds: u64, - pub go_snapshot_bytes: usize, - pub rust_candidate_bytes: usize, + pub go_serving_wall_nanoseconds: u64, + pub rust_shadow_wall_nanoseconds: u64, + pub go_served_output_bytes: usize, + pub rust_shadow_output_bytes: usize, + pub go_peak_resident_bytes: Option, + pub rust_peak_resident_bytes: Option, + pub resident_measurement: String, + pub memory_comparable: bool, + pub served_go_responses_unchanged: bool, + pub shadow_observation_failures: usize, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -129,9 +174,7 @@ pub struct MeasurementSample { pub struct RolloutArtifacts { pub go_executable_bytes: u64, pub rust_executable_bytes: u64, - pub peak_or_current_controller_resident_bytes: Option, - pub resident_measurement: String, - pub memory_scope: &'static str, + pub scope: &'static str, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -140,6 +183,7 @@ pub struct AuthorityReadiness { pub ready_for_later_authority_decision: bool, pub status: &'static str, pub resolved_rollout_limitations: Vec, + pub remaining_caveats: Vec, pub blockers: Vec, } @@ -493,7 +537,6 @@ struct ProducerSelection { struct CompletedConformanceRun { report: ConformanceReport, - measurement: MeasurementSample, } pub fn run_conformance( @@ -516,22 +559,32 @@ pub fn run_rollout( corpus_root: &Path, repository_revision: &str, ) -> Result { - let mut first = run_conformance_observed(tsfacts_binary, corpus_root, repository_revision)?; - let mut repeated = run_conformance_observed(tsfacts_binary, corpus_root, repository_revision)?; - first.measurement.ordinal = 1; - repeated.measurement.ordinal = 2; + let first = run_conformance_observed(tsfacts_binary, corpus_root, repository_revision)?; + let repeated = run_conformance_observed(tsfacts_binary, corpus_root, repository_revision)?; let first_bytes = serde_json::to_vec(&first.report) .map_err(|error| format!("transport: serialize first conformance report: {error}"))?; let repeated_bytes = serde_json::to_vec(&repeated.report) .map_err(|error| format!("transport: serialize repeated conformance report: {error}"))?; let reports_equal = first_bytes == repeated_bytes; let conformance_passes = first.report.passes && repeated.report.passes; - let readiness = authority_readiness(&first.report.cases); - let (resident_bytes, resident_measurement) = resident_memory(); - let rust_executable_bytes = std::env::current_exe() - .ok() - .and_then(|path| fs::metadata(path).ok()) - .map_or(0, |metadata| metadata.len()); + let rust_executable = std::env::current_exe() + .map_err(|error| format!("transport: resolve Rust rollout executable: {error}"))?; + let first_measurement = + run_production_boundary_sample(tsfacts_binary, &rust_executable, corpus_root, 1)?; + let repeated_measurement = + run_production_boundary_sample(tsfacts_binary, &rust_executable, corpus_root, 2)?; + let measurements_pass = [&first_measurement, &repeated_measurement] + .iter() + .all(|sample| { + sample.memory_comparable + && sample.served_go_responses_unchanged + && sample.shadow_observation_failures == 0 + }); + let failure_paths = exercise_failure_paths(); + let evidence_ready = + conformance_passes && reports_equal && measurements_pass && failure_paths.passes(); + let readiness = authority_readiness(&first.report.cases, evidence_ready); + let rust_executable_bytes = fs::metadata(&rust_executable).map_or(0, |metadata| metadata.len()); let go_executable_bytes = fs::metadata(tsfacts_binary).map_or(0, |metadata| metadata.len()); Ok(RolloutReport { @@ -553,6 +606,17 @@ pub fn run_rollout( ts7_producer_protocol_changed: false, external_consumer_behavior_changed: false, }, + boundary: ProductionBoundary { + name: "one-shot-child-process-shadow", + controller: "release rollout controller", + go_serving_process: "cmd/tsfacts JSON Lines serving child", + rust_shadow_process: "oxc-occurrence-map primitive-shadow-worker child", + request_scope: "same ordered manifest selections, capabilities, budgets, and project per classified corpus case", + served_output: "Go stdout only; Rust stdout is observation-only", + same_one_shot_child_process_boundary: true, + release_artifacts: true, + }, + failure_paths, determinism: DeterminismEvidence { complete_runs: 2, conformance_reports_byte_equal: reports_equal, @@ -560,18 +624,18 @@ pub fn run_rollout( }, conformance: first.report, measurements: RolloutMeasurements { - scope: "one-shot Go process versus in-process Rust producer over identical ordered requests; characterization only", - samples: vec![first.measurement, repeated.measurement], + scope: "sequential one-shot release child processes over identical ordered per-case requests; characterization, not a throughput benchmark", + output_scope: "raw successful child stdout bytes before decoding; Go JSON Lines and the internal Rust shadow payload have different schemas", + memory_scope: "per-child peak resident set from wait4 rusage, aggregated as the maximum across case processes for each producer", + samples: vec![first_measurement, repeated_measurement], artifacts: RolloutArtifacts { go_executable_bytes, rust_executable_bytes, - peak_or_current_controller_resident_bytes: resident_bytes, - resident_measurement, - memory_scope: "Rust rollout controller including decoded Go snapshots; excludes child Go process RSS", + scope: "on-disk bytes of the two release executables invoked at the selected child-process boundary", }, }, readiness, - passes: conformance_passes && reports_equal, + passes: evidence_ready, }) } @@ -591,8 +655,6 @@ fn run_conformance_observed( discovered_cases: case_directories.len(), ..CorpusCoverage::default() }; - let run_started = Instant::now(); - let mut measurement = MeasurementSample::default(); let mut cases = Vec::new(); for case_directory in case_directories { let manifest = read_manifest(&case_directory)?; @@ -621,46 +683,13 @@ fn run_conformance_observed( budgets: request.budgets, selections: request.selections.len(), }; - let go_started = Instant::now(); - let (snapshot, snapshot_bytes) = run_go_oracle(&tsfacts_binary, &case_directory, &request)?; - measurement.go_oracle_nanoseconds = measurement - .go_oracle_nanoseconds - .saturating_add(duration_nanoseconds(go_started.elapsed().as_nanos())); - measurement.go_snapshot_bytes = - measurement.go_snapshot_bytes.saturating_add(snapshot_bytes); - let selections = request - .selections - .iter() - .map(|selection| { - Ok(PrimitiveLiteralSelection { - file: selection.file.clone(), - span: Span { - start: u32::try_from(selection.start) - .map_err(|_| "selection start exceeds u32")?, - end: u32::try_from(selection.end) - .map_err(|_| "selection end exceeds u32")?, - }, - }) - }) - .collect::, String>>()?; + let (snapshot, _) = run_go_oracle(&tsfacts_binary, &case_directory, &request)?; + let selections = primitive_selections(&request)?; let limits = PrimitiveProducerLimits { max_type_nodes: usize::try_from(manifest.budgets.max_type_nodes).unwrap_or(usize::MAX), }; - let rust_started = Instant::now(); let first = produce_primitive_literals(&case_directory, &selections, limits)?; - measurement.rust_producer_nanoseconds = measurement - .rust_producer_nanoseconds - .saturating_add(duration_nanoseconds(rust_started.elapsed().as_nanos())); - measurement.rust_candidate_bytes = measurement.rust_candidate_bytes.saturating_add( - serde_json::to_vec(&first) - .map_err(|error| format!("transport: serialize Rust candidate output: {error}"))? - .len(), - ); - let repeated_started = Instant::now(); let repeated = produce_primitive_literals(&case_directory, &selections, limits)?; - measurement.rust_determinism_check_nanoseconds = measurement - .rust_determinism_check_nanoseconds - .saturating_add(duration_nanoseconds(repeated_started.elapsed().as_nanos())); let repeated_equal = serde_json::to_vec(&first).ok() == serde_json::to_vec(&repeated).ok(); let expectations = manifest .selections @@ -694,8 +723,6 @@ fn run_conformance_observed( let threshold = compatibility_threshold(); let passes = threshold_passes(&summary, threshold); let (typescript_version, typescript_revision) = compiler_identity(&cases)?; - measurement.cases = cases.len(); - measurement.total_nanoseconds = duration_nanoseconds(run_started.elapsed().as_nanos()); let report = ConformanceReport { schema_version: CONFORMANCE_SCHEMA_VERSION, gate_kind: "go-vs-independent-rust-semantic-conformance", @@ -718,10 +745,268 @@ fn run_conformance_observed( summary, passes, }; - Ok(CompletedConformanceRun { - report, - measurement, - }) + Ok(CompletedConformanceRun { report }) +} + +fn run_production_boundary_sample( + tsfacts_binary: &Path, + rust_executable: &Path, + corpus_root: &Path, + ordinal: usize, +) -> Result { + let tsfacts_binary = tsfacts_binary + .canonicalize() + .map_err(|error| format!("transport: resolve {}: {error}", tsfacts_binary.display()))?; + let rust_executable = rust_executable + .canonicalize() + .map_err(|error| format!("transport: resolve {}: {error}", rust_executable.display()))?; + let corpus_root = corpus_root + .canonicalize() + .map_err(|error| format!("transport: resolve {}: {error}", corpus_root.display()))?; + let mut sample = MeasurementSample { + ordinal, + served_go_responses_unchanged: true, + ..MeasurementSample::default() + }; + let mut resident_methods = BTreeSet::new(); + let mut controller = ServingShadowController::default(); + + for case_directory in sorted_case_directories(&corpus_root)? { + let manifest = read_manifest(&case_directory)?; + let expectation_count = manifest + .selections + .iter() + .filter(|selection| selection.conformance.is_some()) + .count(); + if expectation_count == 0 { + continue; + } + if expectation_count != manifest.selections.len() { + return Err(format!( + "transport: case {:?} has {expectation_count}/{} conformance expectations; measured cases must classify every fixture", + manifest.name, + manifest.selections.len() + )); + } + validate_expectations(&manifest)?; + let request = build_request(&case_directory, &manifest)?; + let go_request = serde_json::to_vec(&request) + .map_err(|error| format!("transport: encode Go serving request: {error}"))?; + let shadow_request = serde_json::to_vec(&PrimitiveShadowRequest { + schema_version: request.schema_version, + project: request.project.to_owned(), + required_capabilities: request.required_capabilities.to_vec(), + budgets: PrimitiveShadowBudgets { + max_type_nodes: request.budgets.max_type_nodes, + max_type_depth: request.budgets.max_type_depth, + }, + selections: primitive_selections(&request)?, + limits: PrimitiveProducerLimits { + max_type_nodes: usize::try_from(manifest.budgets.max_type_nodes) + .unwrap_or(usize::MAX), + }, + }) + .map_err(|error| format!("transport: encode internal Rust shadow request: {error}"))?; + + let mut go_command = Command::new(&tsfacts_binary); + go_command.current_dir(&case_directory); + let go = run_child(&mut go_command, &go_request) + .map_err(|error| format!("transport: Go serving child: {error}"))?; + require_success("Go serving", &go)?; + SemanticSnapshot::from_json_lines(BufReader::new(go.stdout.as_slice())) + .map_err(|error| format!("transport: decode Go serving output: {error}"))?; + observe_process(&mut sample, &mut resident_methods, &go, true); + + let expected_go_response = go.stdout.clone(); + let mut rust_process = None; + let decision = controller.serve_with_shadow(Ok(expected_go_response.clone()), || { + let mut rust_command = Command::new(&rust_executable); + rust_command + .arg("primitive-shadow-worker") + .arg(&case_directory); + let observation = run_child(&mut rust_command, &shadow_request) + .map_err(|error| format!("transport: Rust shadow child: {error}"))?; + require_success("Rust shadow", &observation)?; + validate_shadow_output(&observation.stdout, request.selections.len())?; + let response = observation.stdout.clone(); + rust_process = Some(observation); + Ok(response) + })?; + + sample.served_go_responses_unchanged &= decision.served_response == expected_go_response; + match decision.shadow_observation { + ShadowObservation::Observed => { + let rust = rust_process.as_ref().ok_or_else(|| { + "transport: observed Rust shadow has no process result".to_owned() + })?; + observe_process(&mut sample, &mut resident_methods, rust, false); + } + ShadowObservation::FailedAndRolledBack => { + sample.shadow_observation_failures += 1; + } + ShadowObservation::SkippedAfterRollback => { + return Err( + "transport: normal measurement unexpectedly skipped the Rust shadow".to_owned(), + ); + } + } + sample.cases += 1; + } + + sample.resident_measurement = resident_methods + .iter() + .cloned() + .collect::>() + .join(","); + sample.memory_comparable = sample.go_peak_resident_bytes.is_some() + && sample.rust_peak_resident_bytes.is_some() + && resident_methods.len() == 1; + Ok(sample) +} + +fn primitive_selections( + request: &ProducerRequest<'_>, +) -> Result, String> { + request + .selections + .iter() + .map(|selection| { + Ok(PrimitiveLiteralSelection { + file: selection.file.clone(), + span: Span { + start: u32::try_from(selection.start) + .map_err(|_| "selection start exceeds u32")?, + end: u32::try_from(selection.end).map_err(|_| "selection end exceeds u32")?, + }, + }) + }) + .collect() +} + +fn require_success(name: &str, observation: &ProcessObservation) -> Result<(), String> { + if observation.status.success() { + Ok(()) + } else { + Err(format!( + "{name} exited with {}: {}", + observation.status, + String::from_utf8_lossy(&observation.stderr).trim() + )) + } +} + +fn validate_shadow_output(bytes: &[u8], expected_candidates: usize) -> Result<(), String> { + let output: serde_json::Value = serde_json::from_slice(bytes) + .map_err(|error| format!("transport: decode internal Rust shadow output: {error}"))?; + let producer_version = output + .get("producerVersion") + .and_then(serde_json::Value::as_u64); + let candidates = output + .get("candidates") + .and_then(serde_json::Value::as_array) + .map(Vec::len); + if producer_version != Some(u64::from(INDEPENDENT_PRIMITIVE_LITERAL_PRODUCER_VERSION)) + || candidates != Some(expected_candidates) + { + return Err(format!( + "transport: Rust shadow output contract mismatch: producerVersion={producer_version:?}, candidates={candidates:?}, expectedCandidates={expected_candidates}" + )); + } + Ok(()) +} + +fn observe_process( + sample: &mut MeasurementSample, + resident_methods: &mut BTreeSet, + observation: &ProcessObservation, + go: bool, +) { + resident_methods.insert(observation.resident_measurement.to_owned()); + if go { + sample.go_serving_wall_nanoseconds = sample + .go_serving_wall_nanoseconds + .saturating_add(observation.wall_nanoseconds); + sample.go_served_output_bytes = sample + .go_served_output_bytes + .saturating_add(observation.stdout.len()); + sample.go_peak_resident_bytes = max_option( + sample.go_peak_resident_bytes, + observation.peak_resident_bytes, + ); + } else { + sample.rust_shadow_wall_nanoseconds = sample + .rust_shadow_wall_nanoseconds + .saturating_add(observation.wall_nanoseconds); + sample.rust_shadow_output_bytes = sample + .rust_shadow_output_bytes + .saturating_add(observation.stdout.len()); + sample.rust_peak_resident_bytes = max_option( + sample.rust_peak_resident_bytes, + observation.peak_resident_bytes, + ); + } +} + +fn max_option(left: Option, right: Option) -> Option { + match (left, right) { + (Some(left), Some(right)) => Some(left.max(right)), + (left, right) => left.or(right), + } +} + +fn exercise_failure_paths() -> FailurePathEvidence { + let expected = b"go-serving-response".to_vec(); + let mut controller = ServingShadowController::default(); + let failed = controller + .serve_with_shadow(Ok(expected.clone()), || { + Err("forced shadow failure".to_owned()) + }) + .expect("the Go response remains serviceable after a shadow failure"); + let go_response_survives_shadow_failure = failed.served_response == expected; + let shadow_failure_is_observed = + failed.shadow_observation == ShadowObservation::FailedAndRolledBack; + let shadow_failure_detail_retained = + failed.shadow_failure.as_deref() == Some("forced shadow failure"); + let rollback_disables_shadow = !controller.shadow_enabled(); + + let mut shadow_called = false; + let skipped = controller + .serve_with_shadow(Ok(expected.clone()), || { + shadow_called = true; + Ok(Vec::new()) + }) + .expect("the rolled-back shadow cannot affect Go serving"); + let rolled_back_request_skips_shadow = !shadow_called + && skipped.served_response == expected + && skipped.shadow_observation == ShadowObservation::SkippedAfterRollback; + + controller.reset_shadow(); + let reset = controller + .serve_with_shadow(Ok(expected), || Ok(b"shadow-response".to_vec())) + .expect("an explicit reset re-enables shadow observation"); + let explicit_reset_reenables_shadow = + controller.shadow_enabled() && reset.shadow_observation == ShadowObservation::Observed; + + let mut authority_failure_controller = ServingShadowController::default(); + let mut authority_shadow_called = false; + let authority_failure = authority_failure_controller.serve_with_shadow( + Err("forced Go authority failure".to_owned()), + || { + authority_shadow_called = true; + Ok(Vec::new()) + }, + ); + let go_failure_is_not_masked = authority_failure.is_err() && !authority_shadow_called; + + FailurePathEvidence { + go_response_survives_shadow_failure, + shadow_failure_is_observed, + shadow_failure_detail_retained, + rollback_disables_shadow, + rolled_back_request_skips_shadow, + explicit_reset_reenables_shadow, + go_failure_is_not_masked, + } } fn compatibility_threshold() -> CompatibilityThreshold { @@ -771,7 +1056,7 @@ fn compiler_identity(cases: &[ConformanceCase]) -> Result<(String, String), Stri Ok((version, revision)) } -fn authority_readiness(cases: &[ConformanceCase]) -> AuthorityReadiness { +fn authority_readiness(cases: &[ConformanceCase], evidence_ready: bool) -> AuthorityReadiness { let resolved_rollout_limitations = cases .iter() .flat_map(|case| { @@ -790,18 +1075,32 @@ fn authority_readiness(cases: &[ConformanceCase]) -> AuthorityReadiness { }) }) .collect(); - let blockers = vec![ - "the Rust producer is not integrated into the serving path, so production fallback and rollback have not been exercised" - .to_owned(), - "runtime and output measurements compare a one-shot Go process with an in-process Rust shadow path; a production-equivalent boundary is not selected" - .to_owned(), - "controller RSS excludes the child Go process, so per-producer peak-memory parity is not established" - .to_owned(), - ]; + let blockers = if evidence_ready { + Vec::new() + } else { + vec![ + "production-boundary conformance, determinism, failure-path, or comparable-measurement evidence did not pass" + .to_owned(), + ] + }; AuthorityReadiness { - ready_for_later_authority_decision: false, - status: "not-ready", + ready_for_later_authority_decision: evidence_ready, + status: if evidence_ready { + "evidence-ready-authority-unchanged" + } else { + "not-ready" + }, resolved_rollout_limitations, + remaining_caveats: vec![ + "the four unsupported primitive/literal selections and three recovery-file mapping gaps remain explicitly fixture-classified limitations" + .to_owned(), + "raw producer-output byte counts characterize different internal payload schemas and are not protocol-parity evidence" + .to_owned(), + "sequential one-shot process samples characterize the selected boundary; they are not daemon throughput or latency benchmarks" + .to_owned(), + "peak resident memory uses Unix wait4 rusage and requires a separately justified method on non-Unix platforms" + .to_owned(), + ], blockers, } } @@ -1722,10 +2021,6 @@ fn ratio_ppm(numerator: usize, denominator: usize) -> u64 { } } -fn duration_nanoseconds(value: u128) -> u64 { - u64::try_from(value).unwrap_or(u64::MAX) -} - fn tool_version(command: &str, args: &[&str]) -> String { Command::new(command) .args(args) @@ -1933,7 +2228,7 @@ mod tests { } #[test] - fn exact_shadow_conformance_does_not_imply_authority_readiness() { + fn exact_shadow_conformance_requires_boundary_evidence_for_readiness() { let summary = ConformanceSummary { supported_records: 29, matched_supported_records: 29, @@ -1950,10 +2245,20 @@ mod tests { }; assert!(threshold_passes(&summary, compatibility_threshold())); - let readiness = authority_readiness(&[]); + let readiness = authority_readiness(&[], false); assert!(!readiness.ready_for_later_authority_decision); assert_eq!(readiness.status, "not-ready"); assert!(readiness.resolved_rollout_limitations.is_empty()); - assert_eq!(readiness.blockers.len(), 3); + assert_eq!(readiness.blockers.len(), 1); + + let ready = authority_readiness(&[], true); + assert!(ready.ready_for_later_authority_decision); + assert_eq!(ready.status, "evidence-ready-authority-unchanged"); + assert!(ready.blockers.is_empty()); + } + + #[test] + fn reported_failure_paths_are_executable_and_pass() { + assert!(exercise_failure_paths().passes()); } } diff --git a/internal/oxc_reference/crates/occurrence-consumer/src/lib.rs b/internal/oxc_reference/crates/occurrence-consumer/src/lib.rs index 9f7ba3894..326f9f033 100644 --- a/internal/oxc_reference/crates/occurrence-consumer/src/lib.rs +++ b/internal/oxc_reference/crates/occurrence-consumer/src/lib.rs @@ -7,6 +7,7 @@ pub mod fixture; pub mod inspector; pub mod oxc; pub mod primitive_producer; +pub mod serving; #[cfg(test)] mod tests { diff --git a/internal/oxc_reference/crates/occurrence-consumer/src/main.rs b/internal/oxc_reference/crates/occurrence-consumer/src/main.rs index da71d266d..caf96fe5e 100644 --- a/internal/oxc_reference/crates/occurrence-consumer/src/main.rs +++ b/internal/oxc_reference/crates/occurrence-consumer/src/main.rs @@ -9,6 +9,7 @@ use oxc_occurrence_consumer::{ fixture::load_fixtures, inspector::{InspectionReport, InspectorLimits}, oxc::OxcConsumer, + primitive_producer::{PrimitiveShadowRequest, produce_primitive_literals}, }; use serde::Serialize; @@ -79,12 +80,46 @@ fn main() -> Result<(), String> { { print_rollout(tsfacts, corpus, revision, Some(output)) } + [command, project_root] if command == "primitive-shadow-worker" => { + run_primitive_shadow_worker(project_root) + } _ => Err( - "usage: oxc-occurrence-map fixtures | inspect [logical-file] | evidence [--output ] | conformance [--output ] | rollout [--output ]".to_owned(), + "usage: oxc-occurrence-map fixtures | inspect [logical-file] | evidence [--output ] | conformance [--output ] | rollout [--output ] | primitive-shadow-worker ".to_owned(), ), } } +fn run_primitive_shadow_worker(project_root: &str) -> Result<(), String> { + let request: PrimitiveShadowRequest = serde_json::from_reader(std::io::stdin().lock()) + .map_err(|error| format!("decode internal primitive shadow request: {error}"))?; + if request.schema_version != 1 { + return Err(format!( + "unsupported internal primitive shadow schema version {}", + request.schema_version + )); + } + let project_root = std::path::Path::new(project_root); + let project = project_root.join(&request.project); + if !project.is_file() { + return Err(format!( + "internal primitive shadow project does not exist: {}", + project.display() + )); + } + if request.budgets.max_type_nodes + != u32::try_from(request.limits.max_type_nodes).unwrap_or(u32::MAX) + { + return Err("internal primitive shadow node budgets do not match".to_owned()); + } + let output = produce_primitive_literals(project_root, &request.selections, request.limits)?; + println!( + "{}", + serde_json::to_string(&output) + .map_err(|error| format!("encode internal primitive shadow response: {error}"))? + ); + Ok(()) +} + fn print_rollout( tsfacts: &str, corpus: &str, @@ -113,7 +148,7 @@ fn print_rollout( if report.passes { Ok(()) } else { - Err("rollout gate failed: conformance must pass and repeated stable reports must be byte-equal".to_owned()) + Err("rollout gate failed: conformance, repeated determinism, failure paths, and comparable production-boundary measurements must pass".to_owned()) } } diff --git a/internal/oxc_reference/crates/occurrence-consumer/src/primitive_producer.rs b/internal/oxc_reference/crates/occurrence-consumer/src/primitive_producer.rs index 0668c1940..fe5d586fe 100644 --- a/internal/oxc_reference/crates/occurrence-consumer/src/primitive_producer.rs +++ b/internal/oxc_reference/crates/occurrence-consumer/src/primitive_producer.rs @@ -12,7 +12,7 @@ use oxc_ast::{ use oxc_parser::{ParseOptions, Parser}; use oxc_semantic::{NodeId, Semantic, SemanticBuilder}; use oxc_span::{GetSpan, SourceType}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::{ candidate::{ @@ -26,18 +26,37 @@ use crate::{ pub const INDEPENDENT_PRIMITIVE_LITERAL_PRODUCER_VERSION: u32 = 2; -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] pub struct PrimitiveLiteralSelection { pub file: String, pub span: Span, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct PrimitiveProducerLimits { pub max_type_nodes: usize, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PrimitiveShadowRequest { + pub schema_version: u32, + pub project: String, + pub required_capabilities: Vec, + pub budgets: PrimitiveShadowBudgets, + pub selections: Vec, + pub limits: PrimitiveProducerLimits, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PrimitiveShadowBudgets { + pub max_type_nodes: u32, + pub max_type_depth: u32, +} + impl Default for PrimitiveProducerLimits { fn default() -> Self { Self { diff --git a/internal/oxc_reference/crates/occurrence-consumer/src/serving.rs b/internal/oxc_reference/crates/occurrence-consumer/src/serving.rs new file mode 100644 index 000000000..4fd32d327 --- /dev/null +++ b/internal/oxc_reference/crates/occurrence-consumer/src/serving.rs @@ -0,0 +1,256 @@ +use std::{ + io::{Read, Write}, + process::{Child, Command, ExitStatus, Stdio}, + time::Instant, +}; + +use serde::Serialize; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ShadowObservation { + Observed, + FailedAndRolledBack, + SkippedAfterRollback, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ServingDecision { + pub served_response: Vec, + pub shadow_observation: ShadowObservation, + pub shadow_failure: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ServingShadowController { + shadow_enabled: bool, + last_shadow_failure: Option, +} + +impl Default for ServingShadowController { + fn default() -> Self { + Self { + shadow_enabled: true, + last_shadow_failure: None, + } + } +} + +impl ServingShadowController { + pub fn serve_with_shadow( + &mut self, + go_serving_result: Result, String>, + shadow: F, + ) -> Result + where + F: FnOnce() -> Result, String>, + { + let served_response = go_serving_result?; + if !self.shadow_enabled { + return Ok(ServingDecision { + served_response, + shadow_observation: ShadowObservation::SkippedAfterRollback, + shadow_failure: self.last_shadow_failure.clone(), + }); + } + + let (shadow_observation, shadow_failure) = match shadow() { + Ok(_) => (ShadowObservation::Observed, None), + Err(error) => { + self.shadow_enabled = false; + self.last_shadow_failure = Some(error.clone()); + (ShadowObservation::FailedAndRolledBack, Some(error)) + } + }; + Ok(ServingDecision { + served_response, + shadow_observation, + shadow_failure, + }) + } + + pub fn reset_shadow(&mut self) { + self.shadow_enabled = true; + self.last_shadow_failure = None; + } + + pub fn shadow_enabled(&self) -> bool { + self.shadow_enabled + } +} + +#[derive(Clone, Debug)] +pub struct ProcessObservation { + pub status: ExitStatus, + pub stdout: Vec, + pub stderr: Vec, + pub wall_nanoseconds: u64, + pub peak_resident_bytes: Option, + pub resident_measurement: &'static str, +} + +pub fn run_child(command: &mut Command, stdin: &[u8]) -> Result { + let started = Instant::now(); + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("launch {:?}: {error}", command.get_program()))?; + child + .stdin + .take() + .ok_or_else(|| "child stdin is unavailable".to_owned())? + .write_all(stdin) + .map_err(|error| format!("write child stdin: {error}"))?; + wait_with_usage(child, started) +} + +#[cfg(unix)] +fn wait_with_usage(mut child: Child, started: Instant) -> Result { + use std::os::unix::process::ExitStatusExt; + + let mut stdout = child + .stdout + .take() + .ok_or_else(|| "child stdout is unavailable".to_owned())?; + let mut stderr = child + .stderr + .take() + .ok_or_else(|| "child stderr is unavailable".to_owned())?; + let pid = i32::try_from(child.id()).map_err(|_| "child PID exceeds i32".to_owned())?; + let (status, usage, stdout, stderr) = std::thread::scope(|scope| { + let stdout_thread = scope.spawn(move || { + let mut bytes = Vec::new(); + stdout + .read_to_end(&mut bytes) + .map_err(|error| format!("read child stdout: {error}"))?; + Ok::<_, String>(bytes) + }); + let stderr_thread = scope.spawn(move || { + let mut bytes = Vec::new(); + stderr + .read_to_end(&mut bytes) + .map_err(|error| format!("read child stderr: {error}"))?; + Ok::<_, String>(bytes) + }); + let mut status = 0; + // SAFETY: `usage` is initialized before being passed to `wait4`; `pid` + // belongs to `child`, and both output pipes are drained concurrently so + // the child cannot block on a full pipe while `wait4` waits. + let mut usage = unsafe { std::mem::zeroed::() }; + // SAFETY: the pointers reference valid writable values for this call, + // and `wait4` retains neither pointer after returning. + let waited = unsafe { libc::wait4(pid, &raw mut status, 0, &raw mut usage) }; + let stdout = stdout_thread + .join() + .map_err(|_| "child stdout reader panicked".to_owned())??; + let stderr = stderr_thread + .join() + .map_err(|_| "child stderr reader panicked".to_owned())??; + if waited == -1 { + return Err(format!("wait4 child: {}", std::io::Error::last_os_error())); + } + Ok::<_, String>((ExitStatus::from_raw(status), usage, stdout, stderr)) + })?; + + #[cfg(target_os = "macos")] + let peak_resident_bytes = u64::try_from(usage.ru_maxrss).ok(); + #[cfg(not(target_os = "macos"))] + let peak_resident_bytes = u64::try_from(usage.ru_maxrss) + .ok() + .map(|kilobytes| kilobytes.saturating_mul(1024)); + + Ok(ProcessObservation { + status, + stdout, + stderr, + wall_nanoseconds: duration_nanoseconds(started.elapsed().as_nanos()), + peak_resident_bytes, + resident_measurement: "wait4-ru-maxrss-peak", + }) +} + +#[cfg(not(unix))] +fn wait_with_usage(child: Child, started: Instant) -> Result { + let output = child + .wait_with_output() + .map_err(|error| format!("wait for child: {error}"))?; + Ok(ProcessObservation { + status: output.status, + stdout: output.stdout, + stderr: output.stderr, + wall_nanoseconds: duration_nanoseconds(started.elapsed().as_nanos()), + peak_resident_bytes: None, + resident_measurement: "unavailable-on-this-platform", + }) +} + +fn duration_nanoseconds(value: u128) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shadow_failure_preserves_go_response_and_rolls_back() { + let expected = b"go-serving-response".to_vec(); + let mut controller = ServingShadowController::default(); + let failed = controller + .serve_with_shadow(Ok(expected.clone()), || Err("shadow failed".to_owned())) + .expect("Go response remains serviceable"); + assert_eq!(failed.served_response, expected); + assert_eq!( + failed.shadow_observation, + ShadowObservation::FailedAndRolledBack + ); + assert_eq!(failed.shadow_failure.as_deref(), Some("shadow failed")); + assert!(!controller.shadow_enabled()); + + let mut shadow_called = false; + let skipped = controller + .serve_with_shadow(Ok(expected.clone()), || { + shadow_called = true; + Ok(Vec::new()) + }) + .expect("rolled-back shadow cannot affect serving"); + assert!(!shadow_called); + assert_eq!(skipped.served_response, expected); + assert_eq!( + skipped.shadow_observation, + ShadowObservation::SkippedAfterRollback + ); + assert_eq!(skipped.shadow_failure.as_deref(), Some("shadow failed")); + } + + #[test] + fn explicit_reset_reenables_shadow_observation() { + let mut controller = ServingShadowController::default(); + controller + .serve_with_shadow(Ok(Vec::new()), || Err("shadow failed".to_owned())) + .expect("Go response remains serviceable"); + controller.reset_shadow(); + let observed = controller + .serve_with_shadow(Ok(Vec::new()), || Ok(b"shadow".to_vec())) + .expect("reset shadow is observable"); + assert_eq!(observed.shadow_observation, ShadowObservation::Observed); + assert!(observed.shadow_failure.is_none()); + assert!(controller.shadow_enabled()); + } + + #[test] + fn go_failure_is_not_masked_by_shadow() { + let mut controller = ServingShadowController::default(); + let mut shadow_called = false; + let error = controller + .serve_with_shadow(Err("Go serving failed".to_owned()), || { + shadow_called = true; + Ok(Vec::new()) + }) + .expect_err("the semantic authority failure must be returned"); + assert_eq!(error, "Go serving failed"); + assert!(!shadow_called); + } +}