From ffe10bfd6ae641e2f00185f3d194ff6f8d2e79a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=20=E6=96=8C?= Date: Mon, 15 Jun 2026 03:33:18 -0400 Subject: [PATCH] test: add node watcher churn stress --- Makefile | 26 ++- .../fff-node/test/watcher-churn-stress.mjs | 156 ++++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 packages/fff-node/test/watcher-churn-stress.mjs diff --git a/Makefile b/Makefile index ff9a688d..8aa0eb67 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ SHELL := bash # string rather than the literal `-o` / `pipefail` tokens. .SHELLFLAGS := -o pipefail -ec -.PHONY: build build-c-lib install uninstall test test-rust test-c-smoke test-c-api test-lua test-lua-snap test-version test-bun test-node prepare-bun prepare-bun-packaged prepare-node set-npm-version header test-stress test-stress-seeded test-stress-random test-stress-repos test-node-stress sync-js-api sync-js-api-check bump-homebrew-formula bump-install-mcp-sh test-bun-compile +.PHONY: build build-c-lib build-c-lib-prof install uninstall test test-rust test-c-smoke test-c-api test-lua test-lua-snap test-version test-bun test-node prepare-bun prepare-bun-packaged prepare-node prepare-node-prof set-npm-version header test-stress test-stress-seeded test-stress-random test-stress-repos test-node-stress test-node-watcher-stress test-node-watcher-stress-prof sync-js-api sync-js-api-check bump-homebrew-formula bump-install-mcp-sh test-bun-compile all: format test lint @@ -52,6 +52,9 @@ build: build-c-lib: cargo build --release -p fff-c --features zlob +build-c-lib-prof: + cargo build --profile prof -p fff-c --features zlob + header: cbindgen --config crates/fff-c/cbindgen.toml --crate fff-c --output crates/fff-c/include/fff.h @@ -163,6 +166,12 @@ prepare-node: build sync-js-api cp target/release/libfff_c.so packages/fff-node/bin/ 2>/dev/null || true; \ cp target/release/fff_c.dll packages/fff-node/bin/ 2>/dev/null || true +prepare-node-prof: build-c-lib-prof sync-js-api + mkdir -p packages/fff-node/bin + cp target/prof/libfff_c.dylib packages/fff-node/bin/ 2>/dev/null || true; \ + cp target/prof/libfff_c.so packages/fff-node/bin/ 2>/dev/null || true; \ + cp target/prof/fff_c.dll packages/fff-node/bin/ 2>/dev/null || true + test-bun: prepare-bun cd packages/fff-bun && bun test test/ cd packages/pi-fff && bun test test/ @@ -219,6 +228,21 @@ test-node-stress: prepare-node cd packages/fff-node && npm run build && \ FFF_STRESS_ITERS=$(FFF_STRESS_ITERS) node test/stress-515.mjs +FFF_WATCHER_STRESS_ITERS ?= 12 +FFF_WATCHER_STRESS_OPS ?= 2500 +test-node-watcher-stress: prepare-node + cd packages/fff-node && npm run build && \ + FFF_WATCHER_STRESS_ITERS=$(FFF_WATCHER_STRESS_ITERS) \ + FFF_WATCHER_STRESS_OPS=$(FFF_WATCHER_STRESS_OPS) \ + node test/watcher-churn-stress.mjs + +test-node-watcher-stress-prof: prepare-node-prof + cd packages/fff-node && npm run build && \ + FFF_WATCHER_STRESS_ITERS=$(FFF_WATCHER_STRESS_ITERS) \ + FFF_WATCHER_STRESS_OPS=$(FFF_WATCHER_STRESS_OPS) \ + FFF_WATCHER_STRESS_LOG_LEVEL=trace \ + node test/watcher-churn-stress.mjs + test: test-rust test-lua test-lua-snap test-version test-bun test-node test-node-stress test-stress-seeded: diff --git a/packages/fff-node/test/watcher-churn-stress.mjs b/packages/fff-node/test/watcher-churn-stress.mjs new file mode 100644 index 00000000..69e6351a --- /dev/null +++ b/packages/fff-node/test/watcher-churn-stress.mjs @@ -0,0 +1,156 @@ +/** + * Node SDK watcher churn stress. + * + * Keeps the native watcher enabled while mutating the watched tree and issuing + * searches concurrently. Useful for macOS FSEvents/debouncer crash triage. + * + * Usage: + * node test/watcher-churn-stress.mjs [iterations] [opsPerIteration] + */ + +import { mkdtemp, mkdir, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { FileFinder } from "../dist/src/index.js"; + +const ITERATIONS = numberArg(0, "FFF_WATCHER_STRESS_ITERS", 12); +const OPS_PER_ITER = numberArg(1, "FFF_WATCHER_STRESS_OPS", 2500); +const SEARCHES_PER_ITER = numberEnv("FFF_WATCHER_STRESS_SEARCHES", OPS_PER_ITER); +const LOG_LEVEL = process.env.FFF_WATCHER_STRESS_LOG_LEVEL || "debug"; + +function numberArg(index, envName, fallback) { + const raw = process.argv[index + 2] || process.env[envName]; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function numberEnv(envName, fallback) { + const parsed = Number(process.env[envName]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function assertOk(result, context) { + if (!result.ok) { + throw new Error(`${context}: ${result.error}`); + } + return result.value; +} + +function assertTrue(result, context) { + const value = assertOk(result, context); + if (value !== true) { + throw new Error(`${context}: timed out`); + } +} + +async function waitForWatcher(finder, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const progress = assertOk(finder.getScanProgress(), "getScanProgress"); + if (!progress.isScanning && progress.isWatcherReady) return; + await sleep(50); + } + throw new Error(`watcher was not ready after ${timeoutMs}ms`); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function seedTree(base) { + for (let dir = 0; dir < 20; dir++) { + const dirPath = join(base, `dir-${dir}`); + await mkdir(dirPath, { recursive: true }); + for (let file = 0; file < 20; file++) { + await writeFile( + join(dirPath, `seed-${file}.txt`), + `seed ${dir}/${file}\nalpha beta gamma\n`, + ); + } + } +} + +async function churn(base, iteration) { + for (let i = 0; i < OPS_PER_ITER; i++) { + const dirPath = join(base, `hot-${iteration}-${i % 64}`); + const filePath = join(dirPath, `file-${i % 97}.txt`); + const nextPath = join(dirPath, `file-${i % 97}.renamed.txt`); + await mkdir(dirPath, { recursive: true }); + + switch (i % 5) { + case 0: + case 1: + await writeFile(filePath, `iter=${iteration} op=${i}\nneedle ${i % 13}\n`); + break; + case 2: + await writeFile(filePath, `rename source ${iteration}/${i}\n`); + await rename(filePath, nextPath).catch(ignoreMissing); + break; + case 3: + await rm(filePath, { force: true }); + await rm(nextPath, { force: true }); + break; + default: + await writeFile(filePath, `touch ${Date.now()} ${i}\n`); + break; + } + + if (i % 100 === 0) await sleep(1); + } +} + +async function search(finder) { + const queries = ["seed", "needle", "dir", "file", "alpha", "gamma", "hot"]; + for (let i = 0; i < SEARCHES_PER_ITER; i++) { + const query = queries[i % queries.length]; + const result = + i % 3 === 0 + ? finder.fileSearch(query, { pageSize: 20 }) + : i % 3 === 1 + ? finder.directorySearch(query, { pageSize: 20 }) + : finder.grep(query, { mode: "plain", pageSize: 20 }); + assertOk(result, `search ${i}`); + if (i % 100 === 0) await sleep(1); + } +} + +function ignoreMissing(error) { + if (error && error.code !== "ENOENT") throw error; +} + +async function runIteration(iteration) { + const base = await mkdtemp(join(tmpdir(), `fff-watcher-${iteration}-`)); + await seedTree(base); + + const logFilePath = join(tmpdir(), `fff-watcher-${process.pid}-${iteration}.log`); + const finder = assertOk( + FileFinder.create({ basePath: base, logFilePath, logLevel: LOG_LEVEL }), + "FileFinder.create", + ); + + try { + assertTrue(await finder.waitForScan(30_000), "waitForScan"); + await waitForWatcher(finder, 30_000); + process.stdout.write( + `[iter ${iteration}] base=${base} log=${logFilePath} ops=${OPS_PER_ITER} searches=${SEARCHES_PER_ITER}\n`, + ); + + await Promise.all([churn(base, iteration), search(finder)]); + await sleep(500); + + const progress = assertOk(finder.getScanProgress(), "final getScanProgress"); + process.stdout.write( + `[iter ${iteration}] done files=${progress.scannedFilesCount} watcher=${progress.isWatcherReady}\n`, + ); + } finally { + finder.destroy(); + await rm(base, { recursive: true, force: true }); + } +} + +for (let i = 0; i < ITERATIONS; i++) { + await runIteration(i); +} + +console.log("Completed watcher churn stress without JS-level errors.");