Nội dung bài viết
Một ngày đẹp trời, mình đăng nhập WordPress admin, bấm vào Plugins và… màn hình trắng xoá. Không một dòng lỗi. Không một thông báo. Chỉ có sự im lặng rất khó chịu của White Screen of Death.
Điều lạ là mọi trang admin khác đều bình thường. Dashboard, Posts, Settings, Media, tất cả đều mở được. Chỉ riêng wp-admin/plugins.php là chết.
Môi trường: Laragon local, PHP 8.3, WordPress 6.9.4. Plugin đang cài: Perfmatters, Rank Math SEO, Post SMTP, LuckyWP Table of Contents.
Bài này mình ghi lại toàn bộ đường đi từ lúc mở debug.log cho tới lúc tìm ra dòng code gây crash. Cái đáng giá ở đây không phải là dòng code đã sửa mà là cách chia nhỏ vấn đề để không phải đoán mò.
Bước 1: debug.log không nói gì, và đó đã là một manh mối
Phản xạ đầu tiên của bất kỳ ai gặp WSOD là mở debug.log:
// wp-config.php
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', true); // đổi từ false sang true
Kết quả: file debug.log trống không có 1 dòng log nào. Bật WP_DEBUG_DISPLAY lên true cũng vẫn màn hình trắng, không hiện chữ nào.
Đặt thêm báo lỗi PHP, đặt ngay đầu wp-config.php:
ini_set('display_errors', 1);
error_reporting(E_ALL);
Vẫn không có gì.
Chỗ này dễ làm người ta bực rồi bỏ qua, nhưng nó chính là thông tin đầu tiên và là thông tin quan trọng nhất của cả bài. Fatal error, parse error, exception chưa bắt được gì => tất cả đều được PHP ghi log trước khi chết. Một tiến trình chết mà không kịp ghi được gì thì gần như chắc chắn không phải PHP error thông thường, mà là segmentation fault: tiến trình bị hệ điều hành giết ở tầng C, engine không còn cơ hội chạy handler để ghi log.
Từ giây phút đó, mình biết sẽ không có log PHP nào để đọc. Muốn tìm thủ phạm thì phải tự sinh ra dấu vết.
Bước 2: PHP có chạy tới file không?
Trước khi đào sâu, cần loại trừ một khả năng cơ bản: có khi web server hỏng, chưa gọi tới PHP.
// dòng đầu tiên sau <?php trong wp-admin/plugins.php
echo "TEST123"; die();
Trình duyệt hiện TEST123. Vậy PHP có chạy, có vào đúng file. Crash nằm đâu đó phía sau, trong quá trình xử lý tiếp theo.
Bước 3: chia đôi
Đây là bước quan trọng nhất về mặt phương pháp. Không có log thì mình tự làm ra log, bằng echo. Và thay vì rải echo khắp nơi, mình chia đôi.
plugins.php mở đầu bằng việc nạp admin.php. Kẹp nó giữa hai dòng log:
// wp-admin/plugins.php
echo "BEFORE-ADMIN";
require_once __DIR__ . '/admin.php';
echo "AFTER-ADMIN";
Kết quả: chỉ thấy BEFORE-ADMIN. AFTER-ADMIN không xuất hiện.
Một câu trả lời nhị phân, và nó cắt bỏ được một nửa: crash nằm bên trong admin.php.
Bước 4: đánh dấu log trong admin.php
Lặp lại đúng kỹ thuật đó, nhưng chi tiết hơn. wp-admin/admin.php là một chuỗi các bước khởi động rất rõ ràng, nên chỉ cần rải log vào từng mốc:
// wp-admin/admin.php — rải log tại các mốc chính
require_once ABSPATH . 'wp-load.php';
echo "A1-AFTER-WP-LOAD "; // WordPress đã nạp xong
require_once ABSPATH . 'wp-admin/includes/admin.php';
echo "A2-AFTER-ADMIN-INCLUDES "; // đã nạp các hàm admin
auth_redirect();
echo "A3-AFTER-AUTH "; // đã qua xác thực
// … các bước khác …
do_action('admin_init');
echo "A7-AFTER-ADMIN-INIT "; // đã qua admin_init
set_current_screen();
do_action("load-{$pagenow}"); // $pagenow = 'plugins.php'
echo "A8-AFTER-LOAD-PAGE "; // đã qua load-plugins.php
Trình duyệt trả về:
A1-AFTER-WP-LOAD A2-AFTER-ADMIN-INCLUDES A3-AFTER-AUTH ... A7-AFTER-ADMIN-INIT
Dừng đúng ở A7. A8 không bao giờ tới. Crash nằm gọn trong:
do_action("load-plugins.php");
Và đây là lúc mọi thứ bắt đầu khớp lại. load-{$pagenow} là một hook động, chỉ chạy đúng trên trang tương ứng. Trang Posts kích hoạt load-edit.php, trang Settings kích hoạt load-options-general.php, còn trang Plugins kích hoạt load-plugins.php. Nó giải thích trọn vẹn triệu chứng lạ nhất: vì sao chỉ mỗi trang Plugins chết còn tất cả trang khác vẫn sống.
Triệu chứng "chỉ một trang bị lỗi" gần như luôn dẫn về một hook chỉ chạy trên trang đó.
Bước 5: ai đang hook vào load-plugins.php?
Tìm trong core, thủ phạm nằm ở wp-includes/update.php:
// wp-includes/update.php
add_action('load-plugins.php', 'wp_update_plugins');
add_action('load-update.php', 'wp_update_plugins');
add_action('load-update-core.php', 'wp_update_plugins');
wp_update_plugins() được WordPress gọi tự động mỗi khi bạn mở trang Plugins, để kiểm tra xem có bản cập nhật mới nào cho các plugin đang cài hay không.
Thử gỡ hook ra:
// tạm thêm vào functions.php để test
add_action('admin_init', function () {
remove_action('load-plugins.php', 'wp_update_plugins');
});
Trang Plugins sống lại. Vậy wp_update_plugins() chính là nơi crash xảy ra.
Đến đây thì đã đủ để có một bản vá. Nhưng chưa đủ để hiểu, vì wp_update_plugins() là hàm core, nó chạy trên hàng triệu site mà không sao cả. Nếu dừng ở đây, mình sẽ đổ oan cho core và không bao giờ biết vấn đề thật nằm ở đâu.
Bước 6: đi vào bên trong wp_update_plugins()
Lại chia đôi, lần này là chia đôi bên trong thân hàm. Mình dựng một AJAX endpoint tạm để chạy lại từng bước của hàm đó, có ghi log giữa các bước:
// endpoint test tạm thời
add_action('wp_ajax_test_update_plugins', function () {
error_log('STEP 1: Getting plugin data...');
$plugins = get_plugins();
error_log('STEP 1: OK - ' . count($plugins) . ' plugins');
error_log('STEP 2: Calling API...');
$url = 'http://api.wordpress.org/plugins/update-check/1.1/';
$response = wp_remote_post($url, [/* ... */]);
error_log('STEP 2: HTTP ' . wp_remote_retrieve_response_code($response));
error_log('STEP 3: Processing response...');
$updates = new stdClass;
// … xử lý response …
error_log('STEP 3: OK');
error_log('STEP 4: Saving transient...');
set_site_transient('update_plugins', $updates);
error_log('STEP 4: OK');
wp_send_json_success('Done');
});
Step 1, 2, 3 đều OK. Crash ở Step 4, tại set_site_transient('update_plugins', $updates).
Nghe rất vô lý. Lưu một transient thì có gì mà chết? Gọi API ra ngoài internet còn không sao, ghi vào database lại chết.
Bước 7: thủ phạm thật
Vô lý cho tới khi nhớ ra set_site_transient() không chỉ ghi database. Trước khi ghi, nó chạy filter pre_set_site_transient_{$transient} => ở đây là pre_set_site_transient_update_plugins. Nói cách khác, dòng lệnh "lưu transient" đó thực chất là một lời mời cho toàn bộ plugin trên site nhảy vào chạy code của chúng.
Liệt kê xem ai đang đứng trong hàng đợi đó:
// Debug: liệt kê mọi callback đang gắn trên filter này
global $wp_filter;
if (isset($wp_filter['pre_set_site_transient_update_plugins'])) {
foreach ($wp_filter['pre_set_site_transient_update_plugins'] as $priority => $callbacks) {
foreach ($callbacks as $id => $callback) {
error_log("Filter callback: priority={$priority}, id={$id}");
}
}
}
Ba plugin đang hook vào:
- Perfmatters — updater của EDD Software Licensing, kiểm tra license key trước khi cho cập nhật
- Post SMTP — Freemius SDK, hệ thống licensing/update riêng
- Rank Math SEO — tự kiểm tra cập nhật từ server riêng
Gỡ sạch chúng ra rồi lưu lại:
remove_all_filters('pre_set_site_transient_update_plugins');
set_site_transient('update_plugins', $updates);
// Chạy ngon. Không crash.
Vậy là rõ: một (hoặc vài) trong ba callback đó làm PHP chết đúng lúc WordPress cố lưu kết quả kiểm tra update.
Điều đáng nói: core WordPress hoàn toàn vô tội. Nó chỉ đơn giản là cái sân khấu nơi plugin của bên thứ ba được mời lên diễn.
Vì sao code PHP lại giết được cả tiến trình?
Chỗ này cần nói cho rõ, vì nếu dừng ở "callback plugin gây segfault" thì nghe khá vô lý. Code PHP thuần không tự segfault được. $a = $b, foreach, if không bao giờ làm chết tiến trình. Vậy làm sao một callback viết bằng PHP lại giết được cả PHP?
Vì một request chạy trên ba tầng:
| Tầng | Là gì | Viết bằng |
|---|---|---|
| Userland | Code plugin, theme, WordPress core | PHP |
| Zend Engine + extension | Thứ thực sự thực thi opcode PHP, kèm curl, openssl, json… | C |
| Hệ điều hành | Cấp phát bộ nhớ, gửi tín hiệu | - |
Bộ máy ghi debug.log nằm ở tầng 2. Khi code tầng 1 gây fatal error hay ném exception, tầng 2 vẫn khoẻ mạnh: nó bắt được, chạy error handler, ghi log, rồi kết thúc êm đẹp. Đó là lý do mọi lỗi PHP thông thường đều để lại dấu vết.
Segfault thì ngược lại: chính tầng 2 đụng vào vùng nhớ không hợp lệ, hệ điều hành bắn SIGSEGV và giết tiến trình ngay lập tức. Mà cái error handler ghi log lại nằm bên trong chính tiến trình vừa bị giết. Nó không có cơ hội chạy. Đó là toàn bộ lý do debug.log trống trơn ở Bước 1.
Nói cách khác, PHP userland chỉ là ngòi nổ, chỗ nổ nằm ở tầng C. Ba đường phổ biến nhất để code PHP châm được ngòi đó:
Đệ quy vô hạn. Mỗi lần gọi hàm PHP tốn một khung trên C stack. memory_limit chỉ quản vùng heap của PHP, hoàn toàn không quản C stack nên PHP không báo "hết bộ nhớ", nó chỉ đơn giản là chết. Đường này khớp đáng ngờ với kịch bản ở đây: set_site_transient() gọi filter, callback lại kích hoạt thêm một lượt kiểm tra update nữa, thành vòng lặp khép kín.
Serialize cấu trúc quá sâu. set_site_transient() phải serialize $updates trước khi ghi vào database, và hàm serialize của PHP là code C đệ quy. Mà chính ba filter kia là thứ nhồi thêm dữ liệu vào $updates ngay trước đó. Object lồng quá sâu hoặc có vòng tham chiếu thì serializer đệ quy tới cạn stack.
Extension C lỗi. Callback gọi curl/openssl để hỏi license server. Extension có bug, hoặc được biên dịch lệch ABI với PHP 8.3, thì chết ngay bên trong extension.
Xác nhận có đúng là segfault không
Ở Bước 1 mới chỉ là suy luận ra segfault từ việc không có log. Nhưng có một cách xác nhận hẳn hoi mà rất dễ bỏ qua: debug.log của WordPress trống không có nghĩa là không còn log nào. Web server là tiến trình cha, nó thấy tiến trình con của mình chết:
# Apache
[core:notice] child pid 12345 exit signal Segmentation fault (11)
# PHP-FPM
WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV)
Thấy dòng này là hết nghi ngờ. Lần sau gặp WSOD mà debug.log im lặng, hãy mở log của Apache hoặc PHP-FPM trước khi làm bất cứ điều gì khác.
Còn chính xác callback nào, theo cơ chế nào thì bài này chưa truy tới cùng. Cái đã chứng minh được là: gỡ ba filter đó ra thì hết crash. Nghi can hàng đầu là mấy bộ SDK licensing (EDD, Freemius) bản cũ chưa tương thích với PHP 8.3, nhưng đó vẫn là phỏng đoán chứ chưa phải kết luận. Muốn truy tiếp thì cần bật xdebug hoặc đọc core dump, và đó là một bài khác.
Giải pháp: sửa từ theme, không đụng core hay plugin
Cám dỗ lúc này là mở code plugin ra sửa. Đừng. Bản sửa đó sẽ bị xoá sạch ở lần update kế tiếp, và người sửa lần sau sẽ không hiểu vì sao site lại chết trở lại.
Cách sạch nhất là gỡ đúng cái hook gây crash, từ theme:
// theme: inc/classes/class-admin.php
/**
* Fix: wp_update_plugins() kích hoạt các filter
* pre_set_site_transient_update_plugins của Perfmatters/Post SMTP/Rank Math,
* gây PHP segfault. Chỉ tắt lần kiểm tra realtime lúc mở trang Plugins;
* cron kiểm tra update vẫn chạy bình thường.
*/
public function fix_plugins_page_crash()
{
remove_action('load-plugins.php', 'wp_update_plugins');
}
Gắn vào admin_init:
add_action('admin_init', [$this, 'fix_plugins_page_crash']);
Vì sao cách này an toàn? Vì wp_update_plugins() không chỉ được gọi từ load-plugins.php. WordPress còn có một cron job chạy nó định kỳ, mặc định mỗi 12 giờ. Gỡ hook load-plugins.php chỉ tắt lần kiểm tra ngay tại thời điểm bạn mở trang Plugins; thông tin về bản cập nhật vẫn được cron làm mới đều đặn. Bạn mất vài giờ độ tươi của dữ liệu update, đổi lại có một trang Plugins mở được.
Cũng nên ghi rõ lý do ngay trong comment như trên. Một remove_action() không có giải thích là thứ mà sáu tháng sau sẽ có người xoá đi vì "chẳng hiểu để làm gì", rồi site chết lại từ đầu.
Bảng tra nhanh theo triệu chứng
| Triệu chứng | Nghi can |
|---|---|
WSOD, debug.log có ghi fatal error | PHP error bình thường, đọc log là ra |
WSOD, bật hết WP_DEBUG + display_errors mà log vẫn trống | Segfault; đối chiếu log Apache/PHP-FPM để xác nhận |
| Chỉ một trang admin chết, các trang khác bình thường | Hook load-{$pagenow} của riêng trang đó |
| Trang Plugins / Updates chết | Gần như chắc là wp_update_plugins() và đám filter của plugin premium |
echo đầu file không hiện gì | Chưa tới PHP, xem lại web server |
Checklist khi gặp WSOD trên một trang admin cụ thể
- Bật
WP_DEBUG+display_errors. Nếu vẫn không có lỗi nào, nghi ngay segfault. - Mở log Apache / PHP-FPM tìm dòng
exit signal Segmentation fault (11)để xác nhận. echo+die()ở đầu file để xác nhận PHP có chạy tới.- Rải log theo kiểu chia đôi để khoanh vùng dòng gây crash.
- Xác định hook nào đang chạy tại điểm crash.
- Liệt kê toàn bộ callback đang gắn trên hook đó.
- Gỡ từng callback để tìm đúng thủ phạm.
- Vá bằng
remove_action()/remove_filter()từ theme hoặc mu-plugin.
Bốn điều mình rút ra
Không có log cũng là một thông tin - nhưng đừng quên còn log ở chỗ khác. Bật hết mọi thứ mà PHP vẫn câm lặng thì đó không phải ngõ cụt, đó là câu trả lời: tiến trình bị giết ở tầng C, trước khi kịp chạy handler ghi log. Nhưng đừng dừng ở kết luận "vậy là không còn log nào để đọc". debug.log trống chỉ có nghĩa là PHP không ghi được, còn web server vẫn ghi lại cái chết của tiến trình con - mở nó ra là xác nhận được ngay thay vì phải suy luận.
Chia đôi luôn thắng đoán mò. Mỗi dòng log đặt đúng chỗ cắt bỏ được một nửa phạm vi tìm kiếm. Bảy bước trong bài này đi từ "toàn bộ WordPress" xuống đúng một dòng gọi hàm. Đoán mò không có gì đảm bảo, còn chia đôi thì luôn về đích trong O(log n) bước.
Hook là nơi code của người khác chạy trong code của bạn. set_site_transient() nhìn như một thao tác ghi database vô hại, nhưng thực chất nó mở cửa cho mọi plugin nhảy vào. Khi crash xảy ra tại một lời gọi hàm core trông rất hiền lành, việc cần làm là liệt kê xem ai đang hook vào đó, chứ không phải nghi ngờ core.
Đừng dừng lại ở bản vá đầu tiên. Ở bước 5 mình đã có một dòng code làm trang Plugins sống lại. Nếu dừng ở đó, kết luận sẽ là "core WordPress bị lỗi" - sai hoàn toàn. Đi thêm hai bước nữa mới lòi ra thủ phạm thật là đám update checker của plugin premium. Một bản vá chạy được nhưng dựa trên chẩn đoán sai thì sớm muộn cũng phản chủ.
Cả bài viết này rốt cuộc gói lại trong đúng một dòng remove_action(). Nhưng để dám viết một dòng đó mà không sợ, mình cần biết chính xác nó đang tắt cái gì và đánh đổi những gì.
Về bài viết này. Đây là một case study có thật, không phải ví dụ dựng lên để minh hoạ. Lỗi xảy ra trên máy mình, và toàn bộ quá trình truy vết ở trên là những gì mình đã thực sự làm để tìm ra nguyên nhân rồi xử lý dứt điểm. Môi trường, tên plugin, thứ tự từng bước đều giữ đúng như lúc gặp.
Phần câu chữ và bố cục bài có sự hỗ trợ của AI để diễn đạt mạch lạc hơn. Mục giải thích cơ chế ở tầng C và cách xác nhận segfault qua log web server là phần bổ sung khi biên tập, nhằm trả lời câu hỏi "vì sao code PHP lại giết được cả tiến trình". Còn bug, quá trình debug và bản vá cuối cùng thì là của mình, đã chạy thật trên site thật.
Cảm ơn bạn đã đọc tới tận đây. Site hiện chưa có phần bình luận, nên nếu bạn có góp ý, thắc mắc, muốn phản biện một kết luận nào đó, hay từng gặp ca WSOD lắt léo hơn, gửi thẳng cho mình qua email info@nguyenlap.net hoặc nhắn qua Facebook nhé.