diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a93206..286339c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,8 +19,10 @@ jobs: uses: ./.github/workflows/ci.yml native-stack: uses: ./.github/workflows/native-stack.yml + sustained: + uses: ./.github/workflows/sustained.yml plan: - needs: [canonical, native-stack] + needs: [canonical, native-stack, sustained] permissions: contents: read id-token: write diff --git a/.github/workflows/sustained.yml b/.github/workflows/sustained.yml new file mode 100644 index 0000000..d80b54a --- /dev/null +++ b/.github/workflows/sustained.yml @@ -0,0 +1,59 @@ +name: sustained runtime + +on: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + sustained: + name: sustained (${{ matrix.target }}, ${{ matrix.carriers }} carriers) + strategy: + fail-fast: false + matrix: + runner: [ubuntu-24.04, macos-14] + carriers: [1, 4] + include: + - runner: ubuntu-24.04 + target: linux-x86-64 + system: Linux + machine: x86_64 + - runner: macos-14 + target: macos-arm64 + system: Darwin + machine: arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 85 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/target/sustained + CARGO_HOME: ${{ github.workspace }}/target/cargo-home + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Verify native host + env: + EXPECTED_SYSTEM: ${{ matrix.system }} + EXPECTED_MACHINE: ${{ matrix.machine }} + run: | + test "$(uname -s)" = "$EXPECTED_SYSTEM" + test "$(uname -m)" = "$EXPECTED_MACHINE" + - name: Install Rust + run: rustup toolchain install 1.96.1 --profile minimal + - name: Build locked default runtime + run: cargo build --locked --release -p vthread-lab --bin vthread-lab + - name: Qualify one sustained process + run: >- + python3 scripts/run-sustained.py + --binary "$CARGO_TARGET_DIR/release/vthread-lab" + --out .qualification/sustained --duration 3600 + --carriers ${{ matrix.carriers }} --tasks 4096 + - name: Upload sustained evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sustained-${{ github.run_id }}-${{ matrix.target }}-${{ matrix.carriers }} + path: .qualification/sustained/ + include-hidden-files: true + retention-days: 90 + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index c10ad9a..0a223dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 0.1.0-rc.3 - 2026-09-11 + +- Require sustained runtime qualification before release planning: one uninterrupted + hour on Linux x86_64 and macOS ARM64, with both one and four carriers and + 4,096-task mixed batches. Verify lifetime accounting, service reclamation, + cancellation, shutdown, and sampled memory and descriptor growth after warmup. +- Preserve the RC.2 runtime implementation and document the repaired inbox + notification boundaries, tested operating scope, and production support contract + intended for `0.1.0`. Keep final publication separate from RC qualification. +- Update shared package versions, exact dependency pins, lockfiles and examples + together. The full Release rehearsal now requires the sustained gate. + ## 0.1.0-rc.2 - 2026-09-11 - Keep the signed-off RC runtime and reconcile its history with public main. diff --git a/Cargo.lock b/Cargo.lock index 5e075c8..796a0b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -291,7 +291,7 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vthread" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "crossbeam-queue", "libc", @@ -303,28 +303,28 @@ dependencies = [ [[package]] name = "vthread-lab" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "vthread", ] [[package]] name = "vthread-stack" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "libc", ] [[package]] name = "vthread-sync-core" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "loom", ] [[package]] name = "vthreads" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "vthread", ] diff --git a/Cargo.toml b/Cargo.toml index 96db530..1693d37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/vthread", "crates/vthreads", "crates/vthread-stack", "crates/ resolver = "3" [workspace.package] -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" edition = "2024" rust-version = "1.96" license = "Apache-2.0" @@ -11,9 +11,9 @@ repository = "https://github.com/zsumz/vthread" authors = ["vthread contributors"] [workspace.dependencies] -vthread = { path = "crates/vthread", version = "=0.1.0-rc.2" } -vthread-stack = { path = "crates/vthread-stack", version = "=0.1.0-rc.2" } -vthread-sync-core = { path = "crates/vthread-sync-core", version = "=0.1.0-rc.2" } +vthread = { path = "crates/vthread", version = "=0.1.0-rc.3" } +vthread-stack = { path = "crates/vthread-stack", version = "=0.1.0-rc.3" } +vthread-sync-core = { path = "crates/vthread-sync-core", version = "=0.1.0-rc.3" } zio = { version = "=0.0.1-dev.1", default-features = false } socket2 = { version = "=0.6.5", default-features = false } libc = "=0.2.189" diff --git a/README.md b/README.md index 17bcdc4..ae820ec 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Add vthread to your project: ```toml [dependencies] -vthread = "=0.1.0-rc.2" +vthread = "=0.1.0-rc.3" ``` ```rust @@ -76,7 +76,7 @@ for verification coverage and known limitations. ## Compatibility -vthread `0.1.0-rc.2` is a candidate for production use on its supported platforms +vthread `0.1.0-rc.3` is a candidate for production use on its supported platforms within the documented boundaries. The eventual `0.1.0` release and subsequent `0.1.x` releases will keep compatible public API updates within `0.1`; breaking API or contract changes move to `0.2`. Tested configurations and known limitations are diff --git a/RELEASE.md b/RELEASE.md index 1d54a53..d28c8b8 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -7,12 +7,81 @@ Release automation uses [zrelease](https://github.com/zsumz/zrelease), pinned to [the Rehearse workflow](.github/workflows/rehearse.yml) and [the Release workflow](.github/workflows/release.yml). -The signed candidate is `0.1.0-rc.2` at -[`793e675`](https://github.com/zsumz/vthread/commit/793e675c2c3ddc13940607ab4ec3342e209d0568). +The current candidate is `0.1.0-rc.3`. It retains the published RC.2 runtime +implementation and adds the sustained production-scope gate described below. Package versions, exact internal dependency pins, and the release tag must agree. Both workflows enable zrelease's lockstep -policy; a stable-looking tag over RC packages is rejected. No final release is -being prepared in this cycle. +policy; a stable-looking tag over RC packages is rejected. This cycle prepares +and qualifies an RC; final `0.1.0` publication remains a separate action. + +## Production support contract + +The release standard for `0.1.0` is **production ready within the documented +supported workloads and platforms**, with a deliberately limited feature set. +Supported applications use `vthread` or its `vthreads` alias on Linux x86_64 or +macOS ARM64 with Rust 1.96+ and unwinding panics. Internal support crates are +implementation dependencies, without a direct-use compatibility commitment. + +Structured ownership, carrier affinity, bounded admission and services, +cooperative cancellation, and controlled shutdown are supported contracts. +Correctness failures within those contracts are bugs to fix. Public API and +contract changes remain compatible throughout `0.1.x`; breaking changes require +`0.2`. Standard-library blocking, arbitrary native faults, stack overflow, and +non-cooperative work retain the boundaries documented in [Security](SECURITY.md). + +Feature completeness is not a release gate. Native correctness checks, the +application load/failure matrix, sustained qualification, and exact archive and +consumer verification are gates. A passing RC qualifies its own version and +source; final-version manifests and archives must pass the release gates again. + +## Inbox progress repairs + +The notification weaknesses discussed in the older release notes have concrete +repairs in the current runtime. Active carriers treat published inbox depth as +work to receive, independently of a delayed notification. A later publisher wakes +an already parked carrier, and waiter registration rechecks the queue under its +mutex before sleeping. These changes are recorded in +[`f92e5de`](https://github.com/zsumz/vthread/commit/f92e5dec76ae52340dd2086649538a1bfa1ef723) +and [`1ac097c`](https://github.com/zsumz/vthread/commit/1ac097c3039c9a167e7504f74edcd652921345fc). + +The canonical gate exercises paused notifiers, active and parked carriers, +registration races, small queues with multiple producers, and a production-shaped +4,096-task refill. Boundary regressions demonstrated failures before the repairs; +the sustained gate exercises repeated lifetimes and reclamation on the repaired +runtime. The old pre-repair warning does not describe today's notification protocol. + +## Sustained qualification + +The full **Release** workflow, including `publish: false`, requires +[sustained runtime](.github/workflows/sustained.yml) before creating a release +plan. The compact automatic branch rehearsal remains a faster package check. + +| Platform | Carriers | Mixed worker batch | Required uninterrupted duration | +| --- | --- | --- | --- | +| Linux x86_64 | 1 and 4, separate processes | 4,096 tasks | One hour per process | +| macOS ARM64 | 1 and 4, separate processes | 4,096 tasks | One hour per process | + +Each default-feature optimized process retains one runtime across batches and +performs payload-checked TCP and bounded-channel exchanges, contended mutex +handoffs, semaphore admission, timers, native blocking jobs, carrier-affinity +checks, and cancellation races. Every batch checks service drain; final shutdown +checks active tasks, pending wakes, readiness registrations and native work. +The supervisor requires at least one million completed task lifetimes per +process, exact spawn/completion and park/wake accounting, exact stack acquisition +accounting, and the expected mutex updates. A timeout, crash, or restart fails. + +RSS and open descriptors are sampled every ten seconds. After ten minutes of +warmup, the baseline is the next ten-minute median. The final ten-minute median +and every warmed sample must stay within the larger of 32 MiB or 20% of baseline +RSS, and within eight descriptors of baseline. Missing measurements fail the +gate. These are declared resource-growth checks for this workload, not a fixed +memory-per-task or universal throughput promise. The receipt records source, +lockfile and binary hashes, host, workload counts, samples, and gate results. + +The separate 22-case application matrix checks concurrency 1/16/64/256 with one +and four carriers, fixed arrivals at 2,000/second, overload rejection, deadline +recovery, and shutdown while clients are blocked. These configurations define +the tested scope; they are not maximum supported capacity or latency guarantees. ## Practice a release @@ -128,7 +197,7 @@ node dist/install.mjs --sha "$(git rev-parse HEAD)" \ ``` Regenerate when the workspace dependency graph changes. Preserve the caller's -canonical and native-stack prerequisites, automatic branch rehearsal, explicit +canonical, native-stack and sustained prerequisites, automatic branch rehearsal, explicit publish condition, `main` requirement for publication, and the consumer smoke inputs and lockstep policy in both workflows. Run `actionlint` and `zcheck run check` before committing the update. @@ -149,9 +218,10 @@ and binary identities for debug and release on both targets. The RC signoff is the runtime baseline; each release run records fresh evidence for its own source commit and archives. Historical closeout records remain in Git history and the archived candidate evidence. Automation does not establish -new scale or performance claims. Alternate-stack sanitizer hooks, larger -simultaneous populations, cross-platform sustained runs, memory footprint, -loaded tails, and controlled-host idle CPU remain unqualified. Local timing is +new scale or performance claims. The sustained matrix above adds cross-platform +lifetime and resource-growth coverage. Alternate-stack sanitizer hooks, populations +above the tested scope, absolute memory footprint, controlled loaded-tail targets, +and controlled-host idle CPU remain unqualified. Local timing is observational; `zcheck run perf-cancellation-history` is a separate timing guard. The eventual `0.1.0` and subsequent `0.1.x` releases preserve public API diff --git a/benchmarks/Cargo.lock b/benchmarks/Cargo.lock index cadacd9..6be02a5 100644 --- a/benchmarks/Cargo.lock +++ b/benchmarks/Cargo.lock @@ -70,7 +70,7 @@ dependencies = [ [[package]] name = "vthread" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "crossbeam-queue", "libc", @@ -82,21 +82,21 @@ dependencies = [ [[package]] name = "vthread-benchmarks" -version = "0.0.0" +version = "0.1.0-rc.3" dependencies = [ "vthread", ] [[package]] name = "vthread-stack" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "libc", ] [[package]] name = "vthread-sync-core" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" [[package]] name = "windows-link" diff --git a/benchmarks/Cargo.toml b/benchmarks/Cargo.toml index 301cd6b..ef8ee2b 100644 --- a/benchmarks/Cargo.toml +++ b/benchmarks/Cargo.toml @@ -1,8 +1,9 @@ [package] name = "vthread-benchmarks" -version = "0.0.0" +version = "0.1.0-rc.3" edition = "2024" publish = false +license = "Apache-2.0" [features] allocation-probe = [] @@ -11,7 +12,7 @@ scheduler-profiling = ["vthread/scheduler-profiling"] handoff-profiling = ["scheduler-profiling", "vthread/handoff-profiling"] [dependencies] -vthread = { path = "../crates/vthread" } +vthread = { path = "../crates/vthread", version = "=0.1.0-rc.3" } [profile.release] codegen-units = 1 diff --git a/crates/vthread-stack/README.md b/crates/vthread-stack/README.md index fdcb7bf..48aadf0 100644 --- a/crates/vthread-stack/README.md +++ b/crates/vthread-stack/README.md @@ -6,7 +6,7 @@ Use [`vthread`](https://github.com/zsumz/vthread) in applications. Its public cr forbids unsafe Rust. This support crate isolates the unsafe stack mechanics and has no compatibility contract for direct downstream use. -See [release notes](https://github.com/zsumz/vthread/blob/v0.1.0-rc.2/RELEASE.md) +See [release notes](https://github.com/zsumz/vthread/blob/v0.1.0-rc.3/RELEASE.md) for verification coverage and known limitations. ## Ownership and safety diff --git a/crates/vthread-sync-core/README.md b/crates/vthread-sync-core/README.md index e3b5b9a..5c6060d 100644 --- a/crates/vthread-sync-core/README.md +++ b/crates/vthread-sync-core/README.md @@ -5,7 +5,7 @@ The narrow exclusive-value and protocol core supporting `vthread` synchronizatio Use [`vthread`](https://github.com/zsumz/vthread) in applications. This support crate has no compatibility contract for direct downstream use. -See [release notes](https://github.com/zsumz/vthread/blob/v0.1.0-rc.2/RELEASE.md) +See [release notes](https://github.com/zsumz/vthread/blob/v0.1.0-rc.3/RELEASE.md) for verification coverage and known limitations. ## Runtime boundary diff --git a/crates/vthread/README.md b/crates/vthread/README.md index 110145b..c52d2b5 100644 --- a/crates/vthread/README.md +++ b/crates/vthread/README.md @@ -7,10 +7,10 @@ suspension points. The public runtime crate forbids unsafe Rust. Supported targets are Linux x86_64 and macOS ARM64, with Rust 1.96 or newer and `panic = "unwind"`. Builds with `panic = "abort"` are rejected. -See [release notes](https://github.com/zsumz/vthread/blob/v0.1.0-rc.2/RELEASE.md) +See [release notes](https://github.com/zsumz/vthread/blob/v0.1.0-rc.3/RELEASE.md) for verification coverage and known limitations. -vthread `0.1.0-rc.2` is a candidate for production use on its supported platforms +vthread `0.1.0-rc.3` is a candidate for production use on its supported platforms within the documented boundaries. The eventual `0.1.0` release and subsequent `0.1.x` releases will keep compatible public API updates within `0.1`; breaking API or contract changes move to `0.2`. @@ -21,7 +21,7 @@ Add vthread to your project: ```toml [dependencies] -vthread = "=0.1.0-rc.2" +vthread = "=0.1.0-rc.3" ``` ```rust diff --git a/crates/vthreads/Cargo.toml b/crates/vthreads/Cargo.toml index 320fed8..ab5c900 100644 --- a/crates/vthreads/Cargo.toml +++ b/crates/vthreads/Cargo.toml @@ -25,7 +25,7 @@ runtime-evidence = ["vthread/runtime-evidence"] qualification = ["vthread/qualification"] [dependencies] -vthread = { path = "../vthread", version = "=0.1.0-rc.2" } +vthread = { path = "../vthread", version = "=0.1.0-rc.3" } [lints] workspace = true diff --git a/crates/vthreads/README.md b/crates/vthreads/README.md index c4aab5e..bcb3ae2 100644 --- a/crates/vthreads/README.md +++ b/crates/vthreads/README.md @@ -9,7 +9,7 @@ Its `runtime-evidence` and `qualification` features forward directly to `vthread It shares vthread's Linux x86_64 and macOS ARM64 targets, Rust 1.96 minimum, and `panic = "unwind"` requirement. -See [release notes](https://github.com/zsumz/vthread/blob/v0.1.0-rc.2/RELEASE.md) +See [release notes](https://github.com/zsumz/vthread/blob/v0.1.0-rc.3/RELEASE.md) for verification coverage and known limitations. This release candidate follows vthread's pre-1.0 compatibility policy. The eventual @@ -22,7 +22,7 @@ Add the alias to your project: ```toml [dependencies] -vthreads = "=0.1.0-rc.2" +vthreads = "=0.1.0-rc.3" ``` ```rust diff --git a/reference/Cargo.lock b/reference/Cargo.lock index bf52df5..0fd05d4 100644 --- a/reference/Cargo.lock +++ b/reference/Cargo.lock @@ -70,7 +70,7 @@ dependencies = [ [[package]] name = "vthread" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "crossbeam-queue", "libc", @@ -82,21 +82,21 @@ dependencies = [ [[package]] name = "vthread-reference" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "vthread", ] [[package]] name = "vthread-stack" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" dependencies = [ "libc", ] [[package]] name = "vthread-sync-core" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" [[package]] name = "windows-link" diff --git a/reference/Cargo.toml b/reference/Cargo.toml index 175ba6d..944fe98 100644 --- a/reference/Cargo.toml +++ b/reference/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vthread-reference" -version = "0.1.0-rc.2" +version = "0.1.0-rc.3" edition = "2024" rust-version = "1.96" publish = false @@ -9,7 +9,7 @@ license = "Apache-2.0" [workspace] [dependencies] -vthread = { path = "../crates/vthread", version = "=0.1.0-rc.2" } +vthread = { path = "../crates/vthread", version = "=0.1.0-rc.3" } [profile.release] panic = "unwind" diff --git a/reference/README.md b/reference/README.md index 9ecfd82..b1fcf0f 100644 --- a/reference/README.md +++ b/reference/README.md @@ -23,7 +23,7 @@ For an application beside a checkout named `vthread`: ```toml [dependencies] -vthread = { path = "../vthread/crates/vthread", version = "=0.1.0-rc.1" } +vthread = { path = "../vthread/crates/vthread", version = "=0.1.0-rc.3" } ``` - Give the runtime one application-level owner. Use scopes for requests and operations, diff --git a/scripts/run-sustained.py b/scripts/run-sustained.py new file mode 100644 index 0000000..13c3b51 --- /dev/null +++ b/scripts/run-sustained.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Supervise one mixed-runtime process and retain source-bound resource evidence.""" + +import argparse +from datetime import datetime, timezone +import json +from pathlib import Path +import platform +import subprocess +import time + +import evidence +import sustained_verify + + +def sample(pid, started): + result = evidence.resources(pid, started) + if platform.system() == "Darwin": + # lsof includes cwd, executable and mapped files; only count numeric descriptors. + listing = subprocess.run(["lsof", "-nP", "-a", "-p", str(pid), "-Ff"], + capture_output=True, text=True, timeout=5, check=True) + result["fds"] = sum(line[1:].isdigit() for line in listing.stdout.splitlines() + if line.startswith("f")) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--duration", type=int, default=3600) + parser.add_argument("--carriers", type=int, choices=(1, 4), required=True) + parser.add_argument("--tasks", type=int, default=4096) + parser.add_argument("--smoke", action="store_true", help="short harness check; never release qualification") + args = parser.parse_args() + if not 30 <= args.duration <= 86400 or not 2 <= args.tasks <= 4096: + parser.error("invalid workload bounds") + if not args.smoke and (args.duration < 3600 or args.tasks != 4096): + parser.error("production qualification requires at least one hour and 4096 tasks") + environment = evidence.metadata("sustained production scope" if not args.smoke else "harness smoke") + if environment["dirty"]: + parser.error("qualification requires a clean committed source tree") + binary = args.binary.resolve(strict=True) + binary_hash = evidence.digest(binary) + args.out.mkdir(parents=True, exist_ok=False) + interval = 1 if args.smoke else sustained_verify.SAMPLE_SECONDS + command = [str(binary), "soak", str(args.duration), str(args.carriers), str(args.tasks)] + started_at = datetime.now(timezone.utc).isoformat() + started = time.monotonic() + samples, collection_errors, timed_out = [], [], False + with (args.out / "stdout.log").open("w") as stdout, (args.out / "stderr.log").open("w") as stderr: + process = subprocess.Popen(command, cwd=evidence.ROOT, stdout=stdout, stderr=stderr) + try: + while process.poll() is None: + if time.monotonic() - started > args.duration + 120: + timed_out = True + process.kill() + break + try: + sampled_at = time.monotonic() + record = sample(process.pid, started) + if process.poll() is not None: + break + samples.append(record) + with (args.out / "samples.jsonl").open("a") as output: + output.write(json.dumps(record) + "\n") + print(json.dumps(record), flush=True) + except (OSError, subprocess.SubprocessError) as error: + # A process may exit between poll and sample. Missing live samples fail closed. + if process.poll() is None: + collection_errors.append(str(error)) + try: + process.wait(timeout=max(0.001, interval - (time.monotonic() - sampled_at))) + except subprocess.TimeoutExpired: + pass + finally: + if process.poll() is None: + process.kill() + process.wait() + wall = time.monotonic() - started + report = {} + try: + lines = (args.out / "stdout.log").read_text().splitlines() + if len(lines) != 1: + raise ValueError("expected exactly one completed workload report") + report = json.loads(lines[0]) + except (ValueError, json.JSONDecodeError) as error: + collection_errors.append(str(error)) + receipt = dict(schema=1, status="failed", environment=environment, + host=dict(system=platform.system(), machine=platform.machine()), + contract=dict(duration=args.duration, carriers=args.carriers, tasks=args.tasks, + smoke=args.smoke, sample_seconds=interval, + minimum_lifetimes=sustained_verify.MINIMUM_LIFETIMES), + process=dict(pid=process.pid, attempts=1, command=command, + binary_sha256=binary_hash, started_at=started_at, + wall_seconds=wall, returncode=process.returncode, + timed_out=timed_out, samples=samples), report=report, + source_unchanged=(evidence.source_digest() == environment["source_sha256"] + and evidence.output("git", "rev-parse", "HEAD") == environment["head"] + and not evidence.output("git", "status", "--porcelain") + and evidence.digest(binary) == binary_hash)) + errors, resource_summary = sustained_verify.validate(receipt) + errors.extend(collection_errors) + if (args.out / "stderr.log").stat().st_size: + errors.append("unexpected workload stderr") + receipt.update(status="failed" if errors else "passed", errors=errors, resources=resource_summary) + receipt["files"] = {p.name: evidence.digest(p) for p in args.out.iterdir() if p.is_file()} + path = args.out / "receipt.json" + path.write_text(json.dumps(receipt, indent=2) + "\n") + (args.out / "receipt.sha256").write_text(evidence.digest(path) + " receipt.json\n") + print(json.dumps(dict(status=receipt["status"], report=report, errors=errors)), flush=True) + return bool(errors) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sustained_verify.py b/scripts/sustained_verify.py new file mode 100644 index 0000000..c8b0a05 --- /dev/null +++ b/scripts/sustained_verify.py @@ -0,0 +1,95 @@ +"""Fail closed on incomplete sustained runtime and resource evidence.""" + +import math +from statistics import median + + +SAMPLE_SECONDS = 10 +MINIMUM_LIFETIMES = 1_000_000 +HOSTS = {("Linux", "x86_64"), ("Darwin", "arm64")} + + +def validate_report(report, *, duration, carriers, tasks, wall_seconds, returncode): + if not isinstance(report, dict): + return ["report must be an object"] + fields = {"schema", "carriers", "tasks", "iterations", "mutex_updates", "elapsed_ns", + "spawned", "completed", "parks", "wakes", "stack_allocated", "stack_reused"} + if any(type(report.get(name)) is not int or report[name] < 0 for name in fields): + return ["missing or invalid nonnegative integer fields"] + lifetimes = report["iterations"] * (tasks + 5) + checks = [ + (returncode == 0, "unsuccessful process exit"), + (report["schema"] == 1, "unexpected schema"), + (report.get("workload") == "mixed-soak", "unexpected workload"), + (report.get("connection_strategy") == "persistent-pair", "unexpected connections"), + (report["carriers"] == carriers and report["tasks"] == tasks, "workload mismatch"), + (report["iterations"] > 0, "no completed iterations"), + (duration <= wall_seconds <= duration + 120, "supervised duration outside bounds"), + (duration * 1e9 <= report["elapsed_ns"] <= wall_seconds * 1e9, "internal duration outside bounds"), + (report["spawned"] == report["completed"] == lifetimes, "task accounting mismatch"), + (report["parks"] == report["wakes"] > 0, "park/wake accounting mismatch"), + (report["stack_allocated"] + report["stack_reused"] == lifetimes, "stack accounting mismatch"), + (report["mutex_updates"] == report["iterations"] * tasks * 8, "mutex accounting mismatch"), + ] + return [message for passed, message in checks if not passed] + + +def validate_resources(samples, duration, interval=SAMPLE_SECONDS): + errors, summary = [], {} + if not isinstance(samples, list) or len(samples) < duration / interval * 0.9: + return ["insufficient resource samples"], summary + previous = -1 + for sample in samples: + if not isinstance(sample, dict): + return ["invalid resource sample"], summary + seconds = sample.get("seconds") + if (type(seconds) not in (int, float) or not math.isfinite(seconds) + or not previous < seconds <= duration + 120 + or seconds - max(previous, 0) > interval * 3): + return ["invalid sample timing or sampling gap"], summary + for name in ("rss_kib", "fds"): + if type(sample.get(name)) is not int or sample[name] <= 0: + return [f"missing or invalid {name} measurement"], summary + previous = seconds + if samples[-1]["seconds"] < duration - interval * 3: + errors.append("resource sampling ended early") + baseline = [s for s in samples if duration / 6 <= s["seconds"] < duration / 3] + tail = [s for s in samples if duration * 5 / 6 <= s["seconds"] <= duration] + if len(baseline) < 2 or len(tail) < 2: + return ["missing warm or final resource window"], summary + warm = [s for s in samples if s["seconds"] >= duration / 6] + for name, allowance in (("rss_kib", 32768), ("fds", 8)): + start = median(s[name] for s in baseline) + end = median(s[name] for s in tail) + # A declared observational envelope, not a universal allocator or latency guarantee. + limit = max(allowance, start * 0.2) if name == "rss_kib" else allowance + peak = max(s[name] for s in warm) + summary[name] = dict(baseline_median=start, final_median=end, warmed_peak=peak, + growth=end - start, allowed_growth=limit) + if end - start > limit or peak - start > limit: + errors.append(f"{name} exceeded warmed growth envelope") + return errors, summary + + +def validate(receipt): + contract, process = receipt["contract"], receipt["process"] + errors = validate_report(receipt["report"], duration=contract["duration"], + carriers=contract["carriers"], tasks=contract["tasks"], + wall_seconds=process["wall_seconds"], returncode=process["returncode"]) + resources, summary = validate_resources(process["samples"], contract["duration"], + contract["sample_seconds"]) + errors.extend(resources) + if (receipt["host"]["system"], receipt["host"]["machine"]) not in HOSTS: + errors.append("unsupported qualification host") + if process["attempts"] != 1 or process["timed_out"]: + errors.append("process restarted or timed out") + if not receipt["source_unchanged"]: + errors.append("source changed during qualification") + if not contract["smoke"]: + if contract["duration"] < 3600 or contract["carriers"] not in (1, 4) or contract["tasks"] != 4096: + errors.append("incomplete production qualification profile") + report = receipt["report"] + completed = report.get("completed") if isinstance(report, dict) else None + if type(completed) is not int or completed < MINIMUM_LIFETIMES: + errors.append("minimum lifetime volume not reached") + return errors, summary diff --git a/scripts/sustained_verify_test.py b/scripts/sustained_verify_test.py new file mode 100644 index 0000000..66c6f79 --- /dev/null +++ b/scripts/sustained_verify_test.py @@ -0,0 +1,95 @@ +"""Negative controls prevent short, leaking or inconsistent runs from qualifying.""" + +import unittest + +import sustained_verify as verify + + +def fixture(): + tasks, iterations = 4096, 300 + lifetimes = (tasks + 5) * iterations + return dict(contract=dict(duration=3600, carriers=4, tasks=tasks, smoke=False, sample_seconds=10), + host=dict(system="Linux", machine="x86_64"), source_unchanged=True, + process=dict(attempts=1, timed_out=False, wall_seconds=3600.5, returncode=0, + samples=[dict(seconds=t + 0.1, rss_kib=100_000, fds=12) + for t in range(0, 3600, 10)]), + report=dict(schema=1, workload="mixed-soak", connection_strategy="persistent-pair", + carriers=4, tasks=tasks, iterations=iterations, mutex_updates=iterations*tasks*8, + elapsed_ns=3_600_100_000_000, spawned=lifetimes, completed=lifetimes, + parks=10*lifetimes, wakes=10*lifetimes, + stack_allocated=4100, stack_reused=lifetimes-4100)) + + +class SustainedEvidenceTests(unittest.TestCase): + def test_both_supported_hosts_qualify(self): + for system, machine in verify.HOSTS: + receipt = fixture() + receipt["host"] = dict(system=system, machine=machine) + self.assertEqual(verify.validate(receipt)[0], []) + + def test_each_accounting_failure_is_rejected(self): + for field in ("spawned", "completed", "wakes", "stack_reused", "mutex_updates"): + receipt = fixture() + receipt["report"][field] -= 1 + with self.subTest(field=field): + self.assertTrue(verify.validate(receipt)[0]) + + def test_invalid_reports_fail_closed(self): + for report in (None, [], {}, {"iterations": True}): + receipt = fixture() + receipt["report"] = report + # Malformed reports must produce errors without hiding a saved receipt. + self.assertTrue(verify.validate(receipt)[0]) + + def test_short_run_is_rejected(self): + for field in ("wall_seconds",): + receipt = fixture() + receipt["process"][field] = 60 + self.assertTrue(verify.validate(receipt)[0]) + receipt = fixture() + receipt["report"]["elapsed_ns"] = 60_000_000_000 + self.assertTrue(verify.validate(receipt)[0]) + + def test_crash_restart_timeout_or_source_change_is_rejected(self): + for field, value in (("returncode", -9), ("attempts", 2), ("timed_out", True)): + receipt = fixture() + receipt["process"][field] = value + self.assertTrue(verify.validate(receipt)[0]) + receipt = fixture() + receipt["source_unchanged"] = False + self.assertTrue(verify.validate(receipt)[0]) + + def test_short_profile_cannot_claim_production_qualification(self): + for field, value in (("duration", 30), ("tasks", 64), ("carriers", 2)): + receipt = fixture() + receipt["contract"][field] = value + self.assertIn("incomplete production qualification profile", verify.validate(receipt)[0]) + + def test_missing_measurements_and_gaps_are_rejected(self): + for name in ("rss_kib", "fds", "seconds"): + receipt = fixture() + receipt["process"]["samples"][100][name] = None + self.assertTrue(verify.validate(receipt)[0]) + for replacement in ([], fixture()["process"]["samples"][:200], + fixture()["process"]["samples"][::2]): + receipt = fixture() + receipt["process"]["samples"] = replacement + self.assertTrue(verify.validate(receipt)[0]) + + def test_memory_and_descriptor_growth_are_rejected(self): + for name, delta in (("rss_kib", 40_000), ("fds", 9)): + receipt = fixture() + for sample in receipt["process"]["samples"][300:]: + sample[name] += delta + self.assertIn(f"{name} exceeded warmed growth envelope", verify.validate(receipt)[0]) + + def test_warmup_is_excluded_but_late_spikes_are_not(self): + receipt = fixture() + receipt["process"]["samples"][0]["rss_kib"] = 500_000 + self.assertEqual(verify.validate(receipt)[0], []) + receipt["process"]["samples"][200]["rss_kib"] = 500_000 + self.assertTrue(verify.validate(receipt)[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/zcheck.toml b/zcheck.toml index 3c2b6b2..24df9c2 100644 --- a/zcheck.toml +++ b/zcheck.toml @@ -78,7 +78,13 @@ needs = ["docs"] [tasks.check] description = "Check the complete vthread source checkout." -needs = ["zrail", "application-smoke", "test-native-release", "benchmark-test-features"] +needs = ["zrail", "application-smoke", "test-native-release", "benchmark-test-features", "sustained-evidence-test"] + +[tasks.sustained-evidence-test] +description = "Reject incomplete sustained runs, resource growth and inconsistent lifetime accounting." +run = ["python3", "-m", "unittest", "discover", "-s", "scripts", "-p", "sustained_*test.py"] +tools = ["python3"] +needs = ["policy"] [tasks.benchmark-format] description = "Validate formatting in the standalone scheduler harness." diff --git a/zrail.lock b/zrail.lock index 772d722..f5e34b1 100644 --- a/zrail.lock +++ b/zrail.lock @@ -6,9 +6,9 @@ contract_sha256 = "8603e24df9addfcc603707c15027481b9ad5e11ada455e48eba1fb9de67b5 ratchet = [] [analysis] -inventory_sha256 = "b70addaf8781407e794eb5badd031f374f1c1f4973967a134cbf240c17610011" +inventory_sha256 = "290bf864d4fffa080e4c3ca746d802caf72dce51e4e42622a1b19c6c923482b0" exclusions_sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" -cargo_lock_sha256 = "2a3abfb8cc9ad5722f1fb0a76f557cb3c03b4bfdfe804f6255ace68b3b20fb4a" +cargo_lock_sha256 = "8771cde95c5d51ab68b6dd048e6d200f58517b0e80ff9c1e10eaa00a2fe232dd" cargo_features_sha256 = "3e43f336e44215719c080510ba8ce053576e4fa44eab81b200117ebcbf4d3d64" feature_worlds_sha256 = "6edfb4a1172e614d373d2aba49cdfa4e544769ce212a82805cc63e4e3b99d116" feature_worlds = 5 @@ -78,7 +78,7 @@ default_features = true [package.dependency.source] kind = "workspace-member" directory = "crates/vthread-stack" -requirement = "=0.1.0-rc.2" +requirement = "=0.1.0-rc.3" [[package.dependency]] alias = "vthread-sync-core" @@ -92,7 +92,7 @@ default_features = true [package.dependency.source] kind = "workspace-member" directory = "crates/vthread-sync-core" -requirement = "=0.1.0-rc.2" +requirement = "=0.1.0-rc.3" [[package.dependency]] alias = "zio" @@ -121,7 +121,7 @@ default_features = true [package.dependency.source] kind = "workspace-member" directory = "crates/vthread" -requirement = "=0.1.0-rc.2" +requirement = "=0.1.0-rc.3" [[package]] name = "vthread-stack" @@ -168,4 +168,4 @@ default_features = true [package.dependency.source] kind = "workspace-member" directory = "crates/vthread" -requirement = "=0.1.0-rc.2" +requirement = "=0.1.0-rc.3"