Skip to content

add std::os::unix::process::CommandExt::fd - #145687

Open
Qelxiros wants to merge 5 commits into
rust-lang:mainfrom
Qelxiros:fd-passing
Open

Qelxiros wants to merge 5 commits into
rust-lang:mainfrom
Qelxiros:fd-passing

Conversation

@Qelxiros

@Qelxiros Qelxiros commented Aug 20, 2025

Copy link
Copy Markdown
Contributor

View all comments

ACP: rust-lang/libs-team#623
Tracking issue: #144989

try-job: aarch64-apple
try-job: arm-android
try-job: test-various

@rustbot

rustbot commented Aug 20, 2025

Copy link
Copy Markdown
Collaborator

r? @tgross35

rustbot has assigned @tgross35.
They will have a look at your PR within the next two weeks and either review your PR or reassign to another reviewer.

Use r? to explicitly pick a reviewer

@rustbot rustbot added S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. T-libs Relevant to the library team, which will review and decide on the PR/issue. labels Aug 20, 2025

@tgross35 tgross35 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Has this code been run? The implementation just duplicates the fd without interacting with self.

Please make sure that all new API, even unstable, always gets document0ation and examples, as well as tests if the functionality can't be covered in a simple example. This is tricky API, make sure to cover the FD_CLOEXEC behavior in tests and include something to the effect of https://rust-lang.zulipchat.com/#narrow/channel/327149-t-libs-api.2Fapi-changes/topic/Extra.20FDs.20in.20CommandExt/near/439547420 in docs.

The tests at https://github.com/google/command-fds/tree/main can probably serve as reference.

View changes since this review

Comment thread library/std/src/os/fd/process.rs Outdated
Comment thread library/std/src/os/fd/process.rs Outdated
Comment thread library/std/src/os/fd/process.rs Outdated
@rustbot rustbot added S-waiting-on-author Status: This is awaiting some action (such as code changes or more information) from the author. and removed S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. labels Aug 27, 2025
@Qelxiros
Qelxiros requested a review from tgross35 August 27, 2025 21:56
@rustbot rustbot added S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. and removed S-waiting-on-author Status: This is awaiting some action (such as code changes or more information) from the author. labels Aug 27, 2025
@rust-log-analyzer

This comment has been minimized.

Comment thread library/std/src/os/fd/process.rs Outdated
Comment on lines +52 to +67
impl CommandExt for Command {
fn fd(&mut self, new_fd: RawFd, old_fd: impl Into<OwnedFd>) -> &mut Self {
let old = old_fd.into().as_raw_fd();
unsafe {
self.as_inner_mut().pre_exec(Box::new(move || {
cvt_r(|| libc::dup2(old, new_fd))?;
let flags = cvt(libc::fcntl(new_fd, F_GETFD))?;
cvt(libc::fcntl(new_fd, F_SETFD, flags & !FD_CLOEXEC))?;
cvt_r(|| libc::close(old))?;
Ok(())
}))
}

self
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Using pre_exec for this can reduce the performance of starting the child process.
See my Zulip thread on this:
#t-libs-api/api-changes > Extra FDs in CommandExt @ 💬
also mentioned in this comment in the tracking issue:
#144989 (comment)

Using pre_exec switches the spawn implementation from posix_spawn to fork + execve syscalls. Forking is slower than posix_spawn, because it needs to copy the programs memory (in practice copy on write optimizations are used, but they are still somewhat costly - see my benchmarks also linked in the Zulip message). posix_spawn supports setting the passed FDs, so this feature should be used if possible.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do you happen to have a sketch of what a better interface would look like?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No, but the issue I've described here is not a problem with the signature of fd. The extension should not use the public pre_exec method, the implementation needs to be modified instead.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I believe this file is of interest:

if let Some(ret) = self.posix_spawn(&theirs, envp.as_ref())? {
return Ok((ret, ours));
}

Note that there are multiple structs called Command because of how the target-specific functionality is implemented.

Command.spawn first tries to run the posix_spawn method, but its implementations return None if an attribute which the limited posix_spawn syscall cannot handle is set - see the implementations of the posix_spawn method.

Please keep this behavior in mind when adding new Command attributes and creating tests - cases when spawn uses the posix_spawn syscall and when it does not should both be tested. I'm not sure if there is a way to check which spawn variant was chosen in the tests, it is an implementation detail after all.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The extension should not use the public pre_exec method, the implementation needs to be modified instead.

See how other methods in the CommandExt trait are implemented in the library/std/src/os/fd/process.rs file that you modified - they all call the actual implementation using .as.inner_mut()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It would be reasonably straightforward to add a Vec<(OwnedFd, RawFd)> to Command that could be used either with something based on posix_spawn_file_actions_adddup2 or with the exec fallback (also avoiding 1 closure per fd). But aside from being slow, is there any correctness problem with the current implementation?

As long as there isn't anything explicitly wrong, I think it would be reasonable to get the current implementation over the line first so we have some tests. Any chance you would be interested in submitting a followup changing the implementation, since you have been looking into this a lot?

I'm not sure if there is a way to check which spawn variant was chosen in the tests, it is an implementation detail after all.

Adding a last_spawn_was_posix_spawn field to Command for debugging would be fine. That will be accessible from tests once they're moved to within std/src.

@dominik-korsa dominik-korsa Sep 5, 2025

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

But aside from being slow, is there any correctness problem with the current implementation?

No, I'm not claiming this implementation is incorrect - I also haven't reviewed it thoroughly though.

Any chance you would be interested in submitting a followup changing the implementation, since you have been looking into this a lot?

If I have the time, sure.

Comment thread library/std/src/os/fd/process.rs Outdated
Comment thread library/std/src/os/fd/process.rs Outdated
Comment thread library/std/src/os/fd/process.rs Outdated
Comment thread library/std/src/os/fd/process.rs Outdated
@rustbot rustbot added S-waiting-on-author Status: This is awaiting some action (such as code changes or more information) from the author. and removed S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. labels Sep 3, 2025
@tgross35

tgross35 commented Sep 3, 2025

Copy link
Copy Markdown
Member

Please make sure that all new API, even unstable, always gets document0ation and examples, as well as tests if the functionality can't be covered in a simple example. This is tricky API, make sure to cover the FD_CLOEXEC behavior in tests and include something to the effect of https://rust-lang.zulipchat.com/#narrow/channel/327149-t-libs-api.2Fapi-changes/topic/Extra.20FDs.20in.20CommandExt/near/439547420 in docs.

The tests at https://github.com/google/command-fds/tree/main can probably serve as reference.

Please make sure to note this; a single test unfortunately doesn't cover the nuances that can show up here. https://github.com/google/command-fds/blob/38670577b393c5b6f4e17b4854128cf6120a3ca1/src/lib.rs#L206 has a few test cases, I think it would be completely reasonable to port each of these to a version that matches our API.

Edit: realizing that repo is Apache-2.0 so we can't take anything directly. But tests from it that we should include are:

  • Multiple parent fds mapped to the same child fd
  • Two fds, swapped between parent and child
  • Stdin is mapped

It would also be good to test that:

  • A nonexistant fd errors on startup
  • Save the original raw fd. Ensure it remains open until the command is run, then gets closed after spawn (just fcntl(old_fd, F_GETFD) to check if it's still open)

@Qelxiros

Qelxiros commented Sep 3, 2025

Copy link
Copy Markdown
Contributor Author

fd_test_swap is broken; I'm not confident that swapping fds like that is possible without a dedicated function since they seem to clobber each other. fd_test_close_time is also broken, at least on my machine. I'm not sure why that is, but I'd appreciate any guidance you may have.

@rust-log-analyzer

This comment has been minimized.

@rustbot rustbot added the O-unix Operating system: Unix-like label Sep 3, 2025
@rust-log-analyzer

This comment has been minimized.

@tgross35

tgross35 commented Sep 4, 2025

Copy link
Copy Markdown
Member

fd_test_swap is broken; I'm not confident that swapping fds like that is possible without a dedicated function since they seem to clobber each other.

That's expected with the current implementation when both fds need to be alive at the same time; this is what we should check for. The goal is to make sure we don't quietly change this behavior by accident.

fd_test_close_time is also broken, at least on my machine. I'm not sure why that is, but I'd appreciate any guidance you may have.

This one is unfortunately trickier. Linux aggressively reuses fds so it's getting mapped to something else after close, so I guess checking flags isn't enough. Instead, checking that the dev+ino are unequal should work:

use std::{fs, io};
use std::os::fd::AsRawFd;
use std::os::unix::fs::MetadataExt;

#[test]
fn whatever() {
    let (_pipe_reader, pipe_writer) = io::pipe().unwrap();

    let fd = pipe_writer.as_raw_fd();
    let fd_path = format!("/dev/fd/{fd}");

    // let mut cmd = Command::new("cat") ...

    // Get the identifier of the fd (metadata follows symlinks)
    let fd_id = md_file_id(&fs::metadata(&fd_path).expect("fd should be open"));

    // stand in for cmd.spawn().unwrap();
    drop(pipe_writer);

    // After the child is spawned, our fd should be closed
    match fs::metadata(&fd_path) {
        // Ok; fd exists but points to a different file
        Ok(md) => assert_ne!(md_file_id(&md), fd_id),
        // Ok; fd does not exist
        Err(_) => ()
    }

    // ...
}

/// Use dev + ino to uniquely identify a file
fn md_file_id(md: &fs::Metadata) -> (u64, u64) {
    (md.dev(), md.ino())
}

Comment thread library/std/tests/fd_passing.rs Outdated
Comment thread library/std/src/os/unix/process.rs Outdated
Comment thread library/std/src/os/unix/process.rs Outdated
Comment thread library/std/src/os/unix/process.rs Outdated
Comment thread library/std/tests/fd_passing.rs Outdated
Comment thread library/std/tests/fd_passing.rs Outdated
Comment thread library/std/tests/fd_passing.rs Outdated
Comment thread library/std/tests/fd_passing.rs Outdated
Comment thread library/std/tests/fd_passing.rs Outdated
@Qelxiros

Qelxiros commented Sep 7, 2025

Copy link
Copy Markdown
Contributor Author

Even when checking device/inode values, fd_test_close_time fails on my machine. I think that guarantees that the file descriptor is staying open longer than it should, but I have no idea why. I'm a little out of my depth here, but I'll try to look into it. Let me know if you think of anything else.

@rust-log-analyzer

This comment has been minimized.

@rust-bors

This comment has been minimized.

@rustbot

This comment has been minimized.

@Qelxiros
Qelxiros force-pushed the fd-passing branch 2 times, most recently from ca2e300 to 240659f Compare March 6, 2026 16:08
}

for &(ref old_fd, new_fd) in self.get_fds() {
cvt_r(|| libc::dup2(old_fd.as_raw_fd(), new_fd))?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is problematic and probably unsound (as it violates I/O safety) if new_fd is accidentally equal to the file descriptor number that is used by the pipe or socket used to return errors. That would lead to spawn inaccurately reporting a success, even if an error occurs (since this half of the pipe will be closed).

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.

Is there a way to check for that? If not, it seems impossible to avoid.

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.

@joboet friendly nudge :)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You could use dup2 (ideally std's try_clone wrapper) to clone the pipe/socket to a different descriptor and then close the original, effectively moving the descriptor.

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.

Maybe I don't understand the issue. If new_fd might collide with the pipe/socket, then it might still collide after I move the pipe/socket, right?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@joboet can you restate the concern? I don't think I'm quite seeing what you're getting at.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Prior to forking, we create a socket pair or pipe used to communicate with the child. If those happen to get assigned e.g. fd 4 and the user called cmd.fd(4, ...), it's going to clobber the pipe.

I think Jonas is saying this can be mitigated by doing something like pass preserve_fd: &mut OwnedFd (from the relevant side of the socket/pipe) to do_exec and, prior to each dup2 call for user-specified FDs, check if the destination FD is the same as preserve_fd. If so then try_clone preserve_fd and replace it with the new one, and in the calling function recreate the socket/pipe from it.

You can actually mem::swap preserve_fd then mem::forget the result to save an extra close call, since dup2 is specified to close an open destination fd if it already exists.

Great catch Jonas.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I see, yeah, that sounds like it's basically the same issue I'm referencing here - #145687 (comment) - i.e., we're not really satisfying I/O Safety here. I don't thinkt he current API allows for that though, even if we mitigate it for a small set of known FDs.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Was able to write a test that reliably fails because of this but passes with posix-spawn. @Qelxiros could you cherry pick tgross35@986dba3?

new_fd,
))?;
cvt_nz(libc::posix_spawn_file_actions_addclose(
file_actions.0.as_mut_ptr(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If old_fd == new_fd (which is useful for clearing FD_CLOEXEC), I think the adddup2 should run, but addclose should be skipped.

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.

seems reasonable

@rust-bors

This comment has been minimized.

@JohnCSimon

Copy link
Copy Markdown

@Qelxiros

Ping from triage: can you post your status on this PR and address the merge conflicts?
if the PR is ready for review reply with @rustbot ready

@rustbot

rustbot commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

This PR was rebased onto a different main commit. Here's a range-diff highlighting what actually changed.

Rebasing is a normal part of keeping PRs up to date, so no action is needed—this note is just to help reviewers.

@Qelxiros

Copy link
Copy Markdown
Contributor Author

@rustbot ready
Note the second commit. I'm not sure it's necessary, but based on #153133 (comment), it doesn't seem crazy to have. I left it as a separate commit in case we want to remove it.

@rustbot rustbot added S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. and removed S-waiting-on-author Status: This is awaiting some action (such as code changes or more information) from the author. labels Sep 17, 2026
}

for &(ref old_fd, new_fd) in self.get_fds() {
cvt_r(|| libc::dup2(old_fd.as_raw_fd(), new_fd))?;

@Mark-Simulacrum Mark-Simulacrum Sep 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should probably mention something about CLOEXEC not being set by us somewhere in the docs.

View changes since the review

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

dup3 is reasonably widely supported, should we be using that instead where available?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nope that doesn't make sense since we haven't execed yet.

}

for &(ref old_fd, new_fd) in self.get_fds() {
cvt_r(|| libc::dup2(old_fd.as_raw_fd(), new_fd))?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@joboet can you restate the concern? I don't think I'm quite seeing what you're getting at.

Comment on lines +264 to +265
/// If this method is called multiple times with the same `new_fd`, all but one file descriptor
/// will be lost.

@Mark-Simulacrum Mark-Simulacrum Sep 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not sure what "file descriptor will be lost" means -- losing an integer is not really a thing, we should say something about closing or not mapping certain fds. I think the semantics we intend is something like:

If new_fd is passed multiple times to this method, or overlaps with inherited fds (e.g., stdin/stderr), then it is not specified which source (old_fd) will end up duplicated into new_fd.

I think what @joboet is getting at in the other thread is that this is inherently in conflict with I/O safety, which states

acting on a file descriptor without proof of ownership can lead to misbehavior and even Undefined Behavior in code that relies on ownership of its file descriptors

Here we are closing arbitrary target fds. My sense is that this API is not really usable as-is. I think the only possible API shape is for the caller to pre-allocate the target FDs for us, e.g., by passing old_fd: OwnedFd, new_fd: OwnedFd; we would then dup2 into the new_fds safely. Or am I missing something?

View changes since the review

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hm: I think this may be safe since we're duping in the child so the parent's originals remain untouched. And afaict we're not claiming exclusive ownership of the FDs except for the output.

This would be a problem if e.g. you hand the child a list of "safe" FDs that it can assume from_raw_fd on, without realizing that it contains duplicates because some non-cloexec FD happens to overlap with a value set in .fd. Perhaps it's worth a note that the child is still responsible for ensuring it only opens one of each FD?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It’s a good point that the damage/risk is mostly (only? not sure if there are kernel APIs that act on any close - iirc, there are bad file locking APIs that unlock on any close) in the child.

But it feels like safe rust code being able to close arbitrary fds in the child is at least border line violating the expected semantics from I/O safety. To some extent that is unavoidable with the APIs present in Linux, but it still seems iffy. I think ideally we would allow dup(fd) semantics with some Rust protocol for retrieving them soundly in the child as owned…

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I certainly don’t disagree, sending numbers around like this is a rather low-level API.

Do you think there’s a good resolution here? Maybe the “It is recommended to provide further information to the child by some other mechanism” bit could be made stronger and put in its own section with examples. And/or an unresolved question on the tracking issue.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think unresolved question is probably the right thing. In the short term we could make this unsafe, though I’m not sure what the exact safety condition should be (and how the caller can guarantee it), especially given that the fd table is inherently a global property. Maybe the target fd should be mandated to be an OwnedFd as I suggest above? That’s a bit wasteful but does seem like it satisfies the requirement that you’re not clobbering random fds…

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Started #t-libs > Safety of sending file descriptors since the others may have thoughts on this.

@rustbot rustbot added S-waiting-on-author Status: This is awaiting some action (such as code changes or more information) from the author. and removed S-waiting-on-review Status: Awaiting review from the assignee but also interested parties. labels Sep 19, 2026
Comment thread library/std/src/os/unix/process.rs Outdated
Comment thread library/std/src/sys/process/unix/common.rs Outdated
}

for &(ref old_fd, new_fd) in self.get_fds() {
cvt_r(|| libc::dup2(old_fd.as_raw_fd(), new_fd))?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Prior to forking, we create a socket pair or pipe used to communicate with the child. If those happen to get assigned e.g. fd 4 and the user called cmd.fd(4, ...), it's going to clobber the pipe.

I think Jonas is saying this can be mitigated by doing something like pass preserve_fd: &mut OwnedFd (from the relevant side of the socket/pipe) to do_exec and, prior to each dup2 call for user-specified FDs, check if the destination FD is the same as preserve_fd. If so then try_clone preserve_fd and replace it with the new one, and in the calling function recreate the socket/pipe from it.

You can actually mem::swap preserve_fd then mem::forget the result to save an extra close call, since dup2 is specified to close an open destination fd if it already exists.

Great catch Jonas.

Comment thread library/std/src/sys/process/unix/common.rs
Comment thread library/std/src/os/unix/process.rs Outdated
@rust-log-analyzer

This comment has been minimized.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

O-unix Operating system: Unix-like S-waiting-on-author Status: This is awaiting some action (such as code changes or more information) from the author. T-libs Relevant to the library team, which will review and decide on the PR/issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants