From 1b532a67af813798cddaa49e88534faff566c013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Bu=CC=88nemann?= Date: Sun, 8 Nov 2026 14:33:39 +0100 Subject: [PATCH] fix: make setlocale() thread-local 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. --- docs/internals.md | 10 + docs/worker.md | 2 +- frankenphp.c | 24 +- testdata/setlocale-worker.php | 10 + testdata/setlocale.php | 29 ++ threadlocale.c | 566 ++++++++++++++++++++++++++++++++++ threadlocale.h | 19 ++ threadlocale_test.go | 67 ++++ 8 files changed, 720 insertions(+), 7 deletions(-) create mode 100644 testdata/setlocale-worker.php create mode 100644 testdata/setlocale.php create mode 100644 threadlocale.c create mode 100644 threadlocale.h create mode 100644 threadlocale_test.go diff --git a/docs/internals.md b/docs/internals.md index 4b0153a454..82eec77db1 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -231,6 +231,16 @@ FrankenPHP sandboxes environment variables per-thread: 4. `frankenphp_getenv()` looks a variable up in thread-local layers, the first match wins: `sandboxed_env` (`putenv()` writes and unsets), `prepared_env` (the `env` of the current request, released after each worker request), `worker_env` (the `env` of the worker script, kept for all its requests), then `main_thread_env`. `putenv()` writes land only in `sandboxed_env` and **never** mutate the process-global OS environment, so a value set by one request is not visible to the Go/Caddy side, to child processes (`proc_open`, `exec`, ...), or to other requests. This also avoids races on the global C environment. 5. `reset_sandboxed_environment()` releases `sandboxed_env` after each PHP script execution. In regular mode that's per request; in worker mode it only runs when the worker script itself exits, so `putenv()` writes are visible to subsequent worker requests on the same thread until the script restarts. +## Per-thread locale + +The C library's `setlocale()` changes the locale of the whole process and isn't thread-safe: a request would use the locale of the requests running at the same time, and concurrent calls can crash the process. FrankenPHP replaces PHP's `setlocale()` with `frankenphp_setlocale()` (`threadlocale.c`), which gives each thread its own locale with the POSIX `newlocale()` and `uselocale()` functions: + +1. Each combination of category locales is created once with `newlocale()`, under a mutex, and kept until the process exits. Threads only switch between these locale objects with `uselocale()`, which the locale-dependent C functions (`strftime()`, `strcoll()`, `localeconv()`, `printf()`...) follow. +2. The locale objects store the name of each category, so `setlocale($category, '0')` and composite `LC_ALL` names (to restore all categories at once) behave as with the C library. +3. When a script ends (`RSHUTDOWN`), the thread switches back to the process locale. In worker mode, it also switches back after each request to the locale the worker script set before handling requests. PHP's own reset of the process locale to `C` never runs, because `BG(locale_changed)` stays unset. + +Windows builds of PHP already keep the locale per thread, so the override is only installed on other platforms. On Windows, FrankenPHP only restores the locale of the worker script after each request. C code calling the C library's `setlocale()` directly, in a PHP extension for instance, still changes the locale of the whole process. + ## Request flow (regular mode) 1. HTTP request arrives at Caddy diff --git a/docs/worker.md b/docs/worker.md index 220b6fee2a..9f2b7e0527 100644 --- a/docs/worker.md +++ b/docs/worker.md @@ -247,7 +247,7 @@ Everything else is shared between the requests handled by the same thread, as in - **Engine and extension state**: `error_get_last()`, `libxml_get_errors()` and `libxml_get_last_error()` return the errors an earlier request left behind. `http_get_last_response_headers()` returns the response headers of the last HTTP call made on the thread, even by an earlier request. `strtok()` continues the string an earlier request gave it. After `mt_srand()` or `srand()` with a seed, `mt_rand()`, `rand()`, `shuffle()`, `str_shuffle()` and `array_rand()` continue that seeded sequence. The errors, headers and strings can contain another client's data, and a seeded sequence makes random values predictable: reset them as shown below. - **Objects in reference cycles**: The garbage collector destroys them when PHP triggers it, possibly while a later request is handled, so their destructors run in that request and their output goes to its client. Call `gc_collect_cycles()` after `frankenphp_handle_request()` returns, as in the example above. - **Shutdown functions**: Callbacks registered with `register_shutdown_function()` run when the worker script stops, not at the end of the request that registered them, and stay in memory until then. If a request stops the script (`exit()`, a fatal error, a timeout), they run inside that request and their output and headers go to its client. Register them once, outside the handler, and run per-request work at the end of the handler or after `frankenphp_handle_request()` returns. -- **Process-wide settings**: `umask()` and `setlocale()` change the whole FrankenPHP process, not only the current thread. Worker mode doesn't restore them between requests, and in every mode they also affect requests running at the same time. Set them once at startup, or call `chmod()` on created files instead of changing the umask. +- **Process-wide settings**: `umask()` changes the whole FrankenPHP process, not only the current thread. Worker mode doesn't restore it between requests, and in every mode it also affects requests running at the same time. Call `chmod()` on created files instead of changing the umask. `setlocale()`, however, only changes the locale of the current thread, and each request starts with the locale the worker script set before handling requests. This is by design and is what makes worker mode fast. However, it requires attention to avoid unintended side effects: diff --git a/frankenphp.c b/frankenphp.c index ca7ec27b14..e88cc65610 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -58,6 +58,7 @@ #include "_cgo_export.h" #include "frankenphp_arginfo.h" +#include "threadlocale.h" #ifdef FRANKENPHP_TEST /* The persistent_zval helpers are only compiled in when a consumer needs * them. The step that lands the first real caller (background workers) @@ -639,6 +640,8 @@ static void frankenphp_worker_request_shutdown() { prepared_env = NULL; } + frankenphp_restore_worker_locale(); + zend_set_memory_limit(PG(memory_limit)); } @@ -650,6 +653,7 @@ bool frankenphp_shutdown_dummy_request(void) { worker_env = prepared_env; prepared_env = NULL; + frankenphp_save_worker_locale(); frankenphp_worker_request_shutdown(); return true; @@ -1272,6 +1276,8 @@ PHP_MINIT_FUNCTION(frankenphp) { php_error(E_WARNING, "Failed to find built-in getenv function"); } + frankenphp_override_setlocale(); + // Override opcache_reset (may not be available yet if opcache loads as a // shared extension in PHP 8.4 and below) frankenphp_override_opcache_reset(); @@ -1341,15 +1347,21 @@ PHP_MINFO_FUNCTION(frankenphp) { } } +static PHP_RSHUTDOWN_FUNCTION(frankenphp) { + frankenphp_reset_thread_locale(); + + return SUCCESS; +} + static zend_module_entry frankenphp_module = { STANDARD_MODULE_HEADER, "frankenphp", - ext_functions, /* function table */ - PHP_MINIT(frankenphp), /* initialization */ - NULL, /* shutdown */ - NULL, /* request initialization */ - NULL, /* request shutdown */ - PHP_MINFO(frankenphp), /* information */ + ext_functions, /* function table */ + PHP_MINIT(frankenphp), /* initialization */ + NULL, /* shutdown */ + NULL, /* request initialization */ + PHP_RSHUTDOWN(frankenphp), /* request shutdown */ + PHP_MINFO(frankenphp), /* information */ TOSTRING(FRANKENPHP_VERSION), STANDARD_MODULE_PROPERTIES}; diff --git a/testdata/setlocale-worker.php b/testdata/setlocale-worker.php new file mode 100644 index 0000000000..c59ff3517d --- /dev/null +++ b/testdata/setlocale-worker.php @@ -0,0 +1,10 @@ + + +#include +#include +#include +#include +#include +#ifdef __APPLE__ +#include +#endif + +#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__NetBSD__) || \ + defined(__OpenBSD__) || defined(__DragonFly__) +#define BSD_COMPOSITE_NAMES +#endif + +typedef struct { + int category; + int mask; + const char *name; +} locale_category; + +/* In the order of the composite name setlocale(LC_ALL, NULL) returns when the + * categories have different locales: "C/C.UTF-8/C/C/C/C" on BSD and macOS, + * "LC_CTYPE=C.UTF-8;LC_NUMERIC=C;..." on glibc and "C.UTF-8;C;C;C;C;C" on + * musl */ +static const locale_category categories[] = { +#ifdef BSD_COMPOSITE_NAMES + {LC_COLLATE, LC_COLLATE_MASK, "LC_COLLATE"}, + {LC_CTYPE, LC_CTYPE_MASK, "LC_CTYPE"}, + {LC_MONETARY, LC_MONETARY_MASK, "LC_MONETARY"}, + {LC_NUMERIC, LC_NUMERIC_MASK, "LC_NUMERIC"}, + {LC_TIME, LC_TIME_MASK, "LC_TIME"}, + {LC_MESSAGES, LC_MESSAGES_MASK, "LC_MESSAGES"}, +#else + {LC_CTYPE, LC_CTYPE_MASK, "LC_CTYPE"}, + {LC_NUMERIC, LC_NUMERIC_MASK, "LC_NUMERIC"}, + {LC_TIME, LC_TIME_MASK, "LC_TIME"}, + {LC_COLLATE, LC_COLLATE_MASK, "LC_COLLATE"}, + {LC_MONETARY, LC_MONETARY_MASK, "LC_MONETARY"}, + {LC_MESSAGES, LC_MESSAGES_MASK, "LC_MESSAGES"}, +#ifdef __GLIBC__ + {LC_PAPER, LC_PAPER_MASK, "LC_PAPER"}, + {LC_NAME, LC_NAME_MASK, "LC_NAME"}, + {LC_ADDRESS, LC_ADDRESS_MASK, "LC_ADDRESS"}, + {LC_TELEPHONE, LC_TELEPHONE_MASK, "LC_TELEPHONE"}, + {LC_MEASUREMENT, LC_MEASUREMENT_MASK, "LC_MEASUREMENT"}, + {LC_IDENTIFICATION, LC_IDENTIFICATION_MASK, "LC_IDENTIFICATION"}, +#endif +#endif +}; + +#define CATEGORY_COUNT (sizeof(categories) / sizeof(categories[0])) + +#ifdef BSD_COMPOSITE_NAMES +#define COMPOSITE_SEPARATOR "/" +#else +#define COMPOSITE_SEPARATOR ";" +#endif + +/* A locale object with the locale name of each category. They are shared by + * all threads and never freed: threads may still use them, a process only + * needs a few, and freelocale() crashes on macOS while another thread creates + * a locale. */ +typedef struct shared_locale { + locale_t locale; + struct shared_locale *next; + char *names[CATEGORY_COUNT]; +} shared_locale; + +static shared_locale *shared_locales = NULL; +static pthread_mutex_t shared_locales_mutex = PTHREAD_MUTEX_INITIALIZER; + +/* Locale of the current thread, NULL for the process locale */ +static __thread shared_locale *thread_locale = NULL; +/* Locale the worker script set before handling requests */ +static __thread shared_locale *worker_locale = NULL; + +static int get_category_index(zend_long category) { + for (size_t i = 0; i < CATEGORY_COUNT; i++) { + if (categories[i].category == category) { + return (int)i; + } + } + + return -1; +} + +static void get_thread_names(const char **names) { + for (size_t i = 0; i < CATEGORY_COUNT; i++) { + names[i] = thread_locale != NULL ? thread_locale->names[i] + : setlocale(categories[i].category, NULL); + } +} + +static bool are_names_equal(const char **names) { + for (size_t i = 1; i < CATEGORY_COUNT; i++) { + if (strcmp(names[i], names[0]) != 0) { + return false; + } + } + + return true; +} + +/* The name setlocale() returns for a category or LC_ALL */ +static zend_string *get_name(zend_long category, const char **names) { + if (category != LC_ALL) { + const char *name = names[get_category_index(category)]; + return zend_string_init(name, strlen(name), 0); + } + + if (are_names_equal(names)) { + return zend_string_init(names[0], strlen(names[0]), 0); + } + + smart_str composite = {0}; + for (size_t i = 0; i < CATEGORY_COUNT; i++) { + if (i > 0) { + smart_str_appends(&composite, COMPOSITE_SEPARATOR); + } +#ifdef __GLIBC__ + smart_str_appends(&composite, categories[i].name); + smart_str_appendc(&composite, '='); +#endif + smart_str_appends(&composite, names[i]); + } + + return smart_str_extract(&composite); +} + +/* Splits a composite name returned by setlocale(LC_ALL, NULL) into names, + * which point into the modified composite string */ +static bool parse_composite_name(char *composite, const char **names) { + size_t count = 0; + char *last = NULL; + + for (char *part = strtok_r(composite, COMPOSITE_SEPARATOR, &last); + part != NULL; part = strtok_r(NULL, COMPOSITE_SEPARATOR, &last)) { +#ifdef __GLIBC__ + char *name = strchr(part, '='); + if (name == NULL) { + return false; + } + *name = '\0'; + + size_t i = 0; + while (i < CATEGORY_COUNT && strcmp(categories[i].name, part) != 0) { + i++; + } + if (i == CATEGORY_COUNT) { + return false; + } + names[i] = name + 1; + count++; +#else + if (count == CATEGORY_COUNT) { + return false; + } + names[count++] = part; +#endif + } + + return count == CATEGORY_COUNT; +} + +/* The locale setlocale() uses for "", from the LC_ALL, LC_* and LANG + * environment variables */ +static const char *get_environment_name(size_t index) { + const char *variables[] = {"LC_ALL", categories[index].name, "LANG"}; + + for (size_t i = 0; i < sizeof(variables) / sizeof(variables[0]); i++) { + const char *value = getenv(variables[i]); + if (value != NULL && value[0] != '\0') { + return value; + } + } + + return "C"; +} + +/* The name the C library's setlocale() returns for a locale */ +static const char *get_canonical_name(const char *name) { +#ifndef BSD_COMPOSITE_NAMES + /* glibc and musl name the POSIX locale "C" */ + if (strcmp(name, "POSIX") == 0) { + return "C"; + } +#endif + + return name; +} + +static locale_t new_locale(const char **names) { + if (are_names_equal(names)) { + return newlocale(LC_ALL_MASK, names[0], (locale_t)0); + } + + locale_t locale = newlocale(LC_ALL_MASK, "C", (locale_t)0); + for (size_t i = 0; locale != (locale_t)0 && i < CATEGORY_COUNT; i++) { + if (strcmp(names[i], "C") == 0) { + continue; + } + + /* On success, newlocale() reuses or frees locale */ + locale_t with_category = newlocale(categories[i].mask, names[i], locale); + if (with_category == (locale_t)0) { + freelocale(locale); + } + locale = with_category; + } + + return locale; +} + +static shared_locale *find_shared_locale(const char **names) { + for (shared_locale *shared = + __atomic_load_n(&shared_locales, __ATOMIC_ACQUIRE); + shared != NULL; shared = shared->next) { + size_t i = 0; + while (i < CATEGORY_COUNT && strcmp(shared->names[i], names[i]) == 0) { + i++; + } + if (i == CATEGORY_COUNT) { + return shared; + } + } + + return NULL; +} + +/* Returns NULL if a locale isn't available */ +static shared_locale *get_shared_locale(const char **names) { + shared_locale *shared = find_shared_locale(names); + if (shared != NULL) { + return shared; + } + + /* Locales are created one at a time, concurrent newlocale() and + * freelocale() calls crash on macOS */ + pthread_mutex_lock(&shared_locales_mutex); + shared = find_shared_locale(names); + if (shared == NULL) { + locale_t locale = new_locale(names); + if (locale != (locale_t)0) { + shared = pemalloc(sizeof(shared_locale), 1); + shared->locale = locale; + for (size_t i = 0; i < CATEGORY_COUNT; i++) { + shared->names[i] = pestrdup(names[i], 1); + } + shared->next = shared_locales; + __atomic_store_n(&shared_locales, shared, __ATOMIC_RELEASE); + } + } + pthread_mutex_unlock(&shared_locales_mutex); + + return shared; +} + +/* Switches the current thread to a locale, NULL for the process locale */ +static void use_locale(shared_locale *shared) { + if (shared == thread_locale) { + return; + } + + int ctype = get_category_index(LC_CTYPE); + const char *previous_ctype = + thread_locale != NULL ? thread_locale->names[ctype] : NULL; + const char *ctype_name = shared != NULL ? shared->names[ctype] : NULL; + + uselocale(shared != NULL ? shared->locale : LC_GLOBAL_LOCALE); + thread_locale = shared; + + if (previous_ctype == ctype_name || + (previous_ctype != NULL && ctype_name != NULL && + strcmp(previous_ctype, ctype_name) == 0)) { + return; + } + + /* Update the LC_CTYPE dependent state like setlocale() does, PCRE uses + * BG(ctype_string) to select its character tables */ + zend_update_current_locale(); + if (BG(ctype_string) != NULL) { + zend_string_release_ex(BG(ctype_string), 0); + BG(ctype_string) = NULL; + } + if (ctype_name != NULL && strcmp(ctype_name, "C") != 0) { + BG(ctype_string) = zend_string_init(ctype_name, strlen(ctype_name), 0); + } +} + +static zend_string *try_setlocale_str(zend_long category, zend_string *loc) { + int index = category == LC_ALL ? -1 : get_category_index(category); + if (category != LC_ALL && index < 0) { + return NULL; + } + + const char *names[CATEGORY_COUNT]; + get_thread_names(names); + + if (zend_string_equals_literal(loc, "0")) { + return get_name(category, names); + } + + if (ZSTR_LEN(loc) >= 255) { + php_error_docref(NULL, E_WARNING, "Specified locale name is too long"); + return NULL; + } + + char composite[255]; + if (ZSTR_LEN(loc) == 0) { + for (size_t i = 0; i < CATEGORY_COUNT; i++) { + if (category == LC_ALL || (int)i == index) { + names[i] = get_environment_name(i); + } + } + } else if (category == LC_ALL && + strstr(ZSTR_VAL(loc), COMPOSITE_SEPARATOR) != NULL) { + memcpy(composite, ZSTR_VAL(loc), ZSTR_LEN(loc) + 1); + if (!parse_composite_name(composite, names)) { + return NULL; + } + } else if (category == LC_ALL) { + for (size_t i = 0; i < CATEGORY_COUNT; i++) { + names[i] = ZSTR_VAL(loc); + } + } else { + names[index] = ZSTR_VAL(loc); + } + + for (size_t i = 0; i < CATEGORY_COUNT; i++) { + names[i] = get_canonical_name(names[i]); + } + + shared_locale *shared = get_shared_locale(names); + if (shared == NULL) { + return NULL; + } + + use_locale(shared); + + return get_name(category, (const char **)shared->names); +} + +static zend_string *try_setlocale_zval(zend_long category, zval *loc_zv) { + zend_string *tmp_loc_str; + zend_string *loc_str = zval_try_get_tmp_string(loc_zv, &tmp_loc_str); + if (UNEXPECTED(loc_str == NULL)) { + return NULL; + } +#if PHP_VERSION_ID >= 80600 + if (zend_str_has_nul_byte(loc_str)) { + zend_argument_value_error(2, "must not contain any null bytes"); + zend_tmp_string_release(tmp_loc_str); + return NULL; + } +#endif + zend_string *result = try_setlocale_str(category, loc_str); + zend_tmp_string_release(tmp_loc_str); + + return result; +} + +#if PHP_VERSION_ID >= 80500 +/* Since PHP 8.5, setlocale() checks the type of all arguments before trying + * a locale, and since PHP 8.6, it rejects null bytes and an array after the + * first locale */ +static bool parse_setlocale_args(zval *args, uint32_t num_args, + zend_string **strings) { + for (uint32_t i = 0; i < num_args; i++) { +#if PHP_VERSION_ID >= 80600 + if (Z_TYPE(args[i]) == IS_ARRAY) { + if (UNEXPECTED(i != 0)) { + zend_wrong_parameter_type_error(i + 2, Z_EXPECTED_STRING_OR_NULL, + &args[i]); + return false; + } + if (UNEXPECTED(num_args > 1)) { + zend_argument_count_error("setlocale() expects exactly 2 arguments " + "when argument #2 ($locales) is an array, " + "%d given", + (int)num_args + 1); + return false; + } + break; + } + if (UNEXPECTED( + !zend_parse_arg_path_str(&args[i], &strings[i], true, i + 2))) { + zend_wrong_parameter_type_error( + i + 2, + Z_TYPE(args[i]) == IS_STRING + ? Z_EXPECTED_PATH + : (i == 0 ? Z_EXPECTED_ARRAY_OR_STRING_OR_NULL + : Z_EXPECTED_STRING_OR_NULL), + &args[i]); + return false; + } +#else + if (UNEXPECTED(Z_TYPE(args[i]) != IS_ARRAY && + !zend_parse_arg_str(&args[i], &strings[i], true, i + 2))) { + zend_wrong_parameter_type_error(i + 2, Z_EXPECTED_ARRAY_OR_STRING_OR_NULL, + &args[i]); + return false; + } +#endif + } + + return true; +} +#endif + +/* Returns the first locale of the arguments that is available */ +static zend_string *try_setlocale_args(zend_long category, zval *args, + uint32_t num_args, + zend_string **strings) { + for (uint32_t i = 0; i < num_args; i++) { + zend_string *result; + if (Z_TYPE(args[i]) == IS_ARRAY) { + zval *elem; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL(args[i]), elem) { + result = try_setlocale_zval(category, elem); + if (EG(exception) || result != NULL) { + return result; + } + } + ZEND_HASH_FOREACH_END(); + continue; + } +#if PHP_VERSION_ID >= 80500 + result = try_setlocale_str(category, Z_ISNULL(args[i]) ? ZSTR_EMPTY_ALLOC() + : strings[i]); +#else + (void)strings; + result = try_setlocale_zval(category, &args[i]); +#endif + if (EG(exception) || result != NULL) { + return result; + } + } + + return NULL; +} + +/* Same as PHP_FUNCTION(setlocale) in ext/standard/string.c, including the + * argument handling of each PHP version */ +static PHP_FUNCTION(frankenphp_setlocale) { + zend_long category; + zval *args = NULL; + uint32_t num_args; + + ZEND_PARSE_PARAMETERS_START(2, -1) + Z_PARAM_LONG(category) + Z_PARAM_VARIADIC('+', args, num_args) + ZEND_PARSE_PARAMETERS_END(); + +#if PHP_VERSION_ID >= 80500 + ALLOCA_FLAG(use_heap); + zend_string **strings = do_alloca(sizeof(zend_string *) * num_args, use_heap); + zend_string *result = + parse_setlocale_args(args, num_args, strings) + ? try_setlocale_args(category, args, num_args, strings) + : NULL; + free_alloca(strings, use_heap); +#else + zend_string *result = try_setlocale_args(category, args, num_args, NULL); +#endif + + if (EG(exception)) { + RETURN_THROWS(); + } + if (result == NULL) { + RETURN_FALSE; + } + RETURN_STR(result); +} + +void frankenphp_override_setlocale(void) { + zend_function *func = zend_hash_str_find_ptr(CG(function_table), "setlocale", + sizeof("setlocale") - 1); + if (func != NULL && func->type == ZEND_INTERNAL_FUNCTION) { + ((zend_internal_function *)func)->handler = ZEND_FN(frankenphp_setlocale); + } else { + php_error(E_WARNING, "Failed to find built-in setlocale function"); + } +} + +void frankenphp_save_worker_locale(void) { worker_locale = thread_locale; } + +void frankenphp_restore_worker_locale(void) { use_locale(worker_locale); } + +void frankenphp_reset_thread_locale(void) { + worker_locale = NULL; + use_locale(NULL); +} + +#else + +/* PHP keeps the locale per thread on Windows, so setlocale() isn't replaced + * there: only the locale the worker script set before handling requests is + * restored after each request */ + +#include "threadlocale.h" + +#include + +#include +#include +#include + +/* Name of the locale the worker script set before handling requests */ +static __thread char *worker_locale = NULL; + +void frankenphp_save_worker_locale(void) { + const char *name = setlocale(LC_ALL, NULL); + if (worker_locale != NULL) { + pefree(worker_locale, 1); + } + worker_locale = name != NULL ? pestrdup(name, 1) : NULL; +} + +void frankenphp_restore_worker_locale(void) { + if (worker_locale == NULL) { + return; + } + + const char *name = setlocale(LC_ALL, NULL); + if (name != NULL && strcmp(name, worker_locale) == 0) { + return; + } + + name = setlocale(LC_ALL, worker_locale); + if (name == NULL) { + return; + } + + /* Update the LC_CTYPE dependent state like setlocale() does */ + zend_update_current_locale(); + if (BG(ctype_string) != NULL) { + zend_string_release_ex(BG(ctype_string), 0); + BG(ctype_string) = NULL; + } + if (strcmp(name, "C") != 0) { + BG(ctype_string) = zend_string_init(name, strlen(name), 0); + } +} + +void frankenphp_reset_thread_locale(void) { + if (worker_locale != NULL) { + pefree(worker_locale, 1); + worker_locale = NULL; + } +} + +#endif diff --git a/threadlocale.h b/threadlocale.h new file mode 100644 index 0000000000..e7fcad2505 --- /dev/null +++ b/threadlocale.h @@ -0,0 +1,19 @@ +#ifndef _THREADLOCALE_H +#define _THREADLOCALE_H + +#ifndef _WIN32 +/* Makes setlocale() change the locale of the current thread only */ +void frankenphp_override_setlocale(void); +#else +/* PHP keeps the locale per thread on Windows */ +static inline void frankenphp_override_setlocale(void) {} +#endif +/* Remembers the locale the worker script set before handling requests */ +void frankenphp_save_worker_locale(void); +/* Restores the locale of the worker script after a request */ +void frankenphp_restore_worker_locale(void); +/* Forgets the locale of the worker script, and switches the thread back to + * the process locale at the end of a script */ +void frankenphp_reset_thread_locale(void); + +#endif diff --git a/threadlocale_test.go b/threadlocale_test.go new file mode 100644 index 0000000000..e94aabaa6d --- /dev/null +++ b/threadlocale_test.go @@ -0,0 +1,67 @@ +package frankenphp_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setlocale() must only change the locale of the current thread, and each +// request must start with the process locale. +func TestSetlocale(t *testing.T) { testSetlocale(t, &testOptions{}) } + +func TestSetlocale_worker(t *testing.T) { + testSetlocale(t, &testOptions{workerScript: "setlocale.php"}) +} + +func testSetlocale(t *testing.T, opts *testOptions) { + // The process locale isn't always "C": the environment (LANG) applies to + // it when PHP or an extension calls setlocale(LC_ALL, "") + var processLocale string + var once sync.Once + + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { + // Read before any request changes its locale, other requests wait + once.Do(func() { + processLocale, _ = testGet("http://example.com/setlocale.php?locale=0", handler, t) + }) + + locale := "C" + if i%2 == 0 { + locale = "other" + } + + body, _ := testGet("http://example.com/setlocale.php?locale="+locale, handler, t) + parts := strings.Split(body, "|") + require.Len(t, parts, 6, body) + initial, set, current, float, decimalPoint, restored := parts[0], parts[1], parts[2], parts[3], parts[4], parts[5] + + assert.Equal(t, processLocale, initial, "a request starts with the process locale") + assert.NotEmpty(t, set, "setlocale() failed") + assert.Equal(t, set, current, "other requests don't change the locale") + assert.Equal(t, "1"+decimalPoint+"5", float) + assert.Equal(t, "restored", restored) + + if locale == "C" { + assert.Equal(t, "C", set) + assert.Equal(t, ".", decimalPoint) + } + }, opts) +} + +// The locale the worker script sets before handling requests is restored +// after each request. +func TestSetlocaleBeforeHandlingRequests_worker(t *testing.T) { + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) { + body, _ := testGet("http://example.com/setlocale-worker.php", handler, t) + workerLocale, current, _ := strings.Cut(body, "|") + + assert.NotContains(t, []string{"", "C"}, workerLocale, "the worker script couldn't set a locale") + assert.Equal(t, workerLocale, current) + }, &testOptions{workerScript: "setlocale-worker.php"}) +}