Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/forkpress-cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions docs/logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions runtime/cow/bootstrap_wp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
45 changes: 41 additions & 4 deletions runtime/cow/router.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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;
22 changes: 22 additions & 0 deletions scripts/cow/git_server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
13 changes: 13 additions & 0 deletions tests/cow/git_server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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', "<?php\n"
. "define('FQDB', '/old/db.sqlite');\n"
. "define('DB_DIR', '/old');\n"
. "define('DB_FILE', '.old.sqlite');\n"
. "define('WP_DEBUG_LOG', '/old/debug.log');\n"
. "define('WP_DISABLE_FATAL_ERROR_HANDLER', false);\n");
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, "define('WP_DISABLE_FATAL_ERROR_HANDLER', true);"), 'wp-config rewrite normalizes enabled WordPress fatal handler');

file_put_contents($config_root . '/wp-config.php', "<?php\ndefine('FQDB', '/old/db.sqlite');\n");
$failed = false;
Expand Down
17 changes: 15 additions & 2 deletions tests/cow/router_paths.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ function rm_tree(string $path): void {
}
@rmdir($path);
}
function router_request(string $child, string $branches, string $cow, string $router, string $uri): array {
function router_request(string $child, string $branches, string $cow, string $router, string $uri, string $debug_log = ''): array {
$descriptor = [
0 => ['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'];
}
Expand All @@ -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');
Expand All @@ -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', "<?php echo \"INDEX\";\n");
file_put_contents($main . '/fatal.php', "<?php forkpress_missing_function_for_router_test();\n");
file_put_contents($main . '/safe.txt', "safe\n");
mkdir($main . '/wp-admin', 0777, true);
file_put_contents($main . '/wp-admin/plugin-install.php', "<?php echo \"PLUGIN INSTALL ADMIN\";\n");
Expand All @@ -85,9 +88,13 @@ function router_request(string $child, string $branches, string $cow, string $ro
$cow = $argv[2];
$router = $argv[3];
$uri = $argv[4];
$debug_log = $argv[5] ?? '';
putenv('FORKPRESS_BRANCHES_DIR=' . $branches);
putenv('FORKPRESS_COW_DIR=' . $cow);
putenv('FORKPRESS_ROOT_HOST=wp.localhost');
if ($debug_log !== '') {
putenv('FORKPRESS_DEBUG_LOG=' . $debug_log);
}
$_SERVER = [
'HTTP_HOST' => 'wp.localhost',
'REQUEST_URI' => $uri,
Expand All @@ -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');
Expand Down
Loading