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]