Repository navigation
chore(mcp-server): restart the dev server only on real source changes - #224
Conversation
On Windows libuv subscribes fs.watch to FILE_NOTIFY_CHANGE_LAST_ACCESS, so merely reading a file emits a change event once NTFS flushes a new last-access time. `node --watch` restarts on any event for a file in its module graph without checking whether the contents moved, so the MCP server restarted once or twice during boot, on the first lazy `better-sqlite3` import (the first connect() of every launch), and whenever another tool walked the tree. Add scripts/dev-watch.cjs: it spawns the child, watches the source trees with the same recursive fs.watch, and compares mtime + size against a cache primed at startup before restarting, so last-access-only events are dropped. Bursts are debounced, the file that triggered the restart is logged, and generated folders (node_modules, dist, target, __tests__, __test__, bench, .turbo, coverage) are skipped. Measured on this repo: boot goes from 1-2 restarts to none, the first connect() no longer restarts, and real content edits still reload in about 1.5s.
|
Warning Review limit reached
Next review available in: 26 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughThe PR adds ChangesMCP development watch workflow
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant Developer
participant dev-watch.cjs
participant MCPServer
Developer->>dev-watch.cjs: Start watcher for src and packages
dev-watch.cjs->>MCPServer: Spawn development command
Developer->>dev-watch.cjs: Modify watched TypeScript file
dev-watch.cjs->>MCPServer: Terminate and restart child process
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
scripts/dev-watch.cjs (2)
194-201: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winWait for the child to exit before the parent exits.
shutdownsendsSIGTERMand then callsprocess.exit(0)in the same tick. The child receives the signal, but the parent does not wait for the child to finish its cleanup, and the shell prompt returns while the HTTP listener may still hold port 3737. Exit after the child exit event, with a timeout fallback.♻️ Proposed graceful shutdown
const shutdown = () => { if (shuttingDown) return shuttingDown = true - if (child) child.kill('SIGTERM') - process.exit(0) + if (!child) { + process.exit(0) + return + } + const dying = child + dying.once('exit', () => process.exit(0)) + dying.kill('SIGTERM') + setTimeout(() => { + dying.kill('SIGKILL') + process.exit(0) + }, KILL_TIMEOUT_MS).unref() }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/dev-watch.cjs` around lines 194 - 201, Update shutdown in dev-watch.cjs to defer process.exit(0) until the child process emits its exit event after SIGTERM. Add a timeout fallback that exits if the child does not terminate promptly, while preserving the shuttingDown guard and handling shutdown when no child exists.
70-73: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueMatch ignored directories relative to the watched root.
isWatchablesplits the absolute path. Every ancestor segment outside the repository is tested againstIGNORED_DIRS. If a developer clones the repository under a folder nameddist,coverage, ortarget, then every file is filtered and the watcher never restarts. Test only the segments below the watched directory.♻️ Proposed relative-path filter
-const isWatchable = (filePath) => { - if (!WATCHED_EXTENSIONS.has(path.extname(filePath))) return false - return !filePath.split(path.sep).some((segment) => IGNORED_DIRS.has(segment)) -} +const isWatchable = (filePath, root = REPO_ROOT) => { + if (!WATCHED_EXTENSIONS.has(path.extname(filePath))) return false + const relative = path.relative(root, filePath) + return !relative.split(path.sep).some((segment) => IGNORED_DIRS.has(segment)) +}Note that
REPO_ROOTis declared at Line 63, so keep the declaration order valid if you reference it as a default value.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/dev-watch.cjs` around lines 70 - 73, Update isWatchable to evaluate ignored-directory segments from the path relative to REPO_ROOT, rather than splitting the absolute file path; preserve the existing extension check and ignored-directory filtering for repository descendants. Ensure REPO_ROOT remains declared before any default-value reference used by the updated logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/mcp-server/package.json`:
- Line 37: Update the development-loop documentation in the MCP server README to
match the package.json dev script: describe scripts/dev-watch.cjs watching
source changes and restarting the process, and remove the outdated HTTP/node
--watch behavior and Windows last-access-time discussion.
In `@scripts/dev-watch.cjs`:
- Around line 186-192: Update the watchDirs loop in the development watcher to
handle recursive fs.watch failures explicitly: either abort startup by
propagating or clearly terminating on the error, or implement a per-directory
watcher fallback before invoking startChild(). Do not allow execution to
continue with no registered watchers after the catch.
---
Nitpick comments:
In `@scripts/dev-watch.cjs`:
- Around line 194-201: Update shutdown in dev-watch.cjs to defer process.exit(0)
until the child process emits its exit event after SIGTERM. Add a timeout
fallback that exits if the child does not terminate promptly, while preserving
the shuttingDown guard and handling shutdown when no child exists.
- Around line 70-73: Update isWatchable to evaluate ignored-directory segments
from the path relative to REPO_ROOT, rather than splitting the absolute file
path; preserve the existing extension check and ignored-directory filtering for
repository descendants. Ensure REPO_ROOT remains declared before any
default-value reference used by the updated logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: cd266adf-dd63-4422-821f-4baa24cef536
📒 Files selected for processing (3)
AGENTS.mdpackages/mcp-server/package.jsonscripts/dev-watch.cjs
There was a problem hiding this comment.
3 issues found across 3 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="scripts/dev-watch.cjs">
<violation number="1" location="scripts/dev-watch.cjs:135">
P2: Editing several files while a reload is already in progress can spawn duplicate server processes. `restart()` doesn't guard against being called while `restarting` is true, and `dying` is the same ChildProcess throughout the kill window (up to KILL_TIMEOUT_MS = 3s). Events that land during that window get debounced and call `restart()` again, which registers another `dying.once('exit', ...)` listener; since `once` fires every registered listener, every extra call runs `startChild()` again once the process exits. The result is two (or more) node servers started back-to-back, with the stragglers failing to bind port 3737 and confusing the dev loop. Add a coalescing guard at the top of `restart()` so a second trigger during an active restart is dropped (the edit is already on disk and covered by the restart in progress).</violation>
<violation number="2" location="scripts/dev-watch.cjs:188">
P2: Replacing or removing a watched root can crash the dev runner because asynchronous watcher errors are unhandled. Keep each watcher and handle its `error` event so failure is logged instead of terminating the process.</violation>
<violation number="3" location="scripts/dev-watch.cjs:197">
P2: A Ctrl+C/SIGTERM issued during an in-progress restart can leave the server holding port 3737. `shutdown()` kills `child` (which is `null` in the window where the old process is being torn down and the new one isn't started yet) and then calls `process.exit(0)` immediately. The pending `killTimer` that would escalate to SIGKILL is `unref()`'d, so it won't keep the parent alive to enforce the kill. If a child swallows SIGTERM during that window it is orphaned and the port stays bound. Consider awaiting the child's exit (or clearing/overriding the pending kill with an immediate SIGKILL) before exiting.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
|
||
| for (const dir of watchDirs) { | ||
| try { | ||
| watch(dir, { recursive: true }, (_eventType, name) => onFsEvent(dir, name)) |
There was a problem hiding this comment.
P2: Replacing or removing a watched root can crash the dev runner because asynchronous watcher errors are unhandled. Keep each watcher and handle its error event so failure is logged instead of terminating the process.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/dev-watch.cjs, line 188:
<comment>Replacing or removing a watched root can crash the dev runner because asynchronous watcher errors are unhandled. Keep each watcher and handle its `error` event so failure is logged instead of terminating the process.</comment>
<file context>
@@ -0,0 +1,204 @@
+
+for (const dir of watchDirs) {
+ try {
+ watch(dir, { recursive: true }, (_eventType, name) => onFsEvent(dir, name))
+ } catch (error) {
+ log(`cannot watch ${dir}: ${error.message}`)
</file context>
| const shutdown = () => { | ||
| if (shuttingDown) return | ||
| shuttingDown = true | ||
| if (child) child.kill('SIGTERM') |
There was a problem hiding this comment.
P2: A Ctrl+C/SIGTERM issued during an in-progress restart can leave the server holding port 3737. shutdown() kills child (which is null in the window where the old process is being torn down and the new one isn't started yet) and then calls process.exit(0) immediately. The pending killTimer that would escalate to SIGKILL is unref()'d, so it won't keep the parent alive to enforce the kill. If a child swallows SIGTERM during that window it is orphaned and the port stays bound. Consider awaiting the child's exit (or clearing/overriding the pending kill with an immediate SIGKILL) before exiting.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/dev-watch.cjs, line 197:
<comment>A Ctrl+C/SIGTERM issued during an in-progress restart can leave the server holding port 3737. `shutdown()` kills `child` (which is `null` in the window where the old process is being torn down and the new one isn't started yet) and then calls `process.exit(0)` immediately. The pending `killTimer` that would escalate to SIGKILL is `unref()`'d, so it won't keep the parent alive to enforce the kill. If a child swallows SIGTERM during that window it is orphaned and the port stays bound. Consider awaiting the child's exit (or clearing/overriding the pending kill with an immediate SIGKILL) before exiting.</comment>
<file context>
@@ -0,0 +1,204 @@
+const shutdown = () => {
+ if (shuttingDown) return
+ shuttingDown = true
+ if (child) child.kill('SIGTERM')
+ process.exit(0)
+}
</file context>
| }) | ||
| } | ||
|
|
||
| const restart = () => { |
There was a problem hiding this comment.
P2: Editing several files while a reload is already in progress can spawn duplicate server processes. restart() doesn't guard against being called while restarting is true, and dying is the same ChildProcess throughout the kill window (up to KILL_TIMEOUT_MS = 3s). Events that land during that window get debounced and call restart() again, which registers another dying.once('exit', ...) listener; since once fires every registered listener, every extra call runs startChild() again once the process exits. The result is two (or more) node servers started back-to-back, with the stragglers failing to bind port 3737 and confusing the dev loop. Add a coalescing guard at the top of restart() so a second trigger during an active restart is dropped (the edit is already on disk and covered by the restart in progress).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/dev-watch.cjs, line 135:
<comment>Editing several files while a reload is already in progress can spawn duplicate server processes. `restart()` doesn't guard against being called while `restarting` is true, and `dying` is the same ChildProcess throughout the kill window (up to KILL_TIMEOUT_MS = 3s). Events that land during that window get debounced and call `restart()` again, which registers another `dying.once('exit', ...)` listener; since `once` fires every registered listener, every extra call runs `startChild()` again once the process exits. The result is two (or more) node servers started back-to-back, with the stragglers failing to bind port 3737 and confusing the dev loop. Add a coalescing guard at the top of `restart()` so a second trigger during an active restart is dropped (the edit is already on disk and covered by the restart in progress).</comment>
<file context>
@@ -0,0 +1,204 @@
+ })
+}
+
+const restart = () => {
+ const changed = Array.from(pendingChanges)
+ pendingChanges.clear()
</file context>
…on exit Review follow-ups on the dev runner. restart() had no guard against being called while a restart was in flight: a change landing inside the kill window stacked another exit listener on the dying child, so each extra trigger spawned one more server once it finally exited. Drop those triggers instead - the child the in-flight restart spawns already reads the newer file from disk. shutdown() killed the child and exited in the same tick with the SIGKILL escalation unref'd, so signalling the runner alone left the child holding the HTTP port and the next run failed to bind. Wait for the child's exit and keep the escalation timer alive to enforce the kill. fs.watch errors (a watched root deleted or replaced while running) had no listener and would throw out of the runner; log them instead. When no directory can be watched at all, abort rather than starting a child in a dev loop that silently never reloads. Also update packages/mcp-server/README.md, which still documented the node --watch dev loop.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/mcp-server/README.md`:
- Around line 108-109: Update the documentation sentence near the node --watch
description to replace “contents moved” with “file contents changed,” preserving
the surrounding explanation and Windows rationale.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 61990ca9-bd9a-431b-a8ae-42b6bbb71858
📒 Files selected for processing (2)
packages/mcp-server/README.mdscripts/dev-watch.cjs
🚧 Files skipped from review as they are similar to previous changes (1)
- scripts/dev-watch.cjs
| > test run, editor indexing, grep). `node --watch` restarts on any event for a | ||
| > file in its module graph without checking whether the contents moved, which |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Replace “contents moved” with “file contents changed”.
“Contents moved” is not a defined file-change condition and makes the Windows rationale unclear.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/mcp-server/README.md` around lines 108 - 109, Update the
documentation sentence near the node --watch description to replace “contents
moved” with “file contents changed,” preserving the surrounding explanation and
Windows rationale.
There was a problem hiding this comment.
5 issues found across 4 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="scripts/dev-watch.cjs">
<violation number="1" location="scripts/dev-watch.cjs:151">
P1: Stopping the watcher during an in-flight restart can leave the replacement MCP server orphaned. The restart exit listener unconditionally calls `startChild()`, while `shutdown()` can be invoked before that listener runs and registers its own exit handler that immediately exits the runner; the newly spawned child then survives the parent and can keep port 3737 bound. The restart completion path should skip spawning when `shuttingDown` is set.</violation>
<violation number="2" location="scripts/dev-watch.cjs:181">
P2: The watcher does not actually verify content changes: it treats `mtimeMs + size` as the file identity. A same-size edit whose timestamp is preserved can be ignored, while a metadata-only mtime update such as `touch` can trigger a restart even when bytes are unchanged. That means the runner can still miss real edits or restart for non-content changes, contrary to the stated content-only behavior; a content digest check (at least after the metadata fingerprint changes) would make this reliable.</violation>
<violation number="3" location="scripts/dev-watch.cjs:198">
P2: Generated directories are still in the recursive watch scope, so activity under `node_modules`, `dist`, or `coverage` still reaches this watcher despite the stated exclusion and can add unnecessary event pressure. Use non-recursive watchers only for eligible directories, adding eligible new directories as needed, when these trees must not be watched.</violation>
</file>
<file name="packages/mcp-server/package.json">
<violation number="1" location="packages/mcp-server/package.json:37">
P2: Changes to the runtime spec sources do not restart this dev server because the watcher only scans `src` and `packages`. For example, `src/proto.ts`, `src/mex.ts`, `src/appstate-spec.ts`, `src/abprops-spec.ts`, and `src/version-spec.ts` import runtime modules from `../spec`; editing `spec/version/index.js`, `spec/mex/index.js`, or generated protobuf output can therefore leave the running MCP process using stale protocol data until it is manually restarted. Including the `spec` tree in the watched roots would keep the advertised reload behavior complete.</violation>
</file>
<file name="packages/mcp-server/README.md">
<violation number="1" location="packages/mcp-server/README.md:109">
P3: Consider rewording "without checking whether the contents moved" to something like "without checking whether the file contents actually changed" — "contents moved" isn't a defined file-change condition and muddles the explanation of why `node --watch` over-restarts on Windows.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| log(`restarting: ${display(changed[0])}${extra}`) | ||
|
|
||
| if (!child) { | ||
| startChild() |
There was a problem hiding this comment.
P1: Stopping the watcher during an in-flight restart can leave the replacement MCP server orphaned. The restart exit listener unconditionally calls startChild(), while shutdown() can be invoked before that listener runs and registers its own exit handler that immediately exits the runner; the newly spawned child then survives the parent and can keep port 3737 bound. The restart completion path should skip spawning when shuttingDown is set.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/dev-watch.cjs, line 151:
<comment>Stopping the watcher during an in-flight restart can leave the replacement MCP server orphaned. The restart exit listener unconditionally calls `startChild()`, while `shutdown()` can be invoked before that listener runs and registers its own exit handler that immediately exits the runner; the newly spawned child then survives the parent and can keep port 3737 bound. The restart completion path should skip spawning when `shuttingDown` is set.</comment>
<file context>
@@ -0,0 +1,239 @@
+ log(`restarting: ${display(changed[0])}${extra}`)
+
+ if (!child) {
+ startChild()
+ return
+ }
</file context>
| let watchedDirs = 0 | ||
| for (const dir of watchDirs) { | ||
| try { | ||
| const watcher = watch(dir, { recursive: true }, (_eventType, name) => onFsEvent(dir, name)) |
There was a problem hiding this comment.
P2: Generated directories are still in the recursive watch scope, so activity under node_modules, dist, or coverage still reaches this watcher despite the stated exclusion and can add unnecessary event pressure. Use non-recursive watchers only for eligible directories, adding eligible new directories as needed, when these trees must not be watched.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/dev-watch.cjs, line 198:
<comment>Generated directories are still in the recursive watch scope, so activity under `node_modules`, `dist`, or `coverage` still reaches this watcher despite the stated exclusion and can add unnecessary event pressure. Use non-recursive watchers only for eligible directories, adding eligible new directories as needed, when these trees must not be watched.</comment>
<file context>
@@ -0,0 +1,239 @@
+let watchedDirs = 0
+for (const dir of watchDirs) {
+ try {
+ const watcher = watch(dir, { recursive: true }, (_eventType, name) => onFsEvent(dir, name))
+ // An unhandled 'error' (watched root deleted or replaced while running)
+ // is thrown by the EventEmitter and would take the runner down.
</file context>
| "test": "node --import tsx --test \"src/**/__tests__/**/*.test.ts\"", | ||
| "start": "node --import tsx src/bin.ts", | ||
| "dev": "cross-env MCP_TRANSPORT=http MCP_EVAL_ENABLED=1 MCP_LOG_LEVEL=trace MCP_AUTH_PATH=../../.auth/state.sqlite node --watch --import tsx src/bin.ts" | ||
| "dev": "cross-env MCP_TRANSPORT=http MCP_EVAL_ENABLED=1 MCP_LOG_LEVEL=trace MCP_AUTH_PATH=../../.auth/state.sqlite node ../../scripts/dev-watch.cjs --watch ../../src --watch ../../packages -- node --import tsx src/bin.ts" |
There was a problem hiding this comment.
P2: Changes to the runtime spec sources do not restart this dev server because the watcher only scans src and packages. For example, src/proto.ts, src/mex.ts, src/appstate-spec.ts, src/abprops-spec.ts, and src/version-spec.ts import runtime modules from ../spec; editing spec/version/index.js, spec/mex/index.js, or generated protobuf output can therefore leave the running MCP process using stale protocol data until it is manually restarted. Including the spec tree in the watched roots would keep the advertised reload behavior complete.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/mcp-server/package.json, line 37:
<comment>Changes to the runtime spec sources do not restart this dev server because the watcher only scans `src` and `packages`. For example, `src/proto.ts`, `src/mex.ts`, `src/appstate-spec.ts`, `src/abprops-spec.ts`, and `src/version-spec.ts` import runtime modules from `../spec`; editing `spec/version/index.js`, `spec/mex/index.js`, or generated protobuf output can therefore leave the running MCP process using stale protocol data until it is manually restarted. Including the `spec` tree in the watched roots would keep the advertised reload behavior complete.</comment>
<file context>
@@ -34,7 +34,7 @@
"test": "node --import tsx --test \"src/**/__tests__/**/*.test.ts\"",
"start": "node --import tsx src/bin.ts",
- "dev": "cross-env MCP_TRANSPORT=http MCP_EVAL_ENABLED=1 MCP_LOG_LEVEL=trace MCP_AUTH_PATH=../../.auth/state.sqlite node --watch --import tsx src/bin.ts"
+ "dev": "cross-env MCP_TRANSPORT=http MCP_EVAL_ENABLED=1 MCP_LOG_LEVEL=trace MCP_AUTH_PATH=../../.auth/state.sqlite node ../../scripts/dev-watch.cjs --watch ../../src --watch ../../packages -- node --import tsx src/bin.ts"
},
"dependencies": {
</file context>
| "dev": "cross-env MCP_TRANSPORT=http MCP_EVAL_ENABLED=1 MCP_LOG_LEVEL=trace MCP_AUTH_PATH=../../.auth/state.sqlite node ../../scripts/dev-watch.cjs --watch ../../src --watch ../../packages -- node --import tsx src/bin.ts" | |
| "dev": "cross-env MCP_TRANSPORT=http MCP_EVAL_ENABLED=1 MCP_LOG_LEVEL=trace MCP_AUTH_PATH=../../.auth/state.sqlite node ../../scripts/dev-watch.cjs --watch ../../src --watch ../../packages --watch ../../spec -- node --import tsx src/bin.ts" |
| const previous = fingerprints.get(filePath) | ||
| // Same mtime and size: a last-access-time flush or a metadata-only touch, | ||
| // not an edit. This is the event `node --watch` restarts on. | ||
| if (previous === next) return |
There was a problem hiding this comment.
P2: The watcher does not actually verify content changes: it treats mtimeMs + size as the file identity. A same-size edit whose timestamp is preserved can be ignored, while a metadata-only mtime update such as touch can trigger a restart even when bytes are unchanged. That means the runner can still miss real edits or restart for non-content changes, contrary to the stated content-only behavior; a content digest check (at least after the metadata fingerprint changes) would make this reliable.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/dev-watch.cjs, line 181:
<comment>The watcher does not actually verify content changes: it treats `mtimeMs + size` as the file identity. A same-size edit whose timestamp is preserved can be ignored, while a metadata-only mtime update such as `touch` can trigger a restart even when bytes are unchanged. That means the runner can still miss real edits or restart for non-content changes, contrary to the stated content-only behavior; a content digest check (at least after the metadata fingerprint changes) would make this reliable.</comment>
<file context>
@@ -0,0 +1,239 @@
+ const previous = fingerprints.get(filePath)
+ // Same mtime and size: a last-access-time flush or a metadata-only touch,
+ // not an edit. This is the event `node --watch` restarts on.
+ if (previous === next) return
+ if (next === null) {
+ fingerprints.delete(filePath)
</file context>
| > `fs.watch` to `FILE_NOTIFY_CHANGE_LAST_ACCESS`, so merely **reading** a file | ||
| > emits a change event once NTFS flushes a new last-access time (module load, | ||
| > test run, editor indexing, grep). `node --watch` restarts on any event for a | ||
| > file in its module graph without checking whether the contents moved, which |
There was a problem hiding this comment.
P3: Consider rewording "without checking whether the contents moved" to something like "without checking whether the file contents actually changed" — "contents moved" isn't a defined file-change condition and muddles the explanation of why node --watch over-restarts on Windows.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/mcp-server/README.md, line 109:
<comment>Consider rewording "without checking whether the contents moved" to something like "without checking whether the file contents actually changed" — "contents moved" isn't a defined file-change condition and muddles the explanation of why `node --watch` over-restarts on Windows.</comment>
<file context>
@@ -81,27 +81,38 @@ processes only when you want isolated stores / auth files.
+> `fs.watch` to `FILE_NOTIFY_CHANGE_LAST_ACCESS`, so merely **reading** a file
+> emits a change event once NTFS flushes a new last-access time (module load,
+> test run, editor indexing, grep). `node --watch` restarts on any event for a
+> file in its module graph without checking whether the contents moved, which
+> makes the server restart on the first lazy import - `better-sqlite3`, loaded
+> on the first `connect()` - and whenever another tool walks the tree. The
</file context>
| > file in its module graph without checking whether the contents moved, which | |
| > file in its module graph without checking whether the file contents actually changed, which |
… tree Review follow-ups on the dev runner. Stopping the runner during an in-flight restart left an orphan holding the HTTP port: exit listeners fire in registration order, so the restart listener spawned the replacement child before shutdown() got its own listener and exited the runner. startChild() now returns early once a shutdown has started. The watch roots missed spec/. The core bridges (proto, mex, appstate, abprops, version) import from ../spec at runtime, so regenerating protobuf output or the version json left the running server on stale protocol data - node --watch did reload on those, since they sit in the module graph. Also drop the "contents moved" phrasing from both docs and the script header: what node --watch skips is checking whether the file was written at all.
On Windows libuv subscribes fs.watch to FILE_NOTIFY_CHANGE_LAST_ACCESS, so merely reading a file emits a change event once NTFS flushes a new last-access time.
node --watchrestarts on any event for a file in its module graph without checking whether the contents moved, so the MCP server restarted once or twice during boot, on the first lazybetter-sqlite3import (the first connect() of every launch), and whenever another tool walked the tree.Add scripts/dev-watch.cjs: it spawns the child, watches the source trees with the same recursive fs.watch, and compares mtime + size against a cache primed at startup before restarting, so last-access-only events are dropped. Bursts are debounced, the file that triggered the restart is logged, and generated folders (node_modules, dist, target, tests, test, bench, .turbo, coverage) are skipped.
Measured on this repo: boot goes from 1-2 restarts to none, the first connect() no longer restarts, and real content edits still reload in about 1.5s.
Summary by CodeRabbit
New Features
Documentation