Skip to content

fix: make setlocale() thread-local - #2831

Open
felixbuenemann wants to merge 1 commit into
php:mainfrom
felixbuenemann:fix/thread-local-setlocale
Open

felixbuenemann wants to merge 1 commit into
php:mainfrom
felixbuenemann:fix/thread-local-setlocale

Conversation

@felixbuenemann

@felixbuenemann felixbuenemann commented Nov 10, 2026 •

Copy link
Copy Markdown

setlocale() changes the locale of the whole process, so in FrankenPHP:

  • a request uses the locale set by requests running at the same time;
  • the end of a request resets the locale of all other requests to C;
  • concurrent calls can crash the process, because setlocale() isn't thread-safe.

This PR replaces setlocale() with a version that gives each thread its own locale, using the POSIX newlocale() and uselocale() functions. It's the same approach as the getenv()/putenv() sandbox. PHP code doesn't need to change.

Related: #1941 (gettext mixing languages, where this approach was first discussed), #1737 (setlocale() named among the MT-unsafe functions WordPress calls). This also updates the worker-mode note added in #2779.

Reproduction

The new tests fail on main. With the PHP 8.5 ZTS build on macOS, all three fail (localeconv()['decimal_point'] returning another request's ,, C instead of de_DE.UTF-8, etc.), and the test binary aborted (SIGABRT) in 2 of 3 runs.

In a real application (TYPO3 patched for worker-mode, alternating German and English pages, 8 concurrent connections), main rendered 171 of 4000 pages with the wrong locale, then aborted. With this PR, all 4000 pages were correct.

How it works

  • setlocale() is replaced in MINIT through CG(function_table), like getenv() and putenv(). Everything is in the new threadlocale.c, and the changes to frankenphp.c are small.
  • Each combination of category locales is created once with newlocale(), under a mutex, and shared by all threads. Threads only switch between these objects with uselocale(), and the locale-dependent C functions (printf(), localeconv(), strftime(), strcoll()…) follow it.
  • The locale objects are never freed, for three reasons:
    • other threads may still use them;
    • a process only needs a few, since there is one per distinct combination;
    • on macOS, freelocale() crashes while another thread calls newlocale().
  • Queries (setlocale(LC_ALL, '0')) return the same names as the C library, including composite LC_ALL names in each platform's own format, which differs between glibc, musl and macOS/BSD. So setlocale(LC_ALL, $saved) still restores all categories.
  • The argument handling is copied from ext/standard/string.c, gated by PHP_VERSION_ID so it matches each version:
    • 8.2–8.4 convert the arguments one at a time;
    • 8.5 type-checks all arguments first;
    • 8.6+ also rejects NUL bytes, and accepts an array only as the sole locale argument.
  • Classic mode: at RSHUTDOWN, the thread switches back to the process locale. PHP's own reset of the process locale to C no longer runs.
  • Worker mode: each request starts with the locale the worker script set before calling frankenphp_handle_request().
  • Windows: PHP already keeps the locale per thread there, so setlocale() isn't replaced. In worker mode, the locale the worker script set is restored after each request, as on other platforms.

Limitation: C code that calls the C library's setlocale() directly, in a PHP extension for instance, still changes the locale of the whole process.

Tests

  • TestSetlocale, TestSetlocale_worker and TestSetlocaleBeforeHandlingRequests_worker run concurrent requests that switch between locales. They pass with -race.
  • A script with 30 locale edge cases and 31 argument edge cases produced output identical to the native PHP CLI. The edge cases include invalid names, '0', '', null, arrays, LC_ALL composites, wrong types, strict_types, and NUL bytes. Platforms:
    • PHP 8.3, 8.4, 8.5, 8.6.0RC5 and master on macOS;
    • PHP 8.2 and master on Debian (glibc 2.41);
    • PHP 8.5 on Debian and Alpine (musl).
  • The full suite (go test -race ./..., library and Caddy module) passes with PHP 8.5 on macOS. The only exception is TestExecuteScriptCLISignals, which crashes the same way on main there.
  • Not tested:

Performance

  • On macOS, changing the locale took 108 µs with the native setlocale(), which reloads the locale data on every call. With this PR it takes 0.15 µs, because the locale objects are reused.
  • TYPO3 pages that alternate between languages got about 16% higher requests/s.

Relation to php-src

The same change was proposed for PHP itself in php/php-src#20531. Reviewers there found the uselocale() approach reasonable, but the PR was closed in August 2026 in favour of a broader ZTS rework. Until PHP has thread-local locales, this fixes the problem for FrankenPHP users on all supported PHP versions. Once PHP has them, the override can be gated by PHP_VERSION_ID and removed later.

setlocale() changes the locale of the whole process, while FrankenPHP
runs requests in threads of one process: a request uses the locale set
by requests running at the same time, the end of a request resets the
locale of all others to "C", and concurrent calls crash the process
(setlocale() isn't thread-safe; on macOS it aborts or segfaults).

Like putenv() and getenv(), setlocale() is now replaced with a version
that gives each thread its own locale with the POSIX newlocale() and
uselocale() functions, which the locale-dependent C functions follow.
Each combination of category locales is created once, under a mutex, and
shared by all threads, so switching locales no longer reloads the locale
data. Queries and composite LC_ALL names return the same names as the C
library, and the arguments are checked like setlocale() does in each PHP
version.

A script now starts with the process locale, and in worker mode each
request starts with the locale the worker script set before handling
requests.

Windows builds of PHP already keep the locale per thread, so there only
the locale of the worker script is restored after each request. A
thread-local setlocale() was proposed for PHP itself (php/php-src#20531),
but closed in favour of a broader ZTS rework.
@felixbuenemann
felixbuenemann force-pushed the fix/thread-local-setlocale branch from ea7d3ee to 1b532a6 Compare November 10, 2026 16:59
@dunglas

dunglas commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

Shouldn't this be changed in PHP directly?

@felixbuenemann

Copy link
Copy Markdown
Author

Shouldn't this be changed in PHP directly?

Ideally it would, but work started on it before was abandoned and it could land earliest in php 8.7 which is a year a way. What would make sense is trying to get the php-specific bits into 8.7 in a parallel PR. However this code provides a solution for 8.2+ when used with frankenphp.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants