en

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
cập nhật
Ngày cập nhật
macOSKernel PanicPlaywrightClaude Code
Cửa sổ đo bộ nhớ, thanh RAM trống còn dài và thanh dung lượng bộ nén ngắn, riêng thanh số trang bộ nén tô gradient đã chạm vạch giới hạn

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:

text
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

bash
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:

bash
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ạyIOSurfaceAGXResourceTrang nén
21:51bắt đầu, Chromium1631.7080
21:53–21:54Chromium109–1101.751–2.0010
21:55WebKit bắt đầu2002.2820
21:57WebKit1.0353.9540
21:59WebKit1.8045.4970
22:00xong, WebKit đã thoát2.2516.349khoả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-gpu cũ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.

ts
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:

bash
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 limit khác hẳn kiểu zone 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 →