blog
Mac còn 65% RAM vẫn sắp panic: bộ nén, IOSurface và WebKit của Playwright
Mac Studio 32 GB panic với 100% of compressed pages limit, rồi tối cùng ngày suýt panic lần hai khi RAM vẫn báo trống 65%. Thứ bị đầy là số trang trong bộ nén, và thủ phạm là IOSurface do WebKit của Playwright để lại trong kernel.
- Ngày đăng
- Ngày cập nhật
Ngày 31/10/2026, Mac của tôi panic lúc 13:55 và suýt panic lần nữa lúc 21:12, khi RAM vẫn báo trống 65%. Cả hai lần, thứ bị đầy là số trang trong bộ nén của macOS. Các chỉ số RAM quen thuộc không nhìn thấy giới hạn này, và thủ phạm cuối cùng là WebKit của Playwright, không phải Chrome headless như tôi nghi lúc đầu.
Đây là lần panic thứ hai trong tháng với một panic string khác. Lần trước là vùng nhớ kernel data.kalloc.1024, ghi ở bài kernel panic data.kalloc.1024 khi chạy nhiều phiên Claude Code.
Máy và bộ e2e: ba trong bốn project chạy WebKit
Mac Studio M1 Max, 32 GB RAM, macOS 26.7.1. Nhiều phiên Claude Code chạy song song trong tmux, mỗi dự án một phiên, điều khiển từ xa bằng Remote Control. Máy chạy liên tục, không có ai ngồi trước.
Các phiên dùng MCP Playwright và Chrome DevTools (Chrome headless), và chạy test e2e bằng Playwright. Bộ e2e có 4 project: desktop chạy Chromium; mobile, tablet, tablet-landscape dùng descriptor thiết bị Apple, tức là chạy WebKit.
12:06 đến 13:55: thiếu RAM thật, rồi panic watchdog
Máy 32 GB đang gánh khoảng 45 đến 50 GB: khoảng 40 tiến trình Claude Code (14,8 GB), Chrome cùng Chrome headless của các MCP (khoảng 8,5 GB), IDE (5,8 GB), app Claude desktop, node. Giọt nước tràn ly là ba phiên tạm có MCP trình duyệt mở thêm trong khoảng 11:37 đến 12:05.
12:06 xuất hiện JetsamEvent đầu tiên: bộ nén 16,3 GB, RAM trống 0,26 GB, macOS giết hàng loạt daemon để lấy RAM. Máy chịu thiếu RAM gần 2 giờ, rồi panic lúc 13:55:
watchdog timeout: no checkins from watchdogd in 92 seconds
Compressor Info: 100% of compressed pages limit (BAD) and 31% of segments limit (OK)
Con số kernel_task 149 đến 159 GB trong JetsamEvent và panic log không phản ánh RAM thật, đừng dựa vào nó.
Đọc JetsamEvent và panic log để biết ai chiếm RAM
ls /Library/Logs/DiagnosticReports/{,Retired/}
Lấy các file JetsamEvent-*.ips và panic-full-*.panic. Trong panic log, memoryStatus.compressorSize (đơn vị trang 16 KiB) cho biết bộ nén lớn cỡ nào, còn processByPid[].residentMemoryBytes gộp theo procname cho biết nhóm tiến trình nào chiếm RAM.
Xử lý đợt 1: hook ram-gate chặn lệnh nặng khi RAM thấp
Lúc này tôi xử lý theo hướng thiếu RAM:
- Đóng bớt ứng dụng, chỉ giữ thứ cần thiết.
- Phiên ít dùng (từ 16 lần nhắn trong 7 ngày trở xuống) chuyển sang chỉ mở khi cần. Phiên tạm xong việc thì đóng.
- Script canh định kỳ ghi thêm dung lượng bộ nén, mức áp lực (
kern.memorystatus_vm_pressure_level) và phần trăm RAM trống. Nó nhắn ở hai mức: cảnh báo khi bộ nén từ 10 GB, nghiêm trọng khi bộ nén từ 13 GB hoặc áp lực lên mức 4. Tin nào cũng kèm 5 nhóm tiến trình lớn nhất.
Tôi thêm một hook PreToolUse của Claude Code tên ram-gate. Hook không cấm chạy song song, chỉ chặn khi RAM thấp. Nó bắt các lệnh nặng: npx playwright, playwright test, make|npm run … e2e, script e2e.sh, vite build, next build, Chrome --headless, lighthouse <url>, và mọi tool của MCP Playwright và Chrome DevTools trừ tool đóng trang.
Hook deny khi một trong các điều kiện sau đúng:
- RAM trống dưới 30% (
kern.memorystatus_level); - bộ nén từ 8 GB và RAM trống dưới 50%;
- bộ nén từ 12 GB, bất kể RAM trống;
- áp lực bộ nhớ từ 2.
Lời deny bảo phiên đợi vài phút rồi chạy lại, và không tìm cách lách. Mỗi lần deny ghi một dòng log. Hook khớp lệnh chứ không khớp chữ: cat e2e/x.spec.ts hay grep playwright vẫn qua. Gặp lỗi bất ngờ thì hook fail-open, tức vẫn cho chạy.
Hai cái bẫy khi chỉnh ngưỡng của hook
- Bộ nén giữ trang cũ rất lâu sau khi RAM đã dư. Chiều hôm đó bộ nén đứng 8,5 GB trong khi RAM trống 65%. Bản đầu của hook chỉ xét bộ nén từ 8 GB nên chặn oan e2e khoảng 30 phút. Vì thế mới thêm điều kiện "RAM trống dưới 50%" cho mốc 8 GB.
- Script bọc lọt qua. Bộ e2e được gọi qua
./scripts/e2e.sh, mẫu ban đầu của hook không nhận ra nên lệnh nặng lọt qua. Phải thêm mẫu cho script bọc.
21:12: sát panic lần hai khi RAM vẫn báo trống 65%
Tối đó chỉ còn khoảng 6 phiên: một phiên chạy e2e, một phiên viết Go, còn lại rảnh. Mọi chỉ số mà hook và script canh đều đẹp: RAM trống 65%, bộ nén chỉ 8,2 GB, áp lực thấp.
Thứ bị đầy là số trang trong bộ nén. Dòng "Pages stored in compressor" của vm_stat là 13.192.238, trong khi giới hạn sysctl vm.compressor.pages_compressed_limit là 13.192.448, tức 99,998%. Dòng "Pages occupied by compressor" chỉ khoảng 537.000 (8,2 GB).
Tỉ lệ nén khoảng 25:1, nghĩa là các trang gần như toàn số 0: khoảng 200 GB dữ liệu chỉ chiếm 8 GB RAM. Máy không có swapfile nào (vm.swapusage total 0).
Chủ của các trang là kernel_task: 208 GB trong top -l 1 -pid 0 -stats mem. Mọi tiến trình người dùng cộng lại chỉ nén khoảng 4,3 GB.
ioclasscount báo IOSurface 34.851, IOSurfaceClient 34.878, AGXResource 71.435, trong khi máy bình thường chỉ có vài trăm IOSurface. 200 GB chia cho 34.851 ra khoảng 5,9 MB mỗi surface, gần đúng một khung hình trình duyệt 1440×900.
Số này đứng yên khi máy rảnh, và không giảm kể cả khi không còn trình duyệt nào chạy: bộ đệm GPU nằm lại trong kernel. footprint của các tiến trình người dùng chỉ thấy vài trăm surface.
Lần panic 13:55 cũng ghi đúng câu 100% of compressed pages limit. RAM trống, dung lượng bộ nén và áp lực bộ nhớ, ba chỉ số mà hook đang dựa vào, đều không thấy được kiểu đầy này.
Thử đối chứng sau khi khởi động lại: WebKit để lại 2.251 IOSurface
Khởi động lại là cách duy nhất trả bộ nhớ kernel. Tôi khởi động lại lúc 21:49 rồi đo từ đầu: máy vừa lên có 194 IOSurface, kernel_task 656 KB, số trang nén 0%. Vòng đo 30 giây một lần trong lúc chạy e2e:
while sleep 30; do
echo "$(date +%T) surf=$(ioclasscount IOSurface | awk -F'= ' '{print $2}') \
agx=$(ioclasscount AGXResource | awk -F'= ' '{print $2}') \
stored=$(vm_stat | awk -F: '/stored in compressor/ {gsub(/[ .]/,"",$2); print $2}')"
done
Lượt 1, đủ bộ (21:51:49 đến 22:00:34, 444 test pass):
| Giờ | Project đang chạy | IOSurface | AGXResource | Trang nén |
|---|---|---|---|---|
| 21:51 | bắt đầu, Chromium | 163 | 1.708 | 0 |
| 21:53–21:54 | Chromium | 109–110 | 1.751–2.001 | 0 |
| 21:55 | WebKit bắt đầu | 200 | 2.282 | 0 |
| 21:57 | WebKit | 1.035 | 3.954 | 0 |
| 21:59 | WebKit | 1.804 | 5.497 | 0 |
| 22:00 | xong, WebKit đã thoát | 2.251 | 6.349 | khoảng 82.000 |
Trong lúc WebKit chạy có Playwright.app của gói WebKit Playwright và com.apple.WebKit.GPU.xpc. Sau khi chúng thoát, IOSurface vẫn giữ 2.251 và kernel_task lên 13 GB.
Lượt 2, chỉ Chromium (--project=desktop, 22:01:14 đến 22:04:32, 215 pass, 46 skip): IOSurface đứng 2.251 đến 2.252 suốt lượt, AGXResource 6.349 đến 6.352. Xong lượt không còn tiến trình trình duyệt nào sót.
Kết luận từ hai lượt đo
- Thủ phạm là WebKit của Playwright trên macOS 26: mỗi lượt e2e có WebKit rò khoảng 2.000 IOSurface (khoảng 6 MB mỗi cái) vào kernel qua tiến trình GPU của WebKit. Khoảng 15 lượt là chạm giới hạn số trang nén và panic.
- Chrome headless, nghi phạm lúc đầu, vô can. Cờ
--disable-gpucũng không liên quan vì đó là cờ của Chromium. - Không có cách giải phóng nào ngoài khởi động lại máy. Đóng trình duyệt sau test là cần nhưng không đủ.
Cách khắc phục: Chromium giả lập thiết bị, canh số trang nén
Bỏ WebKit khỏi e2e chạy trên máy
Màn mobile và tablet vẫn kiểm bố cục, nhưng bằng Chromium giả lập viewport, touch và user agent. Giữ nguyên tên project vì spec lọc theo tên. Không dùng descriptor iPhone và iPad có sẵn của Playwright, vì chúng mặc định chọn WebKit.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile', use: { ...devices['Pixel 7'] } }, // Chromium
{
name: 'tablet',
use: {
browserName: 'chromium',
viewport: { width: 820, height: 1180 },
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
},
},
],
});
Kiểm không còn WebKit: pgrep -fl 'ms-playwright/webkit' phải trả rỗng trong lúc e2e chạy.
Canh số trang nén, không chỉ dung lượng
Hook ram-gate chặn thêm khi số trang nén từ 70% giới hạn. Tự kiểm bằng:
stored=$(vm_stat | awk -F: '/stored in compressor/ {gsub(/[ .]/,"",$2); print $2}')
limit=$(sysctl -n vm.compressor.pages_compressed_limit)
echo "trang nén: $((stored * 100 / limit))% giới hạn, IOSurface: $(ioclasscount IOSurface | awk -F'= ' '{print $2}')"
Truy vết theo lệnh, canh định kỳ, bớt phiên tự mở
- Truy vết theo lệnh: hook PreToolUse ghi số IOSurface trước mỗi lệnh nặng; hook PostToolUse đếm lại rồi ghi chênh lệch kèm lệnh vào log. Lệnh nào lặp lại để lại chênh lệch dương thì đó là thủ phạm.
- Script canh chạy 5 phút một lần (trước là 10 phút), ghi tỉ lệ số trang nén, số IOSurface và bộ nhớ của
kernel_task. Nó nhắn cảnh báo ở 60% và 80%, tới 85% thì xin khởi động lại. - Kiểm trước khi mở thêm phiên: một script kiểm RAM (cùng ngưỡng với hook), số Chrome headless, IOSurface và phiên nào đang dùng chung thư mục, rồi chặn mở khi RAM thấp. Phiên tạm dùng preset cấu hình theo loại việc; chỉ phiên làm giao diện mới gắn MCP trình duyệt.
- Bớt phiên tự khởi động: chỉ vài phiên cốt lõi tự mở khi máy lên, còn lại mở khi cần.
Bài học: đặt luật bằng hook, ngưỡng phải tính độ trễ của bộ nén
- macOS giới hạn số trang trong bộ nén. Trang toàn số 0 nén rất tốt nên chiếm ít RAM, nhưng vẫn bị đếm đủ, nên RAM còn trống chưa có nghĩa là an toàn.
- Đọc panic string trước khi kết luận.
100% of compressed pages limitkhác hẳn kiểuzone map exhaustedở bài về data.kalloc.1024. - Một lượt thử đối chứng ngay sau khi khởi động lại, đo 30 giây một lần và tách riêng từng loại trình duyệt, cho kết quả rõ hơn nhiều giờ đoán. Nghi phạm hiển nhiên là Chrome headless, và nó vô can.
- Lời dặn "mỗi lúc chỉ chạy một Playwright" vừa bó tay khi máy dư RAM, vừa không chặn được lúc máy thiếu. Hook đo RAM thật thì làm được cả hai.
- Bộ nén giữ trang cũ rất lâu, nên ngưỡng chỉ dựa vào dung lượng bộ nén sẽ chặn oan.
Chỗ chưa chứng minh được
Vụ 13:55 nhiều khả năng là hai thứ cộng lại: IOSurface rò từ các lượt e2e buổi sáng, cộng với thiếu RAM thật do khoảng 40 tiến trình. Lúc đó tôi chưa đếm IOSurface nên không chứng minh được. Kết luận về WebKit cũng mới dựa trên hai lượt đo trên một máy.
Bài viết liên quan
tất cả bài viết →- Kernel panic data.kalloc.1024 trên Mac khi chạy nhiều phiên Claude CodeMac Studio 32 GB chạy khoảng 20 phiên Claude Code tự khởi động lại năm lần vì vùng nhớ kernel data.kalloc.1024 rò tới khoảng 20 GB. Cách theo dõi bằng zprint, vì sao bản cài npm làm rò nhanh hơn, và ba bước đã chặn được nó.đọc tiếp →
- REA: cho agent dịch ngược phần mềm, rồi kiểm luôn cái oracleREA cho coding agent đọc một chương trình không có source, xuống tới mã máy, rồi dựng lại tính năng đó bằng code của mình. Bản dựng lại đúng hay sai do oracle quyết định, và oracle cũng là code, cũng có bug.đọc tiếp →
- Top 10 skill Claude Code nên cài, xếp theo sao GitHub: bộ nào làm gì, bộ nào đè lên nhauMười bộ skill cho Claude Code có nhiều sao GitHub nhất tại 29/10/2026: Superpowers, Matt Pocock Skills, Karpathy Skills, Anthropic Skills, Ponytail, UI UX Pro Max, Graphify, Caveman, Agent Skills, Taste Skill. Mỗi bộ có lệnh cài, việc nó làm và chỗ nó trùng với bộ khác.đọc tiếp →
- Claude Code + NotebookLM + Obsidian: dựng pipeline nghiên cứu, và nửa cầu nối không chính chủCách ghép Claude Code, NotebookLM và Obsidian thành pipeline nghiên cứu: skill nào có sẵn, vì sao NotebookLM không có API cho tài khoản cá nhân, và cái giá phải trả khi ghép ba công cụ này.đọc tiếp →