Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Win Trampoline: Use Python executable path encoded in binary #1803

Merged
merged 7 commits into from
Feb 22, 2024

Conversation

MichaReiser
Copy link
Member

@MichaReiser MichaReiser commented Feb 21, 2024

Summary

Fixes #1766

The first commit replaces the check! macro with a standalone function that can be reused for non-boolean returning functions to get nice error messages in case a syscall fails.

The second commit changes the python discovery to work with symlinks too. It also changes a few existing calls to use safe APIs as a surprise for @BurntSushi ;)

The way this is implemented is that I changed the launcher file layout to:

| --------------------------------|
|           <launcher.exe>        |
| --------------------------------|
|       <zipped python scrip      |
| --------------------------------|
|           <python_path>         |
| --------------------------------|
|        len(<python_path>)       | 
| --------------------------------|
|              "UVUV"             |
| --------------------------------|

Windows ignores any content after the executable, which is a fact that the launcher already makes use of today. Python ignores everything after the zip end, which allows us to pack additional data after the zip file. I decided to use the magic number "UVUV" at the end of the file as a "safety" mechanism that the file indeed has the right format rather than just assuming that the last u32 (little endian) is the length of the UTF8 encoded python path.

Alternatives

Resolving symlinks

The downside of this approach is that we now need to read the executable which adds some complexity. An alternative to solve #1766 would be to extend our existing relative resolution to resolve symlinks before searching the python executable.
I intended that this also addresses #1779 where we use a global python installation instead, where simply following symlinks isn't sufficient anymore (assuming it is something we want to support). I've set up a repro PR that uses the new uv binary (the good news, is the error messages are much better). The job still fails with

Failed to spawn the python child process with command '"C:\hostedtoolcache\windows\Python\3.12.2\x64\Scripts\python.exe" "C:\hostedtoolcache\windows\Python\3.12.2\x64\Scripts\tox.exe" -vvvv --notest'

The problem is that our wheel installer assumes that python is in .\Scripts\python.exe but that's not the case for a regular Python installation where the binary is in the root folder. We would need to find the python installation when running uv pip install and pass the instance through to the wheel building code. This feels out of scope for this PR and probably requires input from someone more familiar with uv than I. What I don't understand is why this works for unix where we, presumably, have the same problem (or are we just lucky because the binary in global install happens to be in a bin directory?)

I'm open to changing the implementation to resolve symlinks instead, but this approach seemed more flexible.

Shebang parsing

The launcher used by distutil searches for the python script and then parses the shebang line to retrieve the Python executable name. This is kind of nice because it doesn't require a custom data format, it just works similarly to unix.

The main downside that I'm seeing is that it requires a bit more parsing (and navigating) than the current approach. The launcher first needs to find the end of the zip file entry. From there, find the start of the script, and then parse the shebang.

I decided against this approach (but open to changing) because it is more involved and our launcher isn't intended to be used without uv where the extra ergonomics of simply having to write a shebang brings us much benefit.

Binary size increase

One of the main reasons for the binary increase is that the binary now contains more static strings with possible error messages.

Test Plan

I followed the instructions in #1766 and the command now runs successfully

uv venv
uv pip install pycowsay
New-Item -Path .\pycatsay.exe -ItemType SymbolicLink -Value .\.venv\Scripts\pycowsay.exe
.\pycatsay.exe


<  >

   \   ^__^
    \  (oo)\_______
       (__)\       )\/\
           ||----w |
           ||     ||


@MichaReiser MichaReiser added bug Something isn't working windows Specific to the Windows platform labels Feb 21, 2024

// Start with a size of 1024 bytes which should be enough for most paths but avoids reading the
// entire file.
let mut buffer: Vec<u8> = vec![0; 1024];
Copy link
Member Author

Choose a reason for hiding this comment

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

I tested the "incremental" reading by setting the capacity to 10

@MichaReiser MichaReiser force-pushed the win-trampoline-symlinks branch 3 times, most recently from 73f0bff to e772c05 Compare February 21, 2024 15:29
@MichaReiser MichaReiser marked this pull request as ready for review February 21, 2024 15:30
@AlexWaygood AlexWaygood changed the title Win Trampoline: Use Pyhton executable path encoded in binary Win Trampoline: Use Python executable path encoded in binary Feb 21, 2024
Copy link
Member

@BurntSushi BurntSushi left a comment

Choose a reason for hiding this comment

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

w00t! So much Windows API. Nice work.

I do think there is at least one thing worth changing here before merging, which is the logic for reading the file path from the end of the binary. The main issue I think with the current implementation is that it trusts the path length, which could lead to the program allocating a huge amount of memory if something went wrong (whether intentional or not).

crates/uv-trampoline/src/bounce.rs Outdated Show resolved Hide resolved
crates/uv-trampoline/src/bounce.rs Show resolved Hide resolved
crates/uv-trampoline/src/bounce.rs Show resolved Hide resolved
crates/install-wheel-rs/src/wheel.rs Show resolved Hide resolved
} else {
b"python.exe"
expect_result(unsafe { CloseHandle(file_handle) }, 0, || {
String::from("Failed to close file handle")
Copy link
Member

Choose a reason for hiding this comment

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

OK, so reading the above, I think it works like this:

  • It first tries to read 1KB from the end of a file.
  • If it finds the magic number, path length and path all within that 1KB, it stops.
  • Otherwise, it resizes the buffer's capacity to whatever the decoded path_len is.
  • It then goes back to the first step again, but read {path_len}KB instead.

I think there might be a couple issues with this approach, both of which center around trusting path_len:

  1. If path_len is u32::MAX, then this will allocate 4GB.
  2. If the file is being mutated while we're doing this in a very specific way, then it's possible this loop isn't guaranteed to terminate.

(2) is kind of a stretch, but (1) I think is a real enough issue.

My suggestion here would be to read 4KB from the end of the file, and if you can't find the path in there, consider it malformed and give up.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks for the thorough review. I'll carefully go over the implementation again tomorrow.

  1. shouldn't be a problem because we open the file with FILE_SHARE_READ only, prohibiting other processes from mutating or deleting the file while we're using it.

Not trusting path_len makes sense as well does limiting. I'll probably go with a higher default, e.g. even URl have an upper limit of 2MB. Agreed, URLs may contain application data which is different but I don't think it hurts to go somewhat higher than 4KB.

Copy link
Member

Choose a reason for hiding this comment

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

Ah yeah good catch on (2). Thanks!

Copy link
Member

Choose a reason for hiding this comment

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

From https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=registry:

The Windows API has many functions that also have Unicode versions to permit an extended-length path for a maximum total path length of 32,767 characters.
The maximum path of 32,767 characters is approximate, because the "\?" prefix may be expanded to a longer string by the system at run time, and this expansion applies to the total length.

Copy link
Member

@BurntSushi BurntSushi Feb 22, 2024

Choose a reason for hiding this comment

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

So my read of that doc is that there isn't a pre-defined upper bound on how long a path can be. Does that match your understanding? If that's true, I still think we need to place some kind of reasonable upper bound on what we're willing to accept. I think 4KB is probably sufficient, but 32KB would be fine with me too.

Copy link
Member Author

Choose a reason for hiding this comment

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

I limited it to 32KB

@MichaReiser
Copy link
Member Author

@charliermarsh / @zanieb If I want to add a test case as outlined in the test plan, how would I go about it? Can I install any python package with a binary script as part of the test or do we have a stub package that has a script entry point that I can use?

@zanieb
Copy link
Member

zanieb commented Feb 21, 2024

@MichaReiser you could generate a stub package and add it to the repository or use some "small" real world package with a pinned version

@MichaReiser
Copy link
Member Author

Do you have a link with some resources on how I would do that?

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks @konstin for providing me with a test wheel!

Copy link
Member

@BurntSushi BurntSushi left a comment

Choose a reason for hiding this comment

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

Nice, thank you!

@@ -0,0 +1,3 @@
[unstable]
build-std = ["core", "panic_abort", "alloc", "std"]
build-std-features = ["compiler-builtins-mem"]
Copy link
Member

Choose a reason for hiding this comment

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

Did you mean to include this in this PR?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes that's intentional, considering that I won't land my std branch anytime soon. It requires me to only type cargo build --release --target x86_64-pc-windows-msvc instead of that plus the -Z compiler flags.

if i64::from(bytes_to_read) > file_size {
eprintln!("The length of the python executable path exceeds the file size. Verify that the path length is appended to the end of the launcher script as a u32 in little endian.");
exit_with_status(1);
}
Copy link
Member

Choose a reason for hiding this comment

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

At first I was unsure if this was limiting heap memory since bytes_to_read was still being calculated based on path_len, but I see above that path_len is limited to a maximum. So I think that's right.

@MichaReiser MichaReiser merged commit 12a96ad into main Feb 22, 2024
7 checks passed
@MichaReiser MichaReiser deleted the win-trampoline-symlinks branch February 22, 2024 15:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working windows Specific to the Windows platform
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Windows: symbolic link to the exe created by uv can not work
4 participants