Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/renovate.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"packageRules": [
{
"description": "A numeric Rust toolchain is the MSRV, which follows rust-version in Cargo.toml and moves only with an ADR (ADR 0018).",
"matchDepNames": ["rust"],
"matchCurrentValue": "/^\\d+\\.\\d+(\\.\\d+)?$/",
"enabled": false
}
]
}
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: "1.75"
toolchain: "1.84"
- uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: nightly-2026-07-02
Expand Down Expand Up @@ -52,7 +52,7 @@ jobs:
fail-fast: false
matrix:
os: [windows-2022, windows-latest]
rust: [stable, "1.75"]
rust: [stable, "1.84"]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ The 0.2 API follows `std::process` and places each choice on the type that owns
- `Job::assign` takes any process handle.
- `Debug` prints a `Command` as std does.
- `SuspendedChild::id` is `const`, like `Child::id`, and returns the identifier recorded at creation, so it cannot panic.
- The MSRV is Rust 1.84, as ADR 0018 records.
- `Command::own_process_tree`, `console`, `priority`, `mitigation`, and the creation-flag setters are `const fn`.

### Removed

Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ A change to ownership, handle inheritance, Job lifetime, ConPTY, mitigation, quo
## Development

Tests require Windows 10 version 1809 or later.
The crate supports Rust 1.75 and later.
The crate supports Rust 1.84 and later.
With the tool versions CI uses installed, run:

```powershell
Expand Down Expand Up @@ -67,7 +67,7 @@ cargo +nightly fuzz run environment-block -- -max_total_time=600 -max_len=16384
- Keep the documented ownership and cleanup behavior, including on errors.
- Give every `unsafe` block a specific safety justification.
- Write only doc comments and `// SAFETY:` comments; the reason for a change goes in its commit message (ADR 0013).
- Register every `#[allow]` in `xtask/src/gates.rs` with its reason; `just gates` checks the registry and the other repository laws.
- Write a lint exception as `#[expect(lint, reason = "…")]` on the one item it concerns; `just gates` refuses every `allow` and checks the other repository laws (ADR 0018).
- Add deterministic tests for behavior changes; tests wait for events, never for time (ADR 0010).
- Change the public API snapshot only with an API change, and state its compatibility impact.
- Update the crate docs, ADRs, or security boundary when a contract changes.
Expand Down
7 changes: 5 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "windows-spawn"
version = "0.1.0"
authors = ["Yasunobu Sakashita"]
edition = "2021"
rust-version = "1.75"
rust-version = "1.84"
license = "MIT OR Apache-2.0"
description = "Windows process creation with explicit handle, Job, mitigation, ConPTY, and child ownership"
documentation = "https://docs.rs/windows-spawn"
Expand Down Expand Up @@ -62,7 +62,7 @@ features = ["Win32_System_LibraryLoader", "Win32_UI_Shell"]
[workspace]
members = ["xtask"]
default-members = ["."]
resolver = "2"
resolver = "3"

[workspace.lints.rust]
future_incompatible = { level = "deny", priority = -1 }
Expand All @@ -80,6 +80,7 @@ trivial_numeric_casts = "deny"
unreachable_pub = "deny"
unsafe_op_in_unsafe_fn = "deny"
unused_lifetimes = "deny"
unfulfilled_lint_expectations = "deny"
unused_qualifications = "deny"
variant_size_differences = "deny"
unexpected_cfgs = { level = "deny", check-cfg = ["cfg(fuzzing)", "cfg(kani)", "cfg(windows_spawn_vectors)"] }
Expand All @@ -89,6 +90,8 @@ all = { level = "deny", priority = -1 }
cargo = { level = "deny", priority = -1 }
nursery = { level = "deny", priority = -1 }
pedantic = { level = "deny", priority = -1 }
allow_attributes = "deny"
allow_attributes_without_reason = "deny"
arithmetic_side_effects = "deny"
as_conversions = "deny"
clone_on_ref_ptr = "deny"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ process trees that end with their owner, Jobs, pseudoconsoles, handle handoff, s
Use `std::process::Command` for portable child processes.
Use this crate when a Windows program needs one of these, or a spawn that passes the child no stray handle and releases everything when a step fails.

Requires Windows 10 version 1809 or later and Rust 1.75 or later.
Requires Windows 10 version 1809 or later and Rust 1.84 or later.
Non-Windows targets expose no API.

Not affiliated with Microsoft or `windows-rs`; `windows-` names the target platform.
Expand Down
16 changes: 6 additions & 10 deletions docs/adr/0013-repository-laws-are-enforced-mechanically.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# 0013 — Repository laws are enforced mechanically

Status: accepted (2026-09-25); ADR 0016 extends the trait-object rule to every `dyn`
Status: accepted (2026-09-25); ADR 0016 extends the trait-object rule to every `dyn`, and ADR 0018 replaces the exception registry with `#[expect]`

## Context

Rules kept by review alone erode: timed waits, explanatory comments, and silent conversions came back after they were removed.
Rust 1.75, the MSRV, has neither `#[expect]` nor `reason =`, so a lint exception can neither state its reason nor prove it is still needed.
Rust 1.75, the MSRV then, had neither `#[expect]` nor `reason =`, so a lint exception could neither state its reason nor prove it was still needed.

## Decision

Expand All @@ -19,9 +19,8 @@ Every law has a mechanism, and CI runs it.
`cargo xtask gates` bans time-named identifiers, delay commands in strings, and `WaitForSingleObject` with a bound other than `INFINITE` or `0`.
- Reasons belong in commit messages.
The gate allows doc comments and whole-line `// SAFETY:` runs, and nothing else.
- Every lint exception is registered.
The gate collects each `#[allow]` lint and requires an entry with the same file, lint, and count, with a reason, in `xtask/src/gates.rs`.
An entry no attribute uses also fails.
- Every lint exception states its reason and expires (ADR 0018).
It is an `#[expect(…, reason = …)]` on the one item it concerns, and the gate refuses every `allow`.
- Defaults are chosen explicitly.
The gate bans `#[default]`; a documented `impl Default` states the choice.
- Types are closed or generic.
Expand All @@ -30,12 +29,9 @@ Every law has a mechanism, and CI runs it.
`clippy.toml` bans `mem::forget`, `process::exit`, `env::set_var`, and `env::remove_var`.

The gate is a lexer in xtask with no dependencies, because `syn` needs `unicode-ident`, whose license `deny.toml` does not allow.
Rustc lints are limited to those Rust 1.75 recognizes.
Rustc lints are limited to those the MSRV recognizes.

## Consequences

- Changing a law changes the lint table, `clippy.toml`, or the gate, with its tests.
- Reading a handle's value keeps `as` behind a registered exception in `sys`, because Rust 1.75 has no pointer `addr`.
A value becomes a handle through a byte offset from null.
A `DWORD` width converts through its bytes in `domain::conversion_rules`, where Kani proves each conversion for every input.
- When the MSRV reaches Rust 1.81, `#[expect(…, reason = …)]` replaces the registry.
- A conversion that `From` lacks goes through bytes or a pointer's address in `domain::conversion_rules`, where Kani proves it for every input, so no `as` remains.
2 changes: 1 addition & 1 deletion docs/adr/0016-spawn-state-is-explicit-and-typed.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Main dropped #14, and with it those rules.

## Consequences

- The registry holds no `expect_used` or `too_many_lines` exception for `src/child.rs` or `src/transaction.rs`.
- No lint exception covers `expect_used` or `too_many_lines` in `src/child.rs` or `src/transaction.rs`.
- A handle of the wrong kind, a value of the wrong table, or an attribute value that dies before its list fails to compile.
- Concurrency in the library needs a typed boundary and a model before it can pass the gate.
- The output readers of `Child::wait_with_output` stay threads that own what they read, so they share no state.
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 0018 — Lint exceptions expire, and the MSRV is Rust 1.84

Status: accepted (2026-10-13)

## Context

Rust 1.75 has neither `#[expect]` nor `reason =`, so ADR 0013 registered every `#[allow]` in `xtask/src/gates.rs` with a reason and a count.
A registered exception could still outlive its need, and three remained: the `unsafe_code` of `sys`, the `as` that read a handle's value, and a probe's `process::exit`.
Rust 1.75 also has no pointer `addr`, so reading a handle's value took `as`, and the FFI calls borrowed each local as a reference before it became a pointer.
Rust 1.84 has `#[expect]` with `reason =`, `addr` and `ptr::without_provenance_mut`, `&raw mut`, and a resolver that keeps dependencies within the MSRV.

## Decision

- The MSRV is Rust 1.84.
- Every lint exception is an `#[expect(lint, reason = "…")]` on the one item it concerns.
Clippy's `allow_attributes` and `allow_attributes_without_reason` refuse an `#[allow]` and an exception without a reason.
`unfulfilled_lint_expectations` fails an exception the code no longer needs.
The gate refuses every `allow` it reads, including the inner attributes that Clippy skips, and the registry goes.
- A handle converts through its address alone.
`addr` reads its value, and `ptr::without_provenance_mut` makes one from a value, so Kani proves the round trip for every `isize`.
- An FFI call takes a pointer to a local as `&raw mut` or `&raw const`, which makes the pointer without an intermediate reference, as Clippy's `borrow_as_ptr` requires.
- The workspace uses resolver 3, so `cargo update` keeps the dependencies within the MSRV.
- `.github/renovate.json` keeps Renovate off the numeric Rust toolchain in CI, which is the MSRV.
The organization's preset automerges minor updates, and its open update moves the MSRV check from Rust 1.75 to the newest release.

Rejected:

- Rust 1.75 and its registry, which can't tell when an exception stops applying.
- Rust 1.81, which has `#[expect]` but not `addr`, so reading a handle's value would keep `as`.

## Consequences

- A project on a Rust older than 1.84 stays on windows-spawn 0.1.
- The `Command` setters that only record a choice are `const fn`, which Rust 1.83 allows for `&mut self`.
- No `allow` remains in the repository.
2 changes: 2 additions & 0 deletions docs/migration-0.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
Most programs change in three places: the error type, the tree policy, and the creation flags.
[ADR 0015](adr/0015-the-api-follows-std-and-ownership.md) records why.

0.2 requires Rust 1.84, where 0.1 required Rust 1.75; [ADR 0018](adr/0018-lint-exceptions-expire-and-the-msrv-is-rust-1-84.md) records why.

## Errors

Fallible calls return `windows_spawn::Result<T>`, whose error is `SpawnError`, instead of `std::io::Result<T>`.
Expand Down
2 changes: 1 addition & 1 deletion proofs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Each layer runs a pinned tool, accepts only the complete expected result, and ru

| Layer | Tool | What it proves | How it reaches the code |
| --- | --- | --- | --- |
| `kani` | Kani 0.68.0 | The decision cores and `DWORD` conversions in `src/domain` and the mitigation layout, for every input within each harness's bounds | It runs the crate's own functions |
| `kani` | Kani 0.68.0 | The decision cores and Win32 conversions in `src/domain` and the mitigation layout, for every input within each harness's bounds | It runs the crate's own functions |
| `lean` | Lean 4.35.0 | Both C runtime parsers read back every command line the encoder writes, for arguments of every length | Vectors from the proved model, which the Rust tests and the Windows parsers check |
| `verus` | Verus 0.2026.10.04 | The encoder writes what the Lean model writes, for every argument | A copy that must equal the crate's functions token for token |
| `tla` | `TLC` 1.7.4 | `Child::wait_with_output` returns with every byte, and each of two plausible mistakes deadlocks or never returns | A model of the protocol |
Expand Down
18 changes: 9 additions & 9 deletions public-api/windows-spawn.txt
Original file line number Diff line number Diff line change
Expand Up @@ -686,26 +686,26 @@ impl windows_spawn::Command
pub fn windows_spawn::Command::arg<S: core::convert::AsRef<std::ffi::os_str::OsStr>>(&mut self, S) -> &mut Self
pub fn windows_spawn::Command::arg_handle<T: std::os::windows::io::handle::AsHandle>(&mut self, &T) -> windows_spawn::Result<&mut Self>
pub fn windows_spawn::Command::args<I, S>(&mut self, I) -> &mut Self where I: core::iter::traits::collect::IntoIterator<Item = S>, S: core::convert::AsRef<std::ffi::os_str::OsStr>
pub fn windows_spawn::Command::breakaway_from_job(&mut self) -> &mut Self
pub fn windows_spawn::Command::console(&mut self, windows_spawn::ConsoleMode) -> &mut Self
pub const fn windows_spawn::Command::breakaway_from_job(&mut self) -> &mut Self
pub const fn windows_spawn::Command::console(&mut self, windows_spawn::ConsoleMode) -> &mut Self
pub fn windows_spawn::Command::current_dir<P: core::convert::AsRef<std::path::Path>>(&mut self, P) -> &mut Self
pub fn windows_spawn::Command::default_error_mode(&mut self) -> &mut Self
pub const fn windows_spawn::Command::default_error_mode(&mut self) -> &mut Self
pub fn windows_spawn::Command::env<K, V>(&mut self, K, V) -> &mut Self where K: core::convert::AsRef<std::ffi::os_str::OsStr>, V: core::convert::AsRef<std::ffi::os_str::OsStr>
pub fn windows_spawn::Command::env_clear(&mut self) -> &mut Self
pub fn windows_spawn::Command::env_handle<K: core::convert::AsRef<std::ffi::os_str::OsStr>, T: std::os::windows::io::handle::AsHandle>(&mut self, K, &T) -> windows_spawn::Result<&mut Self>
pub fn windows_spawn::Command::env_remove<K: core::convert::AsRef<std::ffi::os_str::OsStr>>(&mut self, K) -> &mut Self
pub fn windows_spawn::Command::envs<I, K, V>(&mut self, I) -> &mut Self where I: core::iter::traits::collect::IntoIterator<Item = (K, V)>, K: core::convert::AsRef<std::ffi::os_str::OsStr>, V: core::convert::AsRef<std::ffi::os_str::OsStr>
pub fn windows_spawn::Command::get_current_dir(&self) -> core::option::Option<&std::path::Path>
pub fn windows_spawn::Command::get_program(&self) -> &std::ffi::os_str::OsStr
pub fn windows_spawn::Command::inherit_parent_affinity(&mut self) -> &mut Self
pub fn windows_spawn::Command::mitigation(&mut self, windows_spawn::MitigationPolicy) -> &mut Self
pub const fn windows_spawn::Command::inherit_parent_affinity(&mut self) -> &mut Self
pub const fn windows_spawn::Command::mitigation(&mut self, windows_spawn::MitigationPolicy) -> &mut Self
pub fn windows_spawn::Command::new<S: core::convert::AsRef<std::ffi::os_str::OsStr>>(S) -> Self
pub fn windows_spawn::Command::new_process_group(&mut self) -> &mut Self
pub const fn windows_spawn::Command::new_process_group(&mut self) -> &mut Self
pub fn windows_spawn::Command::output(&mut self) -> windows_spawn::Result<std::process::Output>
pub fn windows_spawn::Command::output_with(&mut self, windows_spawn::SpawnOptions<'_>) -> windows_spawn::Result<std::process::Output>
pub fn windows_spawn::Command::own_process_tree(&mut self) -> &mut Self
pub fn windows_spawn::Command::preserve_code_authorization(&mut self) -> &mut Self
pub fn windows_spawn::Command::priority(&mut self, windows_spawn::PriorityClass) -> &mut Self
pub const fn windows_spawn::Command::own_process_tree(&mut self) -> &mut Self
pub const fn windows_spawn::Command::preserve_code_authorization(&mut self) -> &mut Self
pub const fn windows_spawn::Command::priority(&mut self, windows_spawn::PriorityClass) -> &mut Self
pub fn windows_spawn::Command::raw_arg<S: core::convert::AsRef<std::ffi::os_str::OsStr>>(&mut self, S) -> &mut Self
pub fn windows_spawn::Command::spawn(&mut self) -> windows_spawn::Result<windows_spawn::Child>
pub fn windows_spawn::Command::spawn_suspended(&mut self) -> windows_spawn::Result<windows_spawn::SuspendedChild>
Expand Down
2 changes: 1 addition & 1 deletion src/child.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ impl ProcessHandle {
}

/// Records that the main thread runs, so dropping the handle leaves the process running.
pub(crate) fn mark_started(&mut self) {
pub(crate) const fn mark_started(&mut self) {
self.unstarted = false;
}

Expand Down
18 changes: 9 additions & 9 deletions src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,59 +256,59 @@ impl Command {
/// The Job allows no breakaway, so a descendant that asks for `CREATE_BREAKAWAY_FROM_JOB` fails to start.
/// A daemon that a build starts and expects to outlive it, such as `mspdbsrv.exe` or an `sccache` server, ends with the tree too.
/// A [`SuspendedChild`] already runs in the Job, and dropping it terminates the child before it runs.
pub fn own_process_tree(&mut self) -> &mut Self {
pub const fn own_process_tree(&mut self) -> &mut Self {
self.creation.owns_tree = true;
self
}

/// Chooses the child's console.
///
/// A pseudoconsole from [`SpawnOptions::pseudo_console`] is the child's console, so it combines only with [`ConsoleMode::Inherit`].
pub fn console(&mut self, mode: ConsoleMode) -> &mut Self {
pub const fn console(&mut self, mode: ConsoleMode) -> &mut Self {
self.creation.console = mode;
self
}

/// Chooses the child's scheduling priority class.
///
/// Without a choice, the child gets the normal class, or the caller's when that is idle or below normal.
pub fn priority(&mut self, class: PriorityClass) -> &mut Self {
pub const fn priority(&mut self, class: PriorityClass) -> &mut Self {
self.creation.set_priority(class);
self
}

/// Applies a process-creation mitigation policy: `PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY`.
pub fn mitigation(&mut self, policy: MitigationPolicy) -> &mut Self {
pub const fn mitigation(&mut self, policy: MitigationPolicy) -> &mut Self {
self.creation.mitigation = policy;
self
}

/// Makes the child the root of a new process group, which `GenerateConsoleCtrlEvent` can signal: `CREATE_NEW_PROCESS_GROUP`.
pub fn new_process_group(&mut self) -> &mut Self {
pub const fn new_process_group(&mut self) -> &mut Self {
self.creation.set(flags::NEW_PROCESS_GROUP);
self
}

/// Gives the child its logical parent's processor affinity: `INHERIT_PARENT_AFFINITY`.
pub fn inherit_parent_affinity(&mut self) -> &mut Self {
pub const fn inherit_parent_affinity(&mut self) -> &mut Self {
self.creation.set(flags::INHERIT_PARENT_AFFINITY);
self
}

/// Lets the child leave the caller's Job where that Job allows it: `CREATE_BREAKAWAY_FROM_JOB`.
pub fn breakaway_from_job(&mut self) -> &mut Self {
pub const fn breakaway_from_job(&mut self) -> &mut Self {
self.creation.set(flags::BREAKAWAY_FROM_JOB);
self
}

/// Keeps the caller's code-authorization level for the child: `CREATE_PRESERVE_CODE_AUTHZ_LEVEL`.
pub fn preserve_code_authorization(&mut self) -> &mut Self {
pub const fn preserve_code_authorization(&mut self) -> &mut Self {
self.creation.set(flags::PRESERVE_CODE_AUTHZ_LEVEL);
self
}

/// Starts the child with the default hard-error mode instead of the caller's: `CREATE_DEFAULT_ERROR_MODE`.
pub fn default_error_mode(&mut self) -> &mut Self {
pub const fn default_error_mode(&mut self) -> &mut Self {
self.creation.set(flags::DEFAULT_ERROR_MODE);
self
}
Expand Down
Loading
Loading