diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b1af733c..8bad1a15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -105,6 +105,7 @@ jobs: sudo apt-get update sudo apt-get install -y \ wget \ + ripgrep \ software-properties-common \ ca-certificates \ gnupg \ diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 5986614e..b4cd03dc 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -44,7 +44,7 @@ jobs: - name: Install LLVM 21 run: | sudo apt-get update - sudo apt-get install -y wget software-properties-common + sudo apt-get install -y wget software-properties-common ripgrep wget --inet4-only --tries=3 --timeout=30 --retry-on-host-error --retry-connrefused https://apt.llvm.org/llvm.sh chmod +x llvm.sh sudo ./llvm.sh 21 @@ -76,7 +76,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_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native tools.test_diagnose_windows_arm64 tools.test_runtime_selection tools.test_runtime_cases + python3 -m unittest tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native tools.test_diagnose_windows_arm64 tools.test_runtime_selection - name: Test Windows ARM64 dependency archive validation shell: pwsh @@ -145,7 +145,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_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native + python3 -m unittest tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native - name: Build release compiler run: cargo build --locked --release --verbose @@ -234,6 +234,11 @@ jobs: --features llvm-target-loongarch --test codegen_regressions loongarch64_ --jobs 2 + - name: Run frontend regressions with the LoongArch backend only + run: >- + cargo test --locked --no-default-features --features llvm-target-loongarch + --test frontend_regressions --jobs 2 + - name: Verify LoongArch64 CRT and target contracts env: WAVE_RUN_LOONGARCH64_INTEROP_TESTS: "1" @@ -347,7 +352,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_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native + python3 -m unittest tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native - name: Build release compiler id: release_build @@ -512,7 +517,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_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native + python3 -m unittest tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native - name: Build release compiler run: cargo build --locked --release --verbose @@ -582,7 +587,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_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native + python3 -m unittest tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree tools.test_check_msvc_native - name: Build release compiler run: cargo build --locked --release --verbose @@ -702,7 +707,7 @@ jobs: - name: Validate Windows Python tooling if: ${{ !cancelled() && steps.python_setup.outcome == 'success' }} run: >- - python -m unittest tools.test_check_case_sources tools.test_case_execution + python -m unittest tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_case_manifest tools.test_test_contracts tools.test_process_tree - name: Check PE dependency failures and case runner selection if: ${{ !cancelled() && steps.python_setup.outcome == 'success' }} @@ -834,7 +839,7 @@ jobs: - name: Check native Windows process supervision if: ${{ !cancelled() && steps.python_setup.outcome == 'success' }} - run: python -m unittest tools.test_check_case_sources tools.test_case_execution tools.test_process_tree + run: python -m unittest tools.test_release_gates tools.test_server_case tools.test_check_wave_corpus tools.test_runtime_cases tools.test_check_case_sources tools.test_case_execution tools.test_process_tree - name: Run native ARM64 unit and frontend driver tests if: ${{ !cancelled() && steps.native_build.outcome == 'success' }} @@ -1002,6 +1007,11 @@ jobs: cargo build --locked --release --no-default-features --features llvm-target-wasm --jobs 2 + - name: Run frontend regressions with the WebAssembly backend only + run: >- + cargo test --locked --no-default-features --features llvm-target-wasm + --test frontend_regressions --jobs 2 + - name: Run WebAssembly compiler regressions run: >- cargo test --locked --no-default-features diff --git a/std/env/cwd.wave b/std/env/cwd.wave index 8c23619c..24c6c784 100644 --- a/std/env/cwd.wave +++ b/std/env/cwd.wave @@ -56,16 +56,19 @@ import("std::sys::fs")::{ }; pub fun env_getcwd(dst: ptr, cap: i64) -> i64 { + if (dst == null || cap <= 0) { return -1; } var r: i64 = getcwd(dst, cap); - if (r <= 0) { + // FreeBSD returns zero on success; Linux returns the bytes written. + if (r < 0) { return -1; } var n: i64 = 0; - while (dst[n] != 0) { + while (n < cap && dst[n] != 0) { n += 1; } + if (n == cap) { return -1; } return n; } diff --git a/std/net/poll.wave b/std/net/poll.wave index 6ea9f46d..3657325e 100644 --- a/std/net/poll.wave +++ b/std/net/poll.wave @@ -51,7 +51,7 @@ const NET_POLL_POSIX: bool = true; const NET_POLL_POSIX: bool = false; pub fun net_wait_readable(fd: i64, timeout_ms: i32) -> i64 { - if (NET_POLL_POSIX && fd < 0) { return -9; } + if (NET_POLL_POSIX && (fd < 0 || fd > 2147483647)) { return -9; } var descriptor: PollFd = _net_poll_fd(fd, NET_POLLIN); var result: i64 = net_poll(&descriptor, 1, timeout_ms); if (result <= 0) { return result; } @@ -68,7 +68,7 @@ pub fun net_wait_readable(fd: i64, timeout_ms: i32) -> i64 { } pub fun net_wait_writable(fd: i64, timeout_ms: i32) -> i64 { - if (NET_POLL_POSIX && fd < 0) { return -9; } + if (NET_POLL_POSIX && (fd < 0 || fd > 2147483647)) { return -9; } var descriptor: PollFd = _net_poll_fd(fd, NET_POLLOUT); var result: i64 = net_poll(&descriptor, 1, timeout_ms); if (result <= 0) { return result; } diff --git a/tests/cases/freebsd/amd64/test14/main.wave b/tests/cases/freebsd/amd64/test14/main.wave new file mode 100644 index 00000000..bb198105 --- /dev/null +++ b/tests/cases/freebsd/amd64/test14/main.wave @@ -0,0 +1,16 @@ +// wave-test: mode=build, runner=compile, target=x86_64-unknown-freebsd, emit=obj, object-arch=x86_64, object-bits=64 +// SPDX-License-Identifier: MPL-2.0 +import("std::env::cwd")::{env_getcwd}; +fun main() -> i32 { + var buffer: array; + var length: i64 = env_getcwd(&buffer[0], 4096); + if (length <= 0 || length >= 4096 || buffer[length] != 0) { return 1; } + var i: i64 = 0; + while (i < length) { + if (buffer[i] == 0) { return 2; } + i += 1; + } + if (env_getcwd(&buffer[0], 1) != -1) { return 3; } + if (env_getcwd(null, 4096) != -1 || env_getcwd(&buffer[0], 0) != -1) { return 4; } + return 0; +} diff --git a/tests/cases/linux/amd64/test5.wave b/tests/cases/linux/amd64/test5.wave index 09245a06..1cb0f8d2 100644 --- a/tests/cases/linux/amd64/test5.wave +++ b/tests/cases/linux/amd64/test5.wave @@ -178,13 +178,17 @@ fun _write(fd: i64, buf: str, len: i64) -> i64 { fun new_server(port: i16) -> i64 { var sockfd: i64 = _socket_create_tcp(); + if (sockfd < 0) { + if (sockfd == -1 || sockfd == -13) { println("WAVE-SERVER-UNAVAILABLE {}", sockfd); } + return -1; + } _setsockopt_reuseaddr(sockfd); - if (sockfd < 0) { return -1; } var b: i64 = _socket_bind_any(sockfd, port); println("bind ret = {}", b); if (b < 0) { + if (b == -1 || b == -13) { println("WAVE-SERVER-UNAVAILABLE {}", b); } _socket_close(sockfd); return -1; } @@ -214,24 +218,54 @@ fun start(server_fd: i64) { println("Client connected! fd: {}", client_fd); - var response: str = "HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: 33\r\nConnection: close\r\n\r\nWelcome to the Wave HTTP Server!"; - - - _write(client_fd, response, len(response)); + var request: array; + var used: i64 = 0; + while (used < 1024) { + var count: i64 = syscall3pi(0, client_fd, &request[used] as ptr, 1024 - used); + if (count <= 0) { break; } + used += count; + if (used >= 4 && request[used - 4] == 13 && request[used - 3] == 10 && request[used - 2] == 13 && request[used - 1] == 10) { break; } + } + var response: str = "HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nConnection: close\r\n\r\nWelcome to the Wave HTTP Server!\n"; + write_all(client_fd, response as ptr, len(response) as i64); + write_all(client_fd, &request[0] as ptr, used); _socket_close(client_fd); println("Client disconnected."); } } -fun main() { - println("--- Wave HTTP Server Application ---"); - - var server_instance: i64 = new_server(8080).listen(10); +fun write_all(fd: i64, buffer: ptr, size: i64) { + var sent: i64 = 0; + while (sent < size) { + var count: i64 = syscall3pi(1, fd, buffer + sent, size - sent); + if (count <= 0) { return; } + sent += count; + } +} - if (server_instance >= 0) { - server_instance.start(); - } else { - println("Server failed to start."); +fun main() -> i32 { + var server_instance: i64 = new_server(0).listen(10); + if (server_instance < 0) { return 1; } + var address: SockAddrIn; + var address_size: i32 = 16; + var result: i64 = syscall3(51, server_instance, &address as i64, &address_size as i64); + if (result < 0) { return 2; } + var network_port: u16 = address.sin_port as u16; + var port: u32 = ((network_port as u32 & 255) << 8) | (network_port as u32 >> 8); + // The runner captures stdout, so publish readiness without stdio buffering. + var prefix: str = "WAVE-SERVER-READY "; + _write(1, prefix, len(prefix) as i64); + var digits: array; + digits[5] = 10; + var offset: i64 = 5; + var remaining: u32 = port; + while (remaining > 0) { + offset -= 1; + digits[offset] = (remaining % 10 + 48) as u8; + remaining /= 10; } + write_all(1, &digits[offset] as ptr, 6 - offset); + server_instance.start(); + return 0; } diff --git a/tests/fixtures/native_providers/cwd.wave b/tests/fixtures/native_providers/cwd.wave new file mode 100644 index 00000000..c572c9e3 --- /dev/null +++ b/tests/fixtures/native_providers/cwd.wave @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MPL-2.0 +import("std::env::cwd")::{env_getcwd}; +fun main() -> i32 { + var buffer: array; + var length: i64 = env_getcwd(&buffer[0], 4096); + if (length <= 0 || length >= 4096 || buffer[length] != 0) { return 1; } + var i: i64 = 0; + while (i < length) { + if (buffer[i] == 0) { return 2; } + i += 1; + } + if (env_getcwd(&buffer[0], 1) != -1) { return 3; } + if (env_getcwd(null, 4096) != -1 || env_getcwd(&buffer[0], 0) != -1) { return 4; } + return 0; +} diff --git a/tests/fixtures/native_providers/posix_poll_range.wave b/tests/fixtures/native_providers/posix_poll_range.wave new file mode 100644 index 00000000..ccb0d2a0 --- /dev/null +++ b/tests/fixtures/native_providers/posix_poll_range.wave @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +import("std::net::poll")::{PollFd, NET_POLLIN, net_poll, net_wait_readable, net_wait_writable, net_wait_connected}; +import("std::sys::fs")::{pipe, write, close}; +fun main() -> i32 { + var invalid: array = [-1, 2147483648, 4294967295, 4294967296, 9223372036854775807]; + var i: i32 = 0; + while (i < 5) { + if (net_wait_readable(invalid[i], -1) != -9) { return 1; } + if (net_wait_writable(invalid[i], -1) != -9) { return 2; } + if (net_wait_connected(invalid[i], -1) != -9) { return 3; } + i += 1; + } + var disabled: PollFd = PollFd { fd: -1, events: NET_POLLIN, revents: 0 }; + if (net_poll(&disabled, 1, 0) != 0) { return 4; } + var fds: array; + if (pipe(&fds[0]) < 0) { return 5; } + var read_fd: i64 = fds[0] as i64; + var write_fd: i64 = fds[1] as i64; + if (net_wait_readable(read_fd, 0) != 0 || net_wait_writable(write_fd, 0) != 1) { return 6; } + var payload: u8 = 42; + if (write(write_fd, &payload, 1) != 1 || net_wait_readable(read_fd, 0) != 1) { return 7; } + if (net_wait_readable(read_fd + 4294967296, 0) != -9) { return 8; } + close(read_fd); + close(write_fd); + return 0; +} diff --git a/tests/native_providers.rs b/tests/native_providers.rs index c797c132..261c2649 100644 --- a/tests/native_providers.rs +++ b/tests/native_providers.rs @@ -121,6 +121,32 @@ fn loongarch_attribute_aliases_select_identical_declarations() { fn native_provider_fixtures_compile_and_run_on_their_host() { let case = Case::new(); for (source, targets) in [ + ( + "cwd.wave", + vec![ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "riscv64-unknown-linux-gnu", + "loongarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", + "x86_64-unknown-freebsd", + ], + ), + ( + "posix_poll_range.wave", + vec![ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "riscv64-unknown-linux-gnu", + "loongarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-freebsd", + ], + ), ( "dup2.wave", vec![ @@ -584,3 +610,60 @@ fn network_error_values_remain_available_without_a_socket_provider() { ); } } + +#[test] +fn cwd_accepts_zero_success_without_scanning_outside_capacity() { + let target = host_target(); + if !supported(&target) { + return; + } + let case = Case::new(); + let provider = fs::read_to_string(root().join("std/env/cwd.wave")).unwrap(); + let body = provider + .split("pub fun env_getcwd") + .nth(1) + .unwrap() + .split("pub fun env_chdir") + .next() + .unwrap(); + let source = case.0.join("cwd-contract.wave"); + fs::write( + &source, + format!( + "pub fun env_getcwd{body}{}", + r#" +fun getcwd(dst: ptr, cap: i64) -> i64 { + if (cap == 4) { return -34; } + if (cap == 5) { + var i: i64 = 0; + while (i < cap) { dst[i] = 47; i += 1; } + return 0; + } + dst[0] = 47; dst[1] = 0; + if (cap == 3) { return 2; } + return 0; +} +fun main() -> i32 { + var buffer: array; + buffer[5] = 123; + if (env_getcwd(&buffer[0], 2) != 1) { return 1; } + if (env_getcwd(&buffer[0], 3) != 1) { return 2; } + if (env_getcwd(&buffer[0], 4) != -1) { return 3; } + if (env_getcwd(&buffer[0], 5) != -1 || buffer[5] != 123) { return 4; } + if (env_getcwd(null, 2) != -1 || env_getcwd(&buffer[0], 0) != -1) { return 5; } + return 0; +} +"# + ), + ) + .unwrap(); + for opt in ["-O0", "-O2"] { + let output = case.0.join("cwd-contract.exe"); + build(&case, &source, &target, opt, &output, false); + checked( + &mut Command::new(output), + "getcwd provider convention", + &case.0, + ); + } +} diff --git a/tools/check_case_sources.py b/tools/check_case_sources.py index 6b538c69..89e5f408 100644 --- a/tools/check_case_sources.py +++ b/tools/check_case_sources.py @@ -1,39 +1,21 @@ """Check all manifest sources and preserve every result, including failures.""" import argparse -import json import os from pathlib import Path import shutil import subprocess -import tempfile import sys try: from tools.case_manifest import CASES_ROOT, ROOT, load_case_manifest + from tools.validation_reports import write_report as _write_report, validate_report_path from tools.process_tree import run_process, timeout_output except ModuleNotFoundError: from case_manifest import CASES_ROOT, ROOT, load_case_manifest + from validation_reports import write_report as _write_report, validate_report_path from process_tree import run_process, timeout_output -def _write_report(report, payload): - report = Path(report) - report.parent.mkdir(parents=True, exist_ok=True) - fd, temp_name = tempfile.mkstemp(prefix=f".{report.name}.", suffix=".tmp", dir=report.parent) - temp_path = Path(temp_name) - try: - try: - stream = os.fdopen(fd, "w", encoding="utf-8") - except Exception: - os.close(fd) - raise - with stream: - stream.write(json.dumps(payload, indent=2) + "\n") - temp_path.replace(report) - finally: - temp_path.unlink(missing_ok=True) - - def validate_compiler(wavec: Path | str) -> Path: raw_str = str(wavec) if isinstance(wavec, Path) and not wavec.parts: @@ -65,8 +47,14 @@ def validate_compiler(wavec: Path | str) -> Path: def check_sources(wavec, sources, report, timeout=15): - records = [] + sources = list(sources) report = Path(report) + try: + validate_report_path(report, [Path(wavec), *(CASES_ROOT / name for name in sources)]) + except (OSError, ValueError) as error: + print(error, file=sys.stderr) + return 1 + records = [] def save(): _write_report(report, {"phase": "source-check", "results": records}) save() @@ -74,7 +62,7 @@ def save(): record = {"source": source, "status": "failed", "exit_code": None} try: result = run_process( - [str(wavec), "check", str(CASES_ROOT / source)], + [str(wavec), "check", str(CASES_ROOT / source), "--std-root", str(ROOT / "std")], cwd=ROOT, capture_output=True, text=True, timeout=timeout, ) record.update(exit_code=result.returncode, stdout=result.stdout, stderr=result.stderr) @@ -97,11 +85,18 @@ def main(argv=None): parser.add_argument("--wavec", required=True) parser.add_argument("--report-json", type=Path, required=True) args = parser.parse_args(argv) + sources = None try: sources = load_case_manifest().sources() compiler = validate_compiler(args.wavec) except (ValueError, OSError) as error: - _write_report(args.report_json, {"phase": "source-check", "error": str(error), "results": []}) + try: + protected = (list(CASES_ROOT.rglob("*.wave")) if sources is None + else [CASES_ROOT / name for name in sources]) + validate_report_path(args.report_json, [Path(args.wavec), ROOT / args.wavec, *protected]) + _write_report(args.report_json, {"phase": "source-check", "error": str(error), "results": []}) + except (ValueError, OSError) as report_error: + print(report_error, file=sys.stderr) print(error, file=sys.stderr) return 1 return check_sources(compiler, sources, args.report_json) diff --git a/tools/check_freebsd_sys.py b/tools/check_freebsd_sys.py index 8e42f24f..7fc73d24 100644 --- a/tools/check_freebsd_sys.py +++ b/tools/check_freebsd_sys.py @@ -119,7 +119,26 @@ def main(): report_path.write_text(json.dumps(report, indent=2) + "\n") +def discover_cases(suite): + cases = {} + for entry in suite.glob("test*"): + name = entry.stem if entry.is_file() and entry.suffix == ".wave" else entry.name + if not re.fullmatch(r"test[0-9]+", name): + raise ValueError(f"invalid FreeBSD case name: {entry}") + source = entry / "main.wave" if entry.is_dir() else entry + if not source.is_file() or source.suffix != ".wave": + raise ValueError(f"missing FreeBSD case source: {source}") + number = int(name[4:]) + if number in cases: + raise ValueError(f"ambiguous FreeBSD case number: {entry}") + cases[number] = (name, source) + if not cases: + raise ValueError("No FreeBSD provider cases selected") + return [cases[number] for number in sorted(cases)] + + def execute(args, report): + cases = discover_cases(ROOT / f"tests/cases/freebsd/{args.arch}") for path in [args.image, args.compiler]: if not path.is_file(): raise ValueError(f"File does not exist: {path}") @@ -139,21 +158,17 @@ def execute(args, report): runtime = work / "start.o" runner.run(args.clang, f"--target={triple}", "-O2", "-fno-builtin", "-ffreestanding", "-fno-stack-protector", "-c", ROOT / "tests/fixtures/freebsd_case_runtime/start.c", "-o", runtime) - cases = sorted((ROOT / f"tests/cases/freebsd/{args.arch}").glob("test*.wave"), - key=lambda path: int(path.stem.removeprefix("test"))) - if not cases: - raise RuntimeError("No FreeBSD provider cases selected") - report["cases"] = [{"name": f"{source.stem}-{opt}", "status": "not_run"} - for source in cases for opt in ("O0", "O2")] - for source in cases: + report["cases"] = [{"name": f"{name}-{opt}", "status": "not_run"} + for name, source in cases for opt in ("O0", "O2")] + for name, source in cases: for opt in ("O0", "O2"): - objects = work / f"{source.stem}-{opt}" + objects = work / f"{name}-{opt}" runner.run(args.compiler.resolve(), "build", source, "--std-root", ROOT / "std", "--target", triple, "--emit=obj", f"-{opt}", "--out-dir", objects) runner.run(args.linker, "-static", "-e", "_start", runtime, objects / f"{source.stem}.o", - "-o", iso_root / f"{source.stem}-{opt}") + "-o", iso_root / f"{name}-{opt}") (iso_root / "run.sh").write_text( - "for name in " + " ".join(source.stem for source in cases) + "; do\n for opt in O0 O2; do\n" + "for name in " + " ".join(name for name, source in cases) + "; do\n for opt in O0 O2; do\n" " /mnt/$name-$opt\n echo \"WAVE-RESULT $name-$opt $?\"\n" " done\ndone\n" ) diff --git a/tools/check_std_policy.py b/tools/check_std_policy.py new file mode 100644 index 00000000..f134b0f3 --- /dev/null +++ b/tools/check_std_policy.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +"""Check binding policy using tokens, independent of Wave source formatting.""" +from pathlib import Path +import re +import subprocess +import sys + +ROOT = Path(__file__).resolve().parent.parent +APPROVED_PROVIDER = re.compile( + r"std/sys/(linux|macos|freebsd)/((amd64|arm64|riscv64)/memory|resolver|interfaces|vector_io|event)\.wave" + r"|std/sys/wasm/(env|fs|io|memory|process|time)\.wave|std/sys/wasm64/memory\.wave" +) + + +def tokens(source): + # Strings/chars remain single tokens; comments (including nested blocks) + # separate tokens and cannot create declarations. This is not a Wave parser. + i, line = 0, 1 + while i < len(source): + ch = source[i] + if ch.isspace(): + line += ch == '\n' + i += 1 + elif source.startswith('//', i): + end = source.find('\n', i) + i = len(source) if end < 0 else end + elif source.startswith('/*', i): + depth, i = 1, i + 2 + while i < len(source) and depth: + if source.startswith('/*', i): + depth, i = depth + 1, i + 2 + elif source.startswith('*/', i): + depth, i = depth - 1, i + 2 + else: + line += source[i] == '\n' + i += 1 + if depth: + raise ValueError('unterminated block comment') + elif ch in ('"', "'"): + quote, start_line, value = ch, line, '' + i += 1 + while i < len(source) and source[i] != quote: + if source[i] == '\\': + i += 1 + if i == len(source): + break + if source[i] == 'x' and re.fullmatch('[0-9a-fA-F]{2}', source[i + 1:i + 3]): + value += chr(int(source[i + 1:i + 3], 16)) + i += 3 + continue + value += {'n': '\n', 'r': '\r', 't': '\t'}.get(source[i], source[i]) + else: + value += source[i] + line += source[i] == '\n' + i += 1 + if i == len(source): + raise ValueError('unterminated literal') + i += 1 + yield ('string' if quote == '"' else 'char', value, start_line) + elif ch.isalpha() or ch == '_': + start = i + i += 1 + while i < len(source) and (source[i].isalnum() or source[i] == '_'): + i += 1 + yield ('word', source[start:i], line) + else: + yield ('punct', ch, line) + i += 1 + + +def violations(path, source): + items = list(tokens(source)) + values = [value for _, value, _ in items] + std = path.startswith('std/') and not path.startswith('std/libc/') + for i, (kind, value, line) in enumerate(items): + if kind != 'word': + continue + if std and value == 'extern' and values[i + 1:i + 3] == ['(', 'c']: + if not APPROVED_PROVIDER.fullmatch(path): + yield line, 'extern(c) found outside approved C ABI providers' + if std and value == 'import' and values[i + 1:i + 2] == ['('] and i + 2 < len(items): + module = items[i + 2] + if module[0] == 'string' and module[1].startswith('std::libc::'): + yield line, 'std::libc import found outside std/libc' + if value == 'let' and (i == 0 or items[i - 1][2] < line or values[i - 1] in ('(', '{', ';', '}')): + j = i + 1 + (values[i + 1:i + 2] == ['mut']) + if j + 1 < len(items) and items[j][0] == 'word' and values[j + 1] == ':': + yield line, 'retired let declaration found' + + +def main(root=ROOT): + try: + found = subprocess.run(['rg', '--files', '--glob', '*.wave', '.'], cwd=root, + capture_output=True, text=True, check=False) + if found.returncode not in (0, 1): + raise RuntimeError(f'rg search failed (exit {found.returncode}): {found.stderr.strip()}') + failed = False + for name in found.stdout.splitlines(): + path = Path(name) + source = (root / path).read_text(encoding='utf-8') + for line, message in violations(path.as_posix().removeprefix('./'), source): + print(f'[FAIL] {path}:{line}: {message}') + failed = True + print('[result] FAILED' if failed else '[result] OK') + return int(failed) + except (OSError, ValueError, RuntimeError) as error: + print(f'[FAIL] std policy validation: {error}', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/check_std_policy.sh b/tools/check_std_policy.sh index 47ec8b7d..a867ae6e 100755 --- a/tools/check_std_policy.sh +++ b/tools/check_std_policy.sh @@ -2,45 +2,4 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$repo_root" - -failed=0 - -echo "[check] std policy validation" - -echo "[check] rule: extern(c) only in std/libc/** and approved target providers" -extern_hits="$(rg -n 'extern\(c([,)])' std --glob '*.wave' || true)" -if [[ -n "$extern_hits" ]]; then - non_libc_extern="$(printf '%s\n' "$extern_hits" | rg -v '^std/(libc/|sys/(linux|macos|freebsd)/((amd64|arm64|riscv64)/memory|resolver|interfaces|vector_io|event)\.wave:|sys/wasm/(env|fs|io|memory|process|time)\.wave:|sys/wasm64/memory\.wave:)' || true)" - if [[ -n "$non_libc_extern" ]]; then - echo "[FAIL] extern(c) found outside approved C ABI providers:" - printf '%s\n' "$non_libc_extern" - failed=1 - fi -fi - -echo "[check] rule: std/** must not import std::libc::*" -libc_import_hits="$(rg -n 'import\("std::libc::' std --glob '*.wave' || true)" -if [[ -n "$libc_import_hits" ]]; then - non_libc_imports="$(printf '%s\n' "$libc_import_hits" | rg -v '^std/libc/' || true)" - if [[ -n "$non_libc_imports" ]]; then - echo "[FAIL] std::libc import found outside std/libc:" - printf '%s\n' "$non_libc_imports" - failed=1 - fi -fi - -echo "[check] rule: Wave sources must not use retired let declarations" -let_hits="$(rg -n '(^|[({;])[[:space:]]*let([[:space:]]+mut)?[[:space:]]+[[:alpha:]_][[:alnum:]_]*[[:space:]]*:' . --glob '*.wave' || true)" -if [[ -n "$let_hits" ]]; then - echo "[FAIL] retired let declaration found:" - printf '%s\n' "$let_hits" - failed=1 -fi - -if [[ "$failed" -ne 0 ]]; then - echo "[result] FAILED" - exit 1 -fi - -echo "[result] OK" +exec python3 "$repo_root/tools/check_std_policy.py" diff --git a/tools/check_wave_corpus.py b/tools/check_wave_corpus.py index 10e09385..8f32feef 100755 --- a/tools/check_wave_corpus.py +++ b/tools/check_wave_corpus.py @@ -71,14 +71,14 @@ def _resolve_compiler_path(candidate: Path | str) -> Path | None: if not str(raw).strip(): return None if raw.is_file(): - return raw + return raw.resolve() if not raw.is_absolute(): rel = ROOT / raw if rel.is_file(): - return rel + return rel.resolve() which = shutil.which(str(candidate)) if which: - return Path(which) + return Path(which).resolve() return None @@ -133,6 +133,8 @@ def run_std_examples( ) -> list[tuple[Path, str]]: examples = sorted((ROOT / "examples" / "std").glob("*.wave")) failures: list[tuple[Path, str]] = [] + if not examples: + return [(Path("examples/std"), "no standard-library examples discovered")] print(f"Running {len(examples)} standard-library examples") for path in examples: @@ -153,6 +155,10 @@ def run_std_examples( failures.append((relative, f"timed out after {timeout:g}s" + (f"\n{detail}" if detail else ""))) print(f"[RUN TIMEOUT] {relative}") continue + except OSError as error: + failures.append((relative, f"compiler launch failed: {error}")) + print(f"[RUN FAIL] {relative}") + continue if result.returncode == 0: print(f"[RUN PASS] {relative}") @@ -180,6 +186,12 @@ def main(argv: list[str] | None = None) -> int: return 2 files = corpus_files() + if not files: + print("no Wave sources discovered in the maintained corpus", file=sys.stderr) + return 2 + if args.run_std_examples and not any((ROOT / "examples/std").glob("*.wave")): + print("no standard-library examples discovered", file=sys.stderr) + return 2 failures: list[tuple[Path, str]] = [] example_failures: list[tuple[Path, str]] = [] @@ -205,6 +217,10 @@ def main(argv: list[str] | None = None) -> int: ) print(f"[TIMEOUT] {relative}") continue + except OSError as error: + failures.append((relative, f"compiler launch failed: {error}")) + print(f"[FAIL] {relative}") + continue if result.returncode == 0: print(f"[PASS] {relative}") diff --git a/tools/run_runtime_cases.py b/tools/run_runtime_cases.py index 76208a9c..12b87306 100644 --- a/tools/run_runtime_cases.py +++ b/tools/run_runtime_cases.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: MPL-2.0 """Execute an already selected QEMU or WebAssembly suite and preserve evidence.""" import argparse -import json import math from pathlib import Path import platform @@ -14,6 +13,7 @@ if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from tools.case_manifest import load_case_manifest +from tools.validation_reports import write_report, validate_report_path from tools.process_tree import run_process, timeout_output ROOT = Path(__file__).resolve().parent.parent @@ -34,6 +34,12 @@ def excerpt(record, name, text): def execute(options): + try: + validate_report_path(options.report_json, [options.wavec, + *(ROOT / "tests/cases" / name for name in options.sources)]) + except (OSError, ValueError) as error: + print(error, file=sys.stderr) + return 2 report = { "schema_version": 1, "phase": "runtime", "compiler": str(options.wavec.resolve()), "host": {"os": platform.system().lower(), "arch": platform.machine()}, @@ -45,8 +51,7 @@ def execute(options): def save(): report["summary"] = {status: sum(row["status"] == status for row in report["tests"]) for status in ("pass", "fail", "timeout", "interrupted", "not_run")} - options.report_json.parent.mkdir(parents=True, exist_ok=True) - options.report_json.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + write_report(options.report_json, report) def command(row, args, phase, timeout): args = list(map(str, args)) @@ -106,7 +111,7 @@ def command(row, args, phase, timeout): output = Path(temporary) / str(index) output.mkdir() executable = output / "case" - built = command(row, [compiler, "build", source, "--target", target.target, + built = command(row, [compiler, "build", source, "--std-root", ROOT / "std", "--target", target.target, "--out-dir", output, "-o", executable], "build", options.build_timeout) if built and not executable.is_file(): row.update(status="fail", reason="build succeeded without producing an executable") @@ -116,7 +121,7 @@ def command(row, args, phase, timeout): else: # wavec owns the target-specific JS/WASI host invocation. # This command includes compilation; it is never labelled a compile-only pass. - passed = command(row, [compiler, "run", source, "--target", target.target], + passed = command(row, [compiler, "run", source, "--std-root", ROOT / "std", "--target", target.target], "build-and-run", options.build_timeout + options.timeout) if passed: row["status"] = "pass" diff --git a/tools/run_tests.py b/tools/run_tests.py index d2b0089f..0ca5bab6 100644 --- a/tools/run_tests.py +++ b/tools/run_tests.py @@ -24,9 +24,14 @@ import shutil import tempfile import errno +import re +import secrets +import signal from functools import cache +from dataclasses import replace try: + from tools.validation_reports import write_report, validate_report_path from tools.process_tree import ProcessTree, run_process, timeout_output from tools.case_manifest import load_case_manifest from tools.test_contracts import ( @@ -35,6 +40,7 @@ validate_compiled_artifact, ) except ModuleNotFoundError: + from validation_reports import write_report, validate_report_path from process_tree import ProcessTree, run_process, timeout_output from case_manifest import load_case_manifest from test_contracts import ( @@ -311,50 +317,75 @@ def send_udp_test_input(): pass def run_server_test(cmd): - tree = ProcessTree( - cmd, - cwd=str(ROOT), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - errors="replace", - ) - - try: - time.sleep(1.0) # server boot wait - - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(2) - s.connect(("127.0.0.1", 8080)) - s.sendall(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") - - data = s.recv(4096) - s.close() - - if b"Welcome to the Wave HTTP Server!" in data: - print(f"{GREEN}→ PASS (server responded){RESET}\n") - return 1, None - else: - print(f"{RED}→ FAIL (unexpected response){RESET}") - print(data) - return 0, failure_detail("unexpected server response", phase="run", stdout=repr(data)) - - except OSError as e: - if e.errno in {errno.EPERM, errno.EACCES}: - print(f"{CYAN}→ SKIP (local TCP sockets blocked by environment){RESET}\n") - return 2, "local TCP sockets blocked by environment" - - print(f"{RED}→ FAIL (server not responding){RESET}") - print(e) - return 0, failure_detail(f"server not responding: {e}", phase="run") - - except Exception as e: - print(f"{RED}→ FAIL (server not responding){RESET}") - print(e) - return 0, failure_detail(f"server not responding: {e}", phase="run") - - finally: - tree.close() + def failure(reason, status=0, **details): + print(f"{RED}→ FAIL ({reason}){RESET}") + for stream in ("stdout", "stderr"): + if details.get(stream): + print(details[stream].rstrip()) + return status, failure_detail(reason, phase="run", **details) + + # Read readiness only from this process, then challenge its ephemeral endpoint. + # Files keep verbose servers from blocking on undrained output pipes. + with tempfile.TemporaryDirectory(prefix="wave-server-") as directory: + stdout_path, stderr_path = Path(directory) / "stdout", Path(directory) / "stderr" + with stdout_path.open("wb") as stdout, stderr_path.open("wb") as stderr: + tree = ProcessTree(cmd, cwd=str(ROOT), stdout=stdout, stderr=stderr) + def output(path): + with path.open("rb") as stream: + return stream.read(4096).decode("utf-8", errors="replace") + try: + deadline = time.monotonic() + TIMEOUT_SEC + port = None + while time.monotonic() < deadline: + status = tree.process.poll() + text = output(stdout_path) + if re.search(r"^WAVE-SERVER-UNAVAILABLE (-1|-13)\r?$", text, re.MULTILINE): + return 2, "local TCP sockets blocked by environment" + if status is not None: + return failure("server exited before readiness", + actual_exit=status, stdout=text, stderr=output(stderr_path)) + ready = re.search(r"^WAVE-SERVER-READY ([0-9]+)\r?$", text, re.MULTILINE) + if ready: + port = int(ready[1]) + if not 0 < port < 65536: + raise ValueError(f"invalid server port: {port}") + break + time.sleep(0.02) + if port is None: + return failure("server readiness timed out", status=-1, + timeout_seconds=TIMEOUT_SEC, stdout=output(stdout_path), stderr=output(stderr_path)) + token = secrets.token_hex(16).encode("ascii") + request = b"GET /" + token + b" HTTP/1.1\r\nHost: localhost\r\n\r\n" + with socket.create_connection(("127.0.0.1", port), timeout=max(0.001, deadline - time.monotonic())) as sock: + sock.sendall(request) + data = b"" + while len(data) < 8192: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("server response timed out") + sock.settimeout(remaining) + chunk = sock.recv(8192 - len(data)) + if not chunk: + break + data += chunk + status = tree.process.poll() + if status is not None: + return failure("server exited during request", + actual_exit=status, stdout=output(stdout_path), stderr=output(stderr_path)) + expected = b"Welcome to the Wave HTTP Server!\n" + request + if not data.startswith(b"HTTP/1.1 200 OK\r\n") or data.partition(b"\r\n\r\n")[2] != expected: + return failure("unexpected server challenge response", stdout=repr(data)) + print(f"{GREEN}→ PASS (owned server answered challenge){RESET}\n") + return 1, None + except OSError as error: + if error.errno in {errno.EPERM, errno.EACCES}: + return 2, "local TCP sockets blocked by environment" + return failure(f"server request failed: {error}", + stdout=output(stdout_path), stderr=output(stderr_path)) + except ValueError as error: + return failure(str(error)) + finally: + tree.close() def looks_like_fail(stderr: str) -> bool: if not stderr: @@ -474,7 +505,14 @@ def classify_program(name, rel_path, cmd, metadata, compile_target): print(f"{MAGENTA}→ PASS (expected exit={expected_exit}){RESET}\n") return 3, None artifact_error = None - if compile_target is None: + if compile_target is not None: + artifact_error = validate_compiled_artifact( + name, ROOT / rel_path, TEST_OUTPUT_DIR, + replace(metadata, mode="build", runner="compile", emit="obj", + asm_contains=(), asm_not_contains=()), + target=compile_target.target, + ) + else: target = metadata.target if not target and (metadata.asm_contains or metadata.asm_not_contains): target = compiler_default_target() @@ -509,6 +547,40 @@ def classify_program(name, rel_path, cmd, metadata, compile_target): return -1, failure_detail(f"timed out after {TIMEOUT_SEC}s", phase=phase, expected_exit=expected_exit, stderr=detail, timeout_seconds=TIMEOUT_SEC) +def save_native_report(entries, active=None, interrupted=False): + if ARGS.report_json is None: + return True + statuses = {-1: "timeout", 0: "fail", 1: "pass", 2: "skip", 3: "pass"} + completed = {name: (result, detail) for name, result, detail in results} + tests = [] + for name, _ in entries: + row = {"name": name, "status": "not_run"} + if name in completed: + result, detail = completed[name] + row.update(status=statuses[result]) + row.update(detail if isinstance(detail, dict) else {"reason": detail} if detail else {}) + elif name == active: + row.update(status="interrupted" if interrupted else "running") + tests.append(row) + report = { + "schema_version": 1, + "phase": "compile" if manifest_compile_target() is not None else "native", + "compiler": str(WAVEC), + "host": {"os": HOST_OS, "arch": HOST_ARCH}, + "selection": report_selection(), + "summary": {status: sum(row["status"] == status for row in tests) + for status in ("pass", "skip", "fail", "timeout", "running", "interrupted", "not_run") + if status in ("pass", "skip", "fail", "timeout") or any(row["status"] == status for row in tests)}, + "tests": tests, + } + try: + write_report(ARGS.report_json, report) + return True + except OSError as error: + print(f"failed to write test report: {error}", file=sys.stderr) + return False + + def main(argv=None): global ARGS, WAVEC, TEST_OUTPUT_DIR ARGS = parse_args(argv) @@ -521,6 +593,9 @@ def main(argv=None): configured_target.cache_clear() compiler_default_target.cache_clear() TEST_OUTPUT_DIR = Path(tempfile.mkdtemp(prefix="wave-test-output-")) + def interrupted(signum, frame): + raise KeyboardInterrupt + previous_sigterm = signal.signal(signal.SIGTERM, interrupted) try: try: @@ -539,17 +614,33 @@ def main(argv=None): print("no tests selected", file=sys.stderr) sys.exit(2) + if ARGS.report_json is not None: + try: + validate_report_path(ARGS.report_json, [WAVEC, *(ROOT / path for _, path in entries)]) + except (ValueError, OSError) as error: + print(error, file=sys.stderr) + sys.exit(2) + active = None + if not save_native_report(entries): + sys.exit(1) try: for name, rel_path in entries: + active = name + if not save_native_report(entries, active): + sys.exit(1) result, detail = run_and_classify( name, rel_path, command_for_test(name, rel_path) ) results.append((name, result, detail)) + active = None + if not save_native_report(entries): + sys.exit(1) time.sleep(0.3) except KeyboardInterrupt: + save_native_report(entries, active, interrupted=True) print(f"\n{YELLOW}Interrupted by user.{RESET}") sys.exit(130) except ValueError as error: @@ -594,44 +685,12 @@ def main(argv=None): print(f"{YELLOW}TIMEOUT: {len(timeout_tests)}{RESET}") print("=========================\n") - report_failed = False - if ARGS.report_json is not None: - statuses = {-1: "timeout", 0: "fail", 1: "pass", 2: "skip", 3: "pass"} - report = { - "schema_version": 1, - "phase": "compile" if manifest_compile_target() is not None else "native", - "compiler": str(WAVEC), - "host": {"os": HOST_OS, "arch": HOST_ARCH}, - "selection": report_selection(), - "summary": { - "pass": len(pass_zero) + len(pass_nonzero), - "skip": len(skip_tests), - "fail": len(fail_tests), - "timeout": len(timeout_tests), - }, - "tests": [ - { - "name": name, - "status": statuses[result], - **(detail if isinstance(detail, dict) else {"reason": detail} if detail else {}), - } - for name, result, detail in results - ], - } - try: - ARGS.report_json.parent.mkdir(parents=True, exist_ok=True) - ARGS.report_json.write_text( - json.dumps(report, indent=2, sort_keys=True) + "\n", - encoding="utf-8", - ) - print(f"Wrote test report to {ARGS.report_json}") - except OSError as error: - print(f"failed to write test report: {error}", file=sys.stderr) - report_failed = True + report_failed = not save_native_report(entries) if fail_tests or timeout_tests or report_failed: sys.exit(1) finally: + signal.signal(signal.SIGTERM, previous_sigterm) if TEST_OUTPUT_DIR: shutil.rmtree(TEST_OUTPUT_DIR, ignore_errors=True) diff --git a/tools/test_check_wave_corpus.py b/tools/test_check_wave_corpus.py index 42c953c7..469d7494 100644 --- a/tools/test_check_wave_corpus.py +++ b/tools/test_check_wave_corpus.py @@ -137,10 +137,10 @@ def test_valid_explicit_path_selected(self): fallback.touch() with patch.object(check_wave_corpus, "ROOT", root): - self.assertEqual(resolve_wavec(custom), custom) + self.assertEqual(resolve_wavec(custom), custom.resolve()) self.assertEqual( resolve_wavec(Path("bin/custom_wavec")), - custom, + custom.resolve(), ) def test_valid_wavec_env_selected(self): @@ -156,7 +156,23 @@ def test_valid_wavec_env_selected(self): with patch.object(check_wave_corpus, "ROOT", root): with patch.dict(os.environ, {"WAVEC": str(custom)}, clear=False): - self.assertEqual(resolve_wavec(None), custom) + self.assertEqual(resolve_wavec(None), custom.resolve()) + + def test_explicit_and_environment_paths_resolve_symlinked_parent(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + real = root / "real compiler dir" + real.mkdir() + compiler = real / "wavec" + compiler.touch(mode=0o700) + alias = root / "alias" + try: + alias.symlink_to(real, target_is_directory=True) + except OSError as error: + self.skipTest(f"directory symlinks unavailable: {error}") + self.assertEqual(resolve_wavec(alias / "wavec"), compiler.resolve()) + with patch.dict(os.environ, {"WAVEC": str(alias / "wavec")}): + self.assertEqual(resolve_wavec(None), compiler.resolve()) def test_no_override_falls_back_to_discovery(self): with tempfile.TemporaryDirectory() as td: diff --git a/tools/test_contracts.py b/tools/test_contracts.py index 09b1c088..bc0e6e97 100644 --- a/tools/test_contracts.py +++ b/tools/test_contracts.py @@ -309,6 +309,17 @@ def read_elf_contract(path: Path): if flags_offset is None or len(data) < flags_offset + 4: return None, f"invalid or truncated ELF header in {path}" + header_size = {1: 52, 2: 64}[elf_class] + size_offset = {1: 40, 2: 52}[elf_class] + if len(data) < header_size: + return None, f"truncated ELF header in {path}" + if data[6] != 1 or int.from_bytes(data[20:24], byteorder) != 1: + return None, f"invalid ELF version in {path}" + if int.from_bytes(data[size_offset:size_offset + 2], byteorder) != header_size: + return None, f"invalid ELF header size in {path}" + if int.from_bytes(data[16:18], byteorder) != 1: + return None, f"expected relocatable ELF object (ET_REL) in {path}" + return { "bits": {1: 32, 2: 64}[elf_class], "machine": int.from_bytes(data[18:20], byteorder), diff --git a/tools/test_freebsd_runtime_reporting.py b/tools/test_freebsd_runtime_reporting.py index 08a6816e..5d8c2bec 100644 --- a/tools/test_freebsd_runtime_reporting.py +++ b/tools/test_freebsd_runtime_reporting.py @@ -76,6 +76,21 @@ def test_all_optimization_results_required_and_checkout_std_selected(self): self.assertEqual(len(builds), 4) self.assertTrue(all(cmd[cmd.index("--std-root") + 1] == self.root / "std" for cmd in builds)) + def test_directory_case_builds_main_object_with_logical_result_name(self): + suite = self.root / "tests/cases/freebsd/amd64" + (suite / "test2.wave").unlink() + (suite / "test2").mkdir() + (suite / "test2/main.wave").write_text("fun main() -> i32 { return 0; }") + report, commands, _, error = self.execute() + self.assertIsNone(error) + self.assertEqual([case["name"] for case in report["cases"]], + ["test1-O0", "test1-O2", "test2-O0", "test2-O2"]) + builds = [cmd for cmd in commands if "build" in cmd and cmd[2].name == "main.wave"] + self.assertEqual(len(builds), 2) + links = [cmd for cmd in commands if cmd[0] == "ld.lld" and any(str(arg).endswith("main.o") for arg in cmd)] + self.assertEqual(len(links), 2) + self.assertEqual([Path(cmd[-1]).name for cmd in links], ["test2-O0", "test2-O2"]) + def test_nonzero_result_fails_but_retains_later_results(self): report, _, _, error = self.execute(failures={"test1-O2": 17}) self.assertIsInstance(error, RuntimeError) diff --git a/tools/test_process_tree.py b/tools/test_process_tree.py index cc84e9d7..ca6d12ee 100644 --- a/tools/test_process_tree.py +++ b/tools/test_process_tree.py @@ -158,16 +158,11 @@ def test_case_runner_keeps_timeout_classification_and_cleans_the_tree(self): self.assert_descendant_stopped() def test_server_cleanup_terminates_generated_server(self): - class Socket: - def settimeout(self, _): pass - def connect(self, _): pass - def sendall(self, _): pass - def recv(self, _): return b"Welcome to the Wave HTTP Server!" - def close(self): pass - with patch.object(run_tests.socket, "socket", return_value=Socket()), \ + # No readiness marker is emitted: even a live descendant cannot pass. + with patch.object(run_tests, "TIMEOUT_SEC", 2), \ contextlib.redirect_stdout(io.StringIO()): status, _ = run_tests.run_server_test(self.command) - self.assertEqual(status, 1) + self.assertEqual(status, -1) self.assert_descendant_stopped() def test_text_mode_replaces_invalid_utf8_on_both_streams(self): diff --git a/tools/test_release_gates.py b/tools/test_release_gates.py new file mode 100644 index 00000000..fa972895 --- /dev/null +++ b/tools/test_release_gates.py @@ -0,0 +1,285 @@ +# SPDX-License-Identifier: MPL-2.0 +"""Release-gate failure paths use temporary inputs and controlled processes.""" +import argparse +import contextlib +import errno +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from tools import check_case_sources as source_check, run_runtime_cases as runtime +from tools import check_wave_corpus as corpus, run_tests as native +from tools import check_std_policy as policy, check_freebsd_sys as freebsd +from tools.test_contracts import TestMetadata, read_elf_contract +from tools.test_test_contracts import make_elf + + +class ReportProtectionTests(unittest.TestCase): + def test_both_runners_reject_source_and_compiler_aliases_before_execution(self): + for runner in ('source', 'runtime'): + for protected in ('source', 'compiler'): + for alias in ('same', 'normalized', 'symlink', 'hardlink'): + with self.subTest(runner=runner, protected=protected, alias=alias), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + cases = root / 'tests/cases' + cases.mkdir(parents=True) + source, compiler = cases / 'input.wave', root / 'wavec' + source.write_bytes(b'fun main() {}\n') + compiler.write_bytes(b'compiler bytes') + target = source if protected == 'source' else compiler + report = target + if alias == 'normalized': + (root / 'sub').mkdir() + report = root / 'sub' / '..' / target.relative_to(root) + elif alias in ('symlink', 'hardlink'): + report = root / 'report.json' + try: + if alias == 'symlink': report.symlink_to(target) + else: os.link(target, report) + except OSError as error: + if error.errno in (errno.EPERM, errno.EACCES, errno.ENOTSUP): + continue + raise + before = (source.read_bytes(), compiler.read_bytes()) + with patch.object(source_check, 'ROOT', root), patch.object(source_check, 'CASES_ROOT', cases), \ + patch.object(runtime, 'ROOT', root), patch.object(source_check, 'run_process') as check, \ + patch.object(runtime, 'run_process') as run, contextlib.redirect_stderr(io.StringIO()): + if runner == 'source': + status = source_check.check_sources(compiler, ['input.wave'], report) + else: + status = runtime.execute(argparse.Namespace(wavec=compiler, sources=['input.wave'], report_json=report)) + self.assertNotEqual(status, 0) + check.assert_not_called() + run.assert_not_called() + self.assertEqual((source.read_bytes(), compiler.read_bytes()), before) + + def test_manifest_failure_report_cannot_replace_source(self): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / 'input.wave' + source.write_text('original') + with patch.object(source_check, 'CASES_ROOT', Path(directory)), \ + patch.object(source_check, 'load_case_manifest', side_effect=ValueError('bad manifest')), \ + contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(source_check.main(['--wavec', 'missing', '--report-json', str(source)]), 1) + self.assertEqual(source.read_text(), 'original') + + def test_checkout_std_path_with_spaces_is_passed_to_source_checker(self): + with tempfile.TemporaryDirectory(prefix='wave root ') as directory: + root = Path(directory) + with patch.object(source_check, 'ROOT', root), \ + patch.object(source_check, 'run_process', return_value=subprocess.CompletedProcess([], 0, '', '')) as run: + self.assertEqual(source_check.check_sources(sys.executable, ['sample.wave'], root / 'report.json'), 0) + command = run.call_args.args[0] + self.assertEqual(command[command.index('--std-root') + 1], str(root / 'std')) + + +class CorpusFailureTests(unittest.TestCase): + def test_caller_relative_compiler_and_environment_remain_absolute(self): + with tempfile.TemporaryDirectory(prefix='caller space ') as directory: + root = Path(directory) + executable = root / 'compiler space' + executable.touch(mode=0o700) + old = Path.cwd() + try: + os.chdir(root) + with patch.object(corpus.shutil, 'which', return_value='wrong-PATH-compiler'): + self.assertEqual(corpus.resolve_wavec(Path('./compiler space')), executable.resolve()) + with patch.dict(os.environ, WAVEC='./compiler space'): + self.assertEqual(corpus.resolve_wavec(None), executable.resolve()) + finally: + os.chdir(old) + + def test_launch_errors_are_reported_in_both_loops(self): + for error in (OSError(errno.ENOEXEC, 'invalid format'), FileNotFoundError(errno.ENOENT, 'missing loader')): + with self.subTest(error=error), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / 'examples/std/example.wave' + source.parent.mkdir(parents=True) + source.write_text('fun main() {}') + out, err = io.StringIO(), io.StringIO() + with patch.object(corpus, 'ROOT', root), patch.object(corpus, 'resolve_wavec', return_value=Path(sys.executable)), \ + patch.object(corpus, 'run_process', side_effect=error) as run, \ + contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + self.assertEqual(corpus.main(['--run-std-examples']), 1) + self.assertEqual(run.call_count, 2) + self.assertIn('examples/std/example.wave', err.getvalue()) + self.assertIn('compiler launch failed', err.getvalue()) + self.assertNotIn('Traceback', err.getvalue()) + + def test_empty_corpus_or_requested_examples_never_launches_compiler(self): + for std_source in (False, True): + with self.subTest(std_source=std_source), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + if std_source: + (root / 'std').mkdir() + (root / 'std/a.wave').touch() + with patch.object(corpus, 'ROOT', root), patch.object(corpus, 'resolve_wavec', return_value=Path(sys.executable)), \ + patch.object(corpus, 'run_process') as run, contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(corpus.main(['--run-std-examples']), 2) + run.assert_not_called() + + def test_nonempty_success_still_passes(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'std').mkdir() + (root / 'std/a.wave').touch() + with patch.object(corpus, 'ROOT', root), patch.object(corpus, 'resolve_wavec', return_value=Path(sys.executable)), \ + patch.object(corpus, 'run_process', return_value=subprocess.CompletedProcess([], 0, '', '')): + self.assertEqual(corpus.main([]), 0) + + +class ArtifactFailureTests(unittest.TestCase): + def test_compile_only_requires_object_for_single_and_multifile_cases(self): + for source_name in ('test1.wave', 'test2/main.wave'): + for status, create in ((0, False), (7, False), (0, True)): + with self.subTest(source=source_name, status=status, create=create), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / source_name + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text('fun main() {}') + out = root / 'out' + (out / 'logical-case').mkdir(parents=True) + if create: (out / 'logical-case' / (source.stem + '.o')).write_bytes(b'object') + target = SimpleNamespace(target='aarch64-unknown-freebsd') + with patch.object(native, 'ROOT', root), patch.object(native, 'TEST_OUTPUT_DIR', out), \ + patch.object(native, 'run_process', return_value=subprocess.CompletedProcess([], status, '', '')), \ + contextlib.redirect_stdout(io.StringIO()): + result = native.classify_program('logical case', source_name, ['fake'], TestMetadata(), target) + self.assertEqual(result[0], 1 if status == 0 and create else 0) + if status == 0 and not create: self.assertIn('expected artifact', result[1]['reason']) + + def test_elf_header_contracts_cover_both_classes_and_byte_orders(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'object.o' + for bits in (32, 64): + for order in ('little', 'big'): + data = make_elf(bits=bits) + data[5] = 1 if order == 'little' else 2 + for offset, width, value in [(16, 2, 1), (18, 2, 243), (20, 4, 1), + (52 if bits == 64 else 40, 2, len(data)), + (48 if bits == 64 else 36, 4, 4)]: + data[offset:offset + width] = value.to_bytes(width, order) + path.write_bytes(data) + parsed, error = read_elf_contract(path) + self.assertIsNone(error) + self.assertEqual(parsed, {'bits': bits, 'machine': 243, 'flags': 4}) + invalid = [data[:51], data[:-1]] + for offset, width, value in [(6, 1, 0), (20, 4, 2), (16, 2, 2), (16, 2, 3), + (52 if bits == 64 else 40, 2, 0)]: + corrupt = data.copy() + corrupt[offset:offset + width] = value.to_bytes(width, order) + invalid.append(corrupt) + for corrupt in invalid: + path.write_bytes(corrupt) + self.assertIsNotNone(read_elf_contract(path)[1]) + + +class NativeProgressTests(unittest.TestCase): + def test_interruption_retains_finished_active_and_unstarted_cases(self): + for before in (0, 1): + with self.subTest(before=before), tempfile.TemporaryDirectory() as directory: + report = Path(directory) / 'report.json' + entries = [('one', 'one.wave'), ('two', 'two.wave'), ('three', 'three.wave')] + outcomes = [(1, None)] * before + [KeyboardInterrupt()] + with patch.object(native, 'resolve_wavec', return_value=Path(sys.executable)), \ + patch.object(native, 'iter_test_entries', return_value=entries), \ + patch.object(native, 'command_for_test', return_value=['fake']), \ + patch.object(native, 'run_and_classify', side_effect=outcomes), \ + patch.object(native, 'manifest_compile_target', return_value=None), \ + patch.object(native, 'report_selection', return_value={}), patch.object(native.time, 'sleep'), \ + contextlib.redirect_stdout(io.StringIO()), self.assertRaises(SystemExit) as error: + native.main(['--report-json', str(report)]) + self.assertEqual(error.exception.code, 130) + rows = json.loads(report.read_text())['tests'] + self.assertEqual([row['status'] for row in rows], ['pass'] * before + ['interrupted'] + ['not_run'] * (2 - before)) + self.assertFalse(list(report.parent.glob('.*.tmp'))) + + def test_report_failure_stops_before_running_unrecordable_tests(self): + with patch.object(native, 'resolve_wavec', return_value=Path(sys.executable)), \ + patch.object(native, 'iter_test_entries', return_value=[('one', 'one.wave')]), \ + patch.object(native, 'manifest_compile_target', return_value=None), \ + patch.object(native, 'report_selection', return_value={}), \ + patch.object(native, 'write_report', side_effect=OSError('disk full')), \ + patch.object(native, 'run_and_classify') as run, contextlib.redirect_stderr(io.StringIO()) as err, \ + self.assertRaises(SystemExit) as error: + native.main(['--report-json', '/tmp/wave-unused-report.json']) + self.assertEqual(error.exception.code, 1) + self.assertIn('failed to write test report', err.getvalue()) + run.assert_not_called() + + +class PolicyTests(unittest.TestCase): + def test_layout_and_nested_comments_do_not_bypass_binding_rules(self): + for source in ('extern(c) fun f();', 'extern ( c , "name" ) fun f();', + 'extern\n(/* outer /* inner */ end */ c) fun f();', + 'import ( "std::libc::io" );', 'import\n(/* comment */ "std::libc::io");', + r'import("std::\x6cibc::io");'): + with self.subTest(source=source): + self.assertTrue(list(policy.violations('std/math/test.wave', source))) + self.assertFalse(list(policy.violations('std/math/test.wave', + '// extern(c)\n/* import("std::libc::io") */\nvar x: str = "extern(c)";'))) + self.assertFalse(list(policy.violations('std/sys/linux/event.wave', 'extern /* x */ (c) fun f();'))) + self.assertFalse(list(policy.violations('std/libc/io.wave', 'import ("std::libc::io"); extern(c) fun f();'))) + self.assertTrue(list(policy.violations('tests/case.wave', 'fun main() { let /* x */ mut a: i32; }'))) + self.assertTrue(list(policy.violations('tests/case.wave', '#[target(os="linux")]\nlet a: i32;'))) + + def test_search_failures_are_distinct_from_no_matches(self): + for result, expected in [(FileNotFoundError('rg missing'), 1), + (subprocess.CompletedProcess([], 2, '', 'search failed'), 1), + (subprocess.CompletedProcess([], 1, '', ''), 0)]: + with patch.object(policy.subprocess, 'run', side_effect=result if isinstance(result, Exception) else None, + return_value=None if isinstance(result, Exception) else result), \ + contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(policy.main(), expected) + + def test_active_binding_fails_and_missing_source_is_not_clean(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'std').mkdir() + path = root / 'std/test.wave' + path.write_text('extern (c) fun forbidden();') + with patch.object(policy.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, 'std/test.wave\n', '')), \ + contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(policy.main(root), 1) + path.write_text('// extern(c) fun example();') + self.assertEqual(policy.main(root), 0) + path.unlink() + self.assertEqual(policy.main(root), 1) + + +class FreeBSDDiscoveryTests(unittest.TestCase): + def test_mixed_case_layout_uses_numeric_order_and_logical_names(self): + with tempfile.TemporaryDirectory() as directory: + suite = Path(directory) + (suite / 'test10.wave').touch() + (suite / 'test2').mkdir() + (suite / 'test2/main.wave').touch() + (suite / 'test1.wave').touch() + self.assertEqual([(name, path.relative_to(suite).as_posix()) for name, path in freebsd.discover_cases(suite)], + [('test1', 'test1.wave'), ('test2', 'test2/main.wave'), ('test10', 'test10.wave')]) + (suite / 'test2.wave').touch() + with self.assertRaisesRegex(ValueError, 'ambiguous'): freebsd.discover_cases(suite) + + def test_empty_and_invalid_discovery_starts_no_external_command(self): + for name in (None, 'testoops.wave', 'test1'): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + suite = root / 'tests/cases/freebsd/amd64' + suite.mkdir(parents=True) + if name == 'test1': (suite / name).mkdir() + elif name: (suite / name).touch() + with patch.object(freebsd, 'ROOT', root), patch.object(freebsd.Commands, 'run') as run, \ + self.assertRaises(ValueError): + freebsd.execute(SimpleNamespace(arch='amd64'), {}) + run.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/test_runtime_cases.py b/tools/test_runtime_cases.py index c8257f7b..f715ad17 100644 --- a/tools/test_runtime_cases.py +++ b/tools/test_runtime_cases.py @@ -17,10 +17,18 @@ class RuntimeReportTests(unittest.TestCase): - def run_suite(self, executor="wasm", outcomes=(), missing_output=False, sources=None): + def run_suite(self, executor="wasm", outcomes=(), missing_output=False, sources=None, alias_root=False): calls = [] - with tempfile.TemporaryDirectory() as directory: + with tempfile.TemporaryDirectory(prefix="runtime root ") as directory: root = Path(directory) + if alias_root: + real = root / "real checkout" + real.mkdir() + root = root / "alias checkout" + try: + root.symlink_to(real, target_is_directory=True) + except OSError as error: + self.skipTest(f"directory symlinks unavailable: {error}") selected = ["shared/test1.wave", "shared/test2/main.wave"] for name in selected: path = root / "tests/cases" / name @@ -33,6 +41,10 @@ def run_suite(self, executor="wasm", outcomes=(), missing_output=False, sources= target = SimpleNamespace(enabled=True, executor=executor, target="test-triple", suites=("shared",)) def process(command, **kwargs): + if "--std-root" in command: + selected_std = Path(command[command.index("--std-root") + 1]) + self.assertEqual(selected_std.resolve(), (root / "std").resolve()) + self.assertTrue(Path(command[2]).is_relative_to(root.resolve())) index = len(calls) calls.append(command) outcome = outcomes[index] if index < len(outcomes) else 0 @@ -64,6 +76,13 @@ def test_wasm_success_is_explicitly_runtime_and_preserves_selection(self): self.assertTrue(all(command[1] == "run" for command in calls)) self.assertEqual(report["tests"][0]["commands"][0]["phase"], "build-and-run") + def test_compiler_commands_use_same_checkout_through_directory_alias(self): + for executor in ("wasm", "qemu"): + with self.subTest(executor=executor): + status, _, calls = self.run_suite(executor=executor, alias_root=True) + self.assertEqual(status, 0) + self.assertEqual(sum("--std-root" in command for command in calls), 2) + def test_host_import_failure_is_reported_and_later_case_still_runs(self): status, report, _ = self.run_suite(outcomes=[7]) self.assertEqual(status, 1) @@ -81,6 +100,8 @@ def test_qemu_build_and_execution_have_separate_status_and_use_exact_output(self for row, build, run in zip(report["tests"], calls[::2], calls[1::2]): self.assertEqual([cmd["phase"] for cmd in row["commands"]], ["build", "run"]) self.assertEqual(build[build.index("-o") + 1], run[-1]) + self.assertEqual(Path(build[build.index("--std-root") + 1]).name, "std") + self.assertIn("runtime root ", build[build.index("--std-root") + 1]) self.assertEqual(run[0], "fake-qemu") self.assertTrue(any(arg.endswith("main.wave") for arg in calls[2])) diff --git a/tools/test_server_case.py b/tools/test_server_case.py new file mode 100644 index 00000000..b224d8ff --- /dev/null +++ b/tools/test_server_case.py @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: MPL-2.0 +"""An unrelated responder cannot make a failed server fixture pass.""" +import contextlib +import io +import socket +import sys +import unittest +from unittest.mock import patch + +from tools import run_tests as runner + +SERVER = r''' +import socket, sys, time +with socket.socket() as server: + server.bind(('127.0.0.1', 0)) + server.listen() + time.sleep(float(sys.argv[1])) + print('WAVE-SERVER-READY', server.getsockname()[1], flush=True) + conn, _ = server.accept() + with conn: + request = b'' + while not request.endswith(b'\r\n\r\n'): + request += conn.recv(1024) + body = b'Welcome to the Wave HTTP Server!\n' + if sys.argv[2] == 'challenge': + body += request + conn.sendall(b'HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n' + body) + time.sleep(30) +''' + + +class ServerTests(unittest.TestCase): + def test_failed_process_cannot_pass_from_unrelated_response(self): + with patch.object(runner.socket, 'create_connection') as connect, contextlib.redirect_stdout(io.StringIO()): + status, detail = runner.run_server_test([sys.executable, '-c', 'raise SystemExit(7)']) + self.assertEqual(status, 0) + self.assertEqual(detail['actual_exit'], 7) + connect.assert_not_called() + + def test_delayed_ready_owned_server_passes_but_fixed_response_fails(self): + try: + with socket.socket() as probe: probe.bind(('127.0.0.1', 0)) + except PermissionError: + self.skipTest('local sockets unavailable') + for delay, mode, expected in [('0', 'challenge', 1), ('1.1', 'challenge', 1), ('0', 'unrelated', 0)]: + with self.subTest(delay=delay, mode=mode), contextlib.redirect_stdout(io.StringIO()): + status, detail = runner.run_server_test([sys.executable, '-c', SERVER, delay, mode]) + self.assertEqual(status, expected, detail) + + def test_permission_unavailable_marker_is_a_skip(self): + status, _ = runner.run_server_test([sys.executable, '-c', 'print("WAVE-SERVER-UNAVAILABLE -13", flush=True)']) + self.assertEqual(status, 2) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/test_test_contracts.py b/tools/test_test_contracts.py index 5a4232f4..ff13354f 100644 --- a/tools/test_test_contracts.py +++ b/tools/test_test_contracts.py @@ -26,6 +26,11 @@ def make_elf(machine=243, bits=64, flags=4): data[:4] = b"\x7fELF" data[4] = 2 if bits == 64 else 1 data[5] = 1 + data[6] = 1 + data[16:18] = (1).to_bytes(2, "little") + data[20:24] = (1).to_bytes(4, "little") + size_offset = 52 if bits == 64 else 40 + data[size_offset:size_offset + 2] = size.to_bytes(2, "little") data[18:20] = machine.to_bytes(2, "little") flags_offset = 48 if bits == 64 else 36 data[flags_offset:flags_offset + 4] = flags.to_bytes(4, "little") diff --git a/tools/validation_reports.py b/tools/validation_reports.py new file mode 100644 index 00000000..3aef3aab --- /dev/null +++ b/tools/validation_reports.py @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: MPL-2.0 +"""Protect validation inputs and replace progress reports atomically.""" +import json +import os +from pathlib import Path +import tempfile + + +def validate_report_path(report, inputs): + report = Path(report) + resolved = report.resolve() + for source in inputs: + source = Path(source) + if resolved == source.resolve() or ( + report.exists() and source.exists() and report.samefile(source) + ): + raise ValueError(f"report path aliases an input: {report} ({source})") + + +def write_report(report, payload): + report = Path(report) + report.parent.mkdir(parents=True, exist_ok=True) + fd, name = tempfile.mkstemp(prefix=f".{report.name}.", suffix=".tmp", dir=report.parent) + temporary = Path(name) + try: + try: + stream = os.fdopen(fd, "w", encoding="utf-8") + except Exception: + os.close(fd) + raise + with stream: + stream.write(json.dumps(payload, indent=2, ensure_ascii=False) + "\n") + temporary.replace(report) + finally: + temporary.unlink(missing_ok=True)