Skip to content

Commit fa19e0d

Browse files
committed
fix: serialize streaming writes against in-flight commits with a Committing state
1 parent 2cfd225 commit fa19e0d

2 files changed

Lines changed: 96 additions & 19 deletions

File tree

src/virtual_fs/mod.rs

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2357,18 +2357,6 @@ impl VirtualFs {
23572357
return Err(err.to_errno());
23582358
}
23592359

2360-
// A committed channel already sent Finish to the worker
2361-
// (flush(), or link() on a dirty source): a racing write
2362-
// could enqueue after Finish and be silently dropped. Fail
2363-
// loudly instead.
2364-
{
2365-
let state = channel.state.lock().expect("state poisoned");
2366-
if matches!(&*state, CommitState::Committed | CommitState::Failed(_)) {
2367-
debug!("streaming write after commit rejected for ino={}", handle_ino);
2368-
return Err(libc::EIO);
2369-
}
2370-
}
2371-
23722360
// Enforce append-only: offset must match bytes written so far
23732361
let expected = channel.bytes_written.load(Ordering::Relaxed);
23742362
if offset != expected {
@@ -2380,10 +2368,26 @@ impl VirtualFs {
23802368
}
23812369

23822370
let len = data.len();
2383-
channel.tx.blocking_send(WriteMsg::Data(data.to_vec())).map_err(|_| {
2384-
error!("streaming channel closed for ino={}", handle_ino);
2385-
libc::EIO
2386-
})?;
2371+
// Enqueue under the state mutex to serialize against
2372+
// streaming_commit(), which flips to Committing under the
2373+
// same mutex before enqueueing Finish: either this Data lands
2374+
// ahead of Finish (and is included in the commit), or the
2375+
// write observes Committing/Committed and fails loudly
2376+
// instead of being silently dropped behind Finish.
2377+
{
2378+
let state = channel.state.lock().expect("state poisoned");
2379+
match &*state {
2380+
CommitState::Writing | CommitState::Deferred => {}
2381+
_ => {
2382+
debug!("streaming write after commit rejected for ino={}", handle_ino);
2383+
return Err(libc::EIO);
2384+
}
2385+
}
2386+
channel.tx.blocking_send(WriteMsg::Data(data.to_vec())).map_err(|_| {
2387+
error!("streaming channel closed for ino={}", handle_ino);
2388+
libc::EIO
2389+
})?;
2390+
}
23872391
channel.bytes_written.fetch_add(len as u64, Ordering::Relaxed);
23882392

23892393
let new_size = offset + len as u64;
@@ -2424,7 +2428,7 @@ impl VirtualFs {
24242428
debug!("flush: already failed for ino={}: {}", ino, msg);
24252429
return Err(libc::EIO);
24262430
}
2427-
CommitState::Writing | CommitState::Deferred => {}
2431+
CommitState::Writing | CommitState::Deferred | CommitState::Committing => {}
24282432
}
24292433
}
24302434

@@ -2520,7 +2524,12 @@ impl VirtualFs {
25202524
Some(OpenFile::Streaming { ino, channel }) => {
25212525
let needs_commit = {
25222526
let state = channel.state.lock().expect("state poisoned");
2523-
matches!(&*state, CommitState::Writing | CommitState::Deferred)
2527+
// Committing: an earlier flush()/link() commit failed
2528+
// mid-flight (pending_info parked) — release retries it.
2529+
matches!(
2530+
&*state,
2531+
CommitState::Writing | CommitState::Deferred | CommitState::Committing
2532+
)
25242533
};
25252534
if needs_commit {
25262535
// If flush() didn't defer (e.g. Writing state from a direct
@@ -2656,6 +2665,17 @@ impl VirtualFs {
26562665
return Ok(());
26572666
}
26582667

2668+
// Flip to Committing under the state mutex BEFORE enqueueing Finish:
2669+
// write() enqueues Data under the same mutex, so a racing write either
2670+
// lands ahead of Finish or observes Committing and is rejected instead
2671+
// of being silently dropped behind Finish.
2672+
{
2673+
let mut state = channel.state.lock().expect("state poisoned");
2674+
if matches!(&*state, CommitState::Writing | CommitState::Deferred) {
2675+
*state = CommitState::Committing;
2676+
}
2677+
}
2678+
26592679
let file_info = {
26602680
let pending = channel.pending_info.lock().expect("pending_info poisoned").take();
26612681
if let Some(info) = pending {
@@ -2990,7 +3010,7 @@ impl VirtualFs {
29903010
debug!("streaming commit already failed for ino={}: {}", ino, msg);
29913011
return Err(libc::EIO);
29923012
}
2993-
CommitState::Writing | CommitState::Deferred => {}
3013+
CommitState::Writing | CommitState::Deferred | CommitState::Committing => {}
29943014
}
29953015
}
29963016
// Install hook before commit so concurrent open() can wait on us.
@@ -3109,6 +3129,11 @@ impl VirtualFs {
31093129
}
31103130
// A concurrent create may have won the race since phase 1; the remote
31113131
// add already landed, but locally the newer entry owns the name.
3132+
// Accepted TOCTOU: in this narrow window a failed link leaves the
3133+
// alias (and, for a dirty source, the source commit) on the Hub —
3134+
// same trade-off as pre-existing clean-source links. Deterministic
3135+
// validation failures (EEXIST/ENOTDIR/ENOENT in phase 1b) commit
3136+
// nothing.
31123137
if inodes.lookup_child(newparent, newname).is_some() {
31133138
return Err(libc::EEXIST);
31143139
}
@@ -4067,6 +4092,10 @@ enum CommitState {
40674092
Writing,
40684093
/// flush() deferred commit (dup'd fd or zero writes). release() will handle it.
40694094
Deferred,
4095+
/// A commit is in flight: Finish is (about to be) enqueued. Writes are
4096+
/// rejected from this point — they would land behind Finish and be
4097+
/// silently dropped by the exiting worker.
4098+
Committing,
40704099
/// Commit completed successfully.
40714100
Committed,
40724101
/// Unrecoverable error — inode has been reverted.

src/virtual_fs/tests.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4651,6 +4651,54 @@ fn link_failed_destination_does_not_commit_source() {
46514651
});
46524652
}
46534653

4654+
/// Writes racing a commit IN FLIGHT are rejected too: streaming_commit flips
4655+
/// the channel to Committing (under the same mutex write() enqueues under)
4656+
/// before sending Finish, so a write can never land behind Finish and be
4657+
/// silently dropped by the exiting worker.
4658+
#[test]
4659+
fn write_during_inflight_commit_is_rejected() {
4660+
let hub = MockHub::new();
4661+
let xet = MockXet::new();
4662+
let (rt, vfs) = vfs_simple(&hub, &xet);
4663+
4664+
rt.block_on(async {
4665+
let (attr, fh) = vfs
4666+
.create(ROOT_INODE, "racing.txt", 0o644, 1000, 1000, Some(42))
4667+
.await
4668+
.unwrap();
4669+
let ino = attr.ino;
4670+
write_blocking(&vfs, ino, fh, 0, b"payload").await.unwrap();
4671+
4672+
// Park the commit at the Hub batch call: the channel is Committing
4673+
// (Finish already enqueued) while flush() waits on the barrier.
4674+
let barrier = Arc::new(tokio::sync::Barrier::new(2));
4675+
hub.set_batch_barrier(barrier.clone());
4676+
let vfs_flush = vfs.clone();
4677+
let flush_task = tokio::spawn(async move { vfs_flush.flush(ino, fh, Some(42)).await });
4678+
4679+
// Until the flip a write may still legally succeed (it lands ahead of
4680+
// Finish and joins the commit), so poll until the rejection.
4681+
let mut offset = 7u64;
4682+
let mut rejected = false;
4683+
for _ in 0..200 {
4684+
match write_blocking(&vfs, ino, fh, offset, b"more").await {
4685+
Err(err) => {
4686+
assert_eq!(err, libc::EIO);
4687+
rejected = true;
4688+
break;
4689+
}
4690+
Ok(written) => offset += written as u64,
4691+
}
4692+
tokio::time::sleep(Duration::from_millis(5)).await;
4693+
}
4694+
assert!(rejected, "write must be rejected once the commit is in flight");
4695+
4696+
barrier.wait().await;
4697+
flush_task.await.unwrap().unwrap();
4698+
vfs.release(fh).await.unwrap();
4699+
});
4700+
}
4701+
46544702
/// Writes after the streaming channel has committed (flush(), or link() on a
46554703
/// dirty source) are rejected instead of being silently dropped after Finish.
46564704
#[test]

0 commit comments

Comments
 (0)