diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index e84924c7..286089d4 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -98,7 +98,7 @@ descendant teardown, or fresh evidence on the other five targets. Until 1.0, the four first-party clients use an exact Cargo requirement: ```toml -kernal-api = { version = "=0.1.19", features = ["..."] } +kernal-api = { version = "=0.1.20", features = ["..."] } [profile.dev.package.kernal-api] codegen-units = 1 @@ -107,7 +107,7 @@ codegen-units = 1 codegen-units = 1 ``` -The Python companion is likewise pinned with `kernal-api==0.1.19` when used by +The Python companion is likewise pinned with `kernal-api==0.1.20` when used by first-party Python tooling. A source checkout may temporarily use a path patch only on an explicit migration branch; release branches must resolve the exact registry version. There is no `optional = true` legacy implementation behind diff --git a/Cargo.lock b/Cargo.lock index 25a8612a..0f2e1f89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2758,7 +2758,7 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.19" +version = "0.1.20" dependencies = [ "addr2line 0.24.2", "blake3", diff --git a/Cargo.toml b/Cargo.toml index e16f18b5..86a71306 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kernal-api" -version = "0.1.19" +version = "0.1.20" build = "build.rs" edition = "2021" rust-version = "1.95.0" diff --git a/README.md b/README.md index 2ff3d526..5078a09d 100644 --- a/README.md +++ b/README.md @@ -124,10 +124,10 @@ adds `kernal-api` a second time, as a build-dependency with only this feature: ```toml [dependencies] -kernal-api = { version = "=0.1.19", features = ["window-icon"] } +kernal-api = { version = "=0.1.20", features = ["window-icon"] } [build-dependencies] -kernal-api = { version = "=0.1.19", default-features = false, features = ["build-resources"] } +kernal-api = { version = "=0.1.20", default-features = false, features = ["build-resources"] } ``` Features enabled in the `[dependencies]` entry never reach the build script: diff --git a/benchmarks/wasm-sketch/compiler-guest/Cargo.lock b/benchmarks/wasm-sketch/compiler-guest/Cargo.lock index f41c51c6..1a9dae5d 100644 --- a/benchmarks/wasm-sketch/compiler-guest/Cargo.lock +++ b/benchmarks/wasm-sketch/compiler-guest/Cargo.lock @@ -148,7 +148,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "kernal-api" -version = "0.1.19" +version = "0.1.20" dependencies = [ "blake3", "libc", diff --git a/benchmarks/wasm-sketch/compiler-guest/Cargo.toml b/benchmarks/wasm-sketch/compiler-guest/Cargo.toml index 035e493d..27b59cca 100644 --- a/benchmarks/wasm-sketch/compiler-guest/Cargo.toml +++ b/benchmarks/wasm-sketch/compiler-guest/Cargo.toml @@ -29,7 +29,7 @@ required-features = ["guest-proof"] # Independently runnable Core compiler/cache and public-facade hash proofs for #13. zccache-compiler = { git = "https://github.com/zackees/zccache", rev = "c6ddfa974a4920a127eac81773db6a5c56cd30a7", default-features = false } zccache-hash = { git = "https://github.com/zackees/zccache", rev = "2543136ea8b648b295d2f7115a19656ff0854531", default-features = false } -kernal-api = { version = "=0.1.19", path = "../../..", default-features = false } +kernal-api = { version = "=0.1.20", path = "../../..", default-features = false } base64 = "=0.22.1" serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" diff --git a/benchmarks/wasm-sketch/component-guest/Cargo.lock b/benchmarks/wasm-sketch/component-guest/Cargo.lock index d80a3866..c0a5aa6d 100644 --- a/benchmarks/wasm-sketch/component-guest/Cargo.lock +++ b/benchmarks/wasm-sketch/component-guest/Cargo.lock @@ -567,7 +567,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "kernal-api" -version = "0.1.19" +version = "0.1.20" dependencies = [ "blake3", "libc", diff --git a/benchmarks/wasm-sketch/component-guest/Cargo.toml b/benchmarks/wasm-sketch/component-guest/Cargo.toml index 53de6091..4d9042b7 100644 --- a/benchmarks/wasm-sketch/component-guest/Cargo.toml +++ b/benchmarks/wasm-sketch/component-guest/Cargo.toml @@ -25,7 +25,7 @@ compiler-artifact-output = [] zccache-compiler = { git = "https://github.com/zackees/zccache", rev = "c6ddfa974a4920a127eac81773db6a5c56cd30a7", default-features = false } # Exact source-only migration fixture; not published-pin acceptance. zccache-hash = { git = "https://github.com/zackees/zccache", rev = "2543136ea8b648b295d2f7115a19656ff0854531", default-features = false } -kernal-api = { version = "=0.1.19", path = "../../..", default-features = false, features = ["wasm-component-hash-experiment"] } +kernal-api = { version = "=0.1.20", path = "../../..", default-features = false, features = ["wasm-component-hash-experiment"] } wit-bindgen = { version = "=0.58.0", default-features = false, features = ["macros", "realloc", "async", "std"] } [profile.release] diff --git a/benchmarks/wasm-sketch/component-tools/Cargo.lock b/benchmarks/wasm-sketch/component-tools/Cargo.lock index 6a3a3573..9aea9b8c 100644 --- a/benchmarks/wasm-sketch/component-tools/Cargo.lock +++ b/benchmarks/wasm-sketch/component-tools/Cargo.lock @@ -567,7 +567,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "kernal-api" -version = "0.1.19" +version = "0.1.20" dependencies = [ "blake3", "libc", diff --git a/benchmarks/wasm-sketch/component-tools/Cargo.toml b/benchmarks/wasm-sketch/component-tools/Cargo.toml index df147f81..0e7b8223 100644 --- a/benchmarks/wasm-sketch/component-tools/Cargo.toml +++ b/benchmarks/wasm-sketch/component-tools/Cargo.toml @@ -20,7 +20,7 @@ execution-probe = ["engine-probe", "dep:kernal-api"] anyhow = "=1.0.104" wit-component = "=0.251.0" wasmparser = "=0.251.0" -kernal-api = { version = "=0.1.19", path = "../../..", default-features = false, optional = true } +kernal-api = { version = "=0.1.20", path = "../../..", default-features = false, optional = true } wasmtime = { version = "=45.0.0", optional = true, default-features = false, features = ["cranelift", "runtime", "component-model", "component-model-async"] } [dev-dependencies] diff --git a/examples/wasm-tauri-screenshot/README.md b/examples/wasm-tauri-screenshot/README.md index 1b64ccdd..7afb2436 100644 --- a/examples/wasm-tauri-screenshot/README.md +++ b/examples/wasm-tauri-screenshot/README.md @@ -20,7 +20,7 @@ the screenshot lifecycle starts. The map uses a deterministic standard hasher and the channel reads are non-blocking after join: the closed profile therefore adds neither ambient `random_get` nor `poll_oneoff` imports. -The source fixture uses an exact `=0.1.19` version plus a **migration-only local +The source fixture uses an exact `=0.1.20` version plus a **migration-only local path**; it must switch to an actually published guest-capable release before release acceptance. The packaged facade has separately passed a Wasm check, but this is not evidence that the guest-capable package has been published. diff --git a/examples/wasm-tauri-screenshot/guest/Cargo.lock b/examples/wasm-tauri-screenshot/guest/Cargo.lock index 868d8858..54e01a89 100644 --- a/examples/wasm-tauri-screenshot/guest/Cargo.lock +++ b/examples/wasm-tauri-screenshot/guest/Cargo.lock @@ -171,7 +171,7 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.19" +version = "0.1.20" dependencies = [ "blake3", "libc", diff --git a/examples/wasm-tauri-screenshot/guest/Cargo.toml b/examples/wasm-tauri-screenshot/guest/Cargo.toml index 5911f01f..4b3fda78 100644 --- a/examples/wasm-tauri-screenshot/guest/Cargo.toml +++ b/examples/wasm-tauri-screenshot/guest/Cargo.toml @@ -19,6 +19,6 @@ proof-block-after-capture = [] [dependencies] # Migration-only source fixture. Replace the path with the exact release pin # after the guest-capable facade is published; this is not release acceptance. -kernal-api = { version = "=0.1.19", path = "../../..", default-features = false } +kernal-api = { version = "=0.1.20", path = "../../..", default-features = false } dashmap = "6.1.0" crossbeam-channel = "0.5.15" diff --git a/guests/threaded-smoke/Cargo.lock b/guests/threaded-smoke/Cargo.lock index 9803eef3..e1435a99 100644 --- a/guests/threaded-smoke/Cargo.lock +++ b/guests/threaded-smoke/Cargo.lock @@ -162,7 +162,7 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.19" +version = "0.1.20" dependencies = [ "blake3", "libc", diff --git a/guests/threaded-smoke/Cargo.toml b/guests/threaded-smoke/Cargo.toml index feca92b1..5d7af331 100644 --- a/guests/threaded-smoke/Cargo.toml +++ b/guests/threaded-smoke/Cargo.toml @@ -17,6 +17,6 @@ warnings = "deny" [dependencies] # Migration-only source pin for the public streaming proof. The generated # dependency below remains solely for deliberate low-level ABI probes. -kernal-api = { version = "=0.1.19", path = "../..", default-features = false } +kernal-api = { version = "=0.1.20", path = "../..", default-features = false } kernal-api-v1-bindings = { path = "../../src/wasm/generated/v1/guest" } dashmap = "=6.1.0" diff --git a/pyproject.toml b/pyproject.toml index 0719cef3..d1da94bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "kernal-api" -version = "0.1.19" +version = "0.1.20" description = "Async OS HAL, profiling, symbolization, and allocator instrumentation" readme = "README.md" requires-python = ">=3.10" diff --git a/python/kernal_api/__init__.py b/python/kernal_api/__init__.py index 7b204c36..7eda4241 100644 --- a/python/kernal_api/__init__.py +++ b/python/kernal_api/__init__.py @@ -11,7 +11,7 @@ import sys from dataclasses import dataclass -__version__ = "0.1.19" +__version__ = "0.1.20" RUST_MSRV = "1.95.0" SUPPORTED_SYSTEMS = frozenset({"Linux", "Darwin", "Windows"}) SUPPORTED_MACHINES = frozenset({"x86_64", "amd64", "aarch64", "arm64"}) diff --git a/python/tests/test_compatibility.py b/python/tests/test_compatibility.py index e0b1369e..eb527e76 100644 --- a/python/tests/test_compatibility.py +++ b/python/tests/test_compatibility.py @@ -4,7 +4,7 @@ def test_python_and_rust_versions_are_explicit() -> None: - assert kernal_api.__version__ == "0.1.19" + assert kernal_api.__version__ == "0.1.20" assert kernal_api.RUST_MSRV == "1.95.0" diff --git a/src/platform/fs.rs b/src/platform/fs.rs index a5289cca..31c959de 100644 --- a/src/platform/fs.rs +++ b/src/platform/fs.rs @@ -107,10 +107,10 @@ pub use async_io::AsyncFileIo; mod materialize; #[cfg(feature = "fs")] pub use materialize::{ - allocated_bytes, apply_metadata_mode, classify, file_change_marker, file_id_width, + allocated_bytes, apply_metadata_mode, await_no_writers, classify, file_change_marker, file_id_width, hard_link_count, make_executable, metadata_mode, native_call_path, path_from_raw_bytes, set_readonly, symlink_file, sync_directory_if_supported, volume_identity_u128, - FileChangeMarker, LinkKind, + FileChangeMarker, LinkKind, WriterWait, }; /// Path-observed file identity with the native identifier width. #[cfg(feature = "fs")] diff --git a/src/platform/fs/materialize.rs b/src/platform/fs/materialize.rs index 6de2cd3f..89c67547 100644 --- a/src/platform/fs/materialize.rs +++ b/src/platform/fs/materialize.rs @@ -54,6 +54,40 @@ pub fn hard_link_count(path: &Path) -> io::Result { native::hard_link_count(path) } +/// Result of [`await_no_writers`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WriterWait { + /// No process held the file open for writing when last observed. + /// `waited` is how long other writers kept it open (zero when none did). + Clear { + /// Time spent waiting for other writers to close. + waited: std::time::Duration, + }, + /// Some process still held the file open for writing at the deadline. + TimedOut, + /// This host or file system cannot report writers. Hosts that execute a + /// file regardless of writers report this without waiting. + Unobservable, +} + +/// Wait up to `timeout` until no process (this one or any other) holds the +/// file at `path` open for writing. +/// +/// Publishing an executable is only safe to `exec` once every write +/// descriptor on its inode is closed. A child forked while the publisher's +/// write descriptor was open inherits that descriptor until its own `exec`, +/// so closing the publisher's copy is not enough: Linux refuses to execute +/// the file (`ETXTBSY`) for the child's fork-to-exec window. Call this after +/// closing the file and before handing it to anything that will execute it. +/// The file is opened read-only and no handle is retained. +/// +/// # Errors +/// +/// Returns the error from opening `path`. +pub fn await_no_writers(path: &Path, timeout: std::time::Duration) -> io::Result { + native::await_no_writers(path, timeout) +} + /// Create a symbolic link at `link` to the file `target`, which may not /// exist yet. /// diff --git a/src/platform_linux/fs_materialize.rs b/src/platform_linux/fs_materialize.rs index a650fb34..13dfbfc7 100644 --- a/src/platform_linux/fs_materialize.rs +++ b/src/platform_linux/fs_materialize.rs @@ -9,7 +9,7 @@ use std::io; use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; use std::path::{Path, PathBuf}; -use crate::platform::fs::LinkKind; +use crate::platform::fs::{LinkKind, WriterWait}; // --------------------------------------------------------------------------- // Replacement @@ -112,6 +112,47 @@ pub fn file_change_marker(_path: &Path) -> Option { None } +// --------------------------------------------------------------------------- +// Writers +// --------------------------------------------------------------------------- + +/// A read lease is refused (`EAGAIN`) while any process holds the inode open +/// for writing: the same condition under which `execve` fails with `ETXTBSY`. +pub fn await_no_writers(path: &Path, timeout: std::time::Duration) -> io::Result { + use std::os::fd::AsRawFd as _; + use std::os::unix::fs::OpenOptionsExt as _; + + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(path)?; + let fd = file.as_raw_fd(); + let started = std::time::Instant::now(); + let mut pause = std::time::Duration::from_micros(50); + loop { + // SAFETY: `fd` is a valid descriptor owned by `file` for this call. + if unsafe { libc::fcntl(fd, libc::F_SETLEASE, libc::F_RDLCK) } == 0 { + // SAFETY: as above; releasing the lease just taken. + unsafe { libc::fcntl(fd, libc::F_SETLEASE, libc::F_UNLCK) }; + return Ok(WriterWait::Clear { + waited: started.elapsed(), + }); + } + match io::Error::last_os_error().raw_os_error() { + Some(libc::EAGAIN) => {} + // Leases disabled, unsupported by the file system, or a file this + // user may not lease: writers cannot be observed. + _ => return Ok(WriterWait::Unobservable), + } + let elapsed = started.elapsed(); + if elapsed >= timeout { + return Ok(WriterWait::TimedOut); + } + std::thread::sleep(pause.min(timeout - elapsed)); + pause = (pause * 2).min(std::time::Duration::from_millis(5)); + } +} + // --------------------------------------------------------------------------- // Links // --------------------------------------------------------------------------- @@ -198,3 +239,7 @@ pub fn native_call_path(path: &Path) -> io::Result { pub fn sync_directory_if_supported(directory: &Path) -> io::Result<()> { std::fs::File::open(directory)?.sync_all() } + +#[cfg(test)] +#[path = "fs_materialize_tests.rs"] +mod tests; diff --git a/src/platform_linux/fs_materialize_tests.rs b/src/platform_linux/fs_materialize_tests.rs new file mode 100644 index 00000000..54dfd42c --- /dev/null +++ b/src/platform_linux/fs_materialize_tests.rs @@ -0,0 +1,77 @@ +//! Linux-native behavior of `await_no_writers`: the inherited-descriptor +//! `ETXTBSY` race it exists to close (zackees/soldr#3350). + +use std::io::Write as _; +use std::os::unix::fs::PermissionsExt as _; +use std::os::unix::process::CommandExt as _; +use std::time::Duration; + +use super::await_no_writers; +use crate::platform::fs::WriterWait; + +fn executable_script(dir: &std::path::Path) -> (std::path::PathBuf, std::fs::File) { + let path = dir.join("build-script-build"); + let mut file = std::fs::File::create(&path).expect("create script"); + file.write_all(b"#!/bin/sh\nexit 0\n").expect("write script"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + (path, file) +} + +#[test] +fn an_unwritten_file_is_clear_at_once() { + let dir = tempfile::tempdir().expect("tempdir"); + let (path, file) = executable_script(dir.path()); + drop(file); + match await_no_writers(&path, Duration::ZERO).expect("observe") { + WriterWait::Clear { .. } => {} + other => panic!("expected Clear, got {other:?}"), + } +} + +#[test] +fn an_open_writer_in_this_process_times_out() { + let dir = tempfile::tempdir().expect("tempdir"); + let (path, _writer) = executable_script(dir.path()); + assert_eq!( + await_no_writers(&path, Duration::from_millis(20)).expect("observe"), + WriterWait::TimedOut + ); +} + +/// A child forked while the publisher's write descriptor is open keeps that +/// descriptor until its own exec. The publisher has closed its copy, yet +/// executing the file fails with `ETXTBSY` until the child execs; waiting for +/// writers makes the exec succeed. +#[test] +fn a_descriptor_inherited_across_fork_is_waited_out() { + let dir = tempfile::tempdir().expect("tempdir"); + let (path, writer) = executable_script(dir.path()); + + let child = std::thread::spawn(|| { + let mut command = std::process::Command::new("true"); + // SAFETY: the hook only sleeps, which is async-signal-safe. + unsafe { + command.pre_exec(|| { + std::thread::sleep(Duration::from_millis(400)); + Ok(()) + }); + } + command.status().expect("run lingering child") + }); + // Let the fork happen while `writer` is open, then close the publisher's copy. + std::thread::sleep(Duration::from_millis(100)); + drop(writer); + + let busy = std::process::Command::new(&path).status(); + assert_eq!( + busy.as_ref().err().and_then(std::io::Error::raw_os_error), + Some(libc::ETXTBSY), + "the inherited descriptor must make exec fail: {busy:?}" + ); + match await_no_writers(&path, Duration::from_secs(10)).expect("observe") { + WriterWait::Clear { waited } => assert!(waited > Duration::ZERO), + other => panic!("expected Clear after the child exec'd, got {other:?}"), + } + assert!(std::process::Command::new(&path).status().expect("exec after waiting").success()); + assert!(child.join().expect("child thread").success()); +} diff --git a/src/platform_macos/fs_materialize.rs b/src/platform_macos/fs_materialize.rs index cb804eba..b5f53c0f 100644 --- a/src/platform_macos/fs_materialize.rs +++ b/src/platform_macos/fs_materialize.rs @@ -9,7 +9,7 @@ use std::io; use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; use std::path::{Path, PathBuf}; -use crate::platform::fs::LinkKind; +use crate::platform::fs::{LinkKind, WriterWait}; // --------------------------------------------------------------------------- // Replacement @@ -103,6 +103,17 @@ pub fn file_change_marker(_path: &Path) -> Option { None } +// --------------------------------------------------------------------------- +// Writers +// --------------------------------------------------------------------------- + +/// This host executes a file regardless of other processes' write +/// descriptors, so there is nothing to wait for. +pub fn await_no_writers(path: &Path, _timeout: std::time::Duration) -> io::Result { + std::fs::metadata(path)?; + Ok(WriterWait::Unobservable) +} + // --------------------------------------------------------------------------- // Links // --------------------------------------------------------------------------- diff --git a/src/platform_win/fs_materialize.rs b/src/platform_win/fs_materialize.rs index 9a6b1627..402927f0 100644 --- a/src/platform_win/fs_materialize.rs +++ b/src/platform_win/fs_materialize.rs @@ -17,7 +17,7 @@ use windows_sys::Win32::Storage::FileSystem::{ FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, }; -use crate::platform::fs::LinkKind; +use crate::platform::fs::{LinkKind, WriterWait}; #[path = "fs_replacement.rs"] mod replacement; @@ -197,6 +197,18 @@ fn decode_usn(record: &[u8]) -> Option { } } +// --------------------------------------------------------------------------- +// Writers +// --------------------------------------------------------------------------- + +/// Writers are not observed here. A Windows child inherits only handles +/// marked inheritable, so a spawn cannot extend a write handle's lifetime the +/// way a POSIX fork does. +pub fn await_no_writers(path: &Path, _timeout: std::time::Duration) -> io::Result { + std::fs::metadata(path)?; + Ok(WriterWait::Unobservable) +} + // --------------------------------------------------------------------------- // Links // --------------------------------------------------------------------------- diff --git a/tests/build-resources-consumer/Cargo.lock b/tests/build-resources-consumer/Cargo.lock index fb578d38..8d0275e1 100644 --- a/tests/build-resources-consumer/Cargo.lock +++ b/tests/build-resources-consumer/Cargo.lock @@ -206,7 +206,7 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.19" +version = "0.1.20" dependencies = [ "blake3", "embed-resource", diff --git a/tests/daemon-registration-consumer/Cargo.lock b/tests/daemon-registration-consumer/Cargo.lock index 9a55086d..f7c8a17b 100644 --- a/tests/daemon-registration-consumer/Cargo.lock +++ b/tests/daemon-registration-consumer/Cargo.lock @@ -291,7 +291,7 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.19" +version = "0.1.20" dependencies = [ "blake3", "libc", diff --git a/tests/daemon-registration-v2-consumer/Cargo.lock b/tests/daemon-registration-v2-consumer/Cargo.lock index bb5878f0..348970ad 100644 --- a/tests/daemon-registration-v2-consumer/Cargo.lock +++ b/tests/daemon-registration-v2-consumer/Cargo.lock @@ -243,7 +243,7 @@ dependencies = [ [[package]] name = "kernal-api" -version = "0.1.19" +version = "0.1.20" dependencies = [ "blake3", "libc", diff --git a/tests/fs/materialization.rs b/tests/fs/materialization.rs index 6ed939d0..07e4ae7a 100644 --- a/tests/fs/materialization.rs +++ b/tests/fs/materialization.rs @@ -532,3 +532,34 @@ fn directory_sync_is_best_effort_only_where_unsupported() { missing.expect_err("Unix opens the directory to flush it"); } } + +// --------------------------------------------------------------------------- +// Writers +// --------------------------------------------------------------------------- + +#[test] +fn await_no_writers_never_reports_clear_while_a_writer_is_open() { + use kernal_api::platform::fs::{await_no_writers, WriterWait}; + use std::time::Duration; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("artifact"); + let writer = fs::File::create(&path).expect("create"); + let open = await_no_writers(&path, Duration::from_millis(20)).expect("observe"); + assert!( + matches!(open, WriterWait::TimedOut | WriterWait::Unobservable), + "{open:?}" + ); + drop(writer); + let closed = await_no_writers(&path, Duration::from_secs(5)).expect("observe"); + assert!( + matches!(closed, WriterWait::Clear { .. } | WriterWait::Unobservable), + "{closed:?}" + ); + assert_eq!( + await_no_writers(&dir.path().join("missing"), Duration::ZERO) + .expect_err("missing file") + .kind(), + io::ErrorKind::NotFound + ); +}