Skip to content

uucore: drop the pipe after a failed splice write - #15086

Merged
sylvestre merged 1 commit into
uutils:mainfrom
abendrothj:pipes-discard-leftover
Oct 18, 2026
Merged

sylvestre merged 1 commit into
uutils:mainfrom
abendrothj:pipes-discard-leftover

Conversation

@abendrothj

@abendrothj abendrothj commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

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_file fails on main and passes here.

tee has the same problem with its own pipes; that's #15132.

Closes #15085

Copilot AI balanced review requested due to automatic review settings October 5, 2026 10:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@oech3

This comment was marked as resolved.

Comment thread src/uucore/src/lib/features/pipes.rs Outdated
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recalling pipe is much cheaper than repeating read internally. At least, we should have .unwrap() to catch failure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tests/by-util/test_cp.rs Outdated
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) })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you rewrite the test with strace to drop unsafes?

@xtqqczze xtqqczze Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the unsafe operations are properly encapsulated in SigxfszGuard I think unsafe is fine for FFI.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer strace also for readability.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It matches the approach used in tests/by-util/test_dd.rs; I think it’s OK for this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think 4179394 deserves it's own PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split out into #15131 (c44a370). This PR is based on it, because the cp test uses ignore_sigxfsz. A correction to my earlier numbers: the 143/300 came from a different setup. With --test-threads=2 on the two dd Fsize tests, main fails 26 of 1000 runs and the new method 0 of 1000.

@codspeed

codspeed Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

⚠️ 124 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

ℹ️ 1 benchmark has no flame graph

The flamegraph could not be generated because too much time was spent in runtime internals, making the visualization not useful for performance analysis.

✅ 401 untouched benchmarks
⏩ 70 skipped benchmarks1


Comparing abendrothj:pipes-discard-leftover (d65be17) with main (a770f03)

Open in CodSpeed

Footnotes

  1. 70 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@oech3

This comment was marked as outdated.

Comment thread src/uucore/src/lib/features/pipes.rs 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]

@xtqqczze xtqqczze Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#[inline] shouldn't be necessary for a function that isn't public.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gone along with splice_all.

@oech3

This comment was marked as outdated.

Comment thread src/uucore/src/lib/features/pipes.rs 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@abendrothj
abendrothj force-pushed the pipes-discard-leftover branch from cf614c3 to 87ceeff Compare October 5, 2026 21:11
Copilot AI balanced review requested due to automatic review settings October 5, 2026 21:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread src/uucore/src/lib/features/pipes.rs Outdated
Comment on lines +57 to +60
///
/// 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved it under the loop in 7053f9b.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction: in dadbe4d the comment sits directly above the loop it's about, and the function doc states the rule for callers.

Comment thread tests/by-util/test_tee.rs Outdated
// 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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One utility is enough to avoid regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/uucore/src/lib/features/pipes.rs Outdated

/// 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@abendrothj
abendrothj force-pushed the pipes-discard-leftover branch from 87ceeff to 55a4ca3 Compare October 5, 2026 21:26
Copilot AI balanced review requested due to automatic review settings October 5, 2026 21:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@abendrothj
abendrothj force-pushed the pipes-discard-leftover branch from 55a4ca3 to 7053f9b Compare October 5, 2026 22:14
Copilot AI balanced review requested due to automatic review settings October 5, 2026 22:14
@abendrothj abendrothj changed the title uucore: drop leftover pipe data after a failed splice write uucore: drop the pipe after a failed splice write Oct 5, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread src/uu/tee/src/tee.rs Outdated
#[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/uu/tee/src/tee.rs Outdated
#[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.

This comment was marked as outdated.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make drain_pipe itself safe instead of asking to reflesh pipe?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. It seems best currently. I have no idea how to make code simpler.

Comment thread tests/by-util/test_dd.rs

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to #15131. This PR no longer changes test_dd.rs, apart from carrying that commit as its base until it lands.

@oech3

This comment was marked as resolved.

Copilot AI balanced review requested due to automatic review settings October 6, 2026 07:15
@abendrothj
abendrothj force-pushed the pipes-discard-leftover branch from 054e5ef to 6f612c0 Compare October 8, 2026 01:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI balanced review requested due to automatic review settings October 10, 2026 04:26
@abendrothj
abendrothj force-pushed the pipes-discard-leftover branch from 6f612c0 to 5f22581 Compare October 10, 2026 04:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.


thread_local! {
/// Cache empty pipe pair to avoid calling `pipe2` at each copy.
static PIPE_CACHE: Cell<Option<(PipeReader, PipeWriter)>> = const { Cell::new(None) };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the purpose of the const block here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It lets thread_local! skip the lazy initialization check; clippy's missing_const_for_thread_local asks for it.

Comment thread src/uucore/src/lib/features/pipes.rs Outdated
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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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;?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, by value: take() gives the pair itself, so &(..) doesn't match. Same in send_n_bytes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion, much cleaner.

Comment thread src/uucore/src/lib/features/pipes.rs Outdated
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: the loop moved into the Ok(n) arm, so the match gives the result and there's one PIPE_CACHE.set after it.

Comment thread src/uucore/src/lib/features/pipes.rs Outdated
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, and the same in splice_unbounded_auto.

@abendrothj
abendrothj force-pushed the pipes-discard-leftover branch from 5f22581 to 1a35ff9 Compare October 11, 2026 06:26
Copilot AI balanced review requested due to automatic review settings October 11, 2026 06:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown

GNU testsuite comparison:

Skipping an intermittent issue tests/date/resolution (passes in this run but fails in the 'main' branch)

Copilot AI balanced review requested due to automatic review settings October 12, 2026 10:31
@abendrothj
abendrothj force-pushed the pipes-discard-leftover branch from 1a35ff9 to ec9a55a Compare October 12, 2026 10:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI balanced review requested due to automatic review settings October 16, 2026 08:01
@abendrothj
abendrothj force-pushed the pipes-discard-leftover branch from ec9a55a to 8e5344f Compare October 16, 2026 08:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tests/by-util/test_cp.rs Outdated

#[cfg(any(target_os = "linux", target_os = "android"))]
#[test]
#[cfg_attr(wasi_runner, ignore = "WASI: --sparse is only supported on linux")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend to not compile tests using strace to avoid some side-effect for wasmtime.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))].

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry. I was not able to understand your question.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copilot AI balanced review requested due to automatic review settings October 18, 2026 00:50
@abendrothj
abendrothj force-pushed the pipes-discard-leftover branch from 8e5344f to d65be17 Compare October 18, 2026 00:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cannot we keep this optimization by any way? This is still important if input is just 1 file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in #15516.

@sylvestre
sylvestre merged commit 6ba6658 into uutils:main Oct 18, 2026
190 checks passed
@sylvestre

Copy link
Copy Markdown
Contributor

Thanks for your PR

abendrothj added a commit to abendrothj/coreutils that referenced this pull request Oct 19, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cp: after a write error, the next file copied can contain data from the failed one

6 participants