Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
},
"homepage": "https://github.com/ZenNotes/zennotes/releases/latest",
"engines": {
"node": ">=22"
"node": ">=22.22"
},
"scripts": {
"dev": "electron-vite dev",
Expand Down Expand Up @@ -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",
Expand Down
71 changes: 69 additions & 2 deletions apps/desktop/src/main/watcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
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'

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<string> {
Expand Down Expand Up @@ -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',
Expand Down
37 changes: 24 additions & 13 deletions apps/desktop/src/main/watcher.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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) => {
Expand All @@ -127,6 +129,7 @@ export class VaultWatcher {
pollInterval: 40
}
})
this.watcher = watcher

const handler = (kind: VaultChangeKind) => (absPath: string) => {
const base = path.basename(absPath)
Expand Down Expand Up @@ -218,23 +221,31 @@ 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'))
.on('addDir', dirHandler('add'))
.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<void> {
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)
}
}
}
37 changes: 12 additions & 25 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/*",
Expand Down
Loading