diff --git a/.github/workflows/cases.yml b/.github/workflows/cases.yml index 61d99972..502933c5 100644 --- a/.github/workflows/cases.yml +++ b/.github/workflows/cases.yml @@ -88,24 +88,32 @@ jobs: echo "/usr/lib/llvm-21/bin" >> "$GITHUB_PATH" - name: Install Wave standard library + id: install_std run: | mkdir -p "$HOME/.wave/lib/wave" rm -rf "$HOME/.wave/lib/wave/std" cp -R std "$HOME/.wave/lib/wave/std" - name: Build release compiler + id: build_compiler run: cargo build --locked --release --jobs 2 - name: Check every target case if: ${{ matrix.id == 'linux-amd64' }} - run: | - set -euo pipefail - mapfile -t cases < <(python3 tools/case_manifest.py sources) - for source in "${cases[@]}"; do - target/release/wavec check "tests/cases/$source" - done + run: >- + python3 tools/check_case_sources.py --wavec target/release/wavec + --report-json wave-source-checks.json + + - name: Upload source check report + if: ${{ always() && matrix.id == 'linux-amd64' }} + uses: actions/upload-artifact@v4 + with: + name: wave-source-checks + path: wave-source-checks.json + if-no-files-found: warn - name: Run all Wave cases + if: ${{ !cancelled() && steps.install_std.outcome == 'success' && steps.build_compiler.outcome == 'success' }} run: >- python3 tools/run_tests.py --target-id "${{ matrix.id }}" --report-json wave-cases-${{ matrix.id }}.json @@ -345,21 +353,32 @@ jobs: New-Item -ItemType Directory -Force -Path $libraryRoot | Out-Null Copy-Item -Path std -Destination (Join-Path $libraryRoot "std") -Recurse + # An explicit Cargo target keeps +crt-static off host build scripts. + # llvm-sys 211 tests its own cfg!(crt-static) when classifying SDK + # import libraries; applying target flags to that script requests + # static bundling of psapi instead of leaving it for the MSVC linker. - name: Build native ARM64 compiler run: >- - cargo build --locked --release --no-default-features + cargo build --locked --verbose --release --target aarch64-pc-windows-msvc + --no-default-features --features llvm-target-aarch64 --jobs 2 - name: Verify native compiler architecture shell: pwsh run: | - $compiler = "target\release\wavec.exe" + $compiler = "target\aarch64-pc-windows-msvc\release\wavec.exe" $bytes = [System.IO.File]::ReadAllBytes($compiler) $peOffset = [BitConverter]::ToInt32($bytes, 0x3c) if ([BitConverter]::ToUInt16($bytes, $peOffset + 4) -ne 0xaa64) { throw "wavec.exe is not an ARM64 PE image" } + - name: Stage verified compiler for the case runner + shell: pwsh + run: | + New-Item -ItemType Directory -Force target/release | Out-Null + Copy-Item target/aarch64-pc-windows-msvc/release/wavec.exe target/release/wavec.exe -Force + - name: Run all Wave cases run: >- python tools/run_tests.py --target-id "${{ matrix.id }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb447afd..d7d788fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -176,7 +176,7 @@ jobs: - name: Validate Python tooling run: | python3 -m py_compile x.py tools/check_wave_corpus.py tools/case_manifest.py tools/populate_case_matrix.py tools/run_tests.py tools/test_contracts.py tools/test_case_manifest.py tools/test_test_contracts.py - python3 -m unittest tools.test_case_manifest tools.test_test_contracts + python3 -m unittest tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts - name: Run Rust tests run: cargo test --locked --all-targets --verbose diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 3925f3ab..ed1b19cf 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -80,7 +80,7 @@ jobs: - name: Validate Python tooling run: | python3 -m py_compile x.py tools/check_wave_corpus.py tools/case_manifest.py tools/populate_case_matrix.py tools/run_tests.py tools/test_contracts.py tools/test_case_manifest.py tools/test_test_contracts.py tools/process_tree.py tools/test_process_tree.py - python3 -m unittest tools.test_case_manifest tools.test_test_contracts tools.test_process_tree + python3 -m unittest tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree - name: Test Windows ARM64 dependency archive validation shell: pwsh @@ -221,7 +221,7 @@ jobs: - name: Validate Python tooling run: | python3 -m py_compile x.py tools/check_wave_corpus.py tools/case_manifest.py tools/populate_case_matrix.py tools/run_tests.py tools/test_contracts.py tools/test_case_manifest.py tools/test_test_contracts.py tools/process_tree.py tools/test_process_tree.py - python3 -m unittest tools.test_case_manifest tools.test_test_contracts tools.test_process_tree + python3 -m unittest tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree - name: Build release compiler run: cargo build --locked --release --verbose @@ -304,7 +304,7 @@ jobs: - name: Validate Python tooling run: | python3 -m py_compile x.py tools/check_wave_corpus.py tools/case_manifest.py tools/populate_case_matrix.py tools/run_tests.py tools/test_contracts.py tools/test_case_manifest.py tools/test_test_contracts.py tools/process_tree.py tools/test_process_tree.py - python3 -m unittest tools.test_case_manifest tools.test_test_contracts tools.test_process_tree + python3 -m unittest tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree - name: Build release compiler run: cargo build --locked --release --verbose @@ -575,7 +575,7 @@ jobs: - name: Validate Python tooling run: | python3 -m py_compile x.py tools/check_wave_corpus.py tools/case_manifest.py tools/populate_case_matrix.py tools/run_tests.py tools/test_contracts.py tools/test_case_manifest.py tools/test_test_contracts.py tools/process_tree.py tools/test_process_tree.py - python3 -m unittest tools.test_case_manifest tools.test_test_contracts tools.test_process_tree + python3 -m unittest tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree - name: Build release compiler run: cargo build --locked --release --verbose @@ -640,7 +640,7 @@ jobs: - name: Validate Python tooling run: | python3 -m py_compile x.py tools/check_wave_corpus.py tools/case_manifest.py tools/populate_case_matrix.py tools/run_tests.py tools/test_contracts.py tools/test_case_manifest.py tools/test_test_contracts.py tools/process_tree.py tools/test_process_tree.py - python3 -m unittest tools.test_case_manifest tools.test_test_contracts tools.test_process_tree + python3 -m unittest tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree - name: Build release compiler run: cargo build --locked --release --verbose @@ -770,7 +770,7 @@ jobs: PYTHONIOENCODING: "utf-8" run: | python -m py_compile x.py tools/check_wave_corpus.py tools/case_manifest.py tools/populate_case_matrix.py tools/run_tests.py tools/test_contracts.py tools/test_case_manifest.py tools/test_test_contracts.py tools/process_tree.py tools/test_process_tree.py - python -m unittest tools.test_case_manifest tools.test_test_contracts tools.test_process_tree + python -m unittest tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree - name: Build release compiler shell: msys2 {0} @@ -951,7 +951,7 @@ jobs: retention-days: 7 - name: Check native Windows process supervision - run: python -m unittest tools.test_process_tree + run: python -m unittest tools.test_check_case_sources tools.test_case_execution tools.test_process_tree - name: Run native ARM64 unit and frontend driver tests run: >- @@ -965,10 +965,24 @@ jobs: msvc_link_companions_keep_final_names_and_survive_failed_replacement - name: Run native ARM64 MSVC executor restart regression + id: native_runtime + env: + WAVE_RUNTIME_ARTIFACT_DIR: ${{ runner.temp }} run: >- cargo test --locked --test runtime_regressions --target aarch64-pc-windows-msvc --no-default-features --features llvm-target-aarch64 --jobs 2 + - name: Save failed native runtime compiler and fixture + if: ${{ failure() && steps.native_runtime.outcome == 'failure' }} + uses: actions/upload-artifact@v4 + with: + name: windows-arm64-runtime-failure + path: | + target/aarch64-pc-windows-msvc/debug/wavec.exe + target/aarch64-pc-windows-msvc/debug/wavec.pdb + ${{ runner.temp }}/wave-runtime-*/ + retention-days: 7 + - name: Install Wave stdlib shell: pwsh run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 883d6337..476a196e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,8 +123,12 @@ The build, cases, and release workflows run libxml2 2.13.9 from a SHA-256-pinned [GNOME source archive](https://download.gnome.org/sources/libxml2/2.13/), retaining the pre-2.14 XML ABI used by LLVM's static code. The build uses -native ARM64 clang-cl/MSVC tools, the DLL CRT used by default Rust MSVC -builds, and no optional iconv, compression, Python, or XML DLL dependencies. +native ARM64 clang-cl/MSVC tools and the static CRT matching the pinned LLVM +SDK, with no optional iconv, compression, Python, or XML DLL dependencies. +Use an explicit `--target aarch64-pc-windows-msvc` for native Cargo commands: +this keeps target CRT flags off host build scripts. In llvm-sys 211, applying +those flags to the build script changes Windows SDK import libraries such as +`psapi` into static-bundling requests before the final MSVC link. It supplies the SDK's `xml2s.lib` name and the Windows `bcrypt`/`ws2_32` imports, then checks every COFF archive member for machine `0xaa64` before Cargo links it. Release packaging includes the libxml2 copyright notice. diff --git a/src/module_resolver.rs b/src/module_resolver.rs index 28b4308c..f091e443 100644 --- a/src/module_resolver.rs +++ b/src/module_resolver.rs @@ -1005,178 +1005,190 @@ fn collect_pattern_bindings(pattern: &MatchPattern, locals: &mut HashSet } fn rewrite_expression( - expression: Expression, + mut expression: Expression, names: &NameContext, path: &Path, locals: &HashSet, ) -> Result { - Ok(match expression { - Expression::Located { value, span } => rewrite_expression(*value, names, path, locals) - .map_err(|e| e.with_span(Some(&span)))? - .with_span(Some(span)), - Expression::StructLiteral { name, fields } => Expression::StructLiteral { - name: rewrite_type_name(&name, names, path)?, - fields: fields - .into_iter() - .map(|(name, value)| Ok((name, rewrite_expression(value, names, path, locals)?))) - .collect::>()?, - }, - Expression::FunctionCall { - name, - type_args, - args, - } => { - let symbol = resolve_name(&name, names, path)?; - let type_args = type_args - .into_iter() - .map(|ty| rewrite_type(ty, names, path)) - .collect::, _>>()?; - let args = rewrite_expressions(args, names, path, locals)?; - match symbol { - Some(symbol) if symbol.kind == SymbolKind::Struct && type_args.is_empty() => { - if !args.is_empty() { - return Err(module_error( - path, - "Invalid struct constructor", - format!("struct '{}' must be initialized with named fields", name), - "use `Type { field: value }`; `Type()` is only valid for empty structs", - )); - } - Expression::StructLiteral { - name: symbol.lowered, - fields: Vec::new(), + rewrite_expression_in_place(&mut expression, names, path, locals)?; + Ok(expression) +} + +// Keep expression traversal on a heap worklist. Reconstructing owned variants +// in recursive debug frames exhausted the 1 MiB native Windows process stack +// while resolving ordinary expressions imported from std::task. +fn rewrite_expression_in_place( + expression: &mut Expression, + names: &NameContext, + path: &Path, + locals: &HashSet, +) -> Result<(), WaveError> { + enum Work<'a> { + Expression(&'a mut Expression), + Types(&'a mut [WaveType]), + Failure(Box), + } + let mut pending = vec![(Work::Expression(expression), None)]; + while let Some((work, enclosing_span)) = pending.pop() { + let result = (|| -> Result<(), WaveError> { + let expression = match work { + Work::Types(types) => { + for ty in types { + *ty = rewrite_type(std::mem::replace(ty, WaveType::Void), names, path)?; } + return Ok(()); } - Some(symbol) - if matches!( - symbol.kind, - SymbolKind::Function | SymbolKind::VariantConstructor - ) => - { - Expression::FunctionCall { - name: symbol.lowered, - type_args, - args, + Work::Failure(error) => return Err(*error), + Work::Expression(expression) => expression, + }; + if let Expression::FunctionCall { + name, + type_args, + args, + } = expression + { + if type_args.is_empty() && args.is_empty() { + if let Some(symbol) = resolve_name(name, names, path)? { + if symbol.kind == SymbolKind::Struct { + *expression = Expression::StructLiteral { + name: symbol.lowered, + fields: Vec::new(), + }; + return Ok(()); + } } } - Some(_) => { - return Err(module_error( - path, - "Symbol is not callable", - format!("symbol '{}' cannot be called", name), - "call a function or construct an empty struct", - )) + } + match expression { + Expression::Located { value, span } => { + // Recursive callers previously applied the outermost span + // last. Retain that diagnostic location without recursion. + pending.push((Work::Expression(value), enclosing_span.or(Some(&*span)))); } - None => Expression::FunctionCall { + Expression::StructLiteral { name, fields } => { + *name = rewrite_type_name(name, names, path)?; + for (_, value) in fields.iter_mut().rev() { + pending.push((Work::Expression(value), enclosing_span)); + } + } + Expression::FunctionCall { name, type_args, args, - }, + } => { + let symbol = resolve_name(name, names, path)?; + for ty in type_args.iter_mut() { + *ty = rewrite_type(std::mem::replace(ty, WaveType::Void), names, path)?; + } + match symbol { + Some(symbol) + if symbol.kind == SymbolKind::Struct && type_args.is_empty() => + { + pending.push((Work::Failure(Box::new(module_error( + path, + "Invalid struct constructor", + format!("struct '{}' must be initialized with named fields", name), + "use `Type { field: value }`; `Type()` is only valid for empty structs", + ))), enclosing_span)); + } + Some(symbol) + if matches!( + symbol.kind, + SymbolKind::Function | SymbolKind::VariantConstructor + ) => + { + *name = symbol.lowered; + } + Some(_) => { + pending.push(( + Work::Failure(Box::new(module_error( + path, + "Symbol is not callable", + format!("symbol '{}' cannot be called", name), + "call a function or construct an empty struct", + ))), + enclosing_span, + )); + } + None => {} + } + // Arguments still report errors before a non-callable + // symbol/invalid-constructor error, in source order. + for arg in args.iter_mut().rev() { + pending.push((Work::Expression(arg), enclosing_span)); + } + } + Expression::MethodCall { + object, + type_args, + args, + .. + } => { + for arg in args.iter_mut().rev() { + pending.push((Work::Expression(arg), enclosing_span)); + } + pending.push((Work::Types(type_args), enclosing_span)); + pending.push((Work::Expression(object), enclosing_span)); + } + Expression::Variable(name) => { + if !locals.contains(name) { + if let Some(symbol) = resolve_name(name, names, path)? { + *name = symbol.lowered; + } + } + } + Expression::Deref(inner) + | Expression::AddressOf(inner) + | Expression::Await(inner) + | Expression::Grouped(inner) => { + pending.push((Work::Expression(inner), enclosing_span)) + } + Expression::BinaryExpression { left, right, .. } => { + pending.push((Work::Expression(right), enclosing_span)); + pending.push((Work::Expression(left), enclosing_span)); + } + Expression::IndexAccess { target, index } => { + pending.push((Work::Expression(index), enclosing_span)); + pending.push((Work::Expression(target), enclosing_span)); + } + Expression::ArrayLiteral(values) => { + for value in values.iter_mut().rev() { + pending.push((Work::Expression(value), enclosing_span)); + } + } + Expression::AssignOperation { target, value, .. } + | Expression::Assignment { target, value } => { + pending.push((Work::Expression(value), enclosing_span)); + pending.push((Work::Expression(target), enclosing_span)); + } + Expression::AsmBlock { + inputs, outputs, .. + } => { + for (_, value) in inputs.iter_mut().chain(outputs).rev() { + pending.push((Work::Expression(value), enclosing_span)); + } + } + Expression::FieldAccess { object, .. } => { + pending.push((Work::Expression(object), enclosing_span)); + } + Expression::Unary { expr, .. } => { + pending.push((Work::Expression(expr), enclosing_span)) + } + Expression::Cast { expr, target_type } => { + pending.push(( + Work::Types(std::slice::from_mut(target_type)), + enclosing_span, + )); + pending.push((Work::Expression(expr), enclosing_span)); + } + Expression::IncDec { target, .. } => { + pending.push((Work::Expression(target), enclosing_span)) + } + Expression::Null | Expression::Literal(_) => {} } - } - Expression::MethodCall { - object, - name, - args, - type_args, - } => Expression::MethodCall { - object: Box::new(rewrite_expression(*object, names, path, locals)?), - name, - type_args: type_args - .into_iter() - .map(|ty| rewrite_type(ty, names, path)) - .collect::>()?, - args: rewrite_expressions(args, names, path, locals)?, - }, - Expression::Variable(name) => Expression::Variable(if locals.contains(&name) { - name - } else { - resolve_name(&name, names, path)?.map_or(name, |symbol| symbol.lowered) - }), - Expression::Deref(inner) => { - Expression::Deref(Box::new(rewrite_expression(*inner, names, path, locals)?)) - } - Expression::AddressOf(inner) => { - Expression::AddressOf(Box::new(rewrite_expression(*inner, names, path, locals)?)) - } - Expression::BinaryExpression { - left, - operator, - right, - } => Expression::BinaryExpression { - left: Box::new(rewrite_expression(*left, names, path, locals)?), - operator, - right: Box::new(rewrite_expression(*right, names, path, locals)?), - }, - Expression::IndexAccess { target, index } => Expression::IndexAccess { - target: Box::new(rewrite_expression(*target, names, path, locals)?), - index: Box::new(rewrite_expression(*index, names, path, locals)?), - }, - Expression::ArrayLiteral(values) => { - Expression::ArrayLiteral(rewrite_expressions(values, names, path, locals)?) - } - Expression::Await(inner) => { - Expression::Await(Box::new(rewrite_expression(*inner, names, path, locals)?)) - } - Expression::Grouped(inner) => { - Expression::Grouped(Box::new(rewrite_expression(*inner, names, path, locals)?)) - } - Expression::AssignOperation { - target, - operator, - value, - } => Expression::AssignOperation { - target: Box::new(rewrite_expression(*target, names, path, locals)?), - operator, - value: Box::new(rewrite_expression(*value, names, path, locals)?), - }, - Expression::Assignment { target, value } => Expression::Assignment { - target: Box::new(rewrite_expression(*target, names, path, locals)?), - value: Box::new(rewrite_expression(*value, names, path, locals)?), - }, - Expression::AsmBlock { - instructions, - inputs, - outputs, - clobbers, - } => Expression::AsmBlock { - instructions, - inputs: inputs - .into_iter() - .map(|(constraint, expression)| { - Ok(( - constraint, - rewrite_expression(expression, names, path, locals)?, - )) - }) - .collect::>()?, - outputs: outputs - .into_iter() - .map(|(constraint, expression)| { - Ok(( - constraint, - rewrite_expression(expression, names, path, locals)?, - )) - }) - .collect::>()?, - clobbers, - }, - Expression::FieldAccess { object, field } => Expression::FieldAccess { - object: Box::new(rewrite_expression(*object, names, path, locals)?), - field, - }, - Expression::Unary { operator, expr } => Expression::Unary { - operator, - expr: Box::new(rewrite_expression(*expr, names, path, locals)?), - }, - Expression::Cast { expr, target_type } => Expression::Cast { - expr: Box::new(rewrite_expression(*expr, names, path, locals)?), - target_type: rewrite_type(target_type, names, path)?, - }, - Expression::IncDec { kind, target } => Expression::IncDec { - kind, - target: Box::new(rewrite_expression(*target, names, path, locals)?), - }, - other => other, - }) + Ok(()) + })(); + result.map_err(|e| e.with_span(enclosing_span))?; + } + Ok(()) } diff --git a/tests/cases/linux/amd64/test13.wave b/tests/cases/linux/amd64/test13.wave index 8b95bd1f..1ed1cb4a 100644 --- a/tests/cases/linux/amd64/test13.wave +++ b/tests/cases/linux/amd64/test13.wave @@ -33,7 +33,6 @@ import("std::process::core")::{ import("std::process::spawn")::{ ProcPipeResult, ProcSpawnStdoutResult, - _proc_dup_child_fd, proc_spawn_exec_raw, proc_spawn, proc_make_pipe, diff --git a/tests/runtime_regressions.rs b/tests/runtime_regressions.rs index 9a65a7c2..edd51d3a 100644 --- a/tests/runtime_regressions.rs +++ b/tests/runtime_regressions.rs @@ -12,6 +12,10 @@ struct FixtureDirectory(PathBuf); impl Drop for FixtureDirectory { fn drop(&mut self) { + if std::thread::panicking() { + eprintln!("retained failed runtime fixture: {}", self.0.display()); + return; + } let _ = fs::remove_dir_all(&self.0); } } @@ -47,25 +51,36 @@ fn run_native_fixture(name: &str) { } let sequence = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed); let directory = FixtureDirectory( - std::env::temp_dir().join(format!("wave-runtime-{}-{sequence}", std::process::id())), + std::env::var_os("WAVE_RUNTIME_ARTIFACT_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join(format!("wave-runtime-{}-{sequence}", std::process::id())), ); fs::create_dir_all(&directory.0).unwrap(); let home = directory.0.join("home"); copy_tree(&root.join("std"), &home.join(".wave/lib/wave/std")); + fs::copy( + root.join("tests/fixtures").join(name), + directory.0.join("source.wave"), + ) + .unwrap(); let executable = directory.0.join(if cfg!(windows) { "fixture.exe" } else { "fixture" }); - let output = Command::new(&compiler) + let mut command = Command::new(&compiler); + command .env("HOME", &home) .env("USERPROFILE", &home) .args(["build", "--target", &host]) .arg(root.join("tests/fixtures").join(name)) .arg("-o") - .arg(&executable) - .output() - .unwrap(); + .arg(&executable); + fs::write(directory.0.join("command.txt"), format!("{command:?}\n")).unwrap(); + let output = command.output().unwrap(); + fs::write(directory.0.join("compiler.stdout"), &output.stdout).unwrap(); + fs::write(directory.0.join("compiler.stderr"), &output.stderr).unwrap(); assert!( output.status.success(), "{name} compile: {}\n{}", @@ -91,6 +106,62 @@ fn run_native_fixture(name: &str) { ); } +#[cfg(all( + target_os = "linux", + any(feature = "llvm-target-aarch64", feature = "llvm-target-all") +))] +#[test] +fn executor_imports_compile_with_a_one_mib_process_stack() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let sequence = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed); + let directory = FixtureDirectory( + std::env::var_os("WAVE_RUNTIME_ARTIFACT_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join(format!("wave-runtime-{}-{sequence}", std::process::id())), + ); + let home = directory.0.join("home"); + copy_tree(&root.join("std"), &home.join(".wave/lib/wave/std")); + for phase in ["check", "ir", "obj"] { + let mut command = Command::new("python3"); + command + .current_dir(&root) + .env("HOME", &home) + .args([ + "-c", + r#" +import resource +import sys +from tools.process_tree import run_process + +resource.setrlimit( + resource.RLIMIT_STACK, + (1048576, resource.getrlimit(resource.RLIMIT_STACK)[1]), +) +result = run_process(sys.argv[1:], capture_output=True, text=True, timeout=30) +print(result.stdout) +print(result.stderr, file=sys.stderr) +raise SystemExit(result.returncode) +"#, + env!("CARGO_BIN_EXE_wavec"), + if phase == "check" { "check" } else { "build" }, + ]) + .arg(root.join("tests/fixtures/async/executor_restart.wave")) + .arg("--target=aarch64-pc-windows-msvc"); + if phase != "check" { + command.arg(format!("--emit={phase}")); + command.arg("--out-dir").arg(&directory.0); + } + let output = command.output().unwrap(); + assert!( + output.status.success(), + "{phase} with 1 MiB stack: {}\n{}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } +} + #[cfg(target_os = "linux")] #[test] fn child_standard_streams_preserve_shared_and_cyclic_descriptors() { diff --git a/tools/check_case_sources.py b/tools/check_case_sources.py new file mode 100644 index 00000000..924e790a --- /dev/null +++ b/tools/check_case_sources.py @@ -0,0 +1,61 @@ +"""Check all manifest sources and preserve every result, including failures.""" +import argparse +import json +from pathlib import Path +import subprocess +import sys + +try: + from tools.case_manifest import CASES_ROOT, ROOT, load_case_manifest + from tools.process_tree import run_process, timeout_output +except ModuleNotFoundError: + from case_manifest import CASES_ROOT, ROOT, load_case_manifest + from process_tree import run_process, timeout_output + + +def check_sources(wavec, sources, report, timeout=15): + records = [] + report = Path(report) + report.parent.mkdir(parents=True, exist_ok=True) + def save(): + report.write_text(json.dumps({"phase": "source-check", "results": records}, indent=2) + "\n", encoding="utf-8") + save() + for source in sources: + record = {"source": source, "status": "failed", "exit_code": None} + try: + result = run_process( + [str(wavec), "check", str(CASES_ROOT / source)], + cwd=ROOT, capture_output=True, text=True, timeout=timeout, + ) + record.update(exit_code=result.returncode, stdout=result.stdout, stderr=result.stderr) + if result.returncode == 0: + record["status"] = "passed" + except subprocess.TimeoutExpired as error: + record.update(status="timeout", error=timeout_output(error)) + except OSError as error: + record["error"] = str(error) + records.append(record) + save() # Retain completed checks even if a later invocation is interrupted. + print(f"[{record['status']}] {source}", flush=True) + if record["status"] != "passed": + print(record.get("stderr", "") or record.get("error", ""), flush=True) + return 0 if records and all(r["status"] == "passed" for r in records) else 1 + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wavec", type=Path, required=True) + parser.add_argument("--report-json", type=Path, required=True) + args = parser.parse_args(argv) + try: + sources = load_case_manifest().sources() + except (ValueError, OSError) as error: + args.report_json.parent.mkdir(parents=True, exist_ok=True) + args.report_json.write_text(json.dumps({"phase": "source-check", "error": str(error), "results": []}) + "\n", encoding="utf-8") + print(error, file=sys.stderr) + return 1 + return check_sources(args.wavec.resolve(), sources, args.report_json) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/run_tests.py b/tools/run_tests.py index debaad26..cd7624d6 100644 --- a/tools/run_tests.py +++ b/tools/run_tests.py @@ -356,10 +356,42 @@ def run_and_classify(name, rel_path, cmd): metadata = parse_test_metadata(rel_path) compile_target = manifest_compile_target() - expected_exit = 0 if compile_target is not None else metadata.expected_exit + phase = "compile" if compile_target is not None or metadata.mode != "run" else "build" + try: + if compile_target is None and metadata.mode == "run": + # A compiler/linker exit must never satisfy a program-exit contract. + # Use a fresh output directory so a stale executable cannot pass it. + with tempfile.TemporaryDirectory(prefix="runtime-", dir=TEST_OUTPUT_DIR) as directory: + executable = Path(directory) / ("case.exe" if HOST_OS == "windows" else "case") + build_cmd = [cmd[0], "build", *cmd[2:], "-o", str(executable)] + if metadata.target: + build_cmd.extend(["--target", metadata.target]) + built = run_process( + build_cmd, cwd=str(ROOT), stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, timeout=TIMEOUT_SEC, + ) + if built.returncode != 0 or not executable.is_file(): + detail = f"build failed (exit={built.returncode}, executable={executable.is_file()})" + print(f"{RED}→ FAIL ({detail}){RESET}") + print(built.stdout.rstrip()) + print(built.stderr.rstrip()) + return 0, detail + return classify_program(name, rel_path, [str(executable)], metadata, compile_target) + return classify_program(name, rel_path, cmd, metadata, compile_target) + except subprocess.TimeoutExpired as error: + detail = timeout_output(error) + print(f"{YELLOW}→ TIMEOUT ({phase}, {TIMEOUT_SEC}s){RESET}") + if detail: + print(detail) + return -1, f"{phase} timed out after {TIMEOUT_SEC}s" + except OSError as error: + print(f"{RED}→ FAIL ({phase}: {error}){RESET}") + return 0, f"{phase}: {error}" - stdin_data = f"{metadata.stdin}\n" if metadata.stdin is not None else None +def classify_program(name, rel_path, cmd, metadata, compile_target): + expected_exit = 0 if compile_target is not None or metadata.mode != "run" else metadata.expected_exit + stdin_data = f"{metadata.stdin}\n" if metadata.stdin is not None else None if compile_target is None and metadata.runner == "server": return run_server_test(cmd) @@ -438,6 +470,9 @@ def run_and_classify(name, rel_path, cmd): print() return 0, None + except OSError as error: + print(f"{RED}→ FAIL (launch: {error}){RESET}") + return 0, f"launch failed: {error}" except subprocess.TimeoutExpired as error: detail = timeout_output(error) if detail: diff --git a/tools/test_case_execution.py b/tools/test_case_execution.py new file mode 100644 index 00000000..2b1487b0 --- /dev/null +++ b/tools/test_case_execution.py @@ -0,0 +1,82 @@ +"""Program-exit contracts must not accept build or launch failures.""" +import contextlib +import io +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from tools import run_tests as runner +from tools.test_contracts import TestMetadata + + +class CaseExecutionTests(unittest.TestCase): + def classify(self, build_status=0, runtime_status=1, create=True, launch_error=None): + calls = [] + def execute(cmd, **kwargs): + calls.append((cmd, kwargs)) + if len(calls) == 1: + self.assertEqual(cmd[1], "build") + if create and build_status == 0: + Path(cmd[cmd.index("-o") + 1]).touch() + return subprocess.CompletedProcess(cmd, build_status, "", "build diagnostic") + if launch_error: + raise launch_error + return subprocess.CompletedProcess(cmd, runtime_status, "", "") + + with tempfile.TemporaryDirectory() as directory, contextlib.redirect_stdout(io.StringIO()): + metadata = TestMetadata(expected_exit=1, stdin="input", target="x86_64-pc-windows-msvc") + with patch.object(runner, "TEST_OUTPUT_DIR", Path(directory)), \ + patch.object(runner, "parse_test_metadata", return_value=metadata), \ + patch.object(runner, "manifest_compile_target", return_value=None), \ + patch.object(runner, "run_process", side_effect=execute): + result = runner.run_and_classify("case", "case.wave", ["wavec", "run", "case.wave"]) + self.assertEqual(list(Path(directory).iterdir()), []) + return result, calls + + def test_compile_link_and_compiler_crash_cannot_match_program_status(self): + for status in [1, 2, -11, 0xc0000005]: + with self.subTest(status=status): + result, calls = self.classify(build_status=status) + self.assertEqual(result[0], 0) + self.assertIn("build failed", result[1]) + self.assertEqual(len(calls), 1) + + def test_valid_nonzero_program_executes_after_build_with_target_and_stdin(self): + result, calls = self.classify() + self.assertEqual(result, (3, None)) + self.assertEqual(calls[0][0][-2:], ["--target", "x86_64-pc-windows-msvc"]) + self.assertNotIn("input", calls[0][1]) + self.assertEqual(calls[1][1]["input"], "input\n") + self.assertEqual(len(calls[1][0]), 1) + + def test_missing_output_is_not_executed(self): + result, calls = self.classify(create=False) + self.assertEqual(result[0], 0) + self.assertEqual(len(calls), 1) + + def test_launch_failure_does_not_satisfy_expected_exit(self): + result, _ = self.classify(launch_error=OSError("cannot launch")) + self.assertEqual(result[0], 0) + self.assertIn("launch failed", result[1]) + + def test_runtime_crash_does_not_satisfy_expected_exit(self): + for status in [-11, 0xc0000005]: + with self.subTest(status=status): + result, _ = self.classify(runtime_status=status) + self.assertEqual(result[0], 0) + + def test_build_timeout_remains_failure(self): + with tempfile.TemporaryDirectory() as directory, contextlib.redirect_stdout(io.StringIO()), \ + patch.object(runner, "TEST_OUTPUT_DIR", Path(directory)), \ + patch.object(runner, "parse_test_metadata", return_value=TestMetadata(expected_exit=1)), \ + patch.object(runner, "manifest_compile_target", return_value=None), \ + patch.object(runner, "run_process", side_effect=subprocess.TimeoutExpired("wavec", 5)): + result = runner.run_and_classify("case", "case.wave", ["wavec", "run", "case.wave"]) + self.assertEqual(result[0], -1) + self.assertIn("build timed out", result[1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/test_check_case_sources.py b/tools/test_check_case_sources.py new file mode 100644 index 00000000..a96abab3 --- /dev/null +++ b/tools/test_check_case_sources.py @@ -0,0 +1,54 @@ +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from tools import check_case_sources as checker + + +class SourceCheckTests(unittest.TestCase): + def test_checks_after_multiple_failures_and_keeps_diagnostics(self): + with tempfile.TemporaryDirectory() as directory: + report = Path(directory) / "report.json" + outcomes = [subprocess.CompletedProcess([], 1, "", "bad first"), + subprocess.CompletedProcess([], 2, "", "bad second"), + subprocess.CompletedProcess([], 0, "last ran", "")] + with patch.object(checker, "run_process", side_effect=outcomes) as run: + self.assertEqual(checker.check_sources("wavec", ["one", "two", "three"], report), 1) + self.assertEqual(run.call_count, 3) + records = json.loads(report.read_text())["results"] + self.assertEqual([r["exit_code"] for r in records], [1, 2, 0]) + self.assertEqual([r["status"] for r in records], ["failed", "failed", "passed"]) + self.assertEqual(records[1]["stderr"], "bad second") + self.assertEqual(records[2]["stdout"], "last ran") + + def test_launch_and_timeout_errors_do_not_hide_later_checks(self): + with tempfile.TemporaryDirectory() as directory: + report = Path(directory) / "report.json" + outcomes = [OSError("missing compiler"), subprocess.TimeoutExpired("wavec", 15), + subprocess.CompletedProcess([], 0, "", "")] + with patch.object(checker, "run_process", side_effect=outcomes): + self.assertEqual(checker.check_sources("wavec", ["a", "b", "c"], report), 1) + records = json.loads(report.read_text())["results"] + self.assertEqual([r["status"] for r in records], ["failed", "timeout", "passed"]) + self.assertIn("missing compiler", records[0]["error"]) + + def test_empty_checks_fail_and_all_success_passes(self): + with tempfile.TemporaryDirectory() as directory: + report = Path(directory) / "report.json" + self.assertEqual(checker.check_sources("wavec", [], report), 1) + with patch.object(checker, "run_process", return_value=subprocess.CompletedProcess([], 0, "", "")): + self.assertEqual(checker.check_sources("wavec", ["valid"], report), 0) + + def test_manifest_failure_leaves_a_failure_report(self): + with tempfile.TemporaryDirectory() as directory: + report = Path(directory) / "report.json" + with patch.object(checker, "load_case_manifest", side_effect=ValueError("invalid manifest")): + self.assertEqual(checker.main(["--wavec", "wavec", "--report-json", str(report)]), 1) + self.assertEqual(json.loads(report.read_text())["error"], "invalid manifest") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/test_process_tree.py b/tools/test_process_tree.py index cef2ce82..06aa96c2 100644 --- a/tools/test_process_tree.py +++ b/tools/test_process_tree.py @@ -113,8 +113,10 @@ def test_case_runner_keeps_timeout_classification_and_cleans_the_tree(self): patch.object(run_tests, "parse_test_metadata", return_value=TestMetadata()), \ patch.object(run_tests, "manifest_compile_target", return_value=None), \ contextlib.redirect_stdout(output): - status, detail = run_tests.run_and_classify("case", "case.wave", self.command) - self.assertEqual(status, -1) + status, detail = run_tests.classify_program( + "case", "case.wave", self.command, TestMetadata(), None + ) + self.assertEqual(status, -1, f"{detail}\n{output.getvalue()}") self.assertIn("timed out", detail) self.assertIn("compiler diagnostic", output.getvalue()) self.assert_descendant_stopped()