Skip to content
Closed
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
26 changes: 25 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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:
Expand Down
156 changes: 156 additions & 0 deletions packages/fff-node/test/watcher-churn-stress.mjs
Original file line number Diff line number Diff line change
@@ -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.");
Loading