diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a02f4a3..13ee8b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,9 +3,9 @@ name: check on: pull_request: push: - branches: [main, "release/**"] - tags: ["v*"] + branches: [main] workflow_dispatch: + workflow_call: permissions: contents: read @@ -52,11 +52,8 @@ jobs: if-no-files-found: warn qualification: - name: qualify release (${{ matrix.target }}) - if: >- - github.ref == 'refs/heads/main' || - startsWith(github.ref, 'refs/tags/v') || - startsWith(github.ref, 'refs/heads/release/') + name: qualify application (${{ matrix.target }}) + if: github.event_name != 'pull_request' needs: check strategy: fail-fast: false @@ -68,22 +65,12 @@ jobs: target: macos-arm64 runs-on: ${{ matrix.runner }} env: - CARGO_TARGET_DIR: /tmp/vthread-release-build + CARGO_TARGET_DIR: /tmp/vthread-application-build CARGO_HOME: ${{ github.workspace }}/target/cargo-home steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Rust run: rustup toolchain install 1.96.1 --profile minimal - - name: Verify release ref - run: | - version="$(awk -F '"' '/^version = "/ { print $2; exit }' Cargo.toml)" - case "$GITHUB_REF" in - refs/heads/main) test "$GITHUB_REF_NAME" = main ;; - refs/tags/v*) test "$GITHUB_REF_NAME" = "v$version" ;; - refs/heads/release/*) test "$GITHUB_REF_NAME" = "release/$version" ;; - *) exit 1 ;; - esac - grep --fixed-strings "## $version - " CHANGELOG.md - name: Cache locked dependencies run: cargo fetch --locked - name: Qualify application @@ -93,20 +80,12 @@ jobs: python3 scripts/run-application.py --out .qualification/application --offered-rates 2000 --offered-count 256 - --context "GitHub release qualification ${{ matrix.target }}" - - name: Verify workspace packages - shell: bash - run: | - set -euo pipefail - mkdir -p .qualification/package - cargo package --locked --offline --workspace --exclude vthread-lab 2>&1 | tee .qualification/package/verification.log + --context "GitHub application qualification ${{ matrix.target }}" - name: Upload qualification evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: qualification-${{ github.run_id }}-${{ matrix.target }} - path: | - .qualification/ - ${{ env.CARGO_TARGET_DIR }}/package/*.crate + path: .qualification/ include-hidden-files: true if-no-files-found: warn diff --git a/.github/workflows/native-stack.yml b/.github/workflows/native-stack.yml index 3606dbb..d2f1a1e 100644 --- a/.github/workflows/native-stack.yml +++ b/.github/workflows/native-stack.yml @@ -3,8 +3,9 @@ name: native stack on: pull_request: push: - branches: [main, "perf/scheduler-hot-path", "release/**"] + branches: [main, "perf/scheduler-hot-path"] workflow_dispatch: + workflow_call: permissions: contents: read diff --git a/.github/workflows/rehearse.yml b/.github/workflows/rehearse.yml new file mode 100644 index 0000000..fb0a060 --- /dev/null +++ b/.github/workflows/rehearse.yml @@ -0,0 +1,31 @@ +# Generated by zrelease; preserve CI prerequisites, branch rehearsal and consumer smoke inputs. +name: Rehearse +run-name: Rehearse @ ${{ github.ref_name }} +'on': + push: + branches: ["release/**"] + workflow_dispatch: +permissions: + contents: read +jobs: + canonical: + uses: ./.github/workflows/ci.yml + native-stack: + uses: ./.github/workflows/native-stack.yml + rehearsal: + needs: [canonical, native-stack] + permissions: + contents: read + id-token: write + attestations: write + deployments: write + uses: zsumz/zrelease/.github/workflows/rehearse.yml@1993b45bfae19a00e61a9138565b79e029c2d7f4 + with: + members: '[{"name":"vthread-stack","needs":[]},{"name":"vthread-sync-core","needs":[]},{"name":"vthread","needs":["vthread-stack","vthread-sync-core"]},{"name":"vthreads","needs":["vthread"]}]' + workspace: true + lockstep: true + pipeline-ref: '1993b45bfae19a00e61a9138565b79e029c2d7f4' + toolchain: '1.96.1' + manifest-path: 'Cargo.toml' + base-branch: ${{ github.ref_type == 'branch' && github.ref_name || 'main' }} + smoke-sources: '{"vthread":"scripts/fixtures/release/main.rs","vthreads":"scripts/fixtures/release/main.rs"}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1a93206 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,119 @@ +# Generated by zrelease; preserve CI prerequisites, publish guards and consumer smoke when regenerating. +name: Release +run-name: ${{ inputs.publish && 'Publish' || 'Rehearse' }} @ ${{ github.ref_name }} +'on': + workflow_dispatch: + inputs: + publish: + description: Publish this release? Requires a release tag and one approval. + type: boolean + default: false + required: true +permissions: + contents: read +concurrency: + group: zrelease-${{ github.repository }} + cancel-in-progress: false +jobs: + canonical: + uses: ./.github/workflows/ci.yml + native-stack: + uses: ./.github/workflows/native-stack.yml + plan: + needs: [canonical, native-stack] + permissions: + contents: read + id-token: write + attestations: write + uses: zsumz/zrelease/.github/workflows/plan.yml@1993b45bfae19a00e61a9138565b79e029c2d7f4 + with: + members: '[{"name":"vthread-stack","needs":[]},{"name":"vthread-sync-core","needs":[]},{"name":"vthread","needs":["vthread-stack","vthread-sync-core"]},{"name":"vthreads","needs":["vthread"]}]' + workspace: true + lockstep: true + pipeline-ref: '1993b45bfae19a00e61a9138565b79e029c2d7f4' + toolchain: '1.96.1' + manifest-path: 'Cargo.toml' + publish: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} + base-branch: ${{ !inputs.publish && github.ref_type == 'branch' && github.ref_name || 'main' }} + crate_0: + name: vthread-stack + needs: [plan] + permissions: + contents: read + id-token: write + attestations: write + deployments: write + uses: zsumz/zrelease/.github/workflows/release.yml@1993b45bfae19a00e61a9138565b79e029c2d7f4 + with: + package: 'vthread-stack' + pipeline-ref: '1993b45bfae19a00e61a9138565b79e029c2d7f4' + toolchain: '1.96.1' + manifest-path: 'Cargo.toml' + publish: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} + base-branch: ${{ !inputs.publish && github.ref_type == 'branch' && github.ref_name || 'main' }} + plan-artifact-id: ${{ needs.plan.outputs.artifact-id }} + plan-sha256: ${{ needs.plan.outputs.plan-sha256 }} + approval-artifact-id: ${{ needs.plan.outputs.approval-artifact-id }} + crate_1: + name: vthread-sync-core + needs: [plan, crate_0] + permissions: + contents: read + id-token: write + attestations: write + deployments: write + uses: zsumz/zrelease/.github/workflows/release.yml@1993b45bfae19a00e61a9138565b79e029c2d7f4 + with: + package: 'vthread-sync-core' + pipeline-ref: '1993b45bfae19a00e61a9138565b79e029c2d7f4' + toolchain: '1.96.1' + manifest-path: 'Cargo.toml' + publish: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} + base-branch: ${{ !inputs.publish && github.ref_type == 'branch' && github.ref_name || 'main' }} + plan-artifact-id: ${{ needs.plan.outputs.artifact-id }} + plan-sha256: ${{ needs.plan.outputs.plan-sha256 }} + approval-artifact-id: ${{ needs.plan.outputs.approval-artifact-id }} + crate_2: + name: vthread + needs: [plan, crate_0, crate_1] + permissions: + contents: read + id-token: write + attestations: write + deployments: write + uses: zsumz/zrelease/.github/workflows/release.yml@1993b45bfae19a00e61a9138565b79e029c2d7f4 + with: + package: 'vthread' + smoke-source: 'scripts/fixtures/release/main.rs' + pipeline-ref: '1993b45bfae19a00e61a9138565b79e029c2d7f4' + toolchain: '1.96.1' + manifest-path: 'Cargo.toml' + publish: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} + base-branch: ${{ !inputs.publish && github.ref_type == 'branch' && github.ref_name || 'main' }} + plan-artifact-id: ${{ needs.plan.outputs.artifact-id }} + plan-sha256: ${{ needs.plan.outputs.plan-sha256 }} + approval-artifact-id: ${{ needs.plan.outputs.approval-artifact-id }} + dependency-artifact-ids: '${{ needs.crate_0.outputs.candidate-artifact-id }},${{ needs.crate_1.outputs.candidate-artifact-id }}' + dependency-shas: '["${{ needs.crate_0.outputs.candidate-sha256 }}","${{ needs.crate_1.outputs.candidate-sha256 }}"]' + crate_3: + name: vthreads + needs: [plan, crate_0, crate_1, crate_2] + permissions: + contents: read + id-token: write + attestations: write + deployments: write + uses: zsumz/zrelease/.github/workflows/release.yml@1993b45bfae19a00e61a9138565b79e029c2d7f4 + with: + package: 'vthreads' + smoke-source: 'scripts/fixtures/release/main.rs' + pipeline-ref: '1993b45bfae19a00e61a9138565b79e029c2d7f4' + toolchain: '1.96.1' + manifest-path: 'Cargo.toml' + publish: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} + base-branch: ${{ !inputs.publish && github.ref_type == 'branch' && github.ref_name || 'main' }} + plan-artifact-id: ${{ needs.plan.outputs.artifact-id }} + plan-sha256: ${{ needs.plan.outputs.plan-sha256 }} + approval-artifact-id: ${{ needs.plan.outputs.approval-artifact-id }} + dependency-artifact-ids: '${{ needs.crate_0.outputs.candidate-artifact-id }},${{ needs.crate_1.outputs.candidate-artifact-id }},${{ needs.crate_2.outputs.candidate-artifact-id }}' + dependency-shas: '["${{ needs.crate_0.outputs.candidate-sha256 }}","${{ needs.crate_1.outputs.candidate-sha256 }}","${{ needs.crate_2.outputs.candidate-sha256 }}"]' diff --git a/CHANGELOG.md b/CHANGELOG.md index 16b0407..c10ad9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,35 @@ ## Unreleased -## 0.1.0 - 2026-09-07 - -- Prepare an early public-feedback release with compatible public API updates in - `0.1.x`; breaking API or contract changes move to `0.2`. -- Retain the existing runtime without promoting held performance experiments. +## 0.1.0-rc.2 - 2026-09-11 + +- Keep the signed-off RC runtime and reconcile its history with public main. +- Enforce matching RC tags, shared workspace versions and exact internal pins. +- Preflight all release crate names before approval or upload, with explicit + first-publication bootstrap instructions. Update artifact actions to Node 24. +- Replace custom release packaging and closeout instructions with pinned zrelease + workspace automation, gated by both-target CI, with registry rehearsals and + fresh README-example consumers. The automatic rehearsal uses a compact + workspace graph with per-crate logs and receipts. +- Keep cross-crate synchronization model tests in workspace CI and omit them + from the standalone sync-core archive so its packaged tests are self-contained. + +## 0.1.0-rc.1 - 2026-09-09 + +- Prepare a production release candidate for the supported platforms. 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`. +- Retain the established runtime architecture without promoting held performance + experiments. +- Harden inbox progress for active and parked carriers: published depth is + authoritative while driving, later publishers wake a registered carrier when + the first notifier is delayed, and wait registration rechecks the queue under + its mutex. +- Reject suspension boundaries while a carrier is handling a panic, before + publishing wait or resource state, preserving panic isolation between tasks. - Require the full fixed-arrival application matrix and offline package builds in release qualification on Linux x86_64 and macOS ARM64. -- Update installation examples to `0.1` and use `52` throughout the examples. -- Keep the historical refill stall unresolved and visible in [release notes](RELEASE.md). +- Pin installation examples to `=0.1.0-rc.1` and use `52` throughout the examples. ## 0.0.2 - 2026-09-07 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cf740ff..114e610 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,5 +35,6 @@ harness. Set `CARGO_TARGET_DIR` to an absolute local path to use a separate buil Use small PGP-signed Conventional Commits: one concise subject, no body and no coauthor trailers. Maintainer commits use `zsumz `. -Run `zcheck run check` before submitting changes. Release qualification has -[additional gates](RELEASE.md); a green local check alone is not a release verdict. +Run `zcheck run check` before submitting changes. The [rehearsal and release workflows](RELEASE.md) require both-target CI before zrelease +packages and rehearses the workspace. Push a `release/**` branch to practice; +actual publication requires an explicit tagged dispatch and release approval. diff --git a/Cargo.lock b/Cargo.lock index 4207203..5e075c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -291,7 +291,7 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vthread" -version = "0.1.0" +version = "0.1.0-rc.2" dependencies = [ "crossbeam-queue", "libc", @@ -303,28 +303,28 @@ dependencies = [ [[package]] name = "vthread-lab" -version = "0.1.0" +version = "0.1.0-rc.2" dependencies = [ "vthread", ] [[package]] name = "vthread-stack" -version = "0.1.0" +version = "0.1.0-rc.2" dependencies = [ "libc", ] [[package]] name = "vthread-sync-core" -version = "0.1.0" +version = "0.1.0-rc.2" dependencies = [ "loom", ] [[package]] name = "vthreads" -version = "0.1.0" +version = "0.1.0-rc.2" dependencies = [ "vthread", ] diff --git a/Cargo.toml b/Cargo.toml index c1d8020..96db530 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" +version = "0.1.0-rc.2" 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" } -vthread-stack = { path = "crates/vthread-stack", version = "=0.1.0" } -vthread-sync-core = { path = "crates/vthread-sync-core", version = "=0.1.0" } +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" } 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 46316a7..17bcdc4 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ so they can keep values such as `Rc` across suspension. Task admission, queues, stacks, waiters, timers, I/O registrations, and native jobs have explicit bounds. Cancellation is cooperative and observed at checkpoints. +An operation that reaches a suspension boundary returns +`Error::SuspensionDuringPanic` instead of switching tasks while its carrier is +handling a panic. The runtime provides synchronization, bounded channels, networking, DNS, filesystem operations, and native blocking delegation. Diagnostics expose task @@ -42,7 +45,7 @@ Add vthread to your project: ```toml [dependencies] -vthread = "0.1" +vthread = "=0.1.0-rc.2" ``` ```rust @@ -73,9 +76,11 @@ for verification coverage and known limitations. ## Compatibility -vthread is in early development, intended for evaluation and feedback. The `0.1.x` -series preserves public API compatibility; breaking API or contract changes move -to `0.2`. Tested configurations and known limitations are in the [release notes](RELEASE.md). +vthread `0.1.0-rc.2` 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 +in the [release notes](RELEASE.md). ## Docs diff --git a/RELEASE.md b/RELEASE.md index 83a6c96..bea73c9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,196 +1,149 @@ -# Release 0.1.0 - -Status: release closeout; no publication or tag is authorized. `0.1.0` is intended -for evaluation and feedback, not a production-stability endorsement. The -[recorded qualification](#recorded-qualification) and -[remaining release gates](#remaining-release-gates-and-limitations) define its -current evidence and limits. - -The runtime retains carrier affinity, both cancellation checkpoints, exact wait -generations, bounded resource accounting, structured scope ownership, and -cancellation-safe direct mutex ownership transfer. - -## Compatibility - -The `0.1.x` series preserves public API compatibility. Breaking public API or -contract changes move to `0.2`; `1.0` requires a separate durable-API commitment. -Use `vthread = "0.1"` to receive compatible `0.1.x` releases. - -The exact dependency set includes `zio = "=0.0.1-dev.1"`. A normal vthread version -does not imply that every dependency has a stable-version contract. - -## Known risk - -The historical coalesced-inbox refill stall remains **unclassified**, not fixed. -The regression now records accepted, queued, started, returned and completed work -before cleanup and requires all 4,096 tasks to finish. Passing reruns cannot -reconstruct the missing historical state; the cleanup repairs are not assumed -to explain it. - -The feedback-release policy retains this as a disclosed unresolved reliability -risk. It remains a blocker for a production-stability endorsement. Public issue -tracking and final publication approval are still required before distribution. - -## Scope - -Version `0.1.0` retains the `0.0.2` runtime: native guarded stacks and execution -reuse, compact carrier-owned task storage, resident synchronization waits, and -owner-routed wakes. -It also includes bounded wake cohorts and admission service, deferred publication -and cleanup, cached ingress visibility, routed timers, and revocation-epoch -maintenance. Diagnostic features remain opt-in; default builds do not enable -timing instrumentation. - -Held capacity, readiness, channel, mailbox, polling, and lazy-fault experiments -are not included in this version. Their earlier performance results -are not claims about this version. The standalone benchmark workspace measures -only this runtime. HTTP remains outside the core project. - -Historical experiment reports and raw evidence remain on the preserved -`perf/scheduler-hot-path` branch at `6e7121cd00dc7b3efdc93e128b55f83cdf89c2d0`. -They are not part of the release checkout or distributed crates. Git history is not -rewritten. - -## Correctness repairs - -The source audit found and repaired four concrete correctness defects, with -negative regressions recorded before the repairs: - -| Defect | Repair | -| --- | --- | -| Cleanup used dispatch policy as a queue iterator, allowing repeated visits, missed runnable work, missed reclamation, and incorrect retained-task recovery. | Cold maintenance inspects both queue lanes exactly once, preserving normal dispatch policy and live-task visibility in intermediate snapshots. | -| A typed native-stack context callback could suspend while holding a reference whose owner was valid for only one resume. | Lending that context prevents its fiber from suspending and restores the mount through nested calls and panic. | -| Dropping an unstarted fiber installed a suspension target whose parent context had never been saved. | Reclamation preserves the actual executing mount. | -| Forced-unwind cookie block exhaustion advanced the allocator and could reuse identities. | Exhaustion remains permanent, including after caught panics. | - -These are correctness repairs, not optional performance promotions. Independent -cross-reviews found no additional concrete concern in the repairs. Negative -controls and final-tree qualification are recorded separately; neither the audit -nor the passing tests establish an exhaustive runtime proof. - -## Qualification contract - -| Verification | Required coverage | -| --- | --- | -| Canonical `zcheck run check` | Native debug and release workspace tests; all-feature tests; documentation and compile-fail examples; source, layout and architecture policy; application evidence validation; public-API load and failure smoke tests. | -| Standalone benchmark, also required by `zcheck run check` | Formatting, Clippy, default tests and all-feature tests. A workspace-only pass does not qualify this separate manifest. | -| Release CI, on both targets | Eight closed-loop loads, eight fixed-arrival cases at 2,000 arrivals/second, six failure rounds, then offline verification of all four distributable packages. Logs and package archives are uploaded. | -| Distribution closeout | Audit clean final-version archives, licenses, normalized manifests, exact internal dependency closure and source identity. After publication, run the README example in a fresh registry-only consumer before announcement. | - -Package creation is not publication. The publication order is `vthread-stack`, -`vthread-sync-core`, `vthread`, then `vthreads`; the lab, benchmark and reference -packages remain unpublished. - -## Recorded qualification - -### Reviewed main baseline - -Commit `6882d708207e5549b86f4b76832d7f878f45799c` (`0.0.2`) has successful jobs on -both advertised platforms: - -| Qualification | Linux x86-64 | macOS ARM64 | -| --- | --- | --- | -| Canonical repository check | [Passed](https://github.com/zsumz/vthread/actions/runs/34149732304/job/101829285225) | [Passed](https://github.com/zsumz/vthread/actions/runs/34149732304/job/101829285542) | -| Native-stack debug | [Passed](https://github.com/zsumz/vthread/actions/runs/34149732339/job/101829285608) | [Passed](https://github.com/zsumz/vthread/actions/runs/34149732339/job/101829285669) | -| Native-stack release | [Passed](https://github.com/zsumz/vthread/actions/runs/34149732339/job/101829285510) | [Passed](https://github.com/zsumz/vthread/actions/runs/34149732339/job/101829285607) | -| Application load/failure | [Passed](https://github.com/zsumz/vthread/actions/runs/34149732304/job/101830730576) | [Passed](https://github.com/zsumz/vthread/actions/runs/34149732304/job/101830730549) | - -Public job metadata confirms successful execution, including native host checks. -The baseline application configuration ran eight closed-loop cases and six failure -rounds, **not** the full 22-case matrix: it supplied no fixed-arrival arguments. -That scope follows from the pinned command, successful step and runner validation; -raw CI logs and artifact downloads were not accessible during this closeout. - -These results qualify the reviewed baseline, not newly versioned `0.1.0` archives. -Final-version qualification is recorded separately below. - -### 0.1.0 closeout - -The `0.1.0` code and configuration passed all 18 canonical gates locally on Linux -x86-64 with Rust 1.96.1, including native debug/release tests and doctests. The -code/configuration digest is: - -`801397809bd0bcb1aa990d583764029c4db7c668f446ca6fe01b68f17847b730`. - -Clean commit `46c9ce6e0d358f9ff19f620e2f6ec2f075507476` additionally passed: - -| Check | Result | -| --- | --- | -| Full application matrix | 22 cases: eight closed-loop loads, eight fixed-arrival cases, six failure rounds | -| Standalone reference | All 13 tests passed, including the updated `52` examples | -| Distributable packages | All four archives built offline from packaged contents and passed an independent audit | - -The audit verifies committed source bytes, licenses, normalized manifests, exact -internal pins, registry dependency closure and sibling archive checksums. Evidence -and archives are kept outside the source checkout. These results are not a -registry-consumer check, a controlled performance result or a stability verdict. - -Both-target CI must also qualify the publication candidate. The required release -jobs now run the full 22-case application matrix and package verification; their -uploaded artifacts identify each run and target. Rebuilding after any source -commit changes requires a fresh archive audit, even for documentation-only edits. - -### Historical RC evidence - -These historical results describe `0.0.2-rc.2` on Linux x86-64 with Rust 1.96.1 -and the default native engine unless a feature set is named. They do not attest to -newly versioned `0.1.0` package bytes. The archived code/manifest digest is: - -`8ac4c314fdfe944a53ddf4395b1feb4cfa5bd18465f0c559201823030e3ef73d`. - -| Check | Result | -| --- | --- | -| Canonical `zcheck run check` | All 18 gates passed; repository state preserved | -| Native stack | 79 tests passed in both debug and release | -| Standalone benchmark | 45 default and 53 all-feature tests passed; formatting and Clippy passed | -| Standalone reference | 13 tests passed | -| Full application matrix | 22 cases passed: eight loads, eight fixed-arrival cases, six failure rounds | -| Bounded mixed soak | Three 30-second processes passed; 530,361 task lifetimes completed and reclaimed | -| Distributable crates | All four clean archives built, verified and independently audited | - -### Workload and source boundaries - -The soak covered one/four carriers and 64/1,024-task batches on the preceding -digest `a794281116bfb7708c564345e5b740d9da4bb70389e1502c711a570088f5f3ed`. -The only subsequent Rust change corrected cleanup documentation, not executable -code. Final canonical, application and reference checks were rerun on the archived -digest above. - -The application panel uses concurrency 1/16/64/256, 128 closed-loop rounds, three -failure rounds per carrier count, and 256 offered arrivals at 2,000/second. Its -timing samples are local observations, not controlled-host tail acceptance. - -The two existing manual performance probes are intentionally excluded from the -canonical test count; mandatory cancellation semantics and bounds still pass. - -### Package and evidence identity - -The initial package audit found a missing license file in `vthread-sync-core`; -the root Apache-2.0 license was added byte-for-byte. Final archives match -the clean source commit `58976649f5fb2bf716cb5e2a3694dbb6bf2b2548`, include every -package's license, and have exact internal version pins and archive checksums. - -Raw qualification artifacts are archived separately from the source checkout. -The [historical evidence index](https://github.com/zsumz/vthread/blob/12bac5291b4c262a01ace65330903789880e15cf/release-evidence/0.0.2-rc.2/README.md) -records the preserved bundle's hashes and replay instructions. - -Subsequent version metadata, documentation and unsupported-platform diagnostic -wording are outside that archived snapshot. The recorded hashes identify the -historical artifacts, not newly packaged files. - -## Remaining release gates and limitations - -| Area | Open requirement or limitation | -| --- | --- | -| Historical refill stall | [Known unresolved reliability risk](#known-risk); public issue tracking remains required. | -| Cancellation history | Semantic bounds and cancellation paths remain mandatory tests. The historical wall-time excursion remains separate performance evidence; `zcheck run perf-cancellation-history` retains its explicit optimized guard. | -| Distribution qualification | Preserve exact-source both-target CI and final archive audit results. Run a fresh registry-only README consumer after authorized publication, before announcement. | -| Alternate-stack sanitizers | Hooks are not qualified. Ordinary compiler sanitizer flags do not establish support for the native context-switch boundary. | -| Scale and sustained load | Large simultaneous populations, the full mixed-lifetime stress target, memory footprint, loaded tails and controlled-host idle CPU require separate qualification. Short smoke runs do not replace it. | -| Scaling costs | Wake-depth observation has provisioned-capacity-dependent cost; the readiness driver still reconciles registration maps. Neither held scaling candidate is included. | -| Performance acceptance | No dedicated performance host is currently available. Local timing is observational, with no new performance acceptance or latency guarantee. | - -Feedback release does not require every performance or scale objective to be -complete. It does require accurate claims, final-version qualification, and an -explicit decision to carry the disclosed stall risk. Publication remains a -separate authorized action. +# Releases + +The `0.1.0-rc.1` runtime was signed off at +[`c4b2380`](https://github.com/zsumz/vthread/commit/c4b2380138b9f7f7384b2da9cb4ee9803e229588). +Release automation uses [zrelease](https://github.com/zsumz/zrelease), pinned to +`1993b45bfae19a00e61a9138565b79e029c2d7f4` in +[the Rehearse workflow](.github/workflows/rehearse.yml) and +[the Release workflow](.github/workflows/release.yml). + +The next candidate is `0.1.0-rc.2`. 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. + +## Practice a release + +Push a `release/**` branch, or run **Actions → Rehearse** once the workflow is +on the default branch. Its compact graph shows **Package workspace → Attest → +Rehearse**, with individual crates in the logs and receipts. Branch rehearsals qualify that branch's +commit; tag rehearsals require the commit to be reachable from `main`. + +The workflow first runs the canonical checks, the full application matrix, and +native-stack checks on Linux x86_64 and macOS ARM64. zrelease then processes: + +1. `vthread-stack` +2. `vthread-sync-core` +3. `vthread` +4. `vthreads` + +The lab, benchmark, and reference packages remain unpublished. Each selected crate +is tested from source and from its packaged archive, then built, tested, and run +in a fresh consumer against staged archives. The runtime and alias consumers run +the README task example and assert its result. Rehearsal also simulates a lost +upload acknowledgement and checks that retry does not publish twice. It never +publishes to crates.io and needs no release approval. + +The sync-core archive excludes the two workspace model tests that import +`vthread` source. Canonical CI still runs both models; the archive retains +sync-core's own unit and mailbox model tests. + +Keep the run's release plan, candidate archives, attestations, rehearsal reports, +and delivery receipts. zrelease retains these artifacts for 90 days. Its package +and consumer jobs currently run on Linux; the repository's runtime checks still +cover both supported platforms. + +## Publish + +Before the first real publication, configure crates.io Trusted Publishing for +all four crates with repository `zsumz/vthread`, workflow `release.yml`, and +environment `crates.io`. Each crate must already exist on crates.io. + +The release review on 2026-09-11 confirmed that `vthread-sync-core` does not exist +on crates.io. The other three crates exist at `0.0.2-rc.1`. zrelease now checks +every selected crate name before approval and again before requesting upload +credentials; a missing crate or registry error stops the release before any +upload. This check does not establish ownership or publisher permissions. + +Bootstrap sync-core separately as `0.1.0-rc.1`, using its exact qualified archive +from [rehearsal 34550912875](https://github.com/zsumz/vthread/actions/runs/34550912875) +at source `5953345cd35579da620f926423f6faa3e17b9f58`. Preserve and verify the +workspace attestation, candidate digest, archive digest and upload metadata from +that run. Its first publication requires an API token; do not put a long-lived +token into the reusable release jobs. Then register its Trusted Publisher. +The complete workspace will use `0.1.0-rc.2`, so bootstrap bytes cannot conflict +with a newly generated archive at the same version. + +Create a GitHub `release` environment with required reviewers; allow self-review +if the maintainer starts the release. Create a `crates.io` environment restricted +to release tags, with no required reviewers. zrelease requests one approval for +the complete plan. + +The review found neither environment configured. Confirm both environment rules +and all four crates.io registrations before enabling publication. Creating an +environment alone does not configure Trusted Publishing. + +Keep every workspace package and internal dependency pin on the shared version, +update the changelog and installation examples, and merge the qualified source +to `main`. Create its PGP-signed `v` tag and dispatch **Release** on that +tag with `publish: true`. Review the exact plan and approve the `release` +environment once. zrelease publishes and verifies each crate before proceeding +to the next. Publishing remains an explicit maintainer action. + +Rerun failed jobs in the same run, retaining its candidate artifacts. zrelease +checks registry checksums before retrying and never automatically yanks crates. + +## Qualify this RC integration + +The signed reconciliation commit joins current public `main` and the reviewed +RC history while retaining the RC tree exactly. Both previous tips are preserved +under local `backup/*-before-release-*-20260911` refs. The ancestry guard remains +enabled. Merge the reviewed integration onto `main` before tagging it. + +Create a signed `v0.1.0-rc.2` tag on the resulting qualified commit and dispatch +the full **Release** workflow with `publish: false`. Keep its per-crate candidates, +attestations and delivery receipts. This exercises the full release graph and +bookkeeping; compact Rehearse success is supplementary evidence. It still does +not prove approval, OIDC exchange, a real registry upload or registry-only +consumers. A controlled live RC must establish those before any final release. + +RC-to-final automation should prepare a version-change PR from a verified RC +receipt, updating manifests, exact dependency pins, lockfiles and release docs. +That new commit needs its own qualification and approval. Simply retagging an RC +cannot change its packaged version. Final promotion remains a future design item. + +## Update zrelease + +From a pushed zrelease checkout with Node.js 24 and Rust 1.96.1, generate a new +caller into a temporary file: + +```sh +node dist/install.mjs --sha "$(git rev-parse HEAD)" \ + --source /path/to/vthread --workspace --lockstep --toolchain 1.96.1 \ + --out /path/to/vthread/target/release.generated.yml +node dist/install.mjs --sha "$(git rev-parse HEAD)" \ + --source /path/to/vthread --workspace --lockstep --rehearsal --toolchain 1.96.1 \ + --out /path/to/vthread/target/rehearse.generated.yml +``` + +Regenerate when the workspace dependency graph changes. Preserve the caller's +canonical and native-stack 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. + +## Runtime coverage and limits + +vthread requires Rust 1.96 or newer, Linux x86_64 or macOS ARM64, and unwinding +panics. Standard-library blocking calls occupy the carrier; they are not +transparently virtualized. Started tasks retain carrier affinity. Scopes own +children, resource counts are bounded, and cancellation is cooperative. + +`zcheck run check` covers native debug and release tests, all-feature tests, +documentation and compile-fail examples, source and architecture policy, +panic-isolation regressions, application smoke tests, and standalone benchmark +checks. Hosted application qualification adds eight closed-loop loads, eight +fixed-arrival cases, and six failure rounds. Native-stack CI preserves source +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 +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 +compatibility within `0.1`; breaking API or contract changes move to `0.2`. +The exact dependency set includes `zio = "=0.0.1-dev.1"`; a normal vthread version +does not imply a stable-version contract for every dependency. diff --git a/benchmarks/Cargo.lock b/benchmarks/Cargo.lock index 217613e..cadacd9 100644 --- a/benchmarks/Cargo.lock +++ b/benchmarks/Cargo.lock @@ -70,7 +70,7 @@ dependencies = [ [[package]] name = "vthread" -version = "0.1.0" +version = "0.1.0-rc.2" dependencies = [ "crossbeam-queue", "libc", @@ -89,14 +89,14 @@ dependencies = [ [[package]] name = "vthread-stack" -version = "0.1.0" +version = "0.1.0-rc.2" dependencies = [ "libc", ] [[package]] name = "vthread-sync-core" -version = "0.1.0" +version = "0.1.0-rc.2" [[package]] name = "windows-link" diff --git a/crates/vthread-stack/README.md b/crates/vthread-stack/README.md index 2f238d8..fdcb7bf 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/main/RELEASE.md) +See [release notes](https://github.com/zsumz/vthread/blob/v0.1.0-rc.2/RELEASE.md) for verification coverage and known limitations. ## Ownership and safety diff --git a/crates/vthread-stack/src/engine.rs b/crates/vthread-stack/src/engine.rs index 3d14ae2..3a6da0a 100644 --- a/crates/vthread-stack/src/engine.rs +++ b/crates/vthread-stack/src/engine.rs @@ -207,3 +207,7 @@ fn encode_resume(resume: Resume) -> usize { #[cfg(test)] #[path = "engine_test.rs"] mod engine_test; + +#[cfg(test)] +#[path = "engine_panic_reclaim_test.rs"] +mod engine_panic_reclaim_test; diff --git a/crates/vthread-stack/src/engine_panic_reclaim_test.rs b/crates/vthread-stack/src/engine_panic_reclaim_test.rs new file mode 100644 index 0000000..c43356e --- /dev/null +++ b/crates/vthread-stack/src/engine_panic_reclaim_test.rs @@ -0,0 +1,64 @@ +use std::{cell::Cell, rc::Rc}; + +use super::Execution; +use crate::{Fiber, FiberState, MappedStack, Resume, SuspendError, Suspension, mount::CoreMount}; + +struct CountDrop(Rc>); + +impl Drop for CountDrop { + fn drop(&mut self) { + self.0.set(self.0.get() + 1); + } +} + +struct SuspendOnDrop { + drops: Rc>, + outcome: Rc>>>, +} + +impl Drop for SuspendOnDrop { + fn drop(&mut self) { + self.outcome.set(Some(crate::suspend(Suspension::YieldNow))); + self.drops.set(self.drops.get() + 1); + } +} + +#[test] +fn forced_reclamation_rejects_destructor_suspension_and_reuses_the_stack() { + let stack = MappedStack::new(128 * 1024, 0).unwrap(); + let address = stack.limit(); + let drops = Rc::new(Cell::new(0)); + let outcome = Rc::new(Cell::new(None)); + let outer = CountDrop(Rc::clone(&drops)); + let inner = SuspendOnDrop { + drops: Rc::clone(&drops), + outcome: Rc::clone(&outcome), + }; + // SAFETY: the entry borrows nothing and is reclaimed before this test returns. + let mut execution = unsafe { + Execution::start(stack, move || { + let _outer = outer; + let _inner = inner; + crate::suspend(Suspension::YieldNow).unwrap(); + }) + }; + + { + let _mount = CoreMount::install(execution.core_ptr()); + assert_eq!( + execution.resume(Resume::Continue), + FiberState::Suspended(Suspension::YieldNow) + ); + execution.force_unwind(); + } + + assert_eq!(outcome.get(), Some(Err(SuspendError::Panicking))); + assert_eq!(drops.get(), 2); + assert!(execution.is_complete()); + let stack = execution.into_stack(); + assert_eq!(stack.limit(), address); + + let mut reused = Fiber::new(stack, || {}); + assert_eq!(reused.resume(), FiberState::Complete); + assert_eq!(reused.into_stack().limit(), address); +} diff --git a/crates/vthread-stack/src/lib.rs b/crates/vthread-stack/src/lib.rs index d140dc9..65dfb9e 100644 --- a/crates/vthread-stack/src/lib.rs +++ b/crates/vthread-stack/src/lib.rs @@ -46,9 +46,9 @@ mod suspension; pub use fiber::Fiber; pub use lease::FiberLease; -#[doc(hidden)] -pub use mount::ContextKey; pub use mount::suspend; +#[doc(hidden)] +pub use mount::{ContextKey, check_suspend}; pub use pool::{StackPool, StackPoolSnapshot}; pub use scoped::{FiberScope, fiber_scope}; pub use stack::{MappedStack, STACK_ALIGNMENT}; diff --git a/crates/vthread-stack/src/mount.rs b/crates/vthread-stack/src/mount.rs index b7da8a2..5ede246 100644 --- a/crates/vthread-stack/src/mount.rs +++ b/crates/vthread-stack/src/mount.rs @@ -152,13 +152,28 @@ pub(crate) fn mounted_core() -> *const FiberCore { CURRENT_MOUNT.with(|current| current.get().core) } -/// Suspends the currently mounted fiber. -#[inline] -pub fn suspend(reason: Suspension) -> Result { +fn suspendable_core() -> Result<*const FiberCore, SuspendError> { let core = mounted_core(); if core.is_null() { - return Err(SuspendError); + return Err(SuspendError::NotMounted); + } + if std::thread::panicking() { + return Err(SuspendError::Panicking); } + Ok(core) +} + +/// Checks whether the current carrier can safely suspend its mounted fiber. +#[doc(hidden)] +#[inline] +pub fn check_suspend() -> Result<(), SuspendError> { + suspendable_core().map(|_| ()) +} + +/// Suspends the currently mounted fiber. +#[inline] +pub fn suspend(reason: Suspension) -> Result { + let core = suspendable_core()?; // The pointer is carrier-local and restored before leaving this mount. // SAFETY: it belongs to the currently mounted, non-Send execution. unsafe { Ok(engine::suspend(core, reason)) } diff --git a/crates/vthread-stack/src/mount_test.rs b/crates/vthread-stack/src/mount_test.rs index 0fc96e6..48b0544 100644 --- a/crates/vthread-stack/src/mount_test.rs +++ b/crates/vthread-stack/src/mount_test.rs @@ -1,6 +1,12 @@ -use std::ptr; +use std::{ + cell::Cell, + panic::{AssertUnwindSafe, catch_unwind}, + ptr, + rc::Rc, +}; use super::{ContextKey, ContextSlot, CurrentMount, MountGuard}; +use crate::{Fiber, FiberState, MappedStack, Resume, SuspendError, Suspension, suspend}; static NUMBER: ContextKey = ContextKey::new(); static OTHER_NUMBER: ContextKey = ContextKey::new(); @@ -21,3 +27,41 @@ fn context_keys_select_only_their_own_value() { assert_eq!(NUMBER.with(|value| *value), Some(17)); assert!(OTHER_NUMBER.with(|_| ()).is_none()); } + +#[test] +fn panicking_fiber_cannot_suspend_before_reaching_its_catch_boundary() { + struct SuspendOnDrop(Rc>>>); + + impl Drop for SuspendOnDrop { + fn drop(&mut self) { + self.0.set(Some(suspend(Suspension::YieldNow))); + } + } + + let observed = Rc::new(Cell::new(None)); + let body_observed = Rc::clone(&observed); + let mut fiber = Fiber::new(MappedStack::new(128 * 1024, 0).unwrap(), move || { + let _suspend_on_drop = SuspendOnDrop(body_observed); + panic!("expected fiber panic"); + }); + + let first = catch_unwind(AssertUnwindSafe(|| fiber.resume())); + let suspended = match first { + Ok(FiberState::Suspended(Suspension::YieldNow)) => { + assert!(catch_unwind(AssertUnwindSafe(|| fiber.resume())).is_err()); + true + } + Ok(state) => panic!("panicking fiber returned unexpected state: {state:?}"), + Err(_) => false, + }; + + assert!( + !suspended, + "panicking fiber transferred control to its carrier" + ); + assert_eq!( + observed.take().expect("destructor ran"), + Err(SuspendError::Panicking) + ); + assert!(fiber.is_complete()); +} diff --git a/crates/vthread-stack/src/suspension.rs b/crates/vthread-stack/src/suspension.rs index 3175378..5c400c0 100644 --- a/crates/vthread-stack/src/suspension.rs +++ b/crates/vthread-stack/src/suspension.rs @@ -78,13 +78,25 @@ pub enum Resume { Interrupt, } -/// Suspension was requested without a mounted fiber. +/// Why a requested fiber suspension could not safely start. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SuspendError; +pub enum SuspendError { + /// No virtual-thread stack is mounted on this carrier. + NotMounted, + /// The carrier is running a panic hook or unwinding a panic. + Panicking, +} impl fmt::Display for SuspendError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("no virtual-thread stack is mounted on this carrier") + match self { + Self::NotMounted => { + formatter.write_str("no virtual-thread stack is mounted on this carrier") + } + Self::Panicking => formatter.write_str( + "a virtual-thread stack cannot suspend while its carrier is handling a panic", + ), + } } } diff --git a/crates/vthread-stack/src/suspension_test.rs b/crates/vthread-stack/src/suspension_test.rs index 39990c9..32a3ed1 100644 --- a/crates/vthread-stack/src/suspension_test.rs +++ b/crates/vthread-stack/src/suspension_test.rs @@ -16,7 +16,15 @@ fn the_default_resume_decision_continues() { #[test] fn the_suspend_error_names_the_missing_mount() { assert_eq!( - SuspendError.to_string(), + SuspendError::NotMounted.to_string(), "no virtual-thread stack is mounted on this carrier" ); } + +#[test] +fn the_suspend_error_names_panic_handling() { + assert_eq!( + SuspendError::Panicking.to_string(), + "a virtual-thread stack cannot suspend while its carrier is handling a panic" + ); +} diff --git a/crates/vthread-sync-core/Cargo.toml b/crates/vthread-sync-core/Cargo.toml index 615ab6b..5c0d3c1 100644 --- a/crates/vthread-sync-core/Cargo.toml +++ b/crates/vthread-sync-core/Cargo.toml @@ -9,6 +9,14 @@ repository.workspace = true authors.workspace = true readme = "README.md" publish = ["crates-io"] +# These workspace model tests import production source from the sibling runtime. +# Keep them in canonical CI; a standalone sync-core archive cannot run them. +exclude = [ + "tests/mutex_handoff_model.rs", + "tests/wait_publication_model.rs", + "tests/support/mutex_handoff_test.rs", + "tests/support/publication_*.rs", +] [dev-dependencies] loom = "=0.7.2" diff --git a/crates/vthread-sync-core/README.md b/crates/vthread-sync-core/README.md index 1b7e8c6..e3b5b9a 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/main/RELEASE.md) +See [release notes](https://github.com/zsumz/vthread/blob/v0.1.0-rc.2/RELEASE.md) for verification coverage and known limitations. ## Runtime boundary diff --git a/crates/vthread/README.md b/crates/vthread/README.md index bf951e3..110145b 100644 --- a/crates/vthread/README.md +++ b/crates/vthread/README.md @@ -7,12 +7,13 @@ 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/main/RELEASE.md) +See [release notes](https://github.com/zsumz/vthread/blob/v0.1.0-rc.2/RELEASE.md) for verification coverage and known limitations. -vthread is in early development, intended for evaluation and feedback. The `0.1.x` -series preserves public API compatibility; breaking API or contract changes move -to `0.2`. +vthread `0.1.0-rc.2` 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`. ## A first task @@ -20,7 +21,7 @@ Add vthread to your project: ```toml [dependencies] -vthread = "0.1" +vthread = "=0.1.0-rc.2" ``` ```rust @@ -40,6 +41,8 @@ fn main() -> vthread::Result<()> { - Admission, queues, stacks, timers, wake permits, native work, and channels have explicit bounds. - Every parked or yielded task has an observable reason. Each park generation selects one winner; timers and remote wakes carry the generation they target. +- An operation that reaches a suspension boundary returns `Error::SuspensionDuringPanic` + instead of switching tasks while its carrier is handling a panic. The runtime includes cancellation, deadlines, virtual synchronization, bounded channels, readiness networking, native blocking delegation, diagnostics, and controlled shutdown. diff --git a/crates/vthread/src/admission_progress_test.rs b/crates/vthread/src/admission_progress_test.rs new file mode 100644 index 0000000..8dffb46 --- /dev/null +++ b/crates/vthread/src/admission_progress_test.rs @@ -0,0 +1,296 @@ +//! Per-producer admission evidence that does not serialize unrelated producers. + +use std::{ + cell::RefCell, + marker::PhantomData, + rc::Rc, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, + }, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(usize)] +pub(crate) enum TestAdmissionPhase { + Created, + Producer, + Reserving, + Reserved, + Publishing, + Published, + ReserveRejected, + PublishRejected, + Done, +} + +pub(crate) struct TestAdmissionProgress { + writer_installed: AtomicBool, + sequence: AtomicU64, + index: AtomicUsize, + phase: AtomicUsize, + reserve_entries: AtomicUsize, + reservations: AtomicUsize, + publish_entries: AtomicUsize, + publications: AtomicUsize, + task_capacity: AtomicUsize, + queue_capacity: AtomicUsize, + stopped: AtomicUsize, + other_errors: AtomicUsize, +} + +impl Default for TestAdmissionProgress { + fn default() -> Self { + Self { + writer_installed: AtomicBool::new(false), + sequence: AtomicU64::new(0), + index: AtomicUsize::new(usize::MAX), + phase: AtomicUsize::new(0), + reserve_entries: AtomicUsize::new(0), + reservations: AtomicUsize::new(0), + publish_entries: AtomicUsize::new(0), + publications: AtomicUsize::new(0), + task_capacity: AtomicUsize::new(0), + queue_capacity: AtomicUsize::new(0), + stopped: AtomicUsize::new(0), + other_errors: AtomicUsize::new(0), + } + } +} + +impl TestAdmissionProgress { + pub(crate) fn begin(&self, index: usize) { + self.update(|| { + self.index.store(index, Ordering::Relaxed); + self.phase + .store(TestAdmissionPhase::Producer as usize, Ordering::Relaxed); + }); + } + + pub(crate) fn phase(&self, phase: TestAdmissionPhase) { + self.update(|| { + let counter = match phase { + TestAdmissionPhase::Reserving => Some(&self.reserve_entries), + TestAdmissionPhase::Reserved => Some(&self.reservations), + TestAdmissionPhase::Publishing => Some(&self.publish_entries), + TestAdmissionPhase::Published => Some(&self.publications), + _ => None, + }; + if let Some(counter) = counter { + counter.fetch_add(1, Ordering::Relaxed); + } + self.phase.store(phase as usize, Ordering::Relaxed); + }); + } + + pub(crate) fn rejected(&self, phase: TestAdmissionPhase, error: &crate::Error) { + self.update(|| { + let counter = match error { + crate::Error::Capacity { + resource: crate::error::CapacityResource::Tasks, + .. + } => &self.task_capacity, + crate::Error::Capacity { + resource: crate::error::CapacityResource::CarrierQueue, + .. + } => &self.queue_capacity, + crate::Error::RuntimeStopped => &self.stopped, + _ => &self.other_errors, + }; + counter.fetch_add(1, Ordering::Relaxed); + self.phase.store(phase as usize, Ordering::Relaxed); + }); + } + + pub(crate) fn finish(&self) { + self.phase(TestAdmissionPhase::Done); + } + + pub(crate) fn snapshot(&self) -> TestAdmissionProgressSnapshot { + let before = self.sequence.load(Ordering::Acquire); + let index = self.index.load(Ordering::Relaxed); + let mut snapshot = TestAdmissionProgressSnapshot { + coherent: false, + sequence: before, + index: (index != usize::MAX).then_some(index), + phase: admission_phase(self.phase.load(Ordering::Relaxed)), + reserve_entries: self.reserve_entries.load(Ordering::Relaxed), + reservations: self.reservations.load(Ordering::Relaxed), + publish_entries: self.publish_entries.load(Ordering::Relaxed), + publications: self.publications.load(Ordering::Relaxed), + task_capacity: self.task_capacity.load(Ordering::Relaxed), + queue_capacity: self.queue_capacity.load(Ordering::Relaxed), + stopped: self.stopped.load(Ordering::Relaxed), + other_errors: self.other_errors.load(Ordering::Relaxed), + }; + let after = self.sequence.load(Ordering::Acquire); + snapshot.coherent = before == after && before.is_multiple_of(2); + snapshot + } + + pub(crate) fn assert_successful(&self, tasks: usize) { + let snapshot = self.snapshot(); + assert!(snapshot.coherent, "incoherent final admission record"); + assert_eq!(snapshot.index, tasks.checked_sub(1)); + assert_eq!(snapshot.phase, TestAdmissionPhase::Done); + assert_eq!( + ( + snapshot.reservations, + snapshot.publish_entries, + snapshot.publications, + ), + (tasks, tasks, tasks) + ); + assert_eq!(snapshot.reserve_entries, tasks + snapshot.queue_capacity); + assert_eq!( + ( + snapshot.task_capacity, + snapshot.stopped, + snapshot.other_errors + ), + (0, 0, 0) + ); + } + + fn update(&self, update: impl FnOnce()) { + let sequence = self.sequence.fetch_add(1, Ordering::AcqRel); + assert!(sequence.is_multiple_of(2), "one writer per producer"); + update(); + self.sequence.store(sequence + 2, Ordering::Release); + } +} + +thread_local! { + static ADMISSION_PROGRESS: RefCell>> = const { + RefCell::new(None) + }; +} + +#[must_use = "keep the guard alive while the producer is being observed"] +pub(crate) struct TestAdmissionProgressGuard { + installed: Arc, + previous: Option>, + not_send: PhantomData>, +} + +impl Drop for TestAdmissionProgressGuard { + fn drop(&mut self) { + ADMISSION_PROGRESS.with(|slot| { + let _installed = slot.replace(self.previous.take()); + }); + self.installed + .writer_installed + .store(false, Ordering::Release); + } +} + +pub(crate) fn install_admission_progress( + progress: Arc, +) -> TestAdmissionProgressGuard { + assert!( + progress + .writer_installed + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok(), + "admission recorder already has a producer" + ); + let installed = Arc::clone(&progress); + let previous = ADMISSION_PROGRESS.with(|slot| slot.replace(Some(progress))); + TestAdmissionProgressGuard { + installed, + previous, + not_send: PhantomData, + } +} + +pub(crate) fn record_admission_phase(phase: TestAdmissionPhase) { + ADMISSION_PROGRESS.with(|slot| { + if let Some(progress) = slot.borrow().as_ref() { + progress.phase(phase); + } + }); +} + +pub(crate) fn record_admission_rejection(phase: TestAdmissionPhase, error: &crate::Error) { + ADMISSION_PROGRESS.with(|slot| { + if let Some(progress) = slot.borrow().as_ref() { + progress.rejected(phase, error); + } + }); +} + +fn admission_phase(value: usize) -> TestAdmissionPhase { + match value { + 0 => TestAdmissionPhase::Created, + 1 => TestAdmissionPhase::Producer, + 2 => TestAdmissionPhase::Reserving, + 3 => TestAdmissionPhase::Reserved, + 4 => TestAdmissionPhase::Publishing, + 5 => TestAdmissionPhase::Published, + 6 => TestAdmissionPhase::ReserveRejected, + 7 => TestAdmissionPhase::PublishRejected, + 8 => TestAdmissionPhase::Done, + _ => unreachable!("admission phase"), + } +} + +pub(crate) struct TestAdmissionProgressSnapshot { + coherent: bool, + sequence: u64, + index: Option, + phase: TestAdmissionPhase, + reserve_entries: usize, + reservations: usize, + publish_entries: usize, + publications: usize, + task_capacity: usize, + queue_capacity: usize, + stopped: usize, + other_errors: usize, +} + +impl std::fmt::Debug for TestAdmissionProgressSnapshot { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AdmissionProgress") + .field("coherent", &self.coherent) + .field("sequence", &self.sequence) + .field("index", &self.index) + .field("phase", &self.phase) + .field("reserve_entries", &self.reserve_entries) + .field("reservations", &self.reservations) + .field("publish_entries", &self.publish_entries) + .field("publications", &self.publications) + .field("task_capacity", &self.task_capacity) + .field("queue_capacity", &self.queue_capacity) + .field("stopped", &self.stopped) + .field("other_errors", &self.other_errors) + .finish() + } +} + +#[test] +fn per_producer_recorders_restore_nested_thread_local_state() { + let outer = Arc::new(TestAdmissionProgress::default()); + let inner = Arc::new(TestAdmissionProgress::default()); + { + let _outer = install_admission_progress(Arc::clone(&outer)); + outer.begin(7); + record_admission_phase(TestAdmissionPhase::Reserving); + { + let _inner = install_admission_progress(Arc::clone(&inner)); + inner.begin(3); + record_admission_phase(TestAdmissionPhase::Publishing); + } + record_admission_phase(TestAdmissionPhase::Published); + } + record_admission_phase(TestAdmissionPhase::Done); + let outer = outer.snapshot(); + assert_eq!(outer.index, Some(7)); + assert_eq!(outer.phase, TestAdmissionPhase::Published); + assert_eq!((outer.reserve_entries, outer.publications), (1, 1)); + let inner = inner.snapshot(); + assert_eq!(inner.index, Some(3)); + assert_eq!(inner.phase, TestAdmissionPhase::Publishing); + assert_eq!(inner.publish_entries, 1); +} diff --git a/crates/vthread/src/carrier.rs b/crates/vthread/src/carrier.rs index 6ca01a2..a24113d 100644 --- a/crates/vthread/src/carrier.rs +++ b/crates/vthread/src/carrier.rs @@ -8,7 +8,7 @@ use std::{ }; pub(crate) fn run(shared: Arc, id: CarrierId) { - #[cfg(feature = "runtime-evidence")] + #[cfg(any(test, feature = "runtime-evidence"))] crate::worker_context::set_carrier(id); // A cleanup fault must retain affine stacks rather than run fallible field drops // during unwinding. Such stacks are never resumed and remain allocated until exit. @@ -77,6 +77,8 @@ fn drive(kernel: &mut Kernel) -> Result<()> { // One empty-to-nonempty signal covers every bounded receive batch until drained. loop { let observed = kernel.inbox.signal.version(); + #[cfg(test)] + kernel.record_test_loop(observed, handled); let signal_changed = handled != Some(observed); if signal_changed { if kernel.inbox.stopped() { @@ -90,11 +92,15 @@ fn drive(kernel: &mut Kernel) -> Result<()> { kernel.receive(); } handled = Some(observed); - } else if kernel.remote_pending() { + } else if kernel.remote_receive_required() { kernel.receive(); } else { kernel.receive_local(); } + #[cfg(test)] + kernel.inbox.signal.test_progress.record_handled(observed); + #[cfg(test)] + kernel.record_test_progress(crate::signal::TestCarrierPhase::Tick); if !kernel.tick(signal_changed)? { kernel.wait_for_work(observed); } @@ -109,6 +115,18 @@ mod carrier_test; #[path = "carrier_ingress_test.rs"] mod carrier_ingress_test; +#[cfg(test)] +#[path = "carrier_parked_ingress_test.rs"] +mod carrier_parked_ingress_test; + #[cfg(test)] #[path = "carrier_refill_test.rs"] mod carrier_refill_test; + +#[cfg(test)] +#[path = "carrier_refill_matrix_test.rs"] +mod carrier_refill_matrix_test; + +#[cfg(test)] +#[path = "carrier_published_depth_test.rs"] +mod carrier_published_depth_test; diff --git a/crates/vthread/src/carrier_ingress_test.rs b/crates/vthread/src/carrier_ingress_test.rs index d8b8e86..add1069 100644 --- a/crates/vthread/src/carrier_ingress_test.rs +++ b/crates/vthread/src/carrier_ingress_test.rs @@ -1,10 +1,28 @@ -use crate::{CarrierId, Runtime, control::Shared, signal::lock}; +use crate::{CarrierId, Runtime, control::Shared, signal::lock, support_test::run_isolated}; use std::{ sync::{Arc, mpsc}, thread, time::Duration, }; +fn isolate(name: &str) -> bool { + const CHILD: &str = "VTHREAD_INGRESS_BOUNDARY_CHILD"; + if std::env::var(CHILD).as_deref() == Ok(name) { + return false; + } + let exact = format!("carrier::carrier_ingress_test::{name}"); + let output = run_isolated(&exact, (CHILD, name), Duration::from_secs(25)); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.timed_out && output.status.success() && stdout.contains("1 passed"), + "isolated ingress failed: status={:?} timed_out={}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status, + output.timed_out, + ); + true +} + struct Release(mpsc::Sender<()>); impl Drop for Release { @@ -23,6 +41,9 @@ impl Drop for Stop { #[test] fn accepted_ingress_progresses_while_its_notifier_is_paused() { + if isolate("accepted_ingress_progresses_while_its_notifier_is_paused") { + return; + } let config = Runtime::builder() .carriers(1) .max_vthreads(2) @@ -100,6 +121,66 @@ fn accepted_ingress_progresses_while_its_notifier_is_paused() { ); } +#[test] +fn refill_between_idle_observation_and_wait_registration_is_not_lost() { + if isolate("refill_between_idle_observation_and_wait_registration_is_not_lost") { + return; + } + let config = Runtime::builder() + .carriers(1) + .max_vthreads(2) + .carrier_queue_capacity(2) + .stack_cache_capacity(2) + .build() + .unwrap() + .config(); + let shared = Arc::new(Shared::new(config)); + let scope = shared.begin_scope().unwrap(); + let (first, first_rx) = mpsc::channel(); + shared + .submit(scope, "initial drain".into(), move || { + first.send(()).unwrap() + }) + .unwrap(); + let initial_epoch = shared.inboxes[0].signal.version(); + let (boundary, boundary_rx) = mpsc::channel(); + let (resume, resume_rx) = mpsc::channel(); + shared.inboxes[0].signal.before_wait(move || { + boundary.send(()).unwrap(); + let _ = resume_rx.recv(); + }); + + let outcome = thread::scope(|threads| { + let stop = Stop(Arc::clone(&shared)); + let carrier_shared = Arc::clone(&shared); + let carrier = threads.spawn(move || super::run(carrier_shared, CarrierId(0))); + let resume = Release(resume); + first_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + boundary_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(shared.inboxes[0].signal.waiting(), 0); + let (second, second_rx) = mpsc::channel(); + shared + .submit(scope, "boundary refill".into(), move || { + second.send(()).unwrap() + }) + .unwrap(); + let notified_epoch = shared.inboxes[0].signal.version(); + assert_ne!(notified_epoch, initial_epoch); + assert_eq!(shared.inboxes[0].pending(), 1); + drop(resume); + let progress = second_rx.recv_timeout(Duration::from_secs(5)); + let pending = shared.inboxes[0].pending(); + let unchanged = shared.inboxes[0].signal.version() == notified_epoch; + drop(stop); + carrier.join().unwrap(); + (progress, pending, unchanged) + }); + shared.finish_scope(scope); + assert_eq!(outcome.0, Ok(()), "boundary refill did not run"); + assert_eq!(outcome.1, 0, "boundary refill remained queued"); + assert!(outcome.2, "boundary refill required another notification"); +} + #[test] fn an_idle_work_observation_remembers_remote_ingress_for_the_next_drive() { let shared = Arc::new(Shared::new(crate::RuntimeConfig::default())); diff --git a/crates/vthread/src/carrier_parked_ingress_test.rs b/crates/vthread/src/carrier_parked_ingress_test.rs new file mode 100644 index 0000000..a3d9c06 --- /dev/null +++ b/crates/vthread/src/carrier_parked_ingress_test.rs @@ -0,0 +1,172 @@ +//! Parked carriers retain a coalesced notification handoff across publishers. + +use crate::{CarrierId, Runtime, control::Shared, signal::lock, support_test::run_isolated}; +use std::{ + sync::{Arc, mpsc}, + thread, + time::{Duration, Instant}, +}; + +const WATCHDOG: Duration = Duration::from_secs(5); +const CHILD: &str = "VTHREAD_PARKED_INGRESS_CHILD"; + +#[derive(Clone, Copy)] +enum Boundary { + Parked, + Registering, +} + +struct Release(mpsc::Sender<()>); + +impl Drop for Release { + fn drop(&mut self) { + let _ = self.0.send(()); + } +} + +struct Stop(Arc); + +impl Drop for Stop { + fn drop(&mut self) { + self.0.request_stop(); + } +} + +fn isolate(name: &str) -> bool { + if std::env::var(CHILD).as_deref() == Ok(name) { + return false; + } + let exact = format!("carrier::carrier_parked_ingress_test::{name}"); + let output = run_isolated(&exact, (CHILD, name), WATCHDOG * 5); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.timed_out && output.status.success() && stdout.contains("1 passed"), + "isolated parked ingress failed: status={:?} timed_out={}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status, + output.timed_out, + ); + true +} + +fn exercise(boundary: Boundary) { + let config = Runtime::builder() + .carriers(1) + .max_vthreads(2) + .carrier_queue_capacity(2) + .stack_cache_capacity(2) + .build() + .unwrap() + .config(); + let shared = Arc::new(Shared::new(config)); + let scope = shared.begin_scope().unwrap(); + let registration = matches!(boundary, Boundary::Registering).then(|| { + let (reached, reached_rx) = mpsc::channel(); + let (resume, resume_rx) = mpsc::channel(); + shared.inboxes[0].signal.before_wait(move || { + reached.send(()).unwrap(); + let _ = resume_rx.recv(); + }); + (reached_rx, Release(resume)) + }); + + let outcome = thread::scope(|threads| { + let stop = Stop(Arc::clone(&shared)); + let carrier_shared = Arc::clone(&shared); + let carrier = threads.spawn(move || super::run(carrier_shared, CarrierId(0))); + let mut registration = registration; + if let Some((reached, _)) = ®istration { + reached.recv_timeout(WATCHDOG).unwrap(); + assert_eq!(shared.inboxes[0].signal.waiting(), 0); + } else { + let deadline = Instant::now() + WATCHDOG; + while shared.inboxes[0].signal.waiting() == 0 && Instant::now() < deadline { + thread::yield_now(); + } + assert_eq!(shared.inboxes[0].signal.waiting(), 1); + } + + let observed = shared.inboxes[0].signal.version(); + let (published, published_rx) = mpsc::channel(); + let (release, release_rx) = mpsc::channel(); + *lock(&shared.inboxes[0].before_notify_hook) = Some(Box::new(move || { + published.send(()).unwrap(); + let _ = release_rx.recv(); + })); + let release = Release(release); + let first_shared = Arc::clone(&shared); + let first = threads.spawn(move || { + first_shared + .submit(scope, "first paused notifier".into(), || ()) + .map(|_| ()) + }); + published_rx.recv_timeout(WATCHDOG).unwrap(); + assert_eq!(shared.inboxes[0].pending(), 1); + + let (ran, ran_rx) = mpsc::channel(); + let (submitted, submitted_rx) = mpsc::channel(); + let second_shared = Arc::clone(&shared); + let second = threads.spawn(move || { + let result = second_shared + .submit(scope, "later publisher".into(), move || { + let _ = ran.send(()); + }) + .map(|_| ()); + let _ = submitted.send(result.is_ok()); + result + }); + let submitted = submitted_rx.recv_timeout(WATCHDOG); + if let Some((_, registration_release)) = registration.take() { + drop(registration_release); + } + let ran = ran_rx.recv_timeout(WATCHDOG); + let pending = shared.inboxes[0].pending(); + let waiting = shared.inboxes[0].signal.waiting(); + let unchanged_epoch = shared.inboxes[0].signal.version() == observed; + + drop(release); + let first = first.join().unwrap(); + let second = second.join().unwrap(); + drop(stop); + carrier.join().unwrap(); + ( + submitted, + ran, + pending, + waiting, + unchanged_epoch, + first, + second, + ) + }); + shared.finish_scope(scope); + + assert_eq!(outcome.0, Ok(true), "later submission did not return"); + assert_eq!( + outcome.1, + Ok(()), + "later task stalled before the first notifier resumed: submitted={:?} pending={} waiting={} unchanged_epoch={}", + outcome.0, + outcome.2, + outcome.3, + outcome.4, + ); + assert_eq!(outcome.2, 0, "published tasks remained queued"); + assert!(outcome.4, "coalesced handoff advanced the signal epoch"); + outcome.5.unwrap(); + outcome.6.unwrap(); +} + +#[test] +fn a_later_publisher_wakes_an_already_parked_carrier() { + if !isolate("a_later_publisher_wakes_an_already_parked_carrier") { + exercise(Boundary::Parked); + } +} + +#[test] +fn registration_rechecks_the_queue_before_sleeping() { + if !isolate("registration_rechecks_the_queue_before_sleeping") { + exercise(Boundary::Registering); + } +} diff --git a/crates/vthread/src/carrier_published_depth_test.rs b/crates/vthread/src/carrier_published_depth_test.rs new file mode 100644 index 0000000..ac74a69 --- /dev/null +++ b/crates/vthread/src/carrier_published_depth_test.rs @@ -0,0 +1,144 @@ +//! Published inbox depth remains actionable before its coalesced notification. + +use crate::{CarrierId, Runtime, control::Shared, signal::lock, support_test::run_isolated}; +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread, + time::{Duration, Instant}, +}; + +const CHILD: &str = "VTHREAD_PUBLISHED_DEPTH_CHILD"; +const TEST: &str = + "carrier::carrier_published_depth_test::published_depth_runs_during_sustained_ready_work"; + +fn isolate() -> bool { + if std::env::var(CHILD).as_deref() == Ok("1") { + return false; + } + let output = run_isolated(TEST, (CHILD, "1"), Duration::from_secs(25)); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.timed_out && output.status.success() && stdout.contains("1 passed"), + "isolated published-depth test failed: status={:?} timed_out={}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status, + output.timed_out, + ); + true +} + +struct Release(mpsc::Sender<()>); + +impl Drop for Release { + fn drop(&mut self) { + let _ = self.0.send(()); + } +} + +struct Stop(Arc); + +impl Drop for Stop { + fn drop(&mut self) { + self.0.request_stop(); + } +} + +struct ReleaseYield(Arc); + +impl Drop for ReleaseYield { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } +} + +#[test] +fn published_depth_runs_during_sustained_ready_work() { + if isolate() { + return; + } + let config = Runtime::builder() + .carriers(1) + .max_vthreads(3) + .carrier_queue_capacity(3) + .stack_cache_capacity(3) + .build() + .unwrap() + .config(); + let shared = Arc::new(Shared::new(config)); + let scope = shared.begin_scope().unwrap(); + let (mounted, mounted_rx) = mpsc::channel(); + let (resume, resume_rx) = mpsc::channel(); + shared + .submit(scope, "block first dispatch".into(), move || { + mounted.send(()).unwrap(); + resume_rx.recv().unwrap(); + }) + .unwrap(); + let release_yield = Arc::new(AtomicBool::new(false)); + let yielding = Arc::clone(&release_yield); + shared + .submit(scope, "sustain ready work".into(), move || { + while !yielding.load(Ordering::Acquire) { + if crate::yield_now().is_err() { + break; + } + } + }) + .unwrap(); + + let outcome = thread::scope(|threads| { + let stop = Stop(Arc::clone(&shared)); + let release_yield = ReleaseYield(release_yield); + let carrier_shared = Arc::clone(&shared); + let carrier = threads.spawn(move || super::run(carrier_shared, CarrierId(0))); + let resume = Release(resume); + mounted_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(shared.inboxes[0].pending(), 0); + let epoch = shared.inboxes[0].signal.version(); + + let (published, published_rx) = mpsc::channel(); + let (notify, notify_rx) = mpsc::channel(); + *lock(&shared.inboxes[0].before_notify_hook) = Some(Box::new(move || { + published.send(()).unwrap(); + let _ = notify_rx.recv(); + })); + let notify = Release(notify); + let (third_ran, third_ran_rx) = mpsc::channel(); + let producer_shared = Arc::clone(&shared); + let producer = threads.spawn(move || { + producer_shared.submit(scope, "published before notify".into(), move || { + third_ran.send(()).unwrap(); + }) + }); + published_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(shared.inboxes[0].signal.version(), epoch); + assert_eq!(shared.inboxes[0].pending(), 1); + + drop(resume); + let late = third_ran_rx.recv_timeout(Duration::from_secs(5)); + let pending = shared.inboxes[0].pending(); + let unchanged = shared.inboxes[0].signal.version() == epoch; + drop(release_yield); + drop(notify); + producer.join().unwrap().unwrap(); + let drained = shared.wait_until(scope, None, Some(Instant::now() + Duration::from_secs(5))); + drop(stop); + carrier.join().unwrap(); + (late, pending, unchanged, drained) + }); + shared.finish_scope(scope); + assert_eq!( + outcome.0, + Ok(()), + "published ingress stalled during sustained ready work: pending={}, unchanged_epoch={}", + outcome.1, + outcome.2, + ); + assert_eq!(outcome.1, 0, "published task remained queued"); + assert!(outcome.2, "the producer notification must still be paused"); + assert!(matches!(outcome.3, Ok(true)), "scope did not drain"); +} diff --git a/crates/vthread/src/carrier_refill_matrix_test.rs b/crates/vthread/src/carrier_refill_matrix_test.rs new file mode 100644 index 0000000..d261bc5 --- /dev/null +++ b/crates/vthread/src/carrier_refill_matrix_test.rs @@ -0,0 +1,274 @@ +//! Multi-producer and multi-carrier coverage around the remote receive window. + +use crate::{ + CarrierId, Error, Runtime, + control::Shared, + signal::lock, + support_test::{run_isolated, wait_without_intervention}, +}; +use std::{ + io::Write, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + thread, + time::{Duration, Instant}, +}; + +const PRODUCERS: usize = 4; +const TASKS_PER_PRODUCER: usize = 129; +const TASKS: usize = PRODUCERS * TASKS_PER_PRODUCER; +const QUEUE_CAPACITY: usize = 65; +const WATCHDOG: Duration = Duration::from_secs(5); +const CHILD: &str = "VTHREAD_REFILL_MATRIX_CHILD"; + +#[derive(Default)] +struct Counts { + accepted: AtomicUsize, + returned: AtomicUsize, + retries: AtomicUsize, + executed: [AtomicUsize; 2], +} + +struct Stop(Arc); + +impl Drop for Stop { + fn drop(&mut self) { + self.0.request_stop(); + } +} + +fn isolate() -> bool { + if std::env::var(CHILD).as_deref() == Ok("1") { + return false; + } + let name = "carrier::carrier_refill_matrix_test::small_queues_progress_with_multiple_producers_and_carriers"; + let output = run_isolated(name, (CHILD, "1"), Duration::from_secs(25)); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !output.timed_out && output.status.success() && stdout.contains("1 passed"), + "isolated refill matrix failed: status={:?} timed_out={}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status, + output.timed_out, + ); + true +} + +fn record(shared: &Shared, counts: &Counts, phase: &str) { + let pending = shared + .inboxes + .iter() + .map(|inbox| inbox.pending()) + .collect::>(); + let epochs = shared + .inboxes + .iter() + .map(|inbox| inbox.signal.version()) + .collect::>(); + let carriers = shared + .inboxes + .iter() + .map(|inbox| inbox.signal.test_progress.snapshot()) + .collect::>(); + let mut output = std::io::stdout().lock(); + writeln!( + output, + "refill-matrix phase={phase} accepted={} returned={} retries={} \ + pending={pending:?} epochs={epochs:?} carriers={carriers:?}", + counts.accepted.load(Ordering::Acquire), + counts.returned.load(Ordering::Acquire), + counts.retries.load(Ordering::Acquire), + ) + .unwrap(); + output.flush().unwrap(); +} + +#[test] +fn small_queues_progress_with_multiple_producers_and_carriers() { + if isolate() { + return; + } + let config = Runtime::builder() + .carriers(2) + .max_vthreads(TASKS) + .carrier_queue_capacity(QUEUE_CAPACITY) + .build() + .unwrap() + .config(); + let shared = Arc::new(Shared::new(config)); + for inbox in &shared.inboxes { + inbox.signal.test_progress.enable(); + } + let scope = shared.begin_scope().unwrap(); + let counts = Arc::new(Counts::default()); + let outcome = thread::scope(|threads| { + let stop = Stop(Arc::clone(&shared)); + let (hooked, hooked_rx) = mpsc::channel(); + let releases = shared + .inboxes + .iter() + .enumerate() + .map(|(index, inbox)| { + let hooked = hooked.clone(); + let (release, release_rx) = mpsc::channel(); + *lock(&inbox.before_notify_hook) = Some(Box::new(move || { + let _ = hooked.send(index); + let _ = release_rx.recv(); + })); + release + }) + .collect::>(); + drop(hooked); + let (done, done_rx) = mpsc::channel(); + let producers = (0..PRODUCERS) + .map(|producer| { + let shared = Arc::clone(&shared); + let counts = Arc::clone(&counts); + let done = done.clone(); + threads.spawn(move || { + let result = fill(&shared, scope, producer, &counts); + let _ = done.send(result); + }) + }) + .collect::>(); + drop(done); + let hook_deadline = Instant::now() + WATCHDOG; + let mut hook_order = Vec::new(); + for _ in 0..2 { + if let Ok(index) = + hooked_rx.recv_timeout(hook_deadline.saturating_duration_since(Instant::now())) + { + hook_order.push(index); + } + } + hook_order.sort_unstable(); + if hook_order == [0, 1] { + let fill_deadline = Instant::now() + WATCHDOG; + while (shared + .inboxes + .iter() + .any(|inbox| inbox.pending() != QUEUE_CAPACITY) + || counts.retries.load(Ordering::Acquire) == 0) + && Instant::now() < fill_deadline + { + thread::yield_now(); + } + } + let queues_full = shared + .inboxes + .iter() + .all(|inbox| inbox.pending() == QUEUE_CAPACITY); + let retried = counts.retries.load(Ordering::Acquire) != 0; + record(&shared, &counts, "before-notification"); + for release in releases { + let _ = release.send(()); + } + // Start the consumers only after bounded admission has filled both + // inboxes; later publishers now wake already-running carriers. + let carriers = (0..2) + .map(|index| { + let carrier = Arc::clone(&shared); + threads.spawn(move || super::run(carrier, CarrierId(index))) + }) + .collect::>(); + assert_eq!(hook_order, [0, 1], "both notifier hooks must pause"); + assert!( + queues_full, + "both queues must fill while notification is paused" + ); + assert!(retried, "full queues must reject at least one admission"); + let deadline = Instant::now() + WATCHDOG; + let admission: Result<(), String> = (0..PRODUCERS).try_for_each(|_| { + done_rx + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .map_err(|error| format!("{error:?}"))? + .map_err(|error| format!("{error:?}")) + }); + let drained = admission + .is_ok() + .then(|| shared.wait_until(scope, None, Some(Instant::now() + WATCHDOG))); + let passed = admission.is_ok() && matches!(drained, Some(Ok(true))); + record(&shared, &counts, "primary"); + if !passed { + wait_without_intervention(Duration::from_secs(1)); + record(&shared, &counts, "no-intervention"); + } + let report = shared.scope_report(scope); + let snapshot = shared.snapshot(); + let queued = shared + .inboxes + .iter() + .map(|inbox| inbox.pending()) + .sum::(); + writeln!( + std::io::stdout().lock(), + "refill-matrix-rich admission={admission:?} drained={drained:?} \ + report={report:?} snapshot={snapshot:?}" + ) + .unwrap(); + drop(stop); + for producer in producers { + producer.join().unwrap(); + } + for carrier in carriers { + carrier.join().unwrap(); + } + (admission, drained, report, snapshot, queued) + }); + shared.finish_scope(scope); + assert!( + outcome.0.is_ok(), + "matrix admission failed: {:?}", + outcome.0 + ); + assert!(matches!(outcome.1, Some(Ok(true))), "matrix did not drain"); + assert_eq!(counts.accepted.load(Ordering::Acquire), TASKS); + assert_eq!(counts.returned.load(Ordering::Acquire), TASKS); + assert!( + counts + .executed + .iter() + .all(|count| count.load(Ordering::Acquire) != 0) + ); + assert_eq!( + (outcome.2.completed, outcome.3.active, outcome.4), + (TASKS as u64, 0, 0) + ); + assert!(outcome.2.failures.is_empty(), "{:?}", outcome.2); + assert!( + shared + .inboxes + .iter() + .all(|inbox| inbox.reclaimed.load(Ordering::Acquire)) + ); +} + +fn fill(shared: &Shared, scope: u64, producer: usize, counts: &Arc) -> crate::Result<()> { + for index in 0..TASKS_PER_PRODUCER { + loop { + let body = Arc::clone(counts); + match shared.submit(scope, format!("producer-{producer}-{index}"), move || { + let carrier = crate::worker_context::current_carrier().unwrap(); + body.executed[carrier.0].fetch_add(1, Ordering::Release); + body.returned.fetch_add(1, Ordering::Release); + }) { + Ok(_) => { + counts.accepted.fetch_add(1, Ordering::Release); + break; + } + Err(Error::Capacity { + resource: crate::error::CapacityResource::CarrierQueue, + .. + }) => { + counts.retries.fetch_add(1, Ordering::Relaxed); + thread::yield_now(); + } + Err(error) => return Err(error), + } + } + } + Ok(()) +} diff --git a/crates/vthread/src/carrier_refill_test.rs b/crates/vthread/src/carrier_refill_test.rs index ac6f06d..71dc341 100644 --- a/crates/vthread/src/carrier_refill_test.rs +++ b/crates/vthread/src/carrier_refill_test.rs @@ -1,13 +1,12 @@ //! Preserve progress evidence before shutdown can turn a stall into rejection. - +use crate::support_test::{ + RefillBeforeStop as BeforeStop, RefillCounters as Counters, install_admission_progress, + observe_refill_passive, observe_refill_rich, run_isolated, wait_without_intervention, +}; use crate::{CarrierId, Error, RuntimeConfig, control::Shared}; use std::{ io::Write, - sync::{ - Arc, - atomic::{AtomicBool, AtomicUsize, Ordering}, - mpsc, - }, + sync::{Arc, atomic::Ordering, mpsc}, thread, time::{Duration, Instant}, }; @@ -15,14 +14,6 @@ use std::{ const TASKS: usize = 4_096; const WATCHDOG: Duration = Duration::from_secs(5); -#[derive(Default)] -struct Counters { - accepted: AtomicUsize, - started: AtomicUsize, - returned: AtomicUsize, - cleanup: AtomicBool, -} - struct Stop(Arc, Arc); impl Drop for Stop { @@ -32,52 +23,51 @@ impl Drop for Stop { } } -#[derive(Debug, PartialEq, Eq)] -struct BeforeStop { - accepted_begin: usize, - accepted_end: usize, - queued: usize, - started: usize, - body_returns: usize, - completed_credits: u64, - active: usize, +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Primary { + Passed, + Admission, + Drain, +} + +fn assert_complete(before: BeforeStop, tasks: usize, counters: &Counters) { + assert_eq!( + before, + BeforeStop { + accepted_begin: tasks, + accepted_end: tasks, + queued: 0, + started: tasks, + body_returns: tasks, + completed_credits: tasks as u64, + active: 0, + } + ); + counters.admission.assert_successful(tasks); } -fn observe(shared: &Shared, scope: u64, counters: &Counters) -> BeforeStop { +fn isolate(name: &str) -> bool { + if std::env::var("VTHREAD_REFILL_CHILD").as_deref() == Ok(name) { + return false; + } + let exact = format!("carrier::carrier_refill_test::{name}"); + let output = run_isolated(&exact, ("VTHREAD_REFILL_CHILD", name), WATCHDOG * 5); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); assert!( - !counters.cleanup.load(Ordering::SeqCst), - "progress evidence captured after cleanup" + !output.timed_out && output.status.success() && stdout.contains("1 passed"), + "isolated refill failed: status={:?} timed_out={}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status, + output.timed_out, ); - let accepted_begin = counters.accepted.load(Ordering::SeqCst); - let snapshot = shared.snapshot(); - let report = shared.scope_report(scope); - let before = BeforeStop { - accepted_begin, - accepted_end: counters.accepted.load(Ordering::SeqCst), - queued: shared.inboxes[0].pending(), - started: counters.started.load(Ordering::SeqCst), - body_returns: counters.returned.load(Ordering::SeqCst), - completed_credits: report.completed, - active: snapshot.active, - }; - // Concurrent observations are a window, not one atomic snapshot. Published - // carrier counters may lag; scope credits are distinct from body returns. - writeln!( - std::io::stdout().lock(), - "refill-before-stop progress={before:?} accepting={} epoch={} waiting={} \ - scope={report:?} carriers={:?}", - snapshot.accepting, - shared.inboxes[0].signal.version(), - shared.inboxes[0].signal.waiting(), - snapshot.carriers - ) - .unwrap(); - before + true } fn fill(shared: &Shared, scope: u64, counters: &Arc) -> crate::Result<()> { + let _progress = install_admission_progress(Arc::clone(&counters.admission)); for index in 0..TASKS { loop { + counters.admission.begin(index); if counters.cleanup.load(Ordering::SeqCst) { return Err(Error::RuntimeStopped); } @@ -98,16 +88,20 @@ fn fill(shared: &Shared, scope: u64, counters: &Arc) -> crate::Result< } } } + counters.admission.finish(); Ok(()) } #[test] fn continuously_refilled_coalesced_inbox_is_fully_drained() { + if isolate("continuously_refilled_coalesced_inbox_is_fully_drained") { + return; + } let shared = Arc::new(Shared::new(RuntimeConfig::default())); + shared.inboxes[0].signal.test_progress.enable(); let scope = shared.begin_scope().unwrap(); let counters = Arc::new(Counters::default()); let outcome = thread::scope(|threads| { - // Stop is dropped before scoped joins, including every observer failure. let stop = Stop(Arc::clone(&shared), Arc::clone(&counters)); let worker_shared = Arc::clone(&shared); let worker = threads.spawn(move || super::run(worker_shared, CarrierId(0))); @@ -116,10 +110,16 @@ fn continuously_refilled_coalesced_inbox_is_fully_drained() { thread::yield_now(); } if shared.inboxes[0].signal.waiting() == 0 { - let before = observe(&shared, scope, &counters); + observe_refill_passive(&shared, &counters, "startup-deadline"); + wait_without_intervention(Duration::from_secs(1)); + observe_refill_passive(&shared, &counters, "startup-observation"); + let mut output = std::io::stdout().lock(); + writeln!(output, "refill-startup result=initial-wait-not-observed").unwrap(); + output.flush().unwrap(); + drop(output); + let before = observe_refill_rich(&shared, scope, &counters); panic!("initial carrier wait was not observed: {before:?}"); } - // Admission now necessarily exercises the initial sleeping-owner signal. let producer_shared = Arc::clone(&shared); let produced = Arc::clone(&counters); let (submitted, submitted_rx) = mpsc::sync_channel(1); @@ -130,24 +130,37 @@ fn continuously_refilled_coalesced_inbox_is_fully_drained() { let admission = submitted_rx.recv_timeout(WATCHDOG); let drained = matches!(admission, Ok(Ok(()))) .then(|| shared.wait_until(scope, None, Some(Instant::now() + WATCHDOG))); - let before = observe(&shared, scope, &counters); - writeln!( - std::io::stdout().lock(), - "refill-primary admission={admission:?} drained={drained:?}" - ) - .unwrap(); + let primary = match (&admission, &drained) { + (Ok(Ok(())), Some(Ok(true))) => Primary::Passed, + (Ok(Ok(())), _) => Primary::Drain, + _ => Primary::Admission, + }; + observe_refill_passive(&shared, &counters, "primary"); + let passive_later = primary != Primary::Passed; + if passive_later { + wait_without_intervention(Duration::from_secs(1)); + observe_refill_passive(&shared, &counters, "no-intervention"); + } + let late_before_stop = admission.is_err().then(|| submitted_rx.try_recv()); + let mut output = std::io::stdout().lock(); + writeln!(output, + "refill-primary result={primary:?} admission={admission:?} drained={drained:?} passive_later={passive_later} late_before_stop={late_before_stop:?}").unwrap(); + output.flush().unwrap(); + drop(output); + let before = observe_refill_rich(&shared, scope, &counters); drop(stop); let producer = producer.join().map_err(crate::PanicReport::capture); let late_admission = admission.is_err().then(|| submitted_rx.try_recv()); let worker = worker.join().map_err(crate::PanicReport::capture); writeln!(std::io::stdout().lock(), "refill-cleanup producer={producer:?} worker={worker:?} late_admission={late_admission:?}").unwrap(); - (admission, drained, before, producer, worker) + (primary, admission, drained, before, producer, worker) }); let cleanup = shared.scope_report(scope); writeln!(std::io::stdout().lock(), "refill-final cleanup={cleanup:?}").unwrap(); shared.finish_scope(scope); - let (admission, drained, before, producer, worker) = outcome; + let (primary, admission, drained, before, producer, worker) = outcome; + assert_eq!(primary, Primary::Passed, "primary refill deadline failed"); admission .expect("continuous refill admission gate failed") .expect("admission returned a runtime error"); @@ -155,18 +168,7 @@ fn continuously_refilled_coalesced_inbox_is_fully_drained() { matches!(drained, Some(Ok(true))), "accepted work did not drain: {drained:?}" ); - assert_eq!( - before, - BeforeStop { - accepted_begin: TASKS, - accepted_end: TASKS, - queued: 0, - started: TASKS, - body_returns: TASKS, - completed_credits: TASKS as u64, - active: 0, - } - ); + assert_complete(before, TASKS, &counters); producer.expect("producer panic retained after primary observation"); worker.expect("carrier panic retained after primary observation"); assert!(cleanup.failures.is_empty(), "{cleanup:?}"); @@ -176,3 +178,71 @@ fn continuously_refilled_coalesced_inbox_is_fully_drained() { ); assert!(shared.inboxes[0].reclaimed.load(Ordering::Acquire)); } + +#[test] +fn one_inbox_epoch_drains_multiple_receive_batches() { + const BATCHED_TASKS: usize = 129; + if isolate("one_inbox_epoch_drains_multiple_receive_batches") { + return; + } + let shared = Arc::new(Shared::new(RuntimeConfig::default())); + shared.inboxes[0].signal.test_progress.enable(); + let scope = shared.begin_scope().unwrap(); + let counters = Arc::new(Counters::default()); + let _progress = install_admission_progress(Arc::clone(&counters.admission)); + let empty_epoch = shared.inboxes[0].signal.version(); + for index in 0..BATCHED_TASKS { + counters.admission.begin(index); + let body = Arc::clone(&counters); + shared + .submit(scope, format!("batch-{index}"), move || { + body.started.fetch_add(1, Ordering::SeqCst); + body.returned.fetch_add(1, Ordering::SeqCst); + }) + .unwrap(); + counters.accepted.fetch_add(1, Ordering::SeqCst); + } + counters.admission.finish(); + let queued_epoch = shared.inboxes[0].signal.version(); + assert_ne!(queued_epoch, empty_epoch); + let (drained, before, final_epoch, worker) = thread::scope(|threads| { + let stop = Stop(Arc::clone(&shared), Arc::clone(&counters)); + let carrier = Arc::clone(&shared); + let worker = threads.spawn(move || super::run(carrier, CarrierId(0))); + let drained = shared.wait_until(scope, None, Some(Instant::now() + WATCHDOG)); + observe_refill_passive(&shared, &counters, "multiple-batches"); + let passive_later = !matches!(&drained, Ok(true)); + if passive_later { + wait_without_intervention(Duration::from_secs(1)); + observe_refill_passive(&shared, &counters, "multiple-batches-no-intervention"); + } + let mut output = std::io::stdout().lock(); + writeln!( + output, + "refill-multiple-batches result={drained:?} passive_later={passive_later}" + ) + .unwrap(); + output.flush().unwrap(); + drop(output); + let before = observe_refill_rich(&shared, scope, &counters); + let final_epoch = shared.inboxes[0].signal.version(); + drop(stop); + ( + drained, + before, + final_epoch, + worker.join().map_err(crate::PanicReport::capture), + ) + }); + let cleanup = shared.scope_report(scope); + shared.finish_scope(scope); + assert!( + matches!(drained, Ok(true)), + "multiple batches did not drain: {drained:?}" + ); + assert_eq!(final_epoch, queued_epoch, "backlog required another epoch"); + assert_complete(before, BATCHED_TASKS, &counters); + worker.expect("carrier panic while draining multiple batches"); + assert!(cleanup.failures.is_empty(), "{cleanup:?}"); + assert!(shared.inboxes[0].reclaimed.load(Ordering::Acquire)); +} diff --git a/crates/vthread/src/control_admission.rs b/crates/vthread/src/control_admission.rs index 53be77d..281a059 100644 --- a/crates/vthread/src/control_admission.rs +++ b/crates/vthread/src/control_admission.rs @@ -1,6 +1,8 @@ //! Atomic bounded admission of transferable and carrier-local work. use super::{Shared, control_scope::ScopeRecord}; +#[cfg(test)] +use crate::support_test::{TestAdmissionPhase, record_admission_phase, record_admission_rejection}; use crate::{ CarrierId, Error, Result, TaskId, TaskStatus, id_map::IdHashSet, @@ -224,8 +226,16 @@ impl Shared { ) -> Result> { #[cfg(feature = "lifecycle-profiling")] let reservation_started = std::time::Instant::now(); - let Reservation { record, id, owner } = - self.reserve_with(scope, name, None, options, parent)?; + #[cfg(test)] + record_admission_phase(TestAdmissionPhase::Reserving); + let reservation = self.reserve_with(scope, name, None, options, parent); + #[cfg(test)] + if let Err(error) = &reservation { + record_admission_rejection(TestAdmissionPhase::ReserveRejected, error); + } + let Reservation { record, id, owner } = reservation?; + #[cfg(test)] + record_admission_phase(TestAdmissionPhase::Reserved); #[cfg(feature = "lifecycle-profiling")] let reservation_elapsed = reservation_started.elapsed(); #[cfg(feature = "lifecycle-profiling")] @@ -234,11 +244,15 @@ impl Shared { let packet = SpawnPacket { record: Arc::clone(&record), entry: Some(entry), + #[cfg(test)] + test_id: id, }; #[cfg(feature = "lifecycle-profiling")] let envelope_elapsed = envelope_started.elapsed(); #[cfg(feature = "lifecycle-profiling")] let inbox_started = std::time::Instant::now(); + #[cfg(test)] + record_admission_phase(TestAdmissionPhase::Publishing); if let Err(packet) = self.inboxes[owner].push(packet) { self.release_reservation(&record); drop(packet); @@ -255,8 +269,12 @@ impl Shared { limit: self.config.carrier_queue_capacity(), } }; + #[cfg(test)] + record_admission_rejection(TestAdmissionPhase::PublishRejected, &error); return Err(error); } + #[cfg(test)] + record_admission_phase(TestAdmissionPhase::Published); #[cfg(feature = "lifecycle-profiling")] self.lifecycle_probe.record_admission( reservation_elapsed, diff --git a/crates/vthread/src/error.rs b/crates/vthread/src/error.rs index b0d29c2..8267817 100644 --- a/crates/vthread/src/error.rs +++ b/crates/vthread/src/error.rs @@ -150,6 +150,8 @@ pub enum Error { }, /// Suspension was attempted without a mounted virtual thread. OutsideVThread, + /// A suspension-capable operation was rejected while its carrier was handling a panic. + SuspensionDuringPanic, /// One parker was asked to own two active generations simultaneously. ParkerBusy, /// A relative duration could not be represented as a monotonic deadline. diff --git a/crates/vthread/src/error_display.rs b/crates/vthread/src/error_display.rs index 43ef8a8..34b4514 100644 --- a/crates/vthread/src/error_display.rs +++ b/crates/vthread/src/error_display.rs @@ -63,6 +63,8 @@ impl fmt::Display for Error { write!(formatter, "task {task} aborted: {reason:?}") } Self::OutsideVThread => formatter.write_str("no virtual thread is mounted"), + Self::SuspensionDuringPanic => formatter + .write_str("a virtual thread cannot suspend while its carrier is handling a panic"), Self::ParkerBusy => { formatter.write_str("parker already owns an active wait generation") } @@ -95,8 +97,11 @@ impl StdError for Error { } impl From for Error { - fn from(_: vthread_stack::SuspendError) -> Self { - Self::OutsideVThread + fn from(error: vthread_stack::SuspendError) -> Self { + match error { + vthread_stack::SuspendError::NotMounted => Self::OutsideVThread, + vthread_stack::SuspendError::Panicking => Self::SuspensionDuringPanic, + } } } diff --git a/crates/vthread/src/error_display_test.rs b/crates/vthread/src/error_display_test.rs index 887788f..25593b3 100644 --- a/crates/vthread/src/error_display_test.rs +++ b/crates/vthread/src/error_display_test.rs @@ -18,3 +18,11 @@ fn stack_errors_preserve_the_os_error() { assert!(error.to_string().contains("no memory")); assert!(error.source().is_some()); } + +#[test] +fn panic_suspension_error_explains_the_carrier_boundary() { + assert_eq!( + Error::SuspensionDuringPanic.to_string(), + "a virtual thread cannot suspend while its carrier is handling a panic" + ); +} diff --git a/crates/vthread/src/inbox.rs b/crates/vthread/src/inbox.rs index c528754..bd34249 100644 --- a/crates/vthread/src/inbox.rs +++ b/crates/vthread/src/inbox.rs @@ -1,5 +1,10 @@ //! Bounded transferable start packets and coalesced carrier control requests. - +use crate::{ + TaskFailure, + signal::{Signal, lock}, + task::SharedTaskRecord, + wait::WaitHub, +}; use std::{ collections::{BTreeMap, VecDeque}, sync::{ @@ -7,24 +12,18 @@ use std::{ atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, }, }; - -use crate::{ - TaskFailure, - signal::{Signal, lock}, - task::SharedTaskRecord, - wait::WaitHub, -}; #[cfg(feature = "runtime-evidence")] type EvidenceEmitter = crate::diagnostics::evidence::Emitter; #[cfg(not(feature = "runtime-evidence"))] type EvidenceEmitter = (); - #[repr(align(64))] struct RetiredTasks(AtomicU64); pub(crate) struct SpawnPacket { pub(crate) record: SharedTaskRecord, pub(crate) entry: Option, + #[cfg(test)] + pub(crate) test_id: crate::TaskId, } #[derive(Default)] @@ -142,32 +141,21 @@ impl Inbox { } drop(state); #[cfg(test)] - if let Some(hook) = lock(&self.before_notify_hook).take() { + let hook = lock(&self.before_notify_hook).take(); + #[cfg(test)] + if let Some(hook) = hook { hook(); } if was_empty { self.signal.notify(); + } else { + // Help a registered owner while the first publisher still owes + // the coalesced epoch notification. + self.signal.notify_if_waiting(); } Ok(()) } - #[cfg(test)] - pub(crate) fn pop(&self) -> Option { - if self.pending_starts.load(Ordering::Acquire) == 0 { - return None; - } - let mut state = lock(&self.state); - let packet = state.starts.pop_front(); - let depth = state.starts.len(); - self.pending_starts.store(depth, Ordering::Release); - #[cfg(feature = "runtime-evidence")] - if packet.is_some() { - self.record_depth(depth); - } - drop(state); - packet - } - pub(crate) fn drain_into(&self, packets: &mut VecDeque, limit: usize) -> usize { if limit == 0 || self.pending_starts.load(Ordering::Acquire) == 0 { return 0; @@ -218,6 +206,12 @@ impl Inbox { self.pending_starts.load(Ordering::Acquire) } + pub(crate) fn has_queued_starts_at_wait_boundary(&self) -> bool { + // This mutex handoff makes the post-registration sleep check and a + // later publisher's waiter check one ordered progress protocol. + !lock(&self.state).starts.is_empty() + } + pub(crate) fn retire_tasks(&self, count: usize) { self.retired_tasks.0.fetch_add( u64::try_from(count).expect("completion batch fits u64"), @@ -293,7 +287,6 @@ impl Inbox { ); } } - #[cfg(test)] #[path = "inbox_test.rs"] mod inbox_test; diff --git a/crates/vthread/src/inbox_test.rs b/crates/vthread/src/inbox_test.rs index 19e04c2..8169261 100644 --- a/crates/vthread/src/inbox_test.rs +++ b/crates/vthread/src/inbox_test.rs @@ -1,5 +1,24 @@ -use crate::{Error, Runtime, control::Shared}; -use std::collections::VecDeque; +use super::Inbox; +use crate::{Error, Runtime, control::Shared, signal::lock}; +use std::{collections::VecDeque, sync::atomic::Ordering}; + +impl Inbox { + pub(crate) fn pop(&self) -> Option { + if self.pending_starts.load(Ordering::Acquire) == 0 { + return None; + } + let mut state = lock(&self.state); + let packet = state.starts.pop_front(); + let depth = state.starts.len(); + self.pending_starts.store(depth, Ordering::Release); + #[cfg(feature = "runtime-evidence")] + if packet.is_some() { + self.record_depth(depth); + } + drop(state); + packet + } +} #[test] fn bounded_batch_drain_preserves_fifo_and_pending_count() { @@ -26,7 +45,7 @@ fn bounded_batch_drain_preserves_fifo_and_pending_count() { } #[test] -fn queued_starts_coalesce_notifications_until_the_inbox_is_drained() { +fn queued_starts_coalesce_signal_epochs_without_a_registered_waiter() { let config = Runtime::builder() .carrier_queue_capacity(2) .build() @@ -51,6 +70,31 @@ fn queued_starts_coalesce_notifications_until_the_inbox_is_drained() { assert_ne!(inbox.signal.version(), queued_epoch); } +#[test] +fn before_notify_hook_runs_without_holding_its_slot() { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + + let config = Runtime::builder().build().expect("config").config(); + let shared = Arc::new(Shared::new(config)); + let scope = shared.begin_scope().expect("scope"); + let observed_unlocked = Arc::new(AtomicBool::new(false)); + let hook_shared = Arc::clone(&shared); + let hook_observed = Arc::clone(&observed_unlocked); + *crate::signal::lock(&shared.inboxes[0].before_notify_hook) = Some(Box::new(move || { + hook_observed.store( + hook_shared.inboxes[0].before_notify_hook.try_lock().is_ok(), + Ordering::Release, + ); + })); + + shared.submit(scope, "task".into(), || ()).expect("submit"); + + assert!(observed_unlocked.load(Ordering::Acquire)); +} + #[test] fn concurrent_push_and_batch_drain_publish_exact_pending_depth() { use std::{sync::Arc, thread, time::Instant}; diff --git a/crates/vthread/src/join_wait.rs b/crates/vthread/src/join_wait.rs index 96a4398..d7f83a6 100644 --- a/crates/vthread/src/join_wait.rs +++ b/crates/vthread/src/join_wait.rs @@ -31,6 +31,8 @@ pub(crate) fn wait_for( if mounted.task_id() == record.lock().id && Arc::ptr_eq(execution.record(), record) { return Err(Error::JoinSelf); } + // Completion subscription is externally visible wait state, so reject before it. + vthread_stack::check_suspend().map_err(Error::from)?; let data = Rc::clone(&execution.data); let _guard = WaitGuard { reason: data.replace_reason(reason), diff --git a/crates/vthread/src/join_wait_test.rs b/crates/vthread/src/join_wait_test.rs index 8c90891..312d6c4 100644 --- a/crates/vthread/src/join_wait_test.rs +++ b/crates/vthread/src/join_wait_test.rs @@ -1,4 +1,76 @@ -use crate::{Runtime, park_pair, support_test::until}; +use std::sync::{ + Arc, + atomic::{AtomicU8, Ordering}, +}; +use std::time::Duration; + +use crate::{ + Error, JoinHandle, Runtime, ScopeOptions, SuspensionReason, TaskStatus, UnparkResult, + options::TaskOptions, + park_pair, + support_test::{run_isolated, until}, + task::{SharedTaskRecord, TaskCell, TaskRecord}, +}; + +const UNSET: u8 = 0; +const REJECTED: u8 = 1; +const WAITED: u8 = 2; +const UNEXPECTED: u8 = 3; + +struct JoinOnDrop { + handle: JoinHandle, + outcome: Arc, +} + +struct DirectJoinOnDrop { + record: SharedTaskRecord, + outcome: Arc, +} + +impl Drop for DirectJoinOnDrop { + fn drop(&mut self) { + let task = self.record.lock().id; + let outcome = match super::wait_for(&self.record, SuspensionReason::Join(task), false) { + Err(Error::SuspensionDuringPanic) => REJECTED, + Ok(()) => WAITED, + Err(_) => UNEXPECTED, + }; + self.outcome.store(outcome, Ordering::SeqCst); + } +} + +fn unfinished_record_without_waiter_capacity() -> SharedTaskRecord { + Arc::new(TaskCell::new( + TaskRecord { + id: crate::TaskId::new(u64::MAX), + scope: u64::MAX, + parent: None, + options: Some(TaskOptions::root(ScopeOptions::default(), 1)), + name: "unfinished completion".into(), + carrier: crate::CarrierId(0), + deadline: None, + failure: None, + status: TaskStatus::Queued, + parks: 0, + last_suspension: None, + last_wake: None, + outcome_observed: false, + panic: None, + }, + 0, + )) +} + +impl Drop for JoinOnDrop { + fn drop(&mut self) { + let outcome = match self.handle.wait() { + Err(Error::SuspensionDuringPanic) => REJECTED, + Ok(()) => WAITED, + Err(_) => UNEXPECTED, + }; + self.outcome.store(outcome, Ordering::SeqCst); + } +} #[test] fn a_virtual_join_parks_and_releases_the_single_carrier_for_other_work() { @@ -48,3 +120,72 @@ fn self_join_is_typed_misuse_without_corrupting_completion() { }) .unwrap(); } + +#[test] +fn panic_is_rejected_before_waiting_for_an_unfinished_join() { + const CHILD: &str = "VTHREAD_PANIC_JOIN_CHILD"; + const TEST: &str = + "join_wait::join_wait_test::panic_is_rejected_before_waiting_for_an_unfinished_join"; + if std::env::var_os(CHILD).is_none() { + let output = run_isolated(TEST, (CHILD, "1"), Duration::from_secs(15)); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success() && !output.timed_out && stdout.contains("1 passed"), + "panic-join child failed: status={:?} timed_out={}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status, + output.timed_out, + ); + return; + } + + let runtime = Runtime::builder().carriers(1).build().unwrap(); + let outcome = Arc::new(AtomicU8::new(UNSET)); + runtime + .run_scope(|scope| { + let (parker, unparker) = park_pair(); + let target = scope.spawn("unfinished target", move || parker.park())?; + until(|| scope.runtime_snapshot().parked == 1); + + let task_outcome = Arc::clone(&outcome); + let mut panicking = scope.spawn("panic join", move || { + let _join = JoinOnDrop { + handle: target, + outcome: task_outcome, + }; + panic!("expected join panic"); + })?; + assert!(matches!(panicking.join(), Err(Error::TaskPanicked { .. }))); + assert_eq!(unparker.unpark(), UnparkResult::Woke); + Ok(()) + }) + .unwrap(); + + assert_eq!(outcome.load(Ordering::SeqCst), REJECTED); + assert_eq!(runtime.snapshot().active, 0); + assert_eq!(runtime.snapshot().parked, 0); + runtime.shutdown().unwrap(); +} + +#[test] +fn panic_join_is_rejected_before_completion_subscription() { + let outcome = Arc::new(AtomicU8::new(UNSET)); + let task_outcome = Arc::clone(&outcome); + let record = unfinished_record_without_waiter_capacity(); + Runtime::new() + .unwrap() + .run_scope(|scope| { + let mut task = scope.spawn("panic direct join", move || { + let _join = DirectJoinOnDrop { + record, + outcome: task_outcome, + }; + panic!("expected direct join panic"); + })?; + assert!(matches!(task.join(), Err(Error::TaskPanicked { .. }))); + Ok(()) + }) + .unwrap(); + + assert_eq!(outcome.load(Ordering::SeqCst), REJECTED); +} diff --git a/crates/vthread/src/kernel.rs b/crates/vthread/src/kernel.rs index 3e5fc0a..b8dd295 100644 --- a/crates/vthread/src/kernel.rs +++ b/crates/vthread/src/kernel.rs @@ -103,6 +103,33 @@ impl Kernel { } } + #[cfg(test)] + pub(crate) fn record_test_loop(&self, observed: u64, handled: Option) { + self.inbox + .signal + .test_progress + .record_loop(observed, handled); + self.record_test_progress(crate::signal::TestCarrierPhase::Drive); + } + + #[cfg(test)] + pub(crate) fn record_test_progress(&self, phase: crate::signal::TestCarrierPhase) { + self.inbox.signal.test_progress.record_state( + phase, + crate::signal::TestCarrierState { + remote_pending: self.remote_pending, + admission_pressure: self.admission_pressure, + ready: self.ready.len(), + incoming: self.incoming.len(), + pending_task: self.pending.as_ref().map(|packet| packet.test_id.get()), + completions: self.completions.len(), + in_flight: self + .in_flight + .map(|task| self.task(task).execution().id.get()), + }, + ); + } + pub(crate) fn execution(&self, task: TaskKey) -> Rc { Rc::clone(self.task(task).execution()) } diff --git a/crates/vthread/src/kernel_drive.rs b/crates/vthread/src/kernel_drive.rs index 5252047..b70ceb9 100644 --- a/crates/vthread/src/kernel_drive.rs +++ b/crates/vthread/src/kernel_drive.rs @@ -22,6 +22,8 @@ impl Kernel { self.process_wakes()?; } self.select_ready(); + #[cfg(test)] + self.record_test_progress(crate::signal::TestCarrierPhase::Tick); if self.in_flight.is_none() && (self.local.pending_wakes() != 0 || self.inbox.hub.has_pending()) { diff --git a/crates/vthread/src/kernel_receive.rs b/crates/vthread/src/kernel_receive.rs index 45904f6..54922cf 100644 --- a/crates/vthread/src/kernel_receive.rs +++ b/crates/vthread/src/kernel_receive.rs @@ -19,6 +19,8 @@ const SPINS_PER_SIGNAL_PROBE: usize = 1; impl Kernel { pub(crate) fn receive(&mut self) -> bool { + #[cfg(test)] + self.record_test_progress(crate::signal::TestCarrierPhase::Receive); // The carrier checks once per drive iteration while backlog remains, // independent of the previous dispatch outcome. Keep this off task paths. self.admission_pressure += u32::from(self.remote_pending); @@ -34,10 +36,17 @@ impl Kernel { self.remote_pending } + #[cfg(test)] pub(crate) fn remote_pending(&self) -> bool { self.remote_pending } + pub(crate) fn remote_receive_required(&self) -> bool { + // Published depth is authoritative while this carrier is driving, even + // before notification. The cached bit keeps batch draining hot. + self.remote_pending || self.inbox.pending() != 0 + } + pub(crate) fn receive_local(&mut self) { if self.receive_local_tasks() { self.publish(CarrierStatus::Running); @@ -157,6 +166,8 @@ impl Kernel { } pub(crate) fn wait_for_work(&mut self, observed: u64) { + #[cfg(test)] + self.record_test_progress(crate::signal::TestCarrierPhase::Idle); #[cfg(feature = "handoff-profiling")] let _episode = Span::new(HandoffStage::IdleEpisode); #[cfg(feature = "scheduler-profiling")] @@ -210,7 +221,12 @@ impl Kernel { { #[cfg(feature = "handoff-profiling")] let _wait = Span::new(HandoffStage::WaitApi); - self.inbox.hub.wait(observed, deadline); + #[cfg(test)] + self.record_test_progress(crate::signal::TestCarrierPhase::Waiting); + let inbox = &self.inbox; + inbox.hub.wait_while(observed, deadline, || { + inbox.has_queued_starts_at_wait_boundary() + }); } #[cfg(feature = "scheduler-profiling")] self.scheduler_profile.record_wait_return( diff --git a/crates/vthread/src/kernel_receive_test.rs b/crates/vthread/src/kernel_receive_test.rs index 9fa36df..833f112 100644 --- a/crates/vthread/src/kernel_receive_test.rs +++ b/crates/vthread/src/kernel_receive_test.rs @@ -36,6 +36,29 @@ fn remote_starts_refill_a_bounded_runnable_window() { shared.finish_scope(scope); } +#[test] +fn published_depth_is_an_authoritative_receive_obligation() { + let config = Runtime::builder() + .max_vthreads(1) + .carrier_queue_capacity(1) + .stack_cache_capacity(1) + .build() + .unwrap() + .config(); + let shared = Arc::new(Shared::new(config)); + let scope = shared.begin_scope().unwrap(); + let mut kernel = Kernel::new(Arc::clone(&shared), CarrierId(0)); + shared.submit(scope, "published".into(), || ()).unwrap(); + + assert!(!kernel.remote_pending()); + assert!(kernel.remote_receive_required()); + kernel.receive(); + assert_eq!(kernel.inbox.pending(), 0); + assert!(kernel.tick(true).unwrap()); + assert_eq!(shared.scope_report(scope).completed, 1); + shared.finish_scope(scope); +} + #[test] fn yielding_window_cannot_starve_later_admissions() { let config = Runtime::builder() diff --git a/crates/vthread/src/lib.rs b/crates/vthread/src/lib.rs index 666d8f7..208c380 100644 --- a/crates/vthread/src/lib.rs +++ b/crates/vthread/src/lib.rs @@ -138,6 +138,8 @@ pub use time::{sleep, sleep_until}; /// Cooperatively yields the current virtual thread to the carrier scheduler. /// Runtime policy is checked before the yield commits and again immediately before resumption. +/// Returns [`Error::SuspensionDuringPanic`] without switching tasks while the carrier +/// is running a panic hook or unwinding a panic. pub fn yield_now() -> Result<()> { match vthread_stack::suspend(vthread_stack::Suspension::YieldNow).map_err(Error::from)? { vthread_stack::Resume::Continue => Ok(()), @@ -201,3 +203,11 @@ mod shutdown_test; #[cfg(test)] #[path = "child_control_test.rs"] mod child_control_test; + +#[cfg(test)] +#[path = "panic_suspension_test.rs"] +mod panic_suspension_test; + +#[cfg(test)] +#[path = "panic_parking_test.rs"] +mod panic_parking_test; diff --git a/crates/vthread/src/local_scope.rs b/crates/vthread/src/local_scope.rs index 638ebd9..7559f58 100644 --- a/crates/vthread/src/local_scope.rs +++ b/crates/vthread/src/local_scope.rs @@ -6,7 +6,76 @@ use crate::{ task::SharedTaskRecord, task_context::TaskContext, task_fiber::BorrowedFiber, }; use std::{cell::RefCell, marker::PhantomData, rc::Rc, sync::Arc, time::Instant}; -use vthread_stack::FiberScope; +use vthread_stack::{FiberLease, FiberScope}; + +#[cfg(test)] +thread_local! { + static INJECT_CONSTRUCTION_PANIC: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +#[cfg(test)] +fn inject_construction_panic() { + INJECT_CONSTRUCTION_PANIC.with(|injected| injected.set(true)); +} + +#[cfg(test)] +fn construction_boundary() { + INJECT_CONSTRUCTION_PANIC.with(|injected| { + assert!( + !injected.replace(false), + "injected local construction panic" + ); + }); +} + +struct LocalAdmissionRollback<'a> { + execution: &'a Execution, + records: &'a RefCell>, + record: &'a SharedTaskRecord, + fiber: Option, + listed: bool, + #[cfg(feature = "runtime-evidence")] + stack: Option, + armed: bool, +} + +impl LocalAdmissionRollback<'_> { + fn retain_fiber(&mut self, fiber: &FiberLease) { + self.fiber = Some(fiber.clone()); + } + + fn retain_record(&mut self) { + self.listed = true; + } + + fn commit(mut self) { + self.armed = false; + } +} + +impl Drop for LocalAdmissionRollback<'_> { + fn drop(&mut self) { + if !self.armed { + return; + } + if let Some(fiber) = self.fiber.take() + && let Err(payload) = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fiber.reclaim())) + { + crate::worker_context::payload_failure(crate::PanicReport::capture(payload)); + } + #[cfg(feature = "runtime-evidence")] + if let Some(stack) = self.stack.take() { + self.execution.local().stacks.borrow_mut().retire(stack); + } + if self.listed { + self.records + .borrow_mut() + .retain(|record| !Arc::ptr_eq(record, self.record)); + } + self.execution.shared().release_reservation(self.record); + } +} #[path = "local_scope_run.rs"] mod local_scope_run; @@ -39,6 +108,8 @@ impl<'scope, 'env> LocalScope<'scope, 'env> { name: impl Into, entry: impl FnOnce() -> T + 'scope, ) -> Result> { + // Name conversion is user code and may reenter this scope, so it precedes final checks. + let name = name.into(); self.execution.data.check()?; self.options.check()?; #[cfg(feature = "runtime-evidence")] @@ -57,9 +128,19 @@ impl<'scope, 'env> LocalScope<'scope, 'env> { }; let record = self.execution.shared().reserve( root, - name.into(), + name, Some((carrier, parent, self.options.child(options.deadline))), )?; + let mut rollback = LocalAdmissionRollback { + execution: &self.execution, + records: &self.records, + record: &record, + fiber: None, + listed: false, + #[cfg(feature = "runtime-evidence")] + stack: None, + armed: true, + }; #[cfg(feature = "runtime-evidence")] let acquired = self .execution @@ -72,18 +153,16 @@ impl<'scope, 'env> LocalScope<'scope, 'env> { #[cfg(feature = "runtime-evidence")] let (stack_identity, stack) = match acquired { Ok(stack) => stack, - Err(error) => { - self.execution.shared().release_reservation(&record); - return Err(Error::StackAllocation(error)); - } + Err(error) => return Err(Error::StackAllocation(error)), }; + #[cfg(feature = "runtime-evidence")] + { + rollback.stack = Some(stack_identity); + } #[cfg(not(feature = "runtime-evidence"))] let stack = match acquired { Ok(stack) => stack, - Err(error) => { - self.execution.shared().release_reservation(&record); - return Err(Error::StackAllocation(error)); - } + Err(error) => return Err(Error::StackAllocation(error)), }; let cell = Rc::new(RefCell::new(JoinCell { outcome: None })); let body_cell = Rc::clone(&cell); @@ -94,17 +173,11 @@ impl<'scope, 'env> LocalScope<'scope, 'env> { }); }) { Ok(lease) => lease, - Err(error) => { - #[cfg(feature = "runtime-evidence")] - self.execution - .local() - .stacks - .borrow_mut() - .retire(stack_identity); - self.execution.shared().release_reservation(&record); - return Err(Error::StackAllocation(error)); - } + Err(error) => return Err(Error::StackAllocation(error)), }; + rollback.retain_fiber(&lease); + #[cfg(test)] + construction_boundary(); let data = Rc::new(TaskContext::new( record.lock().options().clone(), self.execution.shared().config.task_local_capacity(), @@ -131,6 +204,7 @@ impl<'scope, 'env> LocalScope<'scope, 'env> { !(record.status.is_terminal() && record.outcome_observed) }); self.records.borrow_mut().push(Arc::clone(&record)); + rollback.retain_record(); #[cfg(feature = "runtime-evidence")] let task_fiber = BorrowedFiber::new(lease, stack_identity); #[cfg(not(feature = "runtime-evidence"))] @@ -139,6 +213,7 @@ impl<'scope, 'env> LocalScope<'scope, 'env> { execution: Some(execution), fiber: Some(task_fiber), }); + rollback.commit(); #[cfg(feature = "runtime-evidence")] { self.execution.shared().record_task_accepted(&record); diff --git a/crates/vthread/src/local_scope_run_test.rs b/crates/vthread/src/local_scope_run_test.rs index c73a770..9df7577 100644 --- a/crates/vthread/src/local_scope_run_test.rs +++ b/crates/vthread/src/local_scope_run_test.rs @@ -91,3 +91,41 @@ fn local_generic_body_failure_preserves_a_local_deadline() { }) .unwrap(); } + +#[test] +fn injected_construction_unwind_releases_admission_and_drains() { + use std::panic::{AssertUnwindSafe, catch_unwind}; + let runtime = crate::Runtime::builder() + .carriers(1) + .max_vthreads(2) + .carrier_queue_capacity(2) + .stack_cache_capacity(0) + .stall_policy(crate::StallPolicy::AbortAfter( + std::time::Duration::from_millis(20), + )) + .build() + .unwrap(); + runtime + .run_scope(|root| { + root.spawn("parent", || { + crate::local_scope(|local| { + super::super::inject_construction_panic(); + assert!( + catch_unwind(AssertUnwindSafe(|| { + local.spawn("injected-construction-panic", || ()) + })) + .is_err() + ); + assert_eq!(local.spawn("after-injected-panic", || 52)?.join()?, 52); + Ok(()) + }) + .unwrap(); + })? + .join() + }) + .unwrap(); + let snapshot = runtime.snapshot(); + assert_eq!((snapshot.active(), snapshot.stats().admitted()), (0, 2)); + assert_eq!(snapshot.stats().rejected(), 1); + runtime.shutdown().unwrap(); +} diff --git a/crates/vthread/src/local_scope_test.rs b/crates/vthread/src/local_scope_test.rs index a91d5e8..0899f70 100644 --- a/crates/vthread/src/local_scope_test.rs +++ b/crates/vthread/src/local_scope_test.rs @@ -223,3 +223,44 @@ fn parent_panic_drains_local_children_before_borrowed_data_can_be_reused() { }) .unwrap(); } + +#[test] +fn reentrant_name_conversion_cannot_exceed_local_queue_capacity() { + use crate::{Error, error::CapacityResource}; + + struct ReentrantName<'borrow, 'scope, 'env: 'scope>(&'borrow super::LocalScope<'scope, 'env>); + + impl<'borrow, 'scope, 'env: 'scope> From> for String { + fn from(name: ReentrantName<'borrow, 'scope, 'env>) -> Self { + drop(name.0.spawn("inner", || ()).unwrap()); + "outer".into() + } + } + + let runtime = Runtime::builder() + .carriers(1) + .max_vthreads(8) + .carrier_queue_capacity(1) + .stack_cache_capacity(0) + .build() + .unwrap(); + runtime + .run_scope(|scope| { + scope + .spawn("parent", || { + local_scope(|local| { + assert!(matches!( + local.spawn(ReentrantName(local), || ()), + Err(Error::Capacity { + resource: CapacityResource::CarrierQueue, + limit: 1, + }) + )); + Ok(()) + }) + .unwrap(); + })? + .join() + }) + .unwrap(); +} diff --git a/crates/vthread/src/panic_parking_test.rs b/crates/vthread/src/panic_parking_test.rs new file mode 100644 index 0000000..e722e19 --- /dev/null +++ b/crates/vthread/src/panic_parking_test.rs @@ -0,0 +1,260 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering}, + mpsc, + }, + time::Duration, +}; + +use crate::{Error, ParkOutcome, Runtime, UnparkResult, support_test::run_isolated}; + +const UNSET: u8 = 0; +const REJECTED: u8 = 1; +const SUSPENDED: u8 = 2; +const UNEXPECTED: u8 = 3; + +struct ParkOnDrop { + parker: Arc, + outcome: Arc, +} + +impl Drop for ParkOnDrop { + fn drop(&mut self) { + let outcome = match self.parker.park_timeout(Duration::from_secs(30)) { + Err(Error::SuspensionDuringPanic) => REJECTED, + Ok(_) => SUSPENDED, + Err(_) => UNEXPECTED, + }; + self.outcome.store(outcome, Ordering::SeqCst); + } +} + +#[test] +fn rejected_panic_park_leaves_no_wait_timer_or_wake_state() { + const CHILD: &str = "VTHREAD_PANIC_PARK_CHILD"; + const TEST: &str = "panic_parking_test::rejected_panic_park_leaves_no_wait_timer_or_wake_state"; + if std::env::var_os(CHILD).is_none() { + let output = run_isolated(TEST, (CHILD, "1"), Duration::from_secs(15)); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success() && !output.timed_out && stdout.contains("1 passed"), + "panic-park child failed: status={:?} timed_out={}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status, + output.timed_out, + ); + return; + } + + let runtime = Runtime::builder().carriers(1).build().unwrap(); + let (parker, unparker) = crate::parking::park_pair(); + let parker = Arc::new(parker); + let drop_outcome = Arc::new(AtomicU8::new(UNSET)); + let sibling_panicking = Arc::new(AtomicBool::new(false)); + let wake_outcome = Arc::new(AtomicU8::new(UNSET)); + + runtime + .run_scope(|scope| { + let (gate_entered, wait_for_gate) = mpsc::sync_channel(1); + let (release_gate, gate_release) = mpsc::sync_channel(1); + let mut gate = scope.spawn("admission gate", move || { + gate_entered.send(()).unwrap(); + gate_release.recv_timeout(Duration::from_secs(5)).unwrap(); + })?; + wait_for_gate.recv_timeout(Duration::from_secs(5)).unwrap(); + + let task_parker = Arc::clone(&parker); + let task_outcome = Arc::clone(&drop_outcome); + let mut unwinding = scope.spawn("panic park", move || { + let _park_on_drop = ParkOnDrop { + parker: task_parker, + outcome: task_outcome, + }; + panic!("expected task panic"); + })?; + let observed = Arc::clone(&sibling_panicking); + let observed_wake = Arc::clone(&wake_outcome); + let mut sibling = scope.spawn("wake sibling", move || { + observed.store(std::thread::panicking(), Ordering::SeqCst); + let outcome = match unparker.unpark() { + UnparkResult::Stored => REJECTED, + UnparkResult::Woke | UnparkResult::Closed => SUSPENDED, + }; + observed_wake.store(outcome, Ordering::SeqCst); + })?; + release_gate.send(()).unwrap(); + + gate.join()?; + assert!(matches!(unwinding.join(), Err(Error::TaskPanicked { .. }))); + sibling.join()?; + let mut permit_consumer = scope.spawn("permit consumer", { + let parker = Arc::clone(&parker); + move || parker.park() + })?; + assert_eq!(permit_consumer.join()??, ParkOutcome::Ready); + Ok(()) + }) + .unwrap(); + + assert_eq!( + ( + drop_outcome.load(Ordering::SeqCst), + wake_outcome.load(Ordering::SeqCst), + sibling_panicking.load(Ordering::SeqCst), + ), + (REJECTED, REJECTED, false), + "panic-time park published wait state or transferred panic state" + ); + assert_drained(&runtime); + runtime.shutdown().unwrap(); +} + +#[test] +fn rejected_panic_park_preserves_a_stored_permit() { + const CHILD: &str = "VTHREAD_PANIC_STORED_PERMIT_CHILD"; + const TEST: &str = "panic_parking_test::rejected_panic_park_preserves_a_stored_permit"; + if std::env::var_os(CHILD).is_none() { + let output = run_isolated(TEST, (CHILD, "1"), Duration::from_secs(15)); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success() && !output.timed_out && stdout.contains("1 passed"), + "stored-permit child failed: status={:?} timed_out={}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status, + output.timed_out, + ); + return; + } + + let runtime = Runtime::builder().carriers(1).build().unwrap(); + let (parker, unparker) = crate::parking::park_pair(); + let parker = Arc::new(parker); + let outcome = Arc::new(AtomicU8::new(UNSET)); + assert_eq!(unparker.unpark(), UnparkResult::Stored); + + runtime + .run_scope(|scope| { + let task_parker = Arc::clone(&parker); + let task_outcome = Arc::clone(&outcome); + let mut failed = scope.spawn("panic with stored permit", move || { + let _park = ParkOnDrop { + parker: task_parker, + outcome: task_outcome, + }; + panic!("expected stored-permit panic"); + })?; + assert!(matches!(failed.join(), Err(Error::TaskPanicked { .. }))); + + let mut consume = scope.spawn("consume preserved permit", { + let parker = Arc::clone(&parker); + move || parker.park() + })?; + assert_eq!(consume.join()??, ParkOutcome::Ready); + Ok(()) + }) + .unwrap(); + + assert_eq!(outcome.load(Ordering::SeqCst), REJECTED); + assert_drained(&runtime); + runtime.shutdown().unwrap(); +} + +struct RegisteredParkOnDrop { + parker: Arc, + callbacks: Arc, + outcome: Arc, +} + +impl Drop for RegisteredParkOnDrop { + fn drop(&mut self) { + let callbacks = Arc::clone(&self.callbacks); + let outcome = match self.parker.park_registered(move |_, _| { + callbacks.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) { + Err(Error::SuspensionDuringPanic) => REJECTED, + Ok(_) => SUSPENDED, + Err(_) => UNEXPECTED, + }; + self.outcome.store(outcome, Ordering::SeqCst); + } +} + +#[test] +fn panic_park_is_rejected_before_registration_or_generation_publication() { + const CHILD: &str = "VTHREAD_PANIC_REGISTRATION_CHILD"; + const TEST: &str = + "panic_parking_test::panic_park_is_rejected_before_registration_or_generation_publication"; + if std::env::var_os(CHILD).is_none() { + let output = run_isolated(TEST, (CHILD, "1"), Duration::from_secs(15)); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success() && !output.timed_out && stdout.contains("1 passed"), + "panic-registration child failed: status={:?} timed_out={}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status, + output.timed_out, + ); + return; + } + + let runtime = Runtime::builder().carriers(1).build().unwrap(); + let (parker, unparker) = crate::parking::park_pair(); + let parker = Arc::new(parker); + let callbacks = Arc::new(AtomicUsize::new(0)); + let outcome = Arc::new(AtomicU8::new(UNSET)); + + runtime + .run_scope(|scope| { + let task_parker = Arc::clone(&parker); + let task_callbacks = Arc::clone(&callbacks); + let task_outcome = Arc::clone(&outcome); + let mut failed = scope.spawn("panic registration", move || { + let _park = RegisteredParkOnDrop { + parker: task_parker, + callbacks: task_callbacks, + outcome: task_outcome, + }; + panic!("expected registration panic"); + })?; + assert!(matches!(failed.join(), Err(Error::TaskPanicked { .. }))); + + let (registered, registration) = mpsc::sync_channel(1); + let mut normal = scope.spawn("first real generation", { + let parker = Arc::clone(&parker); + move || { + parker.park_registered(move |token, _| { + registered.send(token.generation()).unwrap(); + Ok(()) + }) + } + })?; + assert_eq!( + registration.recv_timeout(Duration::from_secs(5)).unwrap(), + 1 + ); + assert_eq!(unparker.unpark(), UnparkResult::Woke); + assert_eq!(normal.join()??, ParkOutcome::Ready); + Ok(()) + }) + .unwrap(); + + assert_eq!(outcome.load(Ordering::SeqCst), REJECTED); + assert_eq!(callbacks.load(Ordering::SeqCst), 0); + assert_drained(&runtime); + runtime.shutdown().unwrap(); +} + +fn assert_drained(runtime: &Runtime) { + let snapshot = runtime.snapshot(); + assert_eq!(snapshot.active, 0); + assert_eq!(snapshot.parked, 0); + assert_eq!(snapshot.timers, 0); + assert!( + snapshot + .carriers + .iter() + .all(|carrier| carrier.pending_wakes == 0) + ); +} diff --git a/crates/vthread/src/panic_suspension_test.rs b/crates/vthread/src/panic_suspension_test.rs new file mode 100644 index 0000000..1a1d4fe --- /dev/null +++ b/crates/vthread/src/panic_suspension_test.rs @@ -0,0 +1,135 @@ +use std::{ + panic, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering}, + mpsc, + }, + time::Duration, +}; + +use crate::{Error, Runtime, support_test::run_isolated}; + +const UNSET: u8 = 0; +const REJECTED: u8 = 1; +const SUSPENDED: u8 = 2; +const UNEXPECTED: u8 = 3; + +struct YieldOnDrop(Arc); + +impl Drop for YieldOnDrop { + fn drop(&mut self) { + let outcome = match crate::yield_now() { + Err(Error::SuspensionDuringPanic) => REJECTED, + Ok(()) => SUSPENDED, + Err(_) => UNEXPECTED, + }; + self.0.store(outcome, Ordering::SeqCst); + } +} + +#[test] +fn unwinding_task_cannot_expose_carrier_panic_state_to_sibling() { + let runtime = Runtime::builder().carriers(1).build().unwrap(); + let drop_outcome = Arc::new(AtomicU8::new(UNSET)); + let sibling_panicking = Arc::new(AtomicBool::new(false)); + + runtime + .run_scope(|scope| { + let (gate_entered, wait_for_gate) = mpsc::sync_channel(1); + let (release_gate, gate_release) = mpsc::sync_channel(1); + let mut gate = scope.spawn("admission gate", move || { + gate_entered.send(()).unwrap(); + gate_release.recv_timeout(Duration::from_secs(5)).unwrap(); + })?; + wait_for_gate.recv_timeout(Duration::from_secs(5)).unwrap(); + + let task_outcome = Arc::clone(&drop_outcome); + let mut unwinding = scope.spawn("unwinding", move || { + let _yield_on_drop = YieldOnDrop(task_outcome); + panic!("expected task panic"); + })?; + let observed = Arc::clone(&sibling_panicking); + let mut sibling = scope.spawn("sibling", move || { + observed.store(std::thread::panicking(), Ordering::SeqCst); + })?; + release_gate.send(()).unwrap(); + + gate.join()?; + assert!(matches!(unwinding.join(), Err(Error::TaskPanicked { .. }))); + sibling.join()?; + Ok(()) + }) + .unwrap(); + + assert_eq!( + ( + drop_outcome.load(Ordering::SeqCst), + sibling_panicking.load(Ordering::SeqCst), + ), + (REJECTED, false), + "an unwinding task transferred carrier panic state to its sibling" + ); + runtime.shutdown().unwrap(); +} + +#[test] +fn panic_hook_cannot_transfer_control_to_another_task() { + const CHILD: &str = "VTHREAD_PANIC_HOOK_SUSPENSION_CHILD"; + const TEST: &str = "panic_suspension_test::panic_hook_cannot_transfer_control_to_another_task"; + if std::env::var_os(CHILD).is_none() { + let output = run_isolated(TEST, (CHILD, "1"), Duration::from_secs(15)); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success() && !output.timed_out && stdout.contains("1 passed"), + "panic-hook suspension child failed: status={:?} timed_out={}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status, + output.timed_out, + ); + return; + } + + let hook_calls = Arc::new(AtomicUsize::new(0)); + let hook_outcome = Arc::new(AtomicU8::new(UNSET)); + let observed_calls = Arc::clone(&hook_calls); + let observed_outcome = Arc::clone(&hook_outcome); + panic::set_hook(Box::new(move |_| { + if observed_calls.fetch_add(1, Ordering::SeqCst) == 0 { + let outcome = match crate::yield_now() { + Err(Error::SuspensionDuringPanic) => REJECTED, + Ok(()) => SUSPENDED, + Err(_) => UNEXPECTED, + }; + observed_outcome.store(outcome, Ordering::SeqCst); + } + })); + + let runtime = Runtime::builder().carriers(1).build().unwrap(); + runtime + .run_scope(|scope| { + let (gate_entered, wait_for_gate) = mpsc::sync_channel(1); + let (release_gate, gate_release) = mpsc::sync_channel(1); + let mut gate = scope.spawn("admission gate", move || { + gate_entered.send(()).unwrap(); + gate_release.recv_timeout(Duration::from_secs(5)).unwrap(); + })?; + wait_for_gate.recv_timeout(Duration::from_secs(5)).unwrap(); + + let mut first = scope.spawn("first panic", move || { + panic!("first expected panic"); + })?; + let mut second = scope.spawn("second panic", || panic!("second expected panic"))?; + release_gate.send(()).unwrap(); + gate.join()?; + assert!(matches!(first.join(), Err(Error::TaskPanicked { .. }))); + assert!(matches!(second.join(), Err(Error::TaskPanicked { .. }))); + Ok(()) + }) + .unwrap(); + runtime.shutdown().unwrap(); + drop(panic::take_hook()); + + assert_eq!(hook_calls.load(Ordering::SeqCst), 2); + assert_eq!(hook_outcome.load(Ordering::SeqCst), REJECTED); +} diff --git a/crates/vthread/src/parking.rs b/crates/vthread/src/parking.rs index ab81ea1..e7aba4c 100644 --- a/crates/vthread/src/parking.rs +++ b/crates/vthread/src/parking.rs @@ -70,6 +70,10 @@ pub enum UnparkResult { } /// The single-consumer side of a bounded one-permit wake primitive. +/// +/// A park operation that reaches its suspension preflight while the carrier is +/// handling a panic returns [`Error::SuspensionDuringPanic`] before consuming a +/// stored permit or publishing wait state. pub struct Parker { pub(crate) wait: WaitCell, } @@ -156,6 +160,9 @@ fn park_wait( handoff: WaitHandoff, register: impl FnOnce(ParkToken, Option<&WaitRegistration>) -> Result, ) -> Result { + // Reject before consuming a permit or publishing a wait generation. The stack + // boundary checks again immediately before every context switch. + vthread_stack::check_suspend().map_err(Error::from)?; let policy = &execution.data; let unmasked = policy.masked() == 0; let inherited_deadline = policy.deadline().filter(|_| unmasked); diff --git a/crates/vthread/src/scope_failure_report.rs b/crates/vthread/src/scope_failure_report.rs index dbbb805..23e16bb 100644 --- a/crates/vthread/src/scope_failure_report.rs +++ b/crates/vthread/src/scope_failure_report.rs @@ -59,6 +59,8 @@ pub enum FailureKind { TaskAborted, /// Suspension was attempted outside a virtual thread. OutsideVThread, + /// Suspension was rejected while the carrier was handling a panic. + SuspensionDuringPanic, /// A parker already owned a generation. ParkerBusy, /// A monotonic deadline could not be represented. diff --git a/crates/vthread/src/scope_failure_report_capture.rs b/crates/vthread/src/scope_failure_report_capture.rs index f21ee95..b8b9df6 100644 --- a/crates/vthread/src/scope_failure_report_capture.rs +++ b/crates/vthread/src/scope_failure_report_capture.rs @@ -73,6 +73,7 @@ impl FailureReport { Error::JoinSelf => K::JoinSelf, Error::TaskAborted { .. } => K::TaskAborted, Error::OutsideVThread => K::OutsideVThread, + Error::SuspensionDuringPanic => K::SuspensionDuringPanic, Error::ParkerBusy => K::ParkerBusy, Error::DeadlineOverflow => K::DeadlineOverflow, Error::StackAllocation(_) => K::StackAllocation, diff --git a/crates/vthread/src/scope_failure_report_capture_test.rs b/crates/vthread/src/scope_failure_report_capture_test.rs index 744a2ab..7db1db8 100644 --- a/crates/vthread/src/scope_failure_report_capture_test.rs +++ b/crates/vthread/src/scope_failure_report_capture_test.rs @@ -2,6 +2,12 @@ use super::*; use crate::{PanicReport, ScopeFailure}; use std::sync::Arc; +#[test] +fn panic_suspension_rejection_keeps_its_typed_failure_kind() { + let report = FailureReport::capture(&Error::SuspensionDuringPanic); + assert_eq!(report.kind(), FailureKind::SuspensionDuringPanic); +} + #[test] fn io_reports_keep_safe_metadata_without_capturing_the_error_source() { let error = Error::io( diff --git a/crates/vthread/src/signal.rs b/crates/vthread/src/signal.rs index ed62c3e..c1c7122 100644 --- a/crates/vthread/src/signal.rs +++ b/crates/vthread/src/signal.rs @@ -16,6 +16,179 @@ pub(crate) struct Signal { waiters: AtomicUsize, gate: Mutex<()>, changed: Condvar, + #[cfg(test)] + before_wait_hook: Mutex>>, + #[cfg(test)] + pub(crate) test_progress: TestCarrierProgress, +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(usize)] +pub(crate) enum TestCarrierPhase { + Created, + Drive, + Receive, + Tick, + Idle, + Waiting, +} + +#[cfg(test)] +pub(crate) struct TestCarrierState { + pub(crate) remote_pending: bool, + pub(crate) admission_pressure: u32, + pub(crate) ready: usize, + pub(crate) incoming: usize, + pub(crate) pending_task: Option, + pub(crate) completions: usize, + pub(crate) in_flight: Option, +} + +#[cfg(test)] +#[derive(Default)] +pub(crate) struct TestCarrierProgress { + enabled: std::sync::atomic::AtomicBool, + sequence: AtomicU64, + drives: AtomicU64, + phase: AtomicUsize, + observed_epoch: AtomicU64, + handled_epoch: AtomicU64, + remote_pending: std::sync::atomic::AtomicBool, + admission_pressure: AtomicUsize, + ready: AtomicUsize, + incoming: AtomicUsize, + pending_task: AtomicU64, + completions: AtomicUsize, + in_flight: AtomicU64, +} + +#[cfg(test)] +impl TestCarrierProgress { + const NO_EPOCH: u64 = u64::MAX; + const NO_TASK: u64 = u64::MAX; + + pub(crate) fn enable(&self) { + self.handled_epoch.store(Self::NO_EPOCH, Ordering::Relaxed); + self.pending_task.store(Self::NO_TASK, Ordering::Relaxed); + self.in_flight.store(Self::NO_TASK, Ordering::Relaxed); + self.enabled.store(true, Ordering::Release); + } + + pub(crate) fn record_loop(&self, observed: u64, handled: Option) { + if !self.enabled.load(Ordering::Acquire) { + return; + } + self.sequence.fetch_add(1, Ordering::AcqRel); + self.drives.fetch_add(1, Ordering::Relaxed); + self.observed_epoch.store(observed, Ordering::Relaxed); + self.handled_epoch + .store(handled.unwrap_or(Self::NO_EPOCH), Ordering::Relaxed); + self.sequence.fetch_add(1, Ordering::Release); + } + + pub(crate) fn record_handled(&self, handled: u64) { + if self.enabled.load(Ordering::Acquire) { + self.sequence.fetch_add(1, Ordering::AcqRel); + self.handled_epoch.store(handled, Ordering::Relaxed); + self.sequence.fetch_add(1, Ordering::Release); + } + } + + pub(crate) fn record_state(&self, phase: TestCarrierPhase, state: TestCarrierState) { + if !self.enabled.load(Ordering::Acquire) { + return; + } + self.sequence.fetch_add(1, Ordering::AcqRel); + self.remote_pending + .store(state.remote_pending, Ordering::Relaxed); + self.admission_pressure + .store(state.admission_pressure as usize, Ordering::Relaxed); + self.ready.store(state.ready, Ordering::Relaxed); + self.incoming.store(state.incoming, Ordering::Relaxed); + self.pending_task.store( + state.pending_task.unwrap_or(Self::NO_TASK), + Ordering::Relaxed, + ); + self.completions.store(state.completions, Ordering::Relaxed); + self.in_flight + .store(state.in_flight.unwrap_or(Self::NO_TASK), Ordering::Relaxed); + self.phase.store(phase as usize, Ordering::Release); + self.sequence.fetch_add(1, Ordering::Release); + } + + pub(crate) fn snapshot(&self) -> TestCarrierProgressSnapshot { + let before = self.sequence.load(Ordering::Acquire); + let phase = match self.phase.load(Ordering::Acquire) { + 0 => TestCarrierPhase::Created, + 1 => TestCarrierPhase::Drive, + 2 => TestCarrierPhase::Receive, + 3 => TestCarrierPhase::Tick, + 4 => TestCarrierPhase::Idle, + 5 => TestCarrierPhase::Waiting, + _ => unreachable!("carrier phase"), + }; + let handled = self.handled_epoch.load(Ordering::Relaxed); + let pending_task = self.pending_task.load(Ordering::Relaxed); + let in_flight = self.in_flight.load(Ordering::Relaxed); + let mut snapshot = TestCarrierProgressSnapshot { + coherent: false, + sequence: before, + drives: self.drives.load(Ordering::Relaxed), + phase, + observed_epoch: self.observed_epoch.load(Ordering::Relaxed), + handled_epoch: (handled != Self::NO_EPOCH).then_some(handled), + remote_pending: self.remote_pending.load(Ordering::Relaxed), + admission_pressure: self.admission_pressure.load(Ordering::Relaxed), + ready: self.ready.load(Ordering::Relaxed), + incoming: self.incoming.load(Ordering::Relaxed), + pending_task: (pending_task != Self::NO_TASK).then_some(pending_task), + completions: self.completions.load(Ordering::Relaxed), + in_flight: (in_flight != Self::NO_TASK).then_some(in_flight), + }; + let after = self.sequence.load(Ordering::Acquire); + snapshot.coherent = before == after && before.is_multiple_of(2); + snapshot + } +} + +#[cfg(test)] +pub(crate) struct TestCarrierProgressSnapshot { + coherent: bool, + sequence: u64, + drives: u64, + phase: TestCarrierPhase, + observed_epoch: u64, + handled_epoch: Option, + remote_pending: bool, + admission_pressure: usize, + ready: usize, + incoming: usize, + pending_task: Option, + completions: usize, + in_flight: Option, +} + +#[cfg(test)] +impl std::fmt::Debug for TestCarrierProgressSnapshot { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CarrierProgress") + .field("coherent", &self.coherent) + .field("sequence", &self.sequence) + .field("drives", &self.drives) + .field("phase", &self.phase) + .field("observed_epoch", &self.observed_epoch) + .field("handled_epoch", &self.handled_epoch) + .field("remote_pending", &self.remote_pending) + .field("admission_pressure", &self.admission_pressure) + .field("ready", &self.ready) + .field("incoming", &self.incoming) + .field("pending_task", &self.pending_task) + .field("completions", &self.completions) + .field("in_flight", &self.in_flight) + .finish() + } } impl Signal { @@ -53,6 +226,12 @@ impl Signal { deadline: Option, mut ready: impl FnMut() -> bool, ) { + #[cfg(test)] + let hook = lock(&self.before_wait_hook).take(); + #[cfg(test)] + if let Some(hook) = hook { + hook(); + } let mut gate = lock(&self.gate); self.waiters.fetch_add(1, Ordering::SeqCst); while self.epoch.load(Ordering::SeqCst) == observed && !ready() { @@ -87,6 +266,11 @@ impl Signal { pub(crate) fn waiting(&self) -> usize { self.waiters.load(Ordering::SeqCst) } + + #[cfg(test)] + pub(crate) fn before_wait(&self, hook: impl FnOnce() + Send + 'static) { + *lock(&self.before_wait_hook) = Some(Box::new(hook)); + } } #[cfg(test)] diff --git a/crates/vthread/src/support_test.rs b/crates/vthread/src/support_test.rs index def198f..01c6c6d 100644 --- a/crates/vthread/src/support_test.rs +++ b/crates/vthread/src/support_test.rs @@ -1,8 +1,189 @@ +#[path = "admission_progress_test.rs"] +mod admission_progress_test; +use crate::control::Shared; +pub(crate) use admission_progress_test::{ + TestAdmissionPhase, TestAdmissionProgress, install_admission_progress, record_admission_phase, + record_admission_rejection, +}; use std::{ + io::{Read, Write}, + process::{Command, ExitStatus, Stdio}, + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, thread, time::{Duration, Instant}, }; +pub(crate) struct IsolatedOutput { + pub(crate) status: ExitStatus, + pub(crate) timed_out: bool, + pub(crate) stdout: Vec, + pub(crate) stderr: Vec, +} + +pub(crate) fn run_isolated(test: &str, child: (&str, &str), timeout: Duration) -> IsolatedOutput { + let mut process = Command::new(std::env::current_exe().expect("current test executable")) + .args(["--exact", test, "--nocapture", "--test-threads=1"]) + .env(child.0, child.1) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn isolated test"); + let stdout = drain(process.stdout.take().expect("child stdout")); + let stderr = drain(process.stderr.take().expect("child stderr")); + let deadline = Instant::now() + timeout; + let (status, timed_out) = loop { + if let Some(status) = process.try_wait().expect("poll isolated test") { + break (status, false); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + let status = match process.kill() { + Ok(()) => process.wait().expect("reap timed out test"), + Err(error) => process + .try_wait() + .expect("poll after failed kill") + .unwrap_or_else(|| panic!("kill timed out test: {error}")), + }; + break (status, true); + } + thread::park_timeout(remaining.min(Duration::from_millis(10))); + }; + IsolatedOutput { + status, + timed_out, + stdout: stdout.join().expect("stdout reader"), + stderr: stderr.join().expect("stderr reader"), + } +} + +fn drain(mut stream: impl Read + Send + 'static) -> thread::JoinHandle> { + thread::spawn(move || { + let mut output = Vec::new(); + stream.read_to_end(&mut output).expect("read child output"); + output + }) +} + +pub(crate) fn wait_without_intervention(duration: Duration) { + let deadline = Instant::now() + duration; + while Instant::now() < deadline { + thread::park_timeout(deadline.saturating_duration_since(Instant::now())); + } +} + +#[derive(Default)] +pub(crate) struct RefillCounters { + pub(crate) accepted: AtomicUsize, + pub(crate) started: AtomicUsize, + pub(crate) returned: AtomicUsize, + pub(crate) cleanup: AtomicBool, + pub(crate) admission: Arc, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct RefillBeforeStop { + pub(crate) accepted_begin: usize, + pub(crate) accepted_end: usize, + pub(crate) queued: usize, + pub(crate) started: usize, + pub(crate) body_returns: usize, + pub(crate) completed_credits: u64, + pub(crate) active: usize, +} + +pub(crate) fn observe_refill_passive(shared: &Shared, counters: &RefillCounters, phase: &str) { + let accepted = counters.accepted.load(Ordering::SeqCst); + let queued = shared.inboxes[0].pending(); + let started = counters.started.load(Ordering::SeqCst); + let body_returns = counters.returned.load(Ordering::SeqCst); + let epoch = shared.inboxes[0].signal.version(); + let waiting = shared.inboxes[0].signal.waiting(); + let cleanup = counters.cleanup.load(Ordering::SeqCst); + let carrier = shared.inboxes[0].signal.test_progress.snapshot(); + let producer = counters.admission.snapshot(); + assert!(!cleanup, "progress evidence captured after cleanup"); + let mut output = std::io::stdout().lock(); + writeln!( + output, + "refill-lock-free phase={phase} accepted={accepted} queued={queued} started={started} \ + body_returns={body_returns} epoch={epoch} waiting={waiting} cleanup={cleanup} \ + carrier={carrier:?} producer={producer:?}", + ) + .unwrap(); + output.flush().unwrap(); +} + +pub(crate) fn observe_refill_rich( + shared: &Shared, + scope: u64, + counters: &RefillCounters, +) -> RefillBeforeStop { + assert!( + !counters.cleanup.load(Ordering::SeqCst), + "progress evidence captured after cleanup" + ); + let accepted_begin = counters.accepted.load(Ordering::SeqCst); + let snapshot = shared.snapshot(); + let report = shared.scope_report(scope); + let before = RefillBeforeStop { + accepted_begin, + accepted_end: counters.accepted.load(Ordering::SeqCst), + queued: shared.inboxes[0].pending(), + started: counters.started.load(Ordering::SeqCst), + body_returns: counters.returned.load(Ordering::SeqCst), + completed_credits: report.completed, + active: snapshot.active, + }; + let mut output = std::io::stdout().lock(); + writeln!( + output, + "refill-before-stop progress={before:?} accepting={} epoch={} waiting={} \ + scope={report:?} carriers={:?}", + snapshot.accepting, + shared.inboxes[0].signal.version(), + shared.inboxes[0].signal.waiting(), + snapshot.carriers + ) + .unwrap(); + output.flush().unwrap(); + before +} + +#[test] +fn isolated_supervisor_bounds_stalled_child_and_preserves_output() { + const CHILD: &str = "VTHREAD_STALLED_CHILD"; + if std::env::var(CHILD).as_deref() == Ok("1") { + let payload = vec![b'x'; 128 * 1_024]; + { + let mut output = std::io::stdout().lock(); + output.write_all(&payload).unwrap(); + writeln!(output, "stdout-tail").unwrap(); + output.flush().unwrap(); + } + { + let mut error = std::io::stderr().lock(); + error.write_all(&payload).unwrap(); + writeln!(error, "stderr-tail").unwrap(); + error.flush().unwrap(); + } + loop { + thread::park_timeout(Duration::from_secs(1)); + } + } + let output = run_isolated( + "support_test::isolated_supervisor_bounds_stalled_child_and_preserves_output", + (CHILD, "1"), + Duration::from_secs(2), + ); + assert!(output.timed_out); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("stdout-tail")); + assert!(String::from_utf8_lossy(&output.stderr).contains("stderr-tail")); +} + pub(crate) fn until(mut condition: impl FnMut() -> bool) { let deadline = Instant::now() + Duration::from_secs(5); while !condition() { diff --git a/crates/vthread/src/sync/mutex_test.rs b/crates/vthread/src/sync/mutex_test.rs index cb23055..84f2902 100644 --- a/crates/vthread/src/sync/mutex_test.rs +++ b/crates/vthread/src/sync/mutex_test.rs @@ -1,6 +1,33 @@ use super::Mutex; use crate::{Error, Runtime, local_scope, yield_now}; -use std::{sync::Arc, thread}; +use std::{ + sync::{ + Arc, + atomic::{AtomicU8, Ordering}, + }, + thread, +}; + +const LOCK_UNSET: u8 = 0; +const LOCK_REJECTED: u8 = 1; +const LOCK_ACQUIRED: u8 = 2; +const LOCK_UNEXPECTED: u8 = 3; + +struct LockOnDrop { + mutex: Arc>, + outcome: Arc, +} + +impl Drop for LockOnDrop { + fn drop(&mut self) { + let outcome = match self.mutex.lock() { + Err(Error::SuspensionDuringPanic) => LOCK_REJECTED, + Ok(_) => LOCK_ACQUIRED, + Err(_) => LOCK_UNEXPECTED, + }; + self.outcome.store(outcome, Ordering::SeqCst); + } +} #[test] fn single_carrier_contention_is_fifo_and_guards_can_yield() { @@ -184,3 +211,61 @@ fn a_panicking_handed_off_guard_releases_its_fifo_successor() { }) .unwrap(); } + +#[test] +fn contended_lock_during_unwind_rejects_before_queue_publication() { + let runtime = Runtime::builder().carriers(1).build().unwrap(); + let mutex = Arc::new(Mutex::with_wait_capacity(0_usize, 1).unwrap()); + let outcome = Arc::new(AtomicU8::new(LOCK_UNSET)); + + runtime + .run_scope(|scope| { + let owner = mutex.try_lock().unwrap(); + let waiting_mutex = Arc::clone(&mutex); + let mut waiter = scope.spawn("queued mutex waiter", move || { + *waiting_mutex.lock()? += 1; + Ok::<_, Error>(()) + })?; + crate::support_test::until(|| mutex.waiting() == 1); + + let mut panicking = scope.spawn("panic-time mutex contender", { + let mutex = Arc::clone(&mutex); + let outcome = Arc::clone(&outcome); + move || { + let _lock_on_drop = LockOnDrop { mutex, outcome }; + panic!("expected mutex contender panic"); + } + })?; + assert!(matches!(panicking.join(), Err(Error::TaskPanicked { .. }))); + assert_eq!(outcome.load(Ordering::SeqCst), LOCK_REJECTED); + assert_eq!(mutex.waiting(), 1); + + drop(owner); + waiter.join()??; + + let successor_mutex = Arc::clone(&mutex); + scope + .spawn("mutex successor", move || { + *successor_mutex.lock().unwrap() = 52; + })? + .join()?; + Ok(()) + }) + .unwrap(); + + assert_eq!(outcome.load(Ordering::SeqCst), LOCK_REJECTED); + assert_eq!(mutex.waiting(), 0); + assert_eq!(*mutex.try_lock().unwrap(), 52); + let snapshot = runtime.snapshot(); + assert_eq!( + (snapshot.active, snapshot.parked, snapshot.timers), + (0, 0, 0) + ); + assert!( + snapshot + .carriers + .iter() + .all(|carrier| carrier.pending_starts == 0 && carrier.pending_wakes == 0) + ); + runtime.shutdown().unwrap(); +} diff --git a/crates/vthread/src/sync/wait.rs b/crates/vthread/src/sync/wait.rs index 33cc46f..dabd49e 100644 --- a/crates/vthread/src/sync/wait.rs +++ b/crates/vthread/src/sync/wait.rs @@ -19,6 +19,8 @@ impl Wait { context::MountedTask::Execution(execution) => execution, context::MountedTask::Cleanup { .. } => return Err(Error::OutsideVThread), }; + // Resource queues may publish this task before the common park path runs. + vthread_stack::check_suspend().map_err(Error::from)?; Ok(Self { previous: execution.data.replace_reason(reason), execution, diff --git a/crates/vthread/src/sync/wait_test.rs b/crates/vthread/src/sync/wait_test.rs index 82ecefd..af7fd6a 100644 --- a/crates/vthread/src/sync/wait_test.rs +++ b/crates/vthread/src/sync/wait_test.rs @@ -1,6 +1,29 @@ use super::Wait; +use std::sync::{ + Arc, + atomic::{AtomicU8, Ordering}, +}; + use crate::{Error, Runtime, SuspensionReason, context}; +const UNSET: u8 = 0; +const REJECTED: u8 = 1; +const ENTERED: u8 = 2; +const UNEXPECTED: u8 = 3; + +struct EnterWaitOnDrop(Arc); + +impl Drop for EnterWaitOnDrop { + fn drop(&mut self) { + let outcome = match Wait::enter_after_check(SuspensionReason::Mutex) { + Err(Error::SuspensionDuringPanic) => REJECTED, + Ok(_) => ENTERED, + Err(_) => UNEXPECTED, + }; + self.0.store(outcome, Ordering::SeqCst); + } +} + #[test] fn diagnostic_reason_is_nested_and_restored() { assert!(matches!( @@ -27,3 +50,22 @@ fn diagnostic_reason_is_nested_and_restored() { }) .unwrap(); } + +#[test] +fn panic_is_rejected_before_a_synchronization_wait_changes_task_state() { + let outcome = Arc::new(AtomicU8::new(UNSET)); + let task_outcome = Arc::clone(&outcome); + Runtime::new() + .unwrap() + .run_scope(|scope| { + let mut task = scope.spawn("panic wait", move || { + let _wait = EnterWaitOnDrop(task_outcome); + panic!("expected synchronization panic"); + })?; + assert!(matches!(task.join(), Err(Error::TaskPanicked { .. }))); + Ok(()) + }) + .unwrap(); + + assert_eq!(outcome.load(Ordering::SeqCst), REJECTED); +} diff --git a/crates/vthread/src/wait_hub.rs b/crates/vthread/src/wait_hub.rs index d498caf..ac05f5a 100644 --- a/crates/vthread/src/wait_hub.rs +++ b/crates/vthread/src/wait_hub.rs @@ -121,9 +121,17 @@ impl WaitHub { self.signal.notify(); } - pub(crate) fn wait(&self, observed: u64, deadline: Option) { - self.signal - .wait_while(observed, deadline, || self.ready.arm_wait()); + pub(crate) fn wait_while( + &self, + observed: u64, + deadline: Option, + mut external_ready: impl FnMut() -> bool, + ) { + // Register with Signal before rechecking both bounded queues. A later + // publisher must either precede this predicate or observe the waiter. + self.signal.wait_while(observed, deadline, || { + self.ready.arm_wait() || external_ready() + }); self.ready.disarm_wait(); } diff --git a/crates/vthread/src/wait_hub_test.rs b/crates/vthread/src/wait_hub_test.rs index eca7dfd..25673e3 100644 --- a/crates/vthread/src/wait_hub_test.rs +++ b/crates/vthread/src/wait_hub_test.rs @@ -44,7 +44,7 @@ fn queued_wakes_release_predicate_waiters_without_advancing_the_epoch() { let empty = signal.version(); std::thread::scope(|threads| { threads.spawn(|| { - hub.wait(empty, None); + hub.wait_while(empty, None, || false); }); while signal.waiting() == 0 { std::thread::yield_now(); diff --git a/crates/vthread/src/wait_publication_test.rs b/crates/vthread/src/wait_publication_test.rs index d3265c0..8024f27 100644 --- a/crates/vthread/src/wait_publication_test.rs +++ b/crates/vthread/src/wait_publication_test.rs @@ -200,7 +200,9 @@ fn a_registered_native_waiter_receives_the_notice_before_claim_completion() { let wait_hub = Arc::clone(&hub); let wait_cell = &cell; let recipient = threads.spawn(move || { - wait_hub.wait(epoch, Some(Instant::now() + Duration::from_secs(5))); + wait_hub.wait_while(epoch, Some(Instant::now() + Duration::from_secs(5)), || { + false + }); let notice = wait_hub.pop_wake().expect("published wake"); let _ = dequeued_tx.send(notice); let _ = finish_rx.recv(); diff --git a/crates/vthread/src/worker_context.rs b/crates/vthread/src/worker_context.rs index 9b5a057..d0aa05f 100644 --- a/crates/vthread/src/worker_context.rs +++ b/crates/vthread/src/worker_context.rs @@ -9,7 +9,7 @@ use std::{ thread_local! { static MANAGED: Cell = const { Cell::new(false) }; static OWNER: RefCell, ThreadComponent)>> = const { RefCell::new(None) }; - #[cfg(feature = "runtime-evidence")] + #[cfg(any(test, feature = "runtime-evidence"))] static CARRIER: Cell> = const { Cell::new(None) }; } @@ -48,12 +48,12 @@ pub(crate) fn is_managed() -> bool { MANAGED.try_with(Cell::get).unwrap_or(true) } -#[cfg(feature = "runtime-evidence")] +#[cfg(any(test, feature = "runtime-evidence"))] pub(crate) fn set_carrier(id: crate::CarrierId) { CARRIER.with(|carrier| carrier.set(Some(id))); } -#[cfg(feature = "runtime-evidence")] +#[cfg(any(test, feature = "runtime-evidence"))] pub(crate) fn current_carrier() -> Option { CARRIER.try_with(Cell::get).ok().flatten() } diff --git a/crates/vthread/tests/inbox_liveness.rs b/crates/vthread/tests/inbox_liveness.rs new file mode 100644 index 0000000..9178778 --- /dev/null +++ b/crates/vthread/tests/inbox_liveness.rs @@ -0,0 +1,239 @@ +//! Production-shaped admission liveness regressions with external cleanup watchdogs. + +use std::{ + io::{Read, Write}, + panic::{AssertUnwindSafe, catch_unwind}, + process::{Command, Stdio}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + thread, + time::{Duration, Instant}, +}; +use vthread::{Error, Runtime, error::CapacityResource}; +use vthread_stack::{FiberState, MappedStack, fiber_scope}; + +const TASKS: usize = 4_096; +const WATCHDOG: Duration = Duration::from_secs(5); +const REFILL_CHILD: &str = "VTHREAD_PRODUCTION_REFILL_CHILD"; +const PANIC_CHILD: &str = "VTHREAD_LOCAL_ADMISSION_PANIC_CHILD"; + +#[test] +fn production_shaped_coalesced_inbox_refill_completes() { + if std::env::var(REFILL_CHILD).as_deref() != Ok("1") { + supervise( + "production_shaped_coalesced_inbox_refill_completes", + REFILL_CHILD, + Duration::from_secs(25), + ); + return; + } + let runtime = Runtime::builder() + .carriers(1) + .max_vthreads(TASKS) + .carrier_queue_capacity(256) + .build() + .unwrap(); + let accepted = Arc::new(AtomicUsize::new(0)); + let returned = Arc::new(AtomicUsize::new(0)); + let retries = Arc::new(AtomicUsize::new(0)); + runtime + .run_scope(|scope| { + let spawner = scope.spawner(); + let producer_accepted = Arc::clone(&accepted); + let returned = Arc::clone(&returned); + let producer_retries = Arc::clone(&retries); + thread::scope(|threads| { + let (submitted, submitted_rx) = mpsc::sync_channel(1); + let producer_returned = Arc::clone(&returned); + let producer = threads.spawn(move || { + let mut handles = Vec::with_capacity(TASKS); + for index in 0..TASKS { + loop { + let returned = Arc::clone(&producer_returned); + match spawner.spawn(format!("refill-{index}"), move || { + returned.fetch_add(1, Ordering::Release); + }) { + Ok(handle) => { + handles.push(handle); + producer_accepted.fetch_add(1, Ordering::Release); + break; + } + Err(Error::Capacity { + resource: CapacityResource::CarrierQueue, + .. + }) => { + producer_retries.fetch_add(1, Ordering::Relaxed); + thread::yield_now(); + } + Err(error) => panic!("refill admission failed: {error}"), + } + } + } + let _ = submitted.send(handles); + }); + let admission = submitted_rx.recv_timeout(WATCHDOG); + if admission.is_err() { + record_progress("admission-deadline", &accepted, &returned, &retries); + wait_without_intervention(Duration::from_secs(1)); + record_progress("admission-no-intervention", &accepted, &returned, &retries); + } + let mut handles = admission.expect("refill admission deadline"); + let deadline = Instant::now() + WATCHDOG; + while returned.load(Ordering::Acquire) != TASKS && Instant::now() < deadline { + thread::yield_now(); + } + let completed_on_time = returned.load(Ordering::Acquire) == TASKS; + record_progress("completion-deadline", &accepted, &returned, &retries); + if !completed_on_time { + wait_without_intervention(Duration::from_secs(1)); + record_progress("completion-no-intervention", &accepted, &returned, &retries); + } + assert!(completed_on_time, "refill body completion deadline"); + for handle in &mut handles { + handle.join().expect("refill task"); + } + producer.join().expect("refill producer"); + }); + Ok(()) + }) + .unwrap(); + assert_eq!(returned.load(Ordering::Acquire), TASKS); + runtime.shutdown().unwrap(); +} + +#[test] +fn local_admission_panic_is_rolled_back() { + const RUNTIME_STACK: usize = 64 * 1024; + if std::env::var(PANIC_CHILD).as_deref() != Ok("1") { + supervise( + "local_admission_panic_is_rolled_back", + PANIC_CHILD, + Duration::from_secs(30), + ); + return; + } + let runtime = Runtime::builder() + .carriers(1) + .stack_size(RUNTIME_STACK) + .max_vthreads(2) + .carrier_queue_capacity(2) + .stack_cache_capacity(0) + .build() + .unwrap(); + runtime + .run_scope(|root| { + root.spawn("parent", || { + vthread::local_scope(|local| { + fiber_scope(1, |helpers| { + let stack = MappedStack::new(16 * 1024 * 1024, 0).unwrap(); + let helper = helpers + .spawn(stack, || { + let capture = [7u8; 128 * 1024]; + let entry = move || std::hint::black_box(capture); + assert!(std::mem::size_of_val(&entry) > RUNTIME_STACK); + match catch_unwind(AssertUnwindSafe(|| { + local.spawn("oversized-entry", entry) + })) { + Ok(Err(_)) => {} + Err(payload) => { + let message = payload + .downcast_ref::() + .map(String::as_str) + .or_else(|| payload.downcast_ref::<&str>().copied()) + .unwrap_or("non-string panic"); + assert!(message.contains("fiber entry does not fit")); + } + Ok(Ok(_)) => panic!("oversized entry was accepted"), + } + }) + .unwrap(); + assert!(matches!(helper.resume(), Some(FiberState::Complete))); + }); + assert_eq!(local.spawn("after-rejected-entry", || 52)?.join()?, 52); + Ok(()) + }) + .unwrap(); + })? + .join()?; + Ok(()) + }) + .unwrap(); + let snapshot = runtime.snapshot(); + assert_eq!((snapshot.active(), snapshot.stats().admitted()), (0, 2)); + assert_eq!(snapshot.stats().rejected(), 1); + runtime.shutdown().unwrap(); +} + +fn record_progress( + phase: &str, + accepted: &AtomicUsize, + returned: &AtomicUsize, + retries: &AtomicUsize, +) { + let mut output = std::io::stdout().lock(); + writeln!( + output, + "production-refill phase={phase} accepted={} returned={} retries={}", + accepted.load(Ordering::Acquire), + returned.load(Ordering::Acquire), + retries.load(Ordering::Acquire), + ) + .unwrap(); + output.flush().unwrap(); +} + +fn wait_without_intervention(duration: Duration) { + let deadline = Instant::now() + duration; + while Instant::now() < deadline { + thread::park_timeout(deadline.saturating_duration_since(Instant::now())); + } +} + +fn supervise(name: &str, child_flag: &str, timeout: Duration) { + let mut child = Command::new(std::env::current_exe().expect("test executable")) + .args(["--exact", name, "--nocapture", "--test-threads=1"]) + .env(child_flag, "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn production-shaped refill"); + let stdout = drain(child.stdout.take().expect("child stdout")); + let stderr = drain(child.stderr.take().expect("child stderr")); + let deadline = Instant::now() + timeout; + let (status, timed_out) = loop { + if let Some(status) = child.try_wait().expect("poll refill child") { + break (status, false); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + let status = match child.kill() { + Ok(()) => child.wait().expect("reap refill child"), + Err(error) => child + .try_wait() + .expect("poll after failed kill") + .unwrap_or_else(|| panic!("kill refill child: {error}")), + }; + break (status, true); + } + thread::park_timeout(remaining.min(Duration::from_millis(10))); + }; + let stdout = stdout.join().expect("stdout reader"); + let stderr = stderr.join().expect("stderr reader"); + assert!( + !timed_out && status.success() && String::from_utf8_lossy(&stdout).contains("1 passed"), + "isolated admission test failed: status={status:?} timed_out={timed_out}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&stdout), + String::from_utf8_lossy(&stderr), + ); +} + +fn drain(mut pipe: impl Read + Send + 'static) -> thread::JoinHandle> { + thread::spawn(move || { + let mut output = Vec::new(); + pipe.read_to_end(&mut output).expect("read child output"); + output + }) +} diff --git a/crates/vthreads/Cargo.toml b/crates/vthreads/Cargo.toml index 8995cc1..320fed8 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" } +vthread = { path = "../vthread", version = "=0.1.0-rc.2" } [lints] workspace = true diff --git a/crates/vthreads/README.md b/crates/vthreads/README.md index 6a78492..c4aab5e 100644 --- a/crates/vthreads/README.md +++ b/crates/vthreads/README.md @@ -9,11 +9,12 @@ 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/main/RELEASE.md) +See [release notes](https://github.com/zsumz/vthread/blob/v0.1.0-rc.2/RELEASE.md) for verification coverage and known limitations. -This alias follows vthread's early-development compatibility policy: compatible -public API updates within `0.1.x`, breaking API or contract changes in `0.2`. +This release candidate follows vthread's pre-1.0 compatibility policy. 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`. ## Using the alias @@ -21,7 +22,7 @@ Add the alias to your project: ```toml [dependencies] -vthreads = "0.1" +vthreads = "=0.1.0-rc.2" ``` ```rust diff --git a/reference/Cargo.lock b/reference/Cargo.lock index 3a49810..bf52df5 100644 --- a/reference/Cargo.lock +++ b/reference/Cargo.lock @@ -70,7 +70,7 @@ dependencies = [ [[package]] name = "vthread" -version = "0.1.0" +version = "0.1.0-rc.2" dependencies = [ "crossbeam-queue", "libc", @@ -82,21 +82,21 @@ dependencies = [ [[package]] name = "vthread-reference" -version = "0.1.0" +version = "0.1.0-rc.2" dependencies = [ "vthread", ] [[package]] name = "vthread-stack" -version = "0.1.0" +version = "0.1.0-rc.2" dependencies = [ "libc", ] [[package]] name = "vthread-sync-core" -version = "0.1.0" +version = "0.1.0-rc.2" [[package]] name = "windows-link" diff --git a/reference/Cargo.toml b/reference/Cargo.toml index ecfcd1d..175ba6d 100644 --- a/reference/Cargo.toml +++ b/reference/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vthread-reference" -version = "0.1.0" +version = "0.1.0-rc.2" 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" } +vthread = { path = "../crates/vthread", version = "=0.1.0-rc.2" } [profile.release] panic = "unwind" diff --git a/reference/README.md b/reference/README.md index 006fdf4..9ecfd82 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" } +vthread = { path = "../vthread/crates/vthread", version = "=0.1.0-rc.1" } ``` - Give the runtime one application-level owner. Use scopes for requests and operations, diff --git a/scripts/fixtures/release/main.rs b/scripts/fixtures/release/main.rs new file mode 100644 index 0000000..0407687 --- /dev/null +++ b/scripts/fixtures/release/main.rs @@ -0,0 +1,9 @@ +use subject as vthread; + +fn main() -> vthread::Result<()> { + vthread::run(|scope| { + let mut answer = scope.spawn("answer", || 52)?; + assert_eq!(answer.join()?, 52); + Ok(()) + }) +} diff --git a/scripts/guardrail-policy.py b/scripts/guardrail-policy.py index 66eab58..417c807 100644 --- a/scripts/guardrail-policy.py +++ b/scripts/guardrail-policy.py @@ -139,7 +139,7 @@ def check_history_performance(errors: list[str], tasks: dict) -> None: errors.append("perf-cancellation-history must retain its explicit optimized timing guard") -def check_release_qualification(errors: list[str], workflow: str) -> None: +def check_application_qualification(errors: list[str], workflow: str) -> None: job = workflow.partition(" qualification:\n")[2] job = re.split(r"^ \S", job, maxsplit=1, flags=re.MULTILINE)[0] steps = {} @@ -151,26 +151,13 @@ def check_release_qualification(errors: list[str], workflow: str) -> None: expected = ( "python3 scripts/run-application.py --out .qualification/application " "--offered-rates 2000 --offered-count 256 " - '--context "GitHub release qualification ${{ matrix.target }}"' + '--context "GitHub application qualification ${{ matrix.target }}"' ) if command != expected or " if:" in application: - errors.append("release qualification must retain the full application and offered-load matrix") - package = steps.get("Verify workspace packages", "") - expected_package = ( - " shell: bash\n" - " run: |\n" - " set -euo pipefail\n" - " mkdir -p .qualification/package\n" - " cargo package --locked --offline --workspace --exclude vthread-lab " - "2>&1 | tee .qualification/package/verification.log\n" - ) - if (package != expected_package or "Qualify application" not in steps - or list(steps).index("Verify workspace packages") <= list(steps).index("Qualify application")): - errors.append("release qualification must verify workspace packages offline after the application") + errors.append("application qualification must retain the full application and offered-load matrix") upload = steps.get("Upload qualification evidence", "") - if (" .qualification/\n" not in upload - or " ${{ env.CARGO_TARGET_DIR }}/package/*.crate\n" not in upload): - errors.append("release qualification must upload application evidence, package logs and archives") + if " path: .qualification/\n" not in upload: + errors.append("application qualification must upload its evidence") def main() -> int: @@ -183,7 +170,7 @@ def main() -> int: check_native_qualification(errors, config["tasks"]) check_history_performance(errors, config["tasks"]) check_benchmark_qualification(errors, config["tasks"]) - check_release_qualification(errors, (ROOT / ".github/workflows/ci.yml").read_text()) + check_application_qualification(errors, (ROOT / ".github/workflows/ci.yml").read_text()) check_blocking_boundaries(errors) if errors: diff --git a/scripts/guardrail_policy_test.py b/scripts/guardrail_policy_test.py index e19ea5c..7b28622 100644 --- a/scripts/guardrail_policy_test.py +++ b/scripts/guardrail_policy_test.py @@ -116,16 +116,16 @@ def test_optional_or_unordered_harness_is_rejected(self): self.assertTrue(self.errors(tasks)) -class ReleaseQualificationTests(unittest.TestCase): +class ApplicationQualificationTests(unittest.TestCase): def setUp(self): self.workflow = (ROOT / ".github/workflows/ci.yml").read_text() def errors(self, workflow): errors = [] - POLICY.check_release_qualification(errors, workflow) + POLICY.check_application_qualification(errors, workflow) return errors - def test_current_release_qualification_is_required(self): + def test_current_application_qualification_is_required(self): self.assertEqual(self.errors(self.workflow), []) def test_missing_or_reduced_offered_load_is_rejected(self): @@ -134,23 +134,8 @@ def test_missing_or_reduced_offered_load_is_rejected(self): with self.subTest(argument=old, replacement=new): self.assertTrue(self.errors(self.workflow.replace(old, new))) - def test_missing_or_unverified_package_is_rejected(self): - for old, new in (("Verify workspace packages", "Skip workspace packages"), - ("--locked --offline --workspace", "--locked --workspace"), - ("--exclude vthread-lab", "--exclude vthread-lab --no-verify")): - with self.subTest(argument=old, replacement=new): - self.assertTrue(self.errors(self.workflow.replace(old, new))) - - def test_packages_must_follow_application(self): - application = self.workflow.index(" - name: Qualify application\n") - package = self.workflow.index(" - name: Verify workspace packages\n") - upload = self.workflow.index(" - name: Upload qualification evidence\n") - reordered = (self.workflow[:application] + self.workflow[package:upload] - + self.workflow[application:package] + self.workflow[upload:]) - self.assertTrue(self.errors(reordered)) - - def test_package_archives_must_be_uploaded(self): - old = " ${{ env.CARGO_TARGET_DIR }}/package/*.crate\n" + def test_application_evidence_must_be_uploaded(self): + old = " path: .qualification/\n" self.assertTrue(self.errors(self.workflow.replace(old, ""))) def test_missing_application_or_job_is_rejected(self): diff --git a/zrail.lock b/zrail.lock index 75f2124..772d722 100644 --- a/zrail.lock +++ b/zrail.lock @@ -6,19 +6,19 @@ contract_sha256 = "8603e24df9addfcc603707c15027481b9ad5e11ada455e48eba1fb9de67b5 ratchet = [] [analysis] -inventory_sha256 = "85f0a37a2871036cf23d10688a0e18160bbdfe2b748611a3edd435562fc6f132" +inventory_sha256 = "b70addaf8781407e794eb5badd031f374f1c1f4973967a134cbf240c17610011" exclusions_sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" -cargo_lock_sha256 = "a0a97f71a1af7fbef4d36407ee2f10b2fadcd74556aaac2968b652d1b78c5c24" +cargo_lock_sha256 = "2a3abfb8cc9ad5722f1fb0a76f557cb3c03b4bfdfe804f6255ace68b3b20fb4a" cargo_features_sha256 = "3e43f336e44215719c080510ba8ce053576e4fa44eab81b200117ebcbf4d3d64" feature_worlds_sha256 = "6edfb4a1172e614d373d2aba49cdfa4e544769ce212a82805cc63e4e3b99d116" feature_worlds = 5 packages = 5 -targets = 10 -physical_rust_files = 431 -base_source_contexts = 3007 +targets = 11 +physical_rust_files = 439 +base_source_contexts = 3047 derived_source_contexts = 0 -source_facts = 146326 -projection_queries = 4147131 +source_facts = 155997 +projection_queries = 4355601 projected_facts = 29 unresolved_bindings = 0 analyzer_semantics = 4 @@ -78,7 +78,7 @@ default_features = true [package.dependency.source] kind = "workspace-member" directory = "crates/vthread-stack" -requirement = "=0.1.0" +requirement = "=0.1.0-rc.2" [[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" +requirement = "=0.1.0-rc.2" [[package.dependency]] alias = "zio" @@ -121,7 +121,7 @@ default_features = true [package.dependency.source] kind = "workspace-member" directory = "crates/vthread" -requirement = "=0.1.0" +requirement = "=0.1.0-rc.2" [[package]] name = "vthread-stack" @@ -168,4 +168,4 @@ default_features = true [package.dependency.source] kind = "workspace-member" directory = "crates/vthread" -requirement = "=0.1.0" +requirement = "=0.1.0-rc.2"