Skip to content

Commit badd9c9

Browse files
pmaxhoganclaude
andauthored
feat(core): shape upload I/O with the io_priority setting (#176)
Follow-up to #170. Max: "i want the user-visible win on all uploads of nontrivial duration, not just the narrow case of bundled uploads." ## The problem #170 left behind #170 could only apply `io_priority` at one site - the `build_bundle` `spawn_blocking` closure - because a `PriorityGuard` is per-thread and only sound where the thread cannot yield. The large-file upload path was unreachable by that lever: its reads go through `tokio::fs`, which hands every read to an anonymous thread in tokio's **shared blocking pool**. Driven neither owns those threads nor may demote them (they also serve UI/IPC work), so no amount of thread-priority plumbing reaches the bytes coming off the disk during an upload. ## The fix: a second, per-HANDLE lever On Windows, `SetFileInformationByHandle(FileIoPriorityHintInfo)` attaches an I/O priority hint to the **file handle**. Every read on that handle is then shaped regardless of which thread issues it - which sidesteps the anonymous-pool problem completely. It also needs no restore, unlike a pooled thread: the handle is opened for one upload and closed when it ends, so there is nothing left behind to leak a demotion into. New `priority::apply_to_file_handle(&std::fs::File, WorkPriority)`: | level | hint | |---|---| | `Low` | `IoPriorityHintLow` | | `Idle` | `IoPriorityHintVeryLow` (what Windows itself uses for background I/O) | Best-effort like the rest of the module: a refused call logs at `debug` and the reads run at normal priority. ## One call site covers every upload read `executor::open_shared` is the single choke point for every source-file read in the executor, so threading `WorkPriority` into it covers all seven production paths from one place: - the plain live open (the normal upload path), - the `vss_mode = always` probe open, - the `FallbackDecision::OpenLive` re-open, - the VSS **snapshot** open (locked-file reads), - the resumable-session identity re-check, - the reconcile re-hash (`rehash_local_plaintext`). The hint is applied while the handle is still a plain `std::fs::File`, before `tokio::fs::File::from_std` wraps it. ## The fact this PR hinged on, verified empirically The MS docs say the handle "must be opened with the appropriate permissions" but never state what those are for this class. If it needed `FILE_WRITE_ATTRIBUTES`, the call would fail `ERROR_ACCESS_DENIED` on the executor's `read(true)` handle and the whole feature would silently no-op - or force us to widen the access mask, changing the open's sharing/locking behaviour, which is load-bearing for the VSS locked-file logic. So I probed it directly against a handle opened exactly the way `open_shared` opens one (`read(true)` + `FILE_SHARE_READ | WRITE | DELETE`): all three hint values succeed, at both `sizeof(struct)` and 8-byte buffer sizes. **A read-only handle is sufficient, and the access mask is unchanged.** That result is now locked in by a `cfg(windows)` test asserting the raw call succeeds. The struct is `#[repr(C, align(8))]` per the documented LONGLONG alignment requirement. ## What this PR deliberately does NOT do - **No restructure of the reader stage.** Routing reads through an owned blocking thread would give cross-platform shaping, but `file` is borrowed by the caller across `fstat_identity`, `stream_upload`, and the post-upload identity rechecks, and the pacer's `permit_bytes` is async. Moving ownership into a blocking task ripples straight through the bounded-memory backpressure (`mem_gauge`), the resumable-session persistence (P1-3), the throughput-probe accounting, and the `ChangedDuringUpload` defences. Not worth it for a scheduling hint. - **No per-chunk guards in `cpu_stage` / `read_hash_encrypt`.** These interleave `.await`s, so a guard would have to be taken and dropped around each 64-KiB burst: two syscalls per chunk inside the AEAD framing loop, the most data-safety-critical code in the repo. And for files at or above `RAYON_HASH_THRESHOLD` (100 MiB) - exactly the "uploads of nontrivial duration" this PR targets - `update_rayon` fans most of that CPU out to rayon's global pool, which the calling thread's priority does not touch. Poor trade on both sides. Nothing in the upload pipeline's control flow, error handling, or byte handling changed: the diff is the extra parameter, one hint call, and docs/tests. ## Platform reality - read before testing **On Linux and macOS this PR changes nothing about upload I/O.** Neither has a per-descriptor I/O priority; both scope it to the thread, and the reads land on tokio's shared pool. Testing the setting on those platforms and concluding it is broken would be a false negative. Windows is where the win is. **On Windows, test with large files, or at `idle`.** There is an asymmetry at the `low` level worth knowing before measuring. Large-file reads are shaped by the new handle hint at both levels. Bundled small-file reads still go through `build_bundle`'s own `std::fs` opens inside the *thread* guard from #170 - and on Windows `low` maps to `THREAD_PRIORITY_BELOW_NORMAL`, which is CPU only (`idle` maps to `THREAD_MODE_BACKGROUND_BEGIN`, which does cover I/O). So at `low`, a backup of many small files still reads at normal I/O priority. Pointing Driven at a folder of tiny files and seeing no I/O change is expected, not a bug. Closing that gap means hinting the handles `build_bundle` opens, which is a separate change in `bundle.rs` - worth a follow-up, out of scope here. ## Tests - `file_handle_hint_accepts_every_level_on_a_read_only_handle` - all three levels on an upload-style handle, every platform (off Windows the no-op must also not panic). - `file_handle_stays_readable_after_the_hint` - reads back the exact bytes after hinting, so a regression that invalidated the handle cannot pass as a scheduling tweak. - `windows_file_handle_hint_call_succeeds_on_a_read_only_handle` - asserts the raw `SetFileInformationByHandle` return, which is what proves the feature is not silently no-opping in production. No elevation needed; `tempfile` for temp dirs. ## Gates - `cargo fmt --all -- --check` clean - `cargo clippy --workspace --all-targets -- -D warnings` clean - `cargo test -p driven-core`: 431 passed, 0 failed (14 in `priority`) - `cargo test -p driven-app`: 295 passed, 0 failed; `driven-chaos`: 44 passed - LF endings, ASCII dashes only. No `ui/` changes. Cross-target `clippy -D warnings` on `x86_64`/`aarch64-unknown-linux-gnu`, `x86_64-unknown-linux-musl`, and `x86_64`/`aarch64-apple-darwin` (a full cross `cargo check` is blocked by `ring`'s build script, so the module is checked in isolation). That caught a real break: with the `cfg(windows)` block compiled out, the early `return` in `apply_to_file_handle` became a `needless_return` error on every unix target. Fixed before pushing; CI's ubuntu/macos legs are the confirmation. ## Docs `design/DESIGN.md` s11.2 gains the per-handle lever alongside the thread-scoped ones, including why `SetPriorityClass` is still not used (process-wide, would drag the UI/IPC threads down) and what remains unshaped on unix. Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JLB3E2Jm7knNJd37fVpH8X Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e6c2c3e commit badd9c9

3 files changed

Lines changed: 262 additions & 25 deletions

File tree

crates/driven-core/src/executor.rs

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,7 +1272,7 @@ impl DefaultExecutor {
12721272
async fn open_effective(&self, live_path: &Path) -> EffectiveOpen {
12731273
let Some(vss) = self.vss.as_ref() else {
12741274
// No VSS configured: live open, lock => skip (historical path).
1275-
return match open_shared(live_path).await {
1275+
return match open_shared(live_path, self.priority.get()).await {
12761276
Ok(file) => EffectiveOpen::Opened {
12771277
read_path: live_path.to_path_buf(),
12781278
file,
@@ -1297,7 +1297,7 @@ impl DefaultExecutor {
12971297
if mode == VssMode::Always {
12981298
// Do not even attempt the live open in always mode; we route reads
12991299
// through the snapshot. Probe lock state only to feed the decision.
1300-
attempt = match open_shared(live_path).await {
1300+
attempt = match open_shared(live_path, self.priority.get()).await {
13011301
Ok(file) => {
13021302
live_file = Some(file);
13031303
OpenAttempt::Ok
@@ -1309,7 +1309,7 @@ impl DefaultExecutor {
13091309
}
13101310
};
13111311
} else {
1312-
match open_shared(live_path).await {
1312+
match open_shared(live_path, self.priority.get()).await {
13131313
Ok(file) => {
13141314
// Live open worked; in auto/never this is the read path.
13151315
return EffectiveOpen::Opened {
@@ -1345,7 +1345,7 @@ impl DefaultExecutor {
13451345
file,
13461346
}
13471347
} else {
1348-
match open_shared(live_path).await {
1348+
match open_shared(live_path, self.priority.get()).await {
13491349
Ok(file) => EffectiveOpen::Opened {
13501350
read_path: live_path.to_path_buf(),
13511351
file,
@@ -1362,7 +1362,7 @@ impl DefaultExecutor {
13621362
// Open the frozen shadow-copy path. A second sharing violation
13631363
// here (extremely unusual - the snapshot is read-only) degrades
13641364
// to skip.
1365-
match open_shared(&snapshot_path).await {
1365+
match open_shared(&snapshot_path, self.priority.get()).await {
13661366
Ok(file) => {
13671367
tracing::info!(target: TARGET, live = %live_path.display(), snapshot = %snapshot_path.display(), "VSS: reading locked file from snapshot");
13681368
EffectiveOpen::Opened {
@@ -4934,7 +4934,7 @@ impl DefaultExecutor {
49344934
// what proves the bytes we are about to re-read are the same ones the
49354935
// crashed run was uploading.
49364936
let full_path = join_source_path(&source.local_path, &op.relative_path);
4937-
let mut file = match open_shared(&full_path).await {
4937+
let mut file = match open_shared(&full_path, self.priority.get()).await {
49384938
Ok(f) => f,
49394939
// File gone/locked: cannot resume; let the caller requeue.
49404940
Err(_) => return Ok(None),
@@ -5275,7 +5275,7 @@ impl DefaultExecutor {
52755275
/// blake3), so it is comparable for both encrypted and plaintext
52765276
/// sources.
52775277
async fn rehash_local_plaintext(&self, full_path: &Path) -> Option<([u8; 32], u64, i64)> {
5278-
let mut file = open_shared(full_path).await.ok()?;
5278+
let mut file = open_shared(full_path, self.priority.get()).await.ok()?;
52795279
let id = fstat_identity(&file).await.ok()?;
52805280
// We only need the blake3-over-plaintext; pass crypto=None so the
52815281
// body bytes are not built up unnecessarily - read_hash_encrypt still
@@ -6479,7 +6479,14 @@ enum EffectiveOpen {
64796479
/// process can atomically replace it while we read the original bytes
64806480
/// (SPEC s8 defence #2). On Unix the default open already allows the path
64816481
/// to be unlinked/replaced under an open handle.
6482-
async fn open_shared(path: &Path) -> Result<tokio::fs::File, OpenError> {
6482+
async fn open_shared(
6483+
path: &Path,
6484+
// SPEC s22 `io_priority`: the level to hint on the returned handle. This is
6485+
// the ONE choke point every executor-side file read goes through (live
6486+
// opens, the VSS snapshot open, the resume identity check, the reconcile
6487+
// re-hash), so hinting here shapes all of them from a single site.
6488+
priority: crate::priority::WorkPriority,
6489+
) -> Result<tokio::fs::File, OpenError> {
64836490
#[cfg(windows)]
64846491
{
64856492
use std::os::windows::fs::OpenOptionsExt;
@@ -6488,12 +6495,28 @@ async fn open_shared(path: &Path) -> Result<tokio::fs::File, OpenError> {
64886495
let mut opts = std::fs::OpenOptions::new();
64896496
opts.read(true).share_mode(SHARE_MODE);
64906497
match opts.open(path) {
6491-
Ok(std_file) => Ok(tokio::fs::File::from_std(std_file)),
6498+
Ok(std_file) => {
6499+
// Hint BEFORE the handle is wrapped for async use, while it is
6500+
// still a plain `std::fs::File`. The hint then rides on the
6501+
// handle for its whole life, so every read is shaped even
6502+
// though `tokio::fs` performs them on shared pool threads we
6503+
// cannot demote. Best-effort: a refusal just means normal-
6504+
// priority reads. The access mask is deliberately unchanged -
6505+
// a read-only handle is enough for this class, so the open's
6506+
// sharing/locking behaviour is byte-identical to before.
6507+
crate::priority::apply_to_file_handle(&std_file, priority);
6508+
Ok(tokio::fs::File::from_std(std_file))
6509+
}
64926510
Err(e) => Err(classify_open_error(e)),
64936511
}
64946512
}
64956513
#[cfg(not(windows))]
64966514
{
6515+
// No per-descriptor I/O priority exists on Linux / macOS (both scope it
6516+
// to the thread), so the hint has nowhere to land and the reads run at
6517+
// normal priority. See `crate::priority` for why the thread-scoped
6518+
// levers cannot reach these reads either.
6519+
let _ = priority;
64976520
match tokio::fs::File::open(path).await {
64986521
Ok(f) => Ok(f),
64996522
Err(e) => Err(classify_open_error(e)),
@@ -11325,7 +11348,7 @@ mod tests {
1132511348
// Sanity: a plain shared open must now fail with a lock.
1132611349
assert!(
1132711350
matches!(
11328-
super::open_shared(&live).await,
11351+
super::open_shared(&live, crate::priority::WorkPriority::Normal).await,
1132911352
Err(super::OpenError::Locked)
1133011353
),
1133111354
"test setup: file must be locked"
@@ -11383,7 +11406,7 @@ mod tests {
1138311406
.expect("open locked-pending.dat exclusively");
1138411407
assert!(
1138511408
matches!(
11386-
super::open_shared(&live).await,
11409+
super::open_shared(&live, crate::priority::WorkPriority::Normal).await,
1138711410
Err(super::OpenError::Locked)
1138811411
),
1138911412
"test setup: file must be locked"

crates/driven-core/src/priority.rs

Lines changed: 208 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,39 @@
3939
//! owns for their entire life (a dedicated walker/hasher worker), where there
4040
//! is no "afterwards" to restore to.
4141
//!
42-
//! # Where this is applied today, and what it does NOT cover yet
42+
//! # Two levers, because one is not enough
4343
//!
44-
//! One site: the executor's `build_bundle` blocking task (V2 small-file
45-
//! bundling), which reads every member off disk and gzips it. So the setting
46-
//! currently shapes **bundled small-file uploads only**.
44+
//! Threads are only half the story. The upload pipeline's reads go through
45+
//! `tokio::fs`, which hands each read to an anonymous thread in tokio's shared
46+
//! blocking pool - Driven neither owns those threads nor may demote them, so no
47+
//! amount of thread-priority work reaches the bytes coming off the disk during
48+
//! an upload. [`apply_to_file_handle`] is the answer to that: on Windows the
49+
//! I/O priority hint rides on the FILE HANDLE, so every read is shaped no
50+
//! matter which thread performs it, and there is nothing to restore because the
51+
//! handle dies with the upload.
4752
//!
48-
//! A large-file backup is not covered, and cannot be by a per-thread guard as
49-
//! things stand: the read/hash/encrypt/upload pipeline is a set of interleaved
50-
//! async stages, and the actual disk reads happen on `tokio::fs`'s internal
51-
//! blocking pool, which Driven does not own a handle to. The scanner's walk and
52-
//! deep-verify hashing are the other big consumers, and they run inline on the
53-
//! async task today. The win there arrives when the scanner moves to dedicated
54-
//! worker threads: those are Driven's own, live for the whole walk, and should
55-
//! call [`apply_to_current_thread`] at startup. This module exists in the shape
56-
//! it does so that adoption is a one-line change.
53+
//! # Where the two levers are applied today
54+
//!
55+
//! - [`apply_to_file_handle`] - on every source file the executor opens for an
56+
//! upload, including the reconcile re-hash and the VSS snapshot read. This is
57+
//! what shapes a large-file backup. **Windows only** (see below).
58+
//! - [`spawn_blocking`] / [`begin_background_work`] - the executor's
59+
//! `build_bundle` task (V2 small-file bundling), which reads members off disk
60+
//! and gzips them inside one blocking closure.
61+
//! - [`apply_to_current_thread`] - the scanner's dedicated walk workers, which
62+
//! Driven owns for the life of the walk (walk + deep-verify hashing).
63+
//!
64+
//! What is still unshaped: read I/O during an upload on **Linux and macOS**.
65+
//! Both scope I/O priority to the thread, neither has a per-descriptor
66+
//! equivalent of the Windows hint, and the reads land on tokio's shared pool.
67+
//! Fixing it means owning the reader thread outright - a restructure of the
68+
//! streaming pipeline that would have to preserve the bounded-memory
69+
//! backpressure, the resumable-session persistence, and the
70+
//! `ChangedDuringUpload` identity defences, which is a bigger and riskier change
71+
//! than this lever is worth. The CPU stages (hash / encrypt) are likewise
72+
//! unshaped: they interleave `.await`s, so a guard cannot legally span them, and
73+
//! for files at or above the rayon hashing threshold most of that CPU is on
74+
//! rayon's pool anyway.
5775
//!
5876
//! # What each level maps to, per OS
5977
//!
@@ -293,6 +311,44 @@ where
293311
})
294312
}
295313

314+
/// Ask the OS to service I/O on `file`'s handle at `priority`, for the life of
315+
/// that handle.
316+
///
317+
/// This is the per-HANDLE lever, and it is the one that escapes the per-thread
318+
/// trap the rest of this module works around: the hint travels with the handle,
319+
/// so every read on it is shaped no matter which thread issues it. That matters
320+
/// because the upload pipeline's reads go through `tokio::fs`, which hands each
321+
/// read to an anonymous thread in tokio's shared blocking pool - a pool Driven
322+
/// has no handle on and must not demote. Setting the hint on the file instead
323+
/// of on a thread sidesteps that entirely.
324+
///
325+
/// It also needs no restore: the handle is opened for one upload and closed
326+
/// when it ends, so unlike a pooled thread there is nothing left behind to leak
327+
/// a demotion into.
328+
///
329+
/// Best-effort like everything else here - a refused call logs at `debug` and
330+
/// the reads run at normal priority.
331+
///
332+
/// **Windows only.** `SetFileInformationByHandle(FileIoPriorityHintInfo)` maps
333+
/// [`WorkPriority::Low`] to `IoPriorityHintLow` and [`WorkPriority::Idle`] to
334+
/// `IoPriorityHintVeryLow` (what Windows itself uses for background I/O).
335+
/// Neither Linux nor macOS has a per-descriptor equivalent - both scope I/O
336+
/// priority to the thread - so this is a no-op there and the caller keeps its
337+
/// normal read priority. Whether a given filesystem driver honours the hint is
338+
/// up to that driver; the API is explicitly a hint.
339+
pub fn apply_to_file_handle(file: &std::fs::File, priority: WorkPriority) {
340+
#[cfg(windows)]
341+
if priority != WorkPriority::Normal {
342+
// Best-effort: the outcome is logged inside, never surfaced.
343+
let _ = windows_impl::apply_io_priority_hint(file, priority);
344+
}
345+
// Off Windows there is no per-descriptor lever to pull. Bind both
346+
// parameters so the signature stays uniform without an `unused_variables`
347+
// blanket that would also hide a real unused argument on Windows.
348+
#[cfg(not(windows))]
349+
let _ = (file, priority);
350+
}
351+
296352
/// Apply `priority` to the calling thread, returning what actually took effect.
297353
fn apply(priority: WorkPriority) -> Applied {
298354
if priority == WorkPriority::Normal {
@@ -362,6 +418,73 @@ mod windows_impl {
362418
fn GetCurrentThread() -> isize;
363419
fn SetThreadPriority(thread: isize, priority: i32) -> i32;
364420
fn GetThreadPriority(thread: isize) -> i32;
421+
fn SetFileInformationByHandle(
422+
file: isize,
423+
class: i32,
424+
info: *const core::ffi::c_void,
425+
size: u32,
426+
) -> i32;
427+
}
428+
429+
/// `FILE_INFO_BY_HANDLE_CLASS::FileIoPriorityHintInfo` - the only class
430+
/// this module sets, and one of the six valid for
431+
/// `SetFileInformationByHandle`.
432+
const FILE_IO_PRIORITY_HINT_INFO: i32 = 12;
433+
434+
/// `FILE_IO_PRIORITY_HINT_INFO`. One `PRIORITY_HINT` field, but the Win32
435+
/// docs require the buffer to sit on a LONGLONG (8-byte) boundary, so the
436+
/// alignment is part of the contract rather than a padding accident.
437+
#[repr(C, align(8))]
438+
struct FileIoPriorityHintInfo {
439+
priority_hint: i32,
440+
}
441+
442+
/// `PRIORITY_HINT::IoPriorityHintVeryLow` - what Windows itself uses for
443+
/// background I/O.
444+
const IO_PRIORITY_HINT_VERY_LOW: i32 = 0;
445+
/// `PRIORITY_HINT::IoPriorityHintLow`.
446+
const IO_PRIORITY_HINT_LOW: i32 = 1;
447+
448+
/// Attach an I/O priority hint to `file`'s handle so every read on it is
449+
/// serviced below normal, whichever thread issues the read.
450+
///
451+
/// A read-only handle is sufficient - verified against a handle opened
452+
/// exactly the way the executor opens a source file (`read(true)` plus
453+
/// `FILE_SHARE_READ | WRITE | DELETE`); no `FILE_WRITE_ATTRIBUTES` is
454+
/// needed, so this never has to widen the access mask and change the
455+
/// locking behaviour of the open.
456+
/// Returns whether the OS accepted the hint, so a test can assert on the
457+
/// raw outcome; production callers go through
458+
/// [`super::apply_to_file_handle`], which discards it.
459+
pub(super) fn apply_io_priority_hint(file: &std::fs::File, priority: WorkPriority) -> bool {
460+
use std::os::windows::io::AsRawHandle;
461+
462+
let priority_hint = match priority {
463+
WorkPriority::Normal => return true,
464+
WorkPriority::Low => IO_PRIORITY_HINT_LOW,
465+
WorkPriority::Idle => IO_PRIORITY_HINT_VERY_LOW,
466+
};
467+
let info = FileIoPriorityHintInfo { priority_hint };
468+
// SAFETY: `info` outlives the call, is correctly sized/aligned for the
469+
// class, and the handle is borrowed from a live `File` so it cannot be
470+
// closed underneath us.
471+
let ok = unsafe {
472+
SetFileInformationByHandle(
473+
file.as_raw_handle() as isize,
474+
FILE_IO_PRIORITY_HINT_INFO,
475+
std::ptr::addr_of!(info).cast(),
476+
std::mem::size_of::<FileIoPriorityHintInfo>() as u32,
477+
)
478+
};
479+
if ok == 0 {
480+
tracing::debug!(
481+
target: TARGET,
482+
error = %std::io::Error::last_os_error(),
483+
"SetFileInformationByHandle(FileIoPriorityHintInfo) refused; reads run at normal I/O priority"
484+
);
485+
return false;
486+
}
487+
true
365488
}
366489

367490
/// One CPU notch below the process priority class.
@@ -712,6 +835,78 @@ mod tests {
712835
drop(guard);
713836
}
714837

838+
/// Open a temp file exactly the way the executor's `open_shared` opens a
839+
/// source file for upload: read-only, sharing read + write + delete. The
840+
/// handle hint has to work on THIS handle - if it needed a wider access
841+
/// mask, the executor would have to change how it opens files, which would
842+
/// change locking behaviour.
843+
fn upload_style_handle(path: &std::path::Path) -> std::fs::File {
844+
let mut opts = std::fs::OpenOptions::new();
845+
opts.read(true);
846+
#[cfg(windows)]
847+
{
848+
use std::os::windows::fs::OpenOptionsExt;
849+
opts.share_mode(0x0000_0001 | 0x0000_0002 | 0x0000_0004);
850+
}
851+
opts.open(path).expect("open the temp file")
852+
}
853+
854+
/// Every level must be accepted on a read-only upload-style handle, on
855+
/// every platform (off Windows the call is a no-op, which must also not
856+
/// panic).
857+
#[test]
858+
fn file_handle_hint_accepts_every_level_on_a_read_only_handle() {
859+
let dir = tempfile::tempdir().expect("temp dir");
860+
let path = dir.path().join("payload.bin");
861+
std::fs::write(&path, b"driven upload priority fixture").expect("write fixture");
862+
let file = upload_style_handle(&path);
863+
864+
for level in [WorkPriority::Normal, WorkPriority::Low, WorkPriority::Idle] {
865+
apply_to_file_handle(&file, level);
866+
}
867+
}
868+
869+
/// The hint must not disturb the handle: the whole point is that reads keep
870+
/// working and only their scheduling priority changes. A regression that
871+
/// invalidated the handle would otherwise surface as corrupt uploads.
872+
#[test]
873+
fn file_handle_stays_readable_after_the_hint() {
874+
use std::io::Read;
875+
876+
let dir = tempfile::tempdir().expect("temp dir");
877+
let path = dir.path().join("payload.bin");
878+
let payload = b"driven upload priority fixture";
879+
std::fs::write(&path, payload).expect("write fixture");
880+
881+
let mut file = upload_style_handle(&path);
882+
apply_to_file_handle(&file, WorkPriority::Idle);
883+
let mut read_back = Vec::new();
884+
file.read_to_end(&mut read_back).expect("read after hint");
885+
assert_eq!(read_back, payload, "the hint must not disturb the bytes");
886+
}
887+
888+
/// The Windows call is the one with an observable success/failure, and a
889+
/// read-only handle must be sufficient for it. This is the assertion that
890+
/// proves the feature is not silently no-opping in production.
891+
#[cfg(windows)]
892+
#[test]
893+
fn windows_file_handle_hint_call_succeeds_on_a_read_only_handle() {
894+
let dir = tempfile::tempdir().expect("temp dir");
895+
let path = dir.path().join("payload.bin");
896+
std::fs::write(&path, b"driven upload priority fixture").expect("write fixture");
897+
let file = upload_style_handle(&path);
898+
899+
for level in [WorkPriority::Low, WorkPriority::Idle] {
900+
// `apply_to_file_handle` swallows the outcome by design, so assert
901+
// on the raw call: a read-only handle must be accepted, with no
902+
// ERROR_ACCESS_DENIED and no ERROR_BAD_LENGTH.
903+
assert!(
904+
windows_impl::apply_io_priority_hint(&file, level),
905+
"SetFileInformationByHandle(FileIoPriorityHintInfo) must accept a read-only handle for {level:?}"
906+
);
907+
}
908+
}
909+
715910
/// Every level must survive an apply/restore round trip on the host OS
716911
/// without panicking, whatever the kernel decides to allow. This is the
717912
/// fail-working contract, and it is the only assertion that can be made

0 commit comments

Comments
 (0)