Conversation
There was a problem hiding this comment.
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.
This comment has been minimized.
This comment has been minimized.
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Do you happen to have a sketch of what a better interface would look like?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I believe this file is of interest:
rust/library/std/src/sys/process/unix/unix.rs
Lines 72 to 74 in 71289c3
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.
There was a problem hiding this comment.
The extension should not use the public
pre_execmethod, 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()
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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:
It would also be good to test that:
|
|
|
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
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.
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())
} |
|
Even when checking device/inode values, |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
ca2e300 to
240659f
Compare
| } | ||
|
|
||
| for &(ref old_fd, new_fd) in self.get_fds() { | ||
| cvt_r(|| libc::dup2(old_fd.as_raw_fd(), new_fd))?; |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
Is there a way to check for that? If not, it seems impossible to avoid.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
@joboet can you restate the concern? I don't think I'm quite seeing what you're getting at.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(), |
There was a problem hiding this comment.
If old_fd == new_fd (which is useful for clearing FD_CLOEXEC), I think the adddup2 should run, but addclose should be skipped.
This comment has been minimized.
This comment has been minimized.
|
Ping from triage: can you post your status on this PR and address the merge conflicts? |
240659f to
2d409b7
Compare
|
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. |
|
@rustbot ready |
| } | ||
|
|
||
| for &(ref old_fd, new_fd) in self.get_fds() { | ||
| cvt_r(|| libc::dup2(old_fd.as_raw_fd(), new_fd))?; |
There was a problem hiding this comment.
We should probably mention something about CLOEXEC not being set by us somewhere in the docs.
There was a problem hiding this comment.
dup3 is reasonably widely supported, should we be using that instead where available?
There was a problem hiding this comment.
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))?; |
There was a problem hiding this comment.
@joboet can you restate the concern? I don't think I'm quite seeing what you're getting at.
| /// If this method is called multiple times with the same `new_fd`, all but one file descriptor | ||
| /// will be lost. |
There was a problem hiding this comment.
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_fdis 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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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…
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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…
There was a problem hiding this comment.
Started #t-libs > Safety of sending file descriptors since the others may have thoughts on this.
| } | ||
|
|
||
| for &(ref old_fd, new_fd) in self.get_fds() { | ||
| cvt_r(|| libc::dup2(old_fd.as_raw_fd(), new_fd))?; |
There was a problem hiding this comment.
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.
cda572f to
bf24f0e
Compare
View all comments
ACP: rust-lang/libs-team#623
Tracking issue: #144989
try-job: aarch64-apple
try-job: arm-android
try-job: test-various