Repository navigation
uucore: drop the pipe after a failed splice write - #15086
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 not alter performance
|
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.
054e5ef to
6f612c0
Compare
6f612c0 to
5f22581
Compare
|
|
||
| thread_local! { | ||
| /// Cache empty pipe pair to avoid calling `pipe2` at each copy. | ||
| static PIPE_CACHE: Cell<Option<(PipeReader, PipeWriter)>> = const { Cell::new(None) }; |
There was a problem hiding this comment.
what is the purpose of the const block here?
There was a problem hiding this comment.
It lets thread_local! skip the lazy initialization check; clippy's missing_const_for_thread_local asks for it.
| pub fn splice_unbounded_auto(source: &impl AsFd, dest: &mut impl AsFd) -> PipeRes { | ||
| static PIPE_CACHE: OnceLock<Option<(PipeReader, PipeWriter)>> = OnceLock::new(); | ||
| let Some((pipe_rd, pipe_wr)) = PIPE_CACHE.get_or_init(|| pipe::<false>().ok()) else { | ||
| let Ok(pipe) = PIPE_CACHE.take().map_or_else(pipe::<false>, Ok) else { |
There was a problem hiding this comment.
| let Ok(pipe) = PIPE_CACHE.take().map_or_else(pipe::<false>, Ok) else { | |
| let Ok(&(pipe_rd, pipe_wr)) = PIPE_CACHE.take().map_or_else(pipe::<false>, Ok) else { |
Is this possible to remove let (pipe_rd, pipe_wr) = &pipe;?
There was a problem hiding this comment.
Done, by value: take() gives the pair itself, so &(..) doesn't match. Same in send_n_bytes.
There was a problem hiding this comment.
Thanks for the suggestion, much cleaner.
| if drain_pipe(pipe_rd, dest, n)?.is_err() { | ||
| return Ok(Err(())); | ||
| // an error returns without caching the pipe: a failed write can leave bytes in it | ||
| let res = 'copy: { |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
Done: the loop moved into the Ok(n) arm, so the match gives the result and there's one PIPE_CACHE.set after it.
| let (broker_r, broker_w) = &pipe; | ||
| let mut n = n; | ||
| let mut bytes_written: u64 = 0; | ||
| // an error returns without caching the pipe: a failed write can leave bytes in it |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
Done, and the same in splice_unbounded_auto.
5f22581 to
1a35ff9
Compare
|
GNU testsuite comparison: |
1a35ff9 to
ec9a55a
Compare
ec9a55a to
8e5344f
Compare
There was a problem hiding this comment.
Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.
An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing. Review the Copilot code review billing settings.
|
|
||
| #[cfg(any(target_os = "linux", target_os = "android"))] | ||
| #[test] | ||
| #[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")] |
There was a problem hiding this comment.
The --sparse test here uses #[cfg_attr(wasi_runner, ignore = "...")]
while the one you added uses #[cfg(not(wasi_runner))]. Both exclude WASI, but one skips
visibly and one compiles out. Matching the style between the two adjacent tests
would avoid a future reader wondering why they differ.
There was a problem hiding this comment.
We should use compiling out if test is essentially Linux only.
We should use ignore if feature is coming from wasi's limitation and it can be supported in the future.
We can mix them.
There was a problem hiding this comment.
I recommend to not compile tests using strace to avoid some side-effect for wasmtime.
There was a problem hiding this comment.
In this particular case, they are both Linux specific tests that should not be compiled to WASI. Therefore, we should set to #[cfg(not(wasi_runner))].
There was a problem hiding this comment.
Switched test_cp_sparse_never_empty to #[cfg(not(wasi_runner))] so the two match. The other 30 --sparse tests in this file still use the ignore; I can switch them in a follow-up PR if you'd like.
| { | ||
| while n > 0 { | ||
| n -= splice(&pipe_rd, dest, n) | ||
| .map_err(|error| PipeError::Write(error.into()))?; |
There was a problem hiding this comment.
now that the cache is shared, a default-size pipe made here for a small n can be reused by splice_unbounded_auto, which expects 1 MiB, no?
is that fine?
There was a problem hiding this comment.
Sorry. I was not able to understand your question.
There was a problem hiding this comment.
We can use both of pipe size or size we want to send for splice's length. So n is fine.
If extending pipe size failed, but 1 MiB was passed, that's also fine as splice stops at default pipe size.
There was a problem hiding this comment.
If you are considering to omit next fcntl syscall for pipe when n is small, I don't think we need to do that because dropping pipe is not a hot code path.
There was a problem hiding this comment.
It works either way: each splice into the empty pipe moves at most what fits and drain_pipe writes exactly that, so a 64 KiB pipe only means smaller chunks (only tail uses both functions). Still, the cached pipe's size shouldn't depend on which function happened to make it, so send_n_bytes now makes its pipe with pipe::<false>() too and the cached pipe always gets the 1 MiB resize.
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.
8e5344f to
d65be17
Compare
There was a problem hiding this comment.
Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.
An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing. Review the Copilot code review billing settings.
| let _ = fcntl_setpipe_size(&target, pipe_size); | ||
| } | ||
| let Some((broker_r, broker_w)) = PIPE_CACHE.get_or_init(|| { | ||
| // use std::io::pipe to avoid unnecessary fcntl |
There was a problem hiding this comment.
Cannot we keep this optimization by any way? This is still important if input is just 1 file.
|
Thanks for your PR |
uutils#15086 had send_n_bytes size every new pipe at 1 MiB, adding an fcntl to small copies like head -c 10. Only resize when the copy is larger than the default pipe size, as before.
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