https://github.com/python/cpython/commit/6318730aeebdf7282fa9a18aca52af4e87c0c39d
commit: 6318730aeebdf7282fa9a18aca52af4e87c0c39d
branch: main
author: Victor Stinner <[email protected]>
committer: vstinner <[email protected]>
date: 2026-10-06T23:02:13+02:00
summary:

gh-158893: Make os.strerror() thread-safe (#158927)

Make os.strerror() thread-safe: use the reentrant strerror_r()
function if available.

* The configure script now checks if strerror_r() is supported.
* Add a stress test to test_free_threading.test_os (new module).
* Add a comment on decode_current_locale() assertion which fails if
  the input string is mutated.
* Add an assertion to _Py_DecodeLocale() to detect if the input
  string was mutated during the function call.

files:
A Lib/test/test_free_threading/test_os.py
A Misc/NEWS.d/next/Library/2026-10-06-20-03-01.gh-issue-158893.B6A53q.rst
M Modules/posixmodule.c
M Python/fileutils.c
M configure
M configure.ac
M pyconfig.h.in

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 <string.h>
+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 <string.h>])
+
+
 # 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
 

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to