diff --git a/Lib/test/test_free_threading/test_os.py b/Lib/test/test_free_threading/test_os.py new file mode 100644 index 000000000000000..72582da29015e93 --- /dev/null +++ b/Lib/test/test_free_threading/test_os.py @@ -0,0 +1,36 @@ +import errno +import os +import sysconfig +import unittest + +from test.support import threading_helper +from test.support.threading_helper import run_concurrently + + +NTHREADS = 10 + + +@threading_helper.requires_working_threading() +class TestOs(unittest.TestCase): + @unittest.skipUnless(sysconfig.get_config_var('_Py_HAVE_STRERROR_R'), + 'need _Py_HAVE_STRERROR_R macro') + def test_strerror(self): + # gh-158893: os.strerror() is implemented with strerror_r() which is + # thread safe. Well, check if it's actually the case. + last_error = max([getattr(errno, name) for name in dir(errno) + if name.startswith('E')]) + test_errors = tuple(range(1, last_error + 1)) + loops = 20 + + def worker(): + for _ in range(loops): + for i in test_errors: + os.strerror(i) + + run_concurrently( + worker_func=worker, nthreads=NTHREADS + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/Misc/NEWS.d/next/Library/2026-10-06-20-03-01.gh-issue-158893.B6A53q.rst b/Misc/NEWS.d/next/Library/2026-10-06-20-03-01.gh-issue-158893.B6A53q.rst new file mode 100644 index 000000000000000..9345b46da0f4762 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-06-20-03-01.gh-issue-158893.B6A53q.rst @@ -0,0 +1,2 @@ +Make :func:`os.strerror` thread-safe: use the reentrant ``strerror_r()`` +function if available. Patch by Victor Stinner. diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c index eacf6556c1ffa41..7eb171641137565 100644 --- a/Modules/posixmodule.c +++ b/Modules/posixmodule.c @@ -13923,6 +13923,78 @@ static PyObject * os_strerror_impl(PyObject *module, int code) /*[clinic end generated code: output=baebf09fa02a78f2 input=75a8673d97915a91]*/ { +#ifdef _Py_HAVE_STRERROR_R + // Check which strerror_r() API is used +# if defined(__GLIBC__) && !((_POSIX_C_SOURCE >= 200112L) && !defined(_GNU_SOURCE)) +# define Py_STRERROR_R_GNU +# elif defined(__ANDROID__) && defined(_GNU_SOURCE) +# define Py_STRERROR_R_GNU +# endif +#endif + +#ifdef Py_STRERROR_R_GNU + // Implementation for the GNU flavor of strerror_r() + + // On Linux, the longest translated strerror() message is 86 bytes + // (including the NUL byte). + char buffer[100]; + char *message = strerror_r(code, buffer, Py_ARRAY_LENGTH(buffer)); + // The strerror_r() GNU flavor doesn't provide a way to check if the error + // message was truncated or not. + // + // When the buffer is used, a trailing NUL byte is always written. + assert(message != buffer || memchr(buffer, 0, Py_ARRAY_LENGTH(buffer)) != NULL); + return PyUnicode_DecodeLocale(message, "surrogateescape"); + +#elif defined(_Py_HAVE_STRERROR_R) + // Implementation for the XSI-compliant flavor of strerror_r() + + // On Linux and FreeBSD, the longest translated strerror() message is 86 + // bytes (including the NUL byte). + char small_buffer[100]; + size_t buflen = Py_ARRAY_LENGTH(small_buffer); + char *buffer = NULL; +#ifndef NDEBUG + // Make sure that strerror_r() writes a trailing null byte + small_buffer[buflen - 1] = '#'; +#endif + int len = strerror_r(code, small_buffer, buflen); + if (len == ERANGE) { + while (len == ERANGE) { + if (buflen > (size_t)PY_SSIZE_T_MAX / 2) { + PyMem_Free(buffer); + PyErr_NoMemory(); + return NULL; + } + buflen = buflen * 2; + + char *new_buffer = PyMem_Realloc(buffer, buflen); + if (new_buffer == NULL) { + PyMem_Free(buffer); + PyErr_NoMemory(); + return NULL; + } + buffer = new_buffer; +#ifndef NDEBUG + buffer[buflen - 1] = '#'; +#endif + len = strerror_r(code, buffer, buflen); + } + } + else { + buffer = small_buffer; + } + + // strerror_r() always writes a trailing NUL byte + assert(memchr(buffer, 0, buflen) != NULL); + PyObject *result = PyUnicode_DecodeLocale(buffer, "surrogateescape"); + if (buffer != small_buffer) { + PyMem_Free(buffer); + } + return result; + +#else + // strerror() implementation char *message = strerror(code); if (message == NULL) { PyErr_SetString(PyExc_ValueError, @@ -13930,6 +14002,7 @@ os_strerror_impl(PyObject *module, int code) return NULL; } return PyUnicode_DecodeLocale(message, "surrogateescape"); +#endif } diff --git a/Python/fileutils.c b/Python/fileutils.c index 8ed88047b35e4ff..9deb474820b55f8 100644 --- a/Python/fileutils.c +++ b/Python/fileutils.c @@ -538,8 +538,14 @@ decode_current_locale(const char* arg, wchar_t **wstr, size_t *wlen, // +1 to write also the trailing NUL character size_t count = _Py_mbstowcs(res, arg, argsize + 1); if (count != DECODE_ERROR) { - // Success + // String decoded successfully. + + // gh-158893: This assertion can fail if the input string was + // mutated during this function call. For example, the assertion + // fails on decoding strerror() result if another thread mutated + // the string in-place by calling strerror() in parallel. assert(count == argsize); + *wstr = res; if (wlen != NULL) { *wlen = count; @@ -694,6 +700,7 @@ _Py_DecodeLocale(const char* arg, wchar_t **wstr, size_t *wlen, assert(wstr != NULL); #ifdef Py_DEBUG + size_t arglen = strlen(arg); size_t wlen_canary = (size_t)-2; if (wlen) { *wlen = wlen_canary; @@ -719,6 +726,11 @@ _Py_DecodeLocale(const char* arg, wchar_t **wstr, size_t *wlen, // Success assert(*wstr != NULL); #ifdef Py_DEBUG + // gh-158893: Detect if the input string was mutated during the + // function call. For example, the assertion fails on decoding + // strerror() result if another thread mutated the string in-place by + // calling strerror() in parallel. + assert(strlen(arg) == arglen); if (wlen != NULL) { assert(*wlen == wcslen(*wstr)); } diff --git a/configure b/configure index deb01864e731780..c0c0e5b193c2066 100755 --- a/configure +++ b/configure @@ -21730,6 +21730,50 @@ then : fi + + + { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking for strerror_r" >&5 +printf %s "checking for strerror_r... " >&6; } +if test ${ac_cv_func_strerror_r+y} +then : + printf %s "(cached) " >&6 +else case e in #( + e) cat confdefs.h - <<_ACEOF >conftest.$ac_ext +/* end confdefs.h. */ +#include +int +main (void) +{ +void *x=strerror_r + ; + return 0; +} +_ACEOF +if ac_fn_c_try_compile "$LINENO" +then : + ac_cv_func_strerror_r=yes +else case e in #( + e) ac_cv_func_strerror_r=no ;; +esac +fi +rm -f core conftest.err conftest.$ac_objext conftest.beam conftest.$ac_ext + ;; +esac +fi +{ printf "%s\n" "$as_me:${as_lineno-$LINENO}: result: $ac_cv_func_strerror_r" >&5 +printf "%s\n" "$ac_cv_func_strerror_r" >&6; } + if test "x$ac_cv_func_strerror_r" = xyes +then : + +printf "%s\n" "#define _Py_HAVE_STRERROR_R 1" >>confdefs.h + +fi + + + + + + # os.statx uses Linux's statx function. AIX also has a function named statx, # but it's unrelated. Check only on Linux (including Android). case $ac_sys_system in #( diff --git a/configure.ac b/configure.ac index a1802f303e95ebb..9e83af6c2d95cac 100644 --- a/configure.ac +++ b/configure.ac @@ -5587,6 +5587,9 @@ AC_CHECK_FUNCS([ \ wait wait3 wait4 waitid waitpid wcscoll wcsftime wcsxfrm wmemcmp writev \ ]) +PY_CHECK_FUNC_PRIVATE([strerror_r], [@%:@include ]) + + # os.statx uses Linux's statx function. AIX also has a function named statx, # but it's unrelated. Check only on Linux (including Android). AS_CASE([$ac_sys_system], diff --git a/pyconfig.h.in b/pyconfig.h.in index d1de60ad757f4da..08306a1dc8ba135 100644 --- a/pyconfig.h.in +++ b/pyconfig.h.in @@ -2214,6 +2214,9 @@ /* Define if you have the 'sinpi' function. */ #undef _Py_HAVE_SINPI +/* Define if you have the 'strerror_r' function. */ +#undef _Py_HAVE_STRERROR_R + /* Define if you have the 'tanpi' function. */ #undef _Py_HAVE_TANPI