From 081ca5dafd06c64412e5d505db2a202f090aa4f7 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 11:20:13 +0200 Subject: [PATCH] Expose branch preview PHP fatals --- crates/forkpress-cli/src/app.rs | 1 + docs/logs.md | 5 ++++ runtime/cow/bootstrap_wp.php | 1 + runtime/cow/router.php | 45 ++++++++++++++++++++++++++++++--- scripts/cow/git_server.php | 22 ++++++++++++++++ tests/cow/git_server.php | 13 ++++++++++ tests/cow/router_paths.php | 17 +++++++++++-- 7 files changed, 98 insertions(+), 6 deletions(-) diff --git a/crates/forkpress-cli/src/app.rs b/crates/forkpress-cli/src/app.rs index da95eb40..3f3f5bb5 100644 --- a/crates/forkpress-cli/src/app.rs +++ b/crates/forkpress-cli/src/app.rs @@ -6162,6 +6162,7 @@ define('NONCE_SALT', 'forkpress-cas-s4-xxxxxxxxxxxxxxxx'); define('WP_DEBUG', true); define('WP_DEBUG_LOG', getenv('FORKPRESS_CAS_DEBUG_LOG') ?: '/tmp/forkpress-cas-wp-debug.log'); define('WP_DEBUG_DISPLAY', false); +define('WP_DISABLE_FATAL_ERROR_HANDLER', true); define('FS_METHOD', 'direct'); define('DISALLOW_FILE_MODS', false); define('DISALLOW_FILE_EDIT', true); diff --git a/docs/logs.md b/docs/logs.md index 251e2519..f42dfe56 100644 --- a/docs/logs.md +++ b/docs/logs.md @@ -3,6 +3,11 @@ ForkPress writes separate logs for WordPress, PHP, the local server wrapper, and background maintenance. +Branch previews disable WordPress' generic fatal-error recovery screen and add a +ForkPress shutdown logger. When a plugin or theme fatals on a branch URL, +`wp-debug.log` should include the branch name, request URI, PHP file, line, and +fatal message instead of only showing WordPress' "critical error" page. + ## Read logs Show WordPress critical errors and PHP fatals: diff --git a/runtime/cow/bootstrap_wp.php b/runtime/cow/bootstrap_wp.php index 99bcf346..91d25738 100644 --- a/runtime/cow/bootstrap_wp.php +++ b/runtime/cow/bootstrap_wp.php @@ -157,6 +157,7 @@ function forkpress_cow_existing_table_prefix(string $branch_root): string { define('WP_DEBUG', true); define('WP_DEBUG_LOG', '__DEBUG_LOG__'); define('WP_DEBUG_DISPLAY', false); +define('WP_DISABLE_FATAL_ERROR_HANDLER', true); define('FS_METHOD', 'direct'); define('DISALLOW_FILE_MODS', false); define('DISALLOW_FILE_EDIT', true); diff --git a/runtime/cow/router.php b/runtime/cow/router.php index 5c58da64..657b2953 100644 --- a/runtime/cow/router.php +++ b/runtime/cow/router.php @@ -1159,12 +1159,49 @@ function forkpress_cow_send_file(string $path): void { readfile($path); } -function forkpress_cow_prepare_php_request(string $path, string $branch_root): void { +function forkpress_cow_register_php_fatal_logger(string $branch, string $script_path): void { + static $registered = false; + if ($registered) { + return; + } + $registered = true; + $debug_log = getenv('FORKPRESS_DEBUG_LOG') ?: ''; + $request_uri = $_SERVER['REQUEST_URI'] ?? $script_path; + register_shutdown_function(static function() use ($branch, $script_path, $debug_log, $request_uri): void { + $error = error_get_last(); + if (!is_array($error)) { + return; + } + $type = (int)($error['type'] ?? 0); + if (!in_array($type, [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR], true)) { + return; + } + $message = (string)($error['message'] ?? 'unknown fatal error'); + $file = (string)($error['file'] ?? $script_path); + $line = (string)($error['line'] ?? '0'); + $entry = sprintf( + "[%s] ForkPress branch '%s' PHP fatal while serving %s: %s in %s:%s\n", + date('c'), + $branch, + $request_uri, + $message, + $file, + $line + ); + error_log(rtrim($entry)); + if ($debug_log !== '') { + @file_put_contents($debug_log, $entry, FILE_APPEND | LOCK_EX); + } + }); +} + +function forkpress_cow_prepare_php_request(string $path, string $branch_root, string $branch): void { chdir($branch_root); $_SERVER['DOCUMENT_ROOT'] = $branch_root; $_SERVER['SCRIPT_FILENAME'] = $path; $_SERVER['SCRIPT_NAME'] = substr($path, strlen($branch_root)); $_SERVER['PHP_SELF'] = $_SERVER['SCRIPT_NAME']; + forkpress_cow_register_php_fatal_logger($branch, $path); } if (file_exists($full_path) && !is_dir($full_path)) { @@ -1174,7 +1211,7 @@ function forkpress_cow_prepare_php_request(string $path, string $branch_root): v return true; } if (strtolower(pathinfo($full_path, PATHINFO_EXTENSION)) === 'php') { - forkpress_cow_prepare_php_request($full_path, $branch_root); + forkpress_cow_prepare_php_request($full_path, $branch_root, $branch); require $full_path; return true; } @@ -1190,7 +1227,7 @@ function forkpress_cow_prepare_php_request(string $path, string $branch_root): v echo "Not found\n"; return true; } - forkpress_cow_prepare_php_request($index, $branch_root); + forkpress_cow_prepare_php_request($index, $branch_root, $branch); require $index; return true; } @@ -1208,6 +1245,6 @@ function forkpress_cow_prepare_php_request(string $path, string $branch_root): v echo "Not found\n"; return true; } -forkpress_cow_prepare_php_request($branch_root . '/index.php', $branch_root); +forkpress_cow_prepare_php_request($branch_root . '/index.php', $branch_root, $branch); require $branch_root . '/index.php'; return true; diff --git a/scripts/cow/git_server.php b/scripts/cow/git_server.php index 4f35a269..8c569466 100644 --- a/scripts/cow/git_server.php +++ b/scripts/cow/git_server.php @@ -1544,6 +1544,28 @@ function cow_git_rewrite_wp_config_for_root(string $config_root, string $runtime } $config = $rewritten; } + $fatal_handler_define = "define('WP_DISABLE_FATAL_ERROR_HANDLER', true);"; + if (preg_match(cow_git_wp_config_define_pattern('WP_DISABLE_FATAL_ERROR_HANDLER'), $config)) { + $rewritten = preg_replace( + cow_git_wp_config_define_pattern('WP_DISABLE_FATAL_ERROR_HANDLER'), + $fatal_handler_define, + $config, + 1 + ); + if ($rewritten === null) { + throw new \RuntimeException("failed to normalize managed wp-config.php constant WP_DISABLE_FATAL_ERROR_HANDLER in $config_path"); + } + $config = $rewritten; + } else { + $anchor = cow_git_wp_config_define_pattern('WP_DEBUG_DISPLAY'); + $count = 0; + $rewritten = preg_replace($anchor, '$0' . "\n" . $fatal_handler_define, $config, 1, $count); + if ($rewritten === null || $count !== 1) { + $config .= "\n" . $fatal_handler_define . "\n"; + } else { + $config = $rewritten; + } + } if (file_put_contents($config_path, $config) === false) { throw new \RuntimeException("failed to write $config_path"); } diff --git a/tests/cow/git_server.php b/tests/cow/git_server.php index 32406dd6..b2ddbdb5 100644 --- a/tests/cow/git_server.php +++ b/tests/cow/git_server.php @@ -1757,12 +1757,14 @@ function run_php_code_env(string $code, array $env): array { assert_true(!str_contains($rewritten_config, $feature_db), 'existing branch push does not keep source branch database path'); assert_true(str_contains($rewritten_config, $main_debug), 'existing branch push rewrites wp-config.php to target debug log'); assert_true(!str_contains($rewritten_config, $feature_debug), 'existing branch push does not keep source branch debug log'); +assert_true(str_contains($rewritten_config, "define('WP_DISABLE_FATAL_ERROR_HANDLER', true);"), 'existing branch push keeps WordPress fatal errors visible to ForkPress logs'); $rewritten_tip = $repo->get_branch_tip('refs/heads/main'); $git_config = $repo->read_object_by_path('wordpress/wp-config.php', $rewritten_tip)->consume_all(); assert_true(str_contains($git_config, $main_db), 'push resync exports rewritten target wp-config.php'); assert_true(!str_contains($git_config, $feature_db), 'push resync removes source wp-config.php database path from Git ref'); assert_true(str_contains($git_config, $main_debug), 'push resync exports rewritten target debug log'); assert_true(!str_contains($git_config, $feature_debug), 'push resync removes source debug log from Git ref'); +assert_true(str_contains($git_config, "define('WP_DISABLE_FATAL_ERROR_HANDLER', true);"), 'push resync exports disabled WordPress fatal handler'); cow_git_remove_tree($tmp); $tmp = sys_get_temp_dir() . '/forkpress-cow-git-config-shapes-' . getmypid() . '-' . bin2hex(random_bytes(4)); @@ -1779,6 +1781,17 @@ function run_php_code_env(string $code, array $env): array { cow_git_rewrite_wp_config_for_root($config_root, $runtime_root, $runtime_root . '/wp-content/database/debug.log'); $rewritten = file_get_contents($config_root . '/wp-config.php'); assert_true(str_contains($rewritten, $runtime_root . '/wp-content/database/.ht.sqlite'), 'wp-config rewrite normalizes double-quoted managed constants'); +assert_true(str_contains($rewritten, "define('WP_DISABLE_FATAL_ERROR_HANDLER', true);"), 'wp-config rewrite inserts disabled WordPress fatal handler when absent'); + +file_put_contents($config_root . '/wp-config.php', " ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; - $process = proc_open([PHP_BINARY, $child, $branches, $cow, $router, $uri], $descriptor, $pipes); + $process = proc_open([PHP_BINARY, $child, $branches, $cow, $router, $uri, $debug_log], $descriptor, $pipes); if (!is_resource($process)) { return ['status' => 0, 'body' => '', 'stderr' => 'proc_open failed']; } @@ -63,6 +63,7 @@ function router_request(string $child, string $branches, string $cow, string $ro $cow = $site . '/.forkpress/cow'; $main = $site . '/main'; $secret = $site . '/.forkpress/site.toml'; +$debug_log = $site . '/.forkpress/logs/wp-debug.log'; $child = $tmp . '/request.php'; $router = realpath(__DIR__ . '/../../runtime/cow/router.php'); assert_true($router !== false, 'router fixture exists'); @@ -72,8 +73,10 @@ function router_request(string $child, string $branches, string $cow, string $ro mkdir($main, 0777, true); mkdir(dirname($secret), 0777, true); +mkdir(dirname($debug_log), 0777, true); mkdir($cow, 0777, true); file_put_contents($main . '/index.php', " 'wp.localhost', 'REQUEST_URI' => $uri, @@ -109,6 +116,12 @@ function router_request(string $child, string $branches, string $cow, string $ro assert_same($response['body'], "safe\n", 'safe static request serves branch file'); assert_same($response['stderr'], '', 'safe static request produces no stderr'); +$response = router_request($child, $branches, $cow, $router, '/fatal.php', $debug_log); +assert_true(($response['exit'] ?? 0) !== 0, 'fatal PHP request exits with failure'); +$logged = file_exists($debug_log) ? (string)file_get_contents($debug_log) : ''; +assert_true(str_contains($logged, "ForkPress branch 'main' PHP fatal while serving /fatal.php"), 'fatal PHP request is logged with branch and URI'); +assert_true(str_contains($logged, 'forkpress_missing_function_for_router_test'), 'fatal PHP request log includes the PHP fatal message'); + $response = router_request($child, $branches, $cow, $router, '/wp-admin/plugin-install.php'); assert_same($response['exit'], 0, 'wp-admin plugin install request exits cleanly'); assert_same($response['status'], 200, 'wp-admin plugin install request returns 200');