diff --git a/README.md b/README.md index 26f190317..1b2505dd8 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ Read [docs/monorepo-architecture.md](docs/monorepo-architecture.md) for the arch ### Requirements -- Node.js 22+ +- Node.js 22.22 or newer - npm - Go 1.22+ for the server build path - Docker optional, for self-hosting diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 120038a1f..e001993c2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -19,7 +19,7 @@ }, "homepage": "https://github.com/ZenNotes/zennotes/releases/latest", "engines": { - "node": ">=22" + "node": ">=22.22" }, "scripts": { "dev": "electron-vite dev", @@ -58,7 +58,7 @@ "@zennotes/app-core": "*", "@zennotes/bridge-contract": "*", "@zennotes/shared-domain": "*", - "chokidar": "^4.0.3", + "chokidar": "^6.0.0", "codemirror": "^6.0.1", "docx": "^9.7.1", "dompurify": "^3.3.4", diff --git a/apps/desktop/src/main/watcher.test.ts b/apps/desktop/src/main/watcher.test.ts index f938291b8..172236739 100644 --- a/apps/desktop/src/main/watcher.test.ts +++ b/apps/desktop/src/main/watcher.test.ts @@ -1,7 +1,9 @@ import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { getActiveResourcesInfo } from 'node:process' +import chokidar from 'chokidar' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { VaultChangeEvent, VaultSettings } from '@shared/ipc' import { VaultWatcher } from './watcher' @@ -9,8 +11,9 @@ const tempDirs: string[] = [] const watchers: VaultWatcher[] = [] afterEach(async () => { - for (const watcher of watchers.splice(0)) watcher.stop() + await Promise.all(watchers.splice(0).map((watcher) => watcher.stop())) await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) + vi.restoreAllMocks() }) async function makeVault(): Promise { @@ -125,6 +128,70 @@ describe('VaultWatcher atomic saves', () => { ) }) +describe('VaultWatcher large vaults', () => { + it('watches external edits with bounded native handles and releases them on stop', async () => { + const root = await makeVault() + const directoryCount = 20 + for (let index = 0; index < directoryCount; index++) { + const dir = path.join(root, `folder-${index}`) + await mkdir(dir) + await Promise.all(Array.from({ length: 50 }, (_, note) => + writeFile(path.join(dir, `Note-${note}.md`), '# Note\n') + )) + } + const nativeHandles = (): number => + getActiveResourcesInfo().filter((resource) => resource === 'FSEventWrap').length + const baseline = nativeHandles() + const events: VaultChangeEvent[] = [] + const watcher = new VaultWatcher() + watchers.push(watcher) + watcher.start(root, (event) => events.push(event)) + + // Keep writing until the initial scan has completed, even on a slow disk. + await expect.poll(async () => { + await writeFile(path.join(root, 'folder-0', 'Note-0.md'), `Updated ${Date.now()}\n`) + return events.some((event) => event.path === 'folder-0/Note-0.md') + }, { interval: 250, timeout: 10_000 }).toBe(true) + // Allow per-directory watching on Linux, but never one handle per file. + expect(nativeHandles() - baseline).toBeLessThan(directoryCount * 2 + 10) + + await watcher.stop() + await expect.poll(nativeHandles).toBeLessThanOrEqual(baseline) + + const nextRoot = await makeVault() + events.length = 0 + watcher.start(nextRoot, (event) => events.push(event)) + await expect.poll(async () => { + await writeFile(path.join(nextRoot, 'Next.md'), `Updated ${Date.now()}\n`) + return events.some((event) => event.path === 'Next.md') + }, { interval: 250, timeout: 10_000 }).toBe(true) + }, 30_000) + + it('closes a failed watcher without an unhandled error and can start again', async () => { + const root = await makeVault() + const watch = vi.spyOn(chokidar, 'watch') + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + const events: VaultChangeEvent[] = [] + const watcher = new VaultWatcher() + watchers.push(watcher) + watcher.start(root, (event) => events.push(event)) + const active = watch.mock.results.find((result) => result.type === 'return')?.value + if (!active) throw new Error('Watcher did not start') + const closed = vi.spyOn(active, 'close') + + active.emit('error', Object.assign(new Error('Watch limit reached'), { code: 'EMFILE' })) + expect(closed).toHaveBeenCalledOnce() + expect(report).toHaveBeenCalledOnce() + await active.close() + + watcher.start(root, (event) => events.push(event)) + await expect.poll(async () => { + await writeFile(path.join(root, 'Recovered.md'), `Updated ${Date.now()}\n`) + return events.some((event) => event.path === 'Recovered.md') + }, { interval: 250, timeout: 10_000 }).toBe(true) + }, 15_000) +}) + describe('VaultWatcher custom templates (#723)', () => { it( 'announces a template file under its own scope, and ignores what is not a template', diff --git a/apps/desktop/src/main/watcher.ts b/apps/desktop/src/main/watcher.ts index faa79458f..0ed9d25a6 100644 --- a/apps/desktop/src/main/watcher.ts +++ b/apps/desktop/src/main/watcher.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import chokidar, { FSWatcher } from 'chokidar' +import chokidar, { type FSWatcher } from 'chokidar' import type { NoteFolder, VaultChangeEvent, VaultChangeKind, VaultSettings } from '@shared/ipc' import { databaseCsvPathFor } from '@shared/databases' import { AGENT_EDITS_DIR, AGENT_EDITS_SUFFIX } from '@shared/agent-edits' @@ -104,12 +104,14 @@ export class VaultWatcher { } start(root: string, onEvent: (ev: VaultChangeEvent) => void): void { - this.stop() + void this.stop() this.root = root this.settings = null this.pending = [] this.refreshSettings(root) - this.watcher = chokidar.watch(root, { + // Chokidar 6 uses native recursive watching on macOS and Windows, avoiding + // a file handle for every note and asset in a large vault. + const watcher = chokidar.watch(root, { ignoreInitial: true, persistent: true, ignored: (p: string) => { @@ -127,6 +129,7 @@ export class VaultWatcher { pollInterval: 40 } }) + this.watcher = watcher const handler = (kind: VaultChangeKind) => (absPath: string) => { const base = path.basename(absPath) @@ -218,7 +221,12 @@ export class VaultWatcher { }) } - this.watcher + watcher + .on('error', (error) => { + if (this.watcher !== watcher) return + console.error('Vault watcher failed; live updates have stopped', error) + void this.stop() + }) .on('add', handler('add')) .on('change', handler('change')) .on('unlink', handler('unlink')) @@ -226,15 +234,18 @@ export class VaultWatcher { .on('unlinkDir', dirHandler('unlink')) } - stop(): void { - if (this.watcher) { - void this.watcher.close() - this.watcher = null - this.root = null - // Anything still waiting on the first load belongs to the vault we just - // stopped watching; nobody wants it now. - this.pending = null - this.settings = null + async stop(): Promise { + const watcher = this.watcher + this.watcher = null + this.root = null + // Anything still waiting on the first load belongs to the vault we just + // stopped watching; nobody wants it now. + this.pending = null + this.settings = null + try { + await watcher?.close() + } catch (error) { + console.error('Failed to close vault watcher', error) } } } diff --git a/package-lock.json b/package-lock.json index 1dc724e53..c322f43a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "vite": "^6.4.3" }, "engines": { - "node": ">=22" + "node": ">=22.22" } }, "apps/desktop": { @@ -46,7 +46,7 @@ "@zennotes/app-core": "*", "@zennotes/bridge-contract": "*", "@zennotes/shared-domain": "*", - "chokidar": "^4.0.3", + "chokidar": "^6.0.0", "codemirror": "^6.0.1", "docx": "^9.7.1", "dompurify": "^3.3.4", @@ -99,7 +99,7 @@ "vitest": "^3.2.6" }, "engines": { - "node": ">=22" + "node": ">=22.22" } }, "apps/desktop/node_modules/@codemirror/lint": { @@ -6970,15 +6970,15 @@ } }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-6.0.0.tgz", + "integrity": "sha512-Ec2Z7wpcKBiMpR7DxteoqS/sq5G5d2A4fpxzPv6+fD7xNNdrTghn7L11YwV5EN1B3i72Erc3XVT5FkWIDx90+A==", "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "readdirp": "^5.1.1" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 22.22" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -13631,12 +13631,12 @@ } }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">= 20.19.0" }, "funding": { "type": "individual", @@ -14113,19 +14113,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/sass/node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/sax": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", diff --git a/package.json b/package.json index 532043046..560133f79 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { - "node": ">=22" + "node": ">=22.22" }, "workspaces": [ "apps/*",