Skip to content

gh-157899: Add support for fds pointing to symlinks in os.readlink(). - #157915

Merged
encukou merged 11 commits into
python:mainfrom
OOTS:feature/gh-157899-fds-in-os-readlink
Oct 13, 2026
Merged

encukou merged 11 commits into
python:mainfrom
OOTS:feature/gh-157899-fds-in-os-readlink

Conversation

@OOTS

@OOTS OOTS commented Sep 21, 2026 •

Copy link
Copy Markdown

@python-cla-bot

python-cla-bot Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

All commit authors signed the Contributor License Agreement.

CLA signed

Comment thread Doc/library/os.rst Outdated
<dir_fd>`.

On MacOS and Linux, *path* can also be a file descriptor referring to a
symbolic link. In that case, *dir_fd* must be ``None``, and the return

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Another sensible behaviour would be to ignore dir_fd completely, but I think raising an error here is the better alternative.

Comment thread Doc/library/os.rst Outdated

On MacOS and Linux, *path* can also be a file descriptor referring to a
symbolic link. In that case, *dir_fd* must be ``None``, and the return
value will be a ``bytes`` object.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This just so happened - maybe a str is more reasonable?

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.

Yes, readlins returns str (with surogatepass encoding, so you can get the bytes out losslessly).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Updated: The implementation returns a "string" (via PyUnicode_DecodeFSDefaultAndSize), the tests check for equality with a string ("symlink" rather than b"symlink"), and the docs are updated, too.

Comment thread Modules/posixmodule.c
@read-the-docs-community

read-the-docs-community Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Comment thread Lib/test/test_os/test_posix.py
@OOTS

OOTS commented Sep 21, 2026

Copy link
Copy Markdown
Author

One open question is whether os.readlink() should be added to supports_fd. I don't have an opinion on that, happy to take others' lead here.

@OOTS
OOTS marked this pull request as ready for review September 21, 2026 19:45
@OOTS
OOTS force-pushed the feature/gh-157899-fds-in-os-readlink branch from d6a5c5c to d7fa0ad Compare September 23, 2026 08:12
…ink().

This feature is only supported on Linux, Android and MacOS, other OSs should raise a NotImplementedError().
@OOTS
OOTS force-pushed the feature/gh-157899-fds-in-os-readlink branch from d7fa0ad to 8196b59 Compare September 23, 2026 10:18
Comment thread Doc/library/os.rst Outdated

On MacOS and Linux, *path* can also be a file descriptor referring to a
symbolic link. In that case, *dir_fd* must be ``None``, and the return
value will be a ``bytes`` object.

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.

Yes, readlins returns str (with surogatepass encoding, so you can get the bytes out losslessly).

Comment thread Modules/posixmodule.c Outdated
}

if (path->is_fd) {
#if defined(__APPLE__) && defined(_Py_HAVE_FREADLINK)

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 we only want _Py_HAVE_FREADLINK here -- if any other platform has it, it's the function to call.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Removed the defined(__APPLE__) check.

Comment thread Doc/library/os.rst Outdated
<dir_fd>`.

On MacOS and Linux, *path* can also be a file descriptor referring to a
symbolic link. In that case, *dir_fd* must be ``None``, and the return

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 nice to document how to get such a file descriptor (O_SYMLINK and O_PATH|O_NOFOLLOW).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added this to the docs.

Comment thread Modules/posixmodule.c Outdated
int readlinkat_unavailable = 0;
#endif
#ifdef _Py_HAVE_FREADLINK
int freadlink_unavailable = 0;

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.

AFAICS, we don't need the *_unavailable variables. The pattern is sometimes used because we can't raise exceptions inside Py_BEGIN_ALLOW_THREADS/Py_END_ALLOW_THREADS, but here, moving the Py_BEGIN_ALLOW_THREADS/Py_END_ALLOW_THREADS to tightly wrap each freadlink/readlinkat/readlink call would allow setting the exception & returning direclty in the else blocks.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. I dropped the freadlink_unavailable variable and the error is now raised directly in the else branch.

I also "inlined" all the invocations of Py_BEGIN_ALLOW_THREADS and Py_END_ALLOW_THREADS, each syscall (freadlink, readlinkat in the path->is_fd case, readlinkat in the old dir_fd case, readlink) are now directly surrounded by those macros.

This is technically a bit of a refactor and slightly out-of-scope of the issue - but I hope it's okay to piggy-back this refactor onto this MR.

Comment thread Modules/posixmodule.c Outdated
Comment thread Modules/posixmodule.c Outdated
Comment on lines +11046 to +11049
// Linux: readlinkat(dir_fd, "", ...) reads the symbolic link
// pointed to by dir_fd
dir_fd = path->fd;
path->narrow = "";

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.

Putting the values in these variables is misleading.
Instead, could you add a separate block below with a call to readlinkat(path->fd, "", ...) & the error handling?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. We now have a if (path->is_fd) { ... } else if (path->dir_fd != DEFAULT_DIR_FD) { ... } else { ... } structure.

The different implementations of this feature (freadlink on MacOS, readlinkat on Linux/Android, NotImplementedError on Android) are now all inside the if (path->is_fd) { ... } branch.

Comment thread Lib/test/test_os/test_posix.py
Comment thread Modules/posixmodule.c Outdated
if (path->is_fd) {
#if defined(__APPLE__) && defined(_Py_HAVE_FREADLINK)
/* nop, freadlink is called below */
#elif defined(__linux__) && defined(HAVE_READLINKAT)

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 it would be useful to make this a configure variable, something like _Py_READLINKAT_SUPPORTS_EMPTY_PATH, only set on Linux.
Then, when someone ports CPython to another system that uses this (or to ancient Linux that doesn't), they can just change pyconfig.h or the configure script.

Alternately, use defined(HAVE_READLINKAT) && defined(O_PATH). O_PATH and empty pathname support were added in Linux 2.6.39; they look like it's essentially a single feature.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I tried with defined(HAVE_READLINKAT) && defined(O_PATH), but that broke on the emscripten build. (Apparently both Macros are defined there even though readlink is not supported on that platform AFAIK.)

So now _Py_READLINKAT_SUPPORTS_EMPTY_PATH is set automatically on Linux and Android (that have O_PATH) above. Anyone who really wants it can still force that variable.

I didn't add anything about this to the autoconfig part - I hope that's okay. If you want it threaded through/into the configure.ac somehow, I can look into that.

Comment thread Doc/whatsnew/3.16.rst Outdated
(Contributed by Md Arif in :gh:`152936`.)

* :func:`os.readlink` now accepts a file descriptor referring to a symlink on
Linux, Android and MacOS.

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.

Could you add Android to the other docs?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done.

Comment thread Lib/test/test_os/test_posix.py Outdated
Comment on lines +1885 to +1891
_support_readlink_with_fd = hasattr(os, 'readlink') and (
"HAVE_FREADLINK" in posix._have_functions # MacOS
or (
os.readlink in os.supports_dir_fd
and sys.platform in ["linux", "android"]
)
)

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.

One open question is whether os.readlink() should be added to supports_fd. I don't have an opinion on that, happy to take others' lead here.

I'd say add them. O_SYMLINK/O_PATH|O_NOFOLLOW are the way to get fds referring to a symlink on their respective platform, so this can be just os.readlink in os.supports_dir_fd.
(Yes, I changed my mind after going through the code.)

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.

Then, please also test that os.readlink in os.supports_dir_fd holds on MacOS, Linux & Android.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done. os.readlink is now added automatically to os.supports_fd. I also added a test that checks presence/absence on all platforms where both os.readlink and os.supports_fd are defined.

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.

_support_readlink_with_fd should now be equivalent to hasattr(os, 'readlink') and (os.readlink in os.supports_fd). Or more fancy, (getattr(os, 'readlink', None) in os.supports_fd)
The in expression can be used directly, as in the test above; no need for a _support_readlink_with_fd wariable.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I'm updating this, but I'd prefer to keep the _support_readlink_with_fd variable (just for avoiding code/logic duplication).

As a side effect, I'm now removing the self.assertEqual(os.readlink in os.supports_fd, self._support_readlink_with_fd) test that you asked for, since this test would be tautological. (It currently sits behind a @unittest.skipUnless(hasattr(os, "readlink")) guard.)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Okay, rather than removing the test completely I added something closer to what you originally asked for:

    @unittest.skipUnless(sys.platform in ["linux", "android", "darwin", "ios"],
                         "feature not supported on this platform")
    def test_readlink_is_marked_as_supports_fd(self):
        self.assertIn(os.readlink, os.supports_fd)

…ddress feedback from MR.

* os.readlink() now returns a `str` (rather than `bytes`) when called with a file descriptor.
* os.readlink now added to `os.supports_fd` on the platforms where this feature is supported.
* Small refactor inside `os.readlink`: drop *_unavailable variables, generate error messages inline.
  Py_BEGIN_ALLOW_THREADS/Py_END_ALLOW_THREADS around each syscall site.
* Docs: Android is now mentioned as supported platform for this feature in all relevant places.
Emscripten apparrently defines O_PATH and readlinkat - even though it doesn't actually support them.
This broke auto-detection of whether os.readlink supports FDs - which broke the tests.
@OOTS

OOTS commented Sep 27, 2026

Copy link
Copy Markdown
Author

Thank you for your review @encukou . I updated this PR based on your comments.

Please re-review when you can spare some minutes.

@OOTS
OOTS requested a review from encukou September 28, 2026 07:45
@bedevere-app bedevere-app Bot added the type-feature A feature request or enhancement label Sep 28, 2026

@vstinner vstinner 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.

macOS should be written "macOS", not "MacOS".

Comment thread Doc/library/os.rst Outdated
Comment thread Doc/library/os.rst Outdated
Comment thread Doc/library/os.rst Outdated
Comment thread Doc/library/os.rst Outdated
Comment thread Doc/whatsnew/3.16.rst Outdated
Comment thread Lib/test/test_os/test_posix.py Outdated
Comment thread Misc/NEWS.d/next/Library/2026-09-21-14-41-07.gh-issue-157899.s6vBQC.rst Outdated
Comment thread Modules/posixmodule.c Outdated
Comment thread Modules/posixmodule.c Outdated
Comment thread Modules/posixmodule.c Outdated
After changing return type of os.readlink when called with an fd from bytes to string,
missed one place in the docs that needed updating.
* Simplified/unified error handling in case reading fds referring to symlinks is not supported.
* Docs: centralized the information on the return type of os.readlink in a single paragraph.
* Docs/comments: rewrite from "MacOS" to "macOS" (lower-case).
@OOTS
OOTS force-pushed the feature/gh-157899-fds-in-os-readlink branch from 6094134 to 20fa1e2 Compare September 29, 2026 14:20

@vstinner vstinner 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.

Overall, the change LGTM. I just have another minor comment.

@encukou: I let you double check the change :-)

I was curious and replaced readlinkat(path->fd, "", buffer, MAXPATHLEN) with readlinkat(path->fd, "", buffer, MAXPATHLEN). With this change, the function fails with OSError: [Errno 14] Bad address: 6. So no, it's not possible to pass NULL: passing an empty string is the right argument.

Comment thread Lib/test/test_os/test_posix.py Outdated
Comment on lines +1950 to +1951
@unittest.skipUnless(hasattr(os, "supports_fd") and hasattr(os, "readlink"),
"feature not supported on this platform")

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.

According to other tests, hasattr(os, "supports_fd") test is not needed, the attribute can always be used.

Suggested change
@unittest.skipUnless(hasattr(os, "supports_fd") and hasattr(os, "readlink"),
"feature not supported on this platform")
@unittest.skipUnless(hasattr(os, "readlink"), "need os.readlink")

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Sorry, I missed this comment. I just pushed an update: 6c8f5f1

Originally I wanted to be careful (because the supports_fd set is only defined within an if _exists("_have_functions") guard), but it appears that the _have_functions list is defined unconditionally (at least in the posixmodule.c).

@encukou

encukou commented Sep 30, 2026

Copy link
Copy Markdown
Member

@encukou: I let you double check the change :-)

I will, after the 3.15.0 release. Please don't merge yet.

@OOTS

OOTS commented Oct 9, 2026

Copy link
Copy Markdown
Author

Resolved a merge conflict with commit cfd6cf7 on a "clinic end generated code" line: cfd6cf71547e#diff-f4ec448d9bc73d373452e97931de916ca83058ae93bce4a5853b8ac18c9d2cfa

@encukou encukou 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.

Thank you! This is looking great, I have a few cleanups to suggest:

Comment thread Lib/test/test_os/test_posix.py Outdated
Comment on lines +1885 to +1891
_support_readlink_with_fd = hasattr(os, 'readlink') and (
"HAVE_FREADLINK" in posix._have_functions # MacOS
or (
os.readlink in os.supports_dir_fd
and sys.platform in ["linux", "android"]
)
)

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.

_support_readlink_with_fd should now be equivalent to hasattr(os, 'readlink') and (os.readlink in os.supports_fd). Or more fancy, (getattr(os, 'readlink', None) in os.supports_fd)
The in expression can be used directly, as in the test above; no need for a _support_readlink_with_fd wariable.

Comment thread Lib/test/test_os/test_posix.py
Comment thread Modules/posixmodule.c Outdated
Comment thread Modules/posixmodule.c Outdated
…ink with a FD referring to a symlink.

Also some code/test cleanup.

@encukou encukou 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.

Thank you, looks good to go now!

Comment thread Lib/test/test_os/test_posix.py Outdated
@encukou
encukou enabled auto-merge (squash) October 13, 2026 13:55
@vstinner

Copy link
Copy Markdown
Member

MSan: test_interrupt() of test_multiprocessing_forkserver.test_processes failed with a timeout.

I suppose that re-run the job should be enough to work around the issue.

ERROR: test_interrupt (test.test_multiprocessing_forkserver.test_processes.WithProcessesTestProcess.test_interrupt)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/runner/work/cpython/cpython/Lib/contextlib.py", line 116, in inner
    return func(*args, **kwds)
  File "/home/runner/work/cpython/cpython/Lib/test/_test_multiprocessing.py", line 651, in test_interrupt
    exitcode = self._kill_process(multiprocessing.Process.interrupt)
  File "/home/runner/work/cpython/cpython/Lib/contextlib.py", line 116, in inner
    return func(*args, **kwds)
  File "/home/runner/work/cpython/cpython/Lib/test/_test_multiprocessing.py", line 632, in _kill_process
    self.assertEqual(join(), None)
                     ~~~~^^
  File "/home/runner/work/cpython/cpython/Lib/test/_test_multiprocessing.py", line 305, in __call__
    return self.func(*args, **kwds)
           ~~~~~~~~~^^^^^^^^^^^^^^^
  File "/home/runner/work/cpython/cpython/Lib/multiprocessing/process.py", line 156, in join
    res = self._popen.wait(timeout)
  File "/home/runner/work/cpython/cpython/Lib/multiprocessing/popen_fork.py", line 44, in wait
    return self.poll(os.WNOHANG if timeout == 0.0 else 0)
           ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/runner/work/cpython/cpython/Lib/multiprocessing/popen_forkserver.py", line 65, in poll
    if not wait([self.sentinel], timeout):
           ~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/runner/work/cpython/cpython/Lib/multiprocessing/connection.py", line 1197, in wait
    ready = selector.select(timeout)
  File "/home/runner/work/cpython/cpython/Lib/selectors.py", line 398, in select
    fd_event_list = self._selector.poll(timeout)
  File "/home/runner/work/cpython/cpython/Lib/test/_test_multiprocessing.py", line 628, in handler
    raise RuntimeError('join took too long: %s' % p)
RuntimeError: join took too long: <Process name='Process-167' pid=20444 parent=18089 started daemon>

@encukou
encukou merged commit 4b0e334 into python:main Oct 13, 2026
101 of 104 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type-feature A feature request or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants