Repository navigation
uucore: drop the pipe after a failed splice write - #15086
abendrothj wants to merge 3 commits into
Conversation
This comment was marked as resolved.
This comment was marked as resolved.
| match splice(pipe, dest, len) { | ||
| Ok(s) => len -= s, | ||
| Err(e) => { | ||
| let _ = std::io::copy(&mut pipe.take(len as u64), &mut std::io::sink()); |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
There was a problem hiding this comment.
Oh wait... it is probally read(2) to user space RAM. Though std might optimize it with splice to /dev/null in the future... We would not see EIO or OOM at here. But I still think we should discard & regenerate pipe to avoid it.
There was a problem hiding this comment.
Right, sink() doesn't open anything: it's a unit struct whose write just returns the length, so this is a read into a buffer that gets thrown away.
I'd still drain rather than recreate the pipe. tee also calls drain_pipe, with its own two pipes that aren't in PIPE_CACHE, so dropping the cached pipe wouldn't fix it, and tee has the same bug: on main, when one output fails part-way, the bytes it didn't take end up in the other outputs. test_tee_failed_output_does_not_spill_into_others in 55a4ca3 covers that. Draining in drain_pipe fixes every caller in one place, and either way it only happens after a failed write.
There was a problem hiding this comment.
Recalling pipe is much cheaper than repeating read internally. At least, we should have .unwrap() to catch failure.
There was a problem hiding this comment.
Agreed, switched to recreating the pipe, so there's no read and nothing to unwrap. tee replaces the pipe a failed output was using with a new one of the same size.
There was a problem hiding this comment.
A correction to my reply above: tee doesn't make the new pipe "the same size" anymore. The 2nd pipe is always 1 MiB and the 1st is only enlarged if the 2nd was. The old pair is closed before the new one is made, and if no new pipe can be made tee falls back to read/write. That's in #15132 now.
There was a problem hiding this comment.
correction: in #15132 tee no longer falls back to read/write, if it can't make a new pipe it reports the error and stops.
| fn ignore() -> Self { | ||
| // SAFETY: signal() with SIG_IGN is async-signal-safe and `drop` puts | ||
| // the old handler back. | ||
| Self(unsafe { libc::signal(libc::SIGXFSZ, libc::SIG_IGN) }) |
There was a problem hiding this comment.
Would you rewrite the test with strace to drop unsafes?
There was a problem hiding this comment.
Since the unsafe operations are properly encapsulated in SigxfszGuard I think unsafe is fine for FFI.
There was a problem hiding this comment.
I prefer strace also for readability.
There was a problem hiding this comment.
It matches the approach used in tests/by-util/test_dd.rs; I think it’s OK for this PR.
There was a problem hiding this comment.
The tests have no unsafe now: 4179394 adds UCommand::ignore_sigxfsz(), which ignores SIGXFSZ in the child's pre_exec, next to the existing limit and umask ones. The old guard changed SIGXFSZ for the whole test binary, and test_dd's two tests that used it were already killing each other's dd under cargo test (143 of 300 runs with two threads). They use the new method now too.
I tried strace with cp as well: inject=splice:error=EIO:when=4 with --reflink=never reproduces it reliably. But strace isn't in the runner images, and only the script-check jobs install it, so in the test jobs it would return early without checking anything. The fsize limit runs on every Linux job.
There was a problem hiding this comment.
Merging this PR will regress 1 benchmark
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
This comment was marked as outdated.
This comment was marked as outdated.
| /// `dest` fails part-way, the bytes still in `pipe` are read out and dropped | ||
| /// before the error is returned, so that they do not end up at the start of | ||
| /// the next destination. | ||
| #[inline] |
There was a problem hiding this comment.
#[inline] shouldn't be necessary for a function that isn't public.
There was a problem hiding this comment.
Gone along with splice_all.
This comment was marked as outdated.
This comment was marked as outdated.
| /// before the error is returned, so that they do not end up at the start of | ||
| /// the next destination. | ||
| #[inline] | ||
| fn splice_all(pipe: &PipeReader, dest: &impl AsFd, mut len: usize) -> std::io::Result<()> { |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
There was a problem hiding this comment.
Done in 55a4ca3: the loops are back as they were, with .inspect_err(|_| discard(pipe, remaining))?. I kept a one-line discard so the drain is written once.
There was a problem hiding this comment.
This one is outdated now: there's no discard and no draining after a failed write anymore. The pipe is dropped instead, so the loops don't need an inspect_err at all.
cf614c3 to
87ceeff
Compare
| /// | ||
| /// If writing to `dest` fails part-way, the bytes still in `pipe` are read out | ||
| /// and dropped before the error is returned: callers reuse their pipes, and | ||
| /// those bytes must not end up at the start of the next destination. |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
There was a problem hiding this comment.
Correction: in dadbe4d the comment sits directly above the loop it's about, and the function doc states the rule for callers.
| // When writing to one output fails part-way, the bytes it did not take must | ||
| // not end up in the other outputs. | ||
| #[test] | ||
| fn test_tee_failed_output_does_not_spill_into_others() { |
There was a problem hiding this comment.
One utility is enough to avoid regression.
There was a problem hiding this comment.
With the pipe recreated, tee has its own code for this (replace_pipe), which the cp test doesn't reach, so I kept the tee test and made it go through both of tee's pipes. It fails if either one isn't replaced.
There was a problem hiding this comment.
This PR now only has the cp test, test_cp_failed_write_does_not_spill_into_next_file. The tee tests went to #15132 with the tee change, since that's the code they exercise.
|
|
||
| /// Read out and drop the `len` bytes left in `pipe`. | ||
| fn discard(pipe: &PipeReader, len: usize) { | ||
| let _ = std::io::copy(&mut pipe.take(len as u64), &mut std::io::sink()); |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
Done in 7053f9b: both caches are a thread-local Cell now. The pipe is taken out for the copy and put back only if the copy succeeded, so after a failed write it's dropped and the next copy makes a new one. discard is gone. One side effect: if creating the pipe fails, the next call tries again instead of using the fallback for the rest of the process.
87ceeff to
55a4ca3
Compare
55a4ca3 to
7053f9b
Compare
| #[cfg(any(target_os = "linux", target_os = "android"))] | ||
| if let Ok((pipe_read, pipe_write)) = io::pipe() | ||
| && let Ok((pipe2_read, pipe2_write)) = io::pipe() | ||
| if let Ok(mut pipe) = io::pipe() |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
Right, and no * is needed since they're plain locals: the names are back (pipe_read/pipe_write, pipe2_read/pipe2_write), and the macro reassigns them with (r, w) = .... This is in the tee PR now, #15132.
| #[cfg(any(target_os = "linux", target_os = "android"))] | ||
| fn replace_pipe(pipe: &mut (io::PipeReader, io::PipeWriter)) -> io::Result<()> { | ||
| use rustix::pipe::{fcntl_getpipe_size, fcntl_setpipe_size}; | ||
| let size = fcntl_getpipe_size(&pipe.0)?; |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
There was a problem hiding this comment.
replace_pipe and the F_GETPIPE_SZ are gone (in #15132). A new 2nd pipe always comes from pipe::<true>(). The 1st one doesn't, because tee only grows its pipes when growing the 2nd one worked at startup, and growing the 1st can fail on its own when the user's pipe pages run out, which would make tee abort where it now goes on. So a new 1st pipe is pipe::<false>() when the 2nd is 1 MiB, and plain io::pipe() otherwise.
There was a problem hiding this comment.
outdated too: in #15132 the macro now makes both new pipes the same way, enlarged only if the 2nd was at startup, so a new 1st pipe that can't be enlarged stops tee as well.
|
|
||
| /// splice `len` bytes from `pipe` into `dest`. | ||
| /// | ||
| /// On error, `pipe` may still hold bytes that were not written: don't reuse it. |
There was a problem hiding this comment.
Can we make drain_pipe itself safe instead of asking to reflesh pipe?
There was a problem hiding this comment.
I kept the contract: drain_pipe can leave bytes behind after a partial splice error, its doc says not to reuse the pipe after an error, and the owners act on that. The cache only gets a pipe back when the copy succeeded, and tee replaces the pair a failed output used (that's in #15132). Making it safe in place means discarding the rest on error, which is more I/O on the error path that can fail too, so we'd still need the drop as a backstop. It also wouldn't cover the later-chunk loop in splice_unbounded_with, which calls splice directly and not drain_pipe.
There was a problem hiding this comment.
OK. It seems best currently. I have no idea how to make code simpler.
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
Moved to #15131. This PR no longer changes test_dd.rs, apart from carrying that commit as its base until it lands.
This comment was marked as resolved.
This comment was marked as resolved.
7053f9b to
dadbe4d
Compare
|
The tee change is now #15132, and the test helper and dd change are in #15131. #15132 and this PR are both based on #15131. What's left here is pipes.rs and the cp test. I tried |
| thread_local! { | ||
| static PIPE_CACHE: Cell<Option<(PipeReader, PipeWriter)>> = const { Cell::new(None) }; | ||
| } | ||
| let Some(pipe) = PIPE_CACHE.take().or_else(|| pipe::<false>().ok()) else { |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
| static PIPE_CACHE: OnceLock<Option<(PipeReader, PipeWriter)>> = OnceLock::new(); | ||
| let Some((pipe_rd, pipe_wr)) = PIPE_CACHE.get_or_init(|| pipe::<false>().ok()) else { | ||
| thread_local! { | ||
| static PIPE_CACHE: Cell<Option<(PipeReader, PipeWriter)>> = const { Cell::new(None) }; |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
Yes, moved it out so both use it.
cat, cp, install, mv, head, tail and tac move data through pipes that are reused for more than one copy. When writing to the destination failed part-way, for example with ENOSPC or EFBIG, the bytes still in the pipe stayed there and the next copy wrote them at the start of its own destination. With cp, a file that failed to copy put the rest of its data into the next file copied. Keep a cached pipe only when the copy through it succeeded, so one that a failed write left data in is dropped and the next copy makes a new one.
cff190a to
054e5ef
Compare
| #[cfg_attr( | ||
| wasi_runner, | ||
| ignore = "WASI: the file size limit also applies to wasmtime, which then fails to start" | ||
| )] |
There was a problem hiding this comment.
| #[cfg_attr( | |
| wasi_runner, | |
| ignore = "WASI: the file size limit also applies to wasmtime, which then fails to start" | |
| )] | |
| #[cfg(not(wasi_runner))] // linux specific |
| /// An empty pipe kept for the next copy. A copy takes it and puts it back only | ||
| /// when it succeeded, since a failed write can leave bytes in it. |
There was a problem hiding this comment.
| /// An empty pipe kept for the next copy. A copy takes it and puts it back only | |
| /// when it succeeded, since a failed write can leave bytes in it. | |
| /// Cache empty pipe pair to avoid calling `pipe2` at each copy. |
Document usefullness of cache itself. Warning about non-enpty pipes are already done at plate it is used.
| return Ok(Err(())); | ||
| }; | ||
| // GNU cat catches all strace injections for 2nd+ splice | ||
| // if this fails part-way, the rest stays in the pipe, so callers drop the pipe on error |
There was a problem hiding this comment.
| // if this fails part-way, the rest stays in the pipe, so callers drop the pipe on error |
Doc of fn itself might enough. We actually have L75's read_to_end too.
| res | ||
| } | ||
|
|
||
| fn splice_unbounded_with( |
There was a problem hiding this comment.
This fn is used just once. How about removing it?
| res | ||
| } | ||
|
|
||
| fn send_n_bytes_with( |
There was a problem hiding this comment.
This fn is used just once. How about removing it?
cat, cp, install, mv, head, tail and tac copy through cached pipes that get reused for more than one copy. If a write to the destination failed part-way (ENOSPC, EFBIG), the bytes still in the pipe stayed there, and the next copy wrote them at the start of its own destination. With cp, a file that failed to copy put the rest of its data into the next file.
A cached pipe is now put back only when the copy through it succeeded. After a failure it's dropped, and the next copy makes a new one.
test_cp_failed_write_does_not_spill_into_next_filefails on main and passes here.tee has the same problem with its own pipes; that's #15132.
Closes #15085