Skip to content

Commit a07a831

Browse files
os-zhuangclaude
andauthored
fix(metadata-fs): declare startWatcher()'s chokidar atomic option explicitly (#12757)
* fix(metadata-fs): declare startWatcher()'s chokidar atomic option explicitly Not passing `atomic` left it inherited from chokidar's own defaults merge, which assigns `atomic: true` before the caller's options are spread in -- so chokidar's own default-correction for usePolling (which would want `atomic: false` here) is dead code and never fires. Runtime-verified the resolved value is unchanged (true, both before and after); this only makes the value declared instead of inherited from a branch that cannot execute. Adds a pin that spies on the actual chokidar.watch() call startWatcher() makes and asserts `atomic` is an OWN key of the options object -- a merged `watcher.options.atomic` read can't distinguish declared from inherited, since both resolve to `true` today. Part of #12696 * test(metadata-fs): split the atomic-declared pin's positive control into its own case The usePolling control assertions were unreachable when the atomic assertion above them failed (single it() block, assertions run in order), so the control never actually demonstrated anything under the ablation. Splitting it into its own it() lets it run to completion, and show green, independently of the atomic case's verdict. Part of #12696 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0acdd2b commit a07a831

3 files changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/metadata-fs": patch
3+
---
4+
5+
fix(metadata-fs): declare `startWatcher()`'s chokidar `atomic` option explicitly (#12696)
6+
7+
`FileSystemRepository.startWatcher()` constructed its chokidar watcher with
8+
`usePolling: true` but never passed `atomic`, leaving it to inherit chokidar's
9+
default. That default is unconditionally `true` in the installed version
10+
(chokidar 5.0.0): the defaults literal assigns `atomic: true` *before* the
11+
caller's options are spread in, so chokidar's own default-correction
12+
(`if (opts.atomic === undefined) opts.atomic = !opts.usePolling`) can never
13+
fire — it only runs when `atomic` is literally `undefined` after the merge,
14+
which it never is. The comment beside that correction ("Editor atomic write
15+
normalization enabled by default with fs.watch") reads as "off under
16+
polling"; the actual resolved behaviour was on regardless.
17+
18+
This change passes `atomic: true` explicitly at the call site, with a comment
19+
explaining why. **Patch, not a behaviour change**: verified at runtime
20+
(constructing a watcher the way `startWatcher()` does and reading back
21+
`watcher.options.atomic`) that the resolved value is identical before and
22+
after — `true` either way, today. The only thing that changes is that the
23+
value is now DECLARED rather than inherited from an upstream branch that
24+
cannot execute, so a future chokidar release that fixes the ordering (making
25+
the correction real) cannot silently flip this repository's watcher to
26+
`atomic: false` under polling and change behaviour with no diff to review.
27+
28+
Not addressed here (see #12696): whether `atomic: true` (the 100ms
29+
unlink-coalescing deferral and the `DOT_RE` editor-temp-file matcher it turns
30+
on) is actually the right value. No evidence surfaced that either has ever
31+
affected a run; flipping it to `false` is a deliberate behaviour change to a
32+
live delivery path that needs its own reverse verification, and is out of
33+
scope for this card.

packages/metadata-fs/src/repository.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,23 @@ export class FileSystemRepository implements MetadataRepository {
595595
usePolling: true,
596596
interval: 1000,
597597
binaryInterval: 2000,
598+
// Declared explicitly, not inherited. chokidar's own default-correction
599+
// (`if (opts.atomic === undefined) opts.atomic = !opts.usePolling`) can
600+
// only fire when the caller omits `atomic`, but its defaults literal
601+
// already assigns `atomic: true` *before* the caller's options are
602+
// spread in — so leaving `atomic` unset here does not mean "off under
603+
// polling" the way the correction's own comment claims, it silently
604+
// resolves to `true` regardless of `usePolling`. That has been this
605+
// repository's actual runtime behaviour all along (verified by reading
606+
// back the resolved option from a real watcher instance, #12696): every
607+
// `unlink` gets chokidar's 100ms editor-atomic-write deferral, and
608+
// `DOT_RE` (vim swap files, `~`, sublime tmp) is folded into
609+
// `_isIgnored` on top of this repository's own `isIgnoredWatchPath`
610+
// (#7150). `atomic: true` here keeps that behaviour byte-for-byte —
611+
// this is a declaration, not a change. Flipping it to `false` would
612+
// remove both behaviours from a live delivery path and needs its own
613+
// reverse verification; see #12696 for the analysis.
614+
atomic: true,
598615
});
599616
w.on('add', (p) => void this.handleFsChange(p, 'add'));
600617
w.on('change', (p) => void this.handleFsChange(p, 'change'));
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #12696 — `startWatcher()` must pass `atomic` to chokidar EXPLICITLY.
5+
*
6+
* chokidar 5's defaults literal assigns `atomic: true` BEFORE the caller's
7+
* options are spread in (`node_modules/chokidar/index.js`):
8+
*
9+
* const opts = {
10+
* // Defaults
11+
* ...
12+
* atomic: true, // NOTE: overwritten later (depends on usePolling)
13+
* ..._opts,
14+
* ...
15+
* };
16+
* ...
17+
* // Editor atomic write normalization enabled by default with fs.watch
18+
* if (opts.atomic === undefined)
19+
* opts.atomic = !opts.usePolling;
20+
*
21+
* so the "overwritten later" comment is aspirational: the correction can
22+
* only run when the caller omits `atomic` AND that omission left
23+
* `opts.atomic === undefined`, but it never does — the defaults literal
24+
* already assigned `true`, and the caller's spread has no `atomic` key to
25+
* override it with. The branch is dead.
26+
*
27+
* The consequence for THIS pin: the RESOLVED value chokidar hands back
28+
* (`watcher.options.atomic`) is `true` whether `startWatcher()` declares it
29+
* or not — a merged-value read cannot tell "declared here" apart from
30+
* "inherited a dead branch that happens to agree today". A pin written
31+
* against that merged read would stay green across the exact regression it
32+
* exists to catch: a future chokidar release that fixes the ordering (making
33+
* the correction real) would then silently flip this repository's watcher to
34+
* `atomic: false` under `usePolling` — losing the 100ms unlink-coalescing
35+
* deferral and the `DOT_RE` editor-temp matcher from a live delivery path —
36+
* and nothing here would notice.
37+
*
38+
* So this pin does not read the merged option. It reads the ACTUAL call
39+
* `startWatcher()` makes to `chokidar.watch()`, via a spy that lets the real
40+
* call through unchanged (this pins DECLARATION, not delivery — every other
41+
* watcher behaviour in this package must keep working identically). That is
42+
* a runtime observation of what this repository hands off, not a grep of the
43+
* call-site literal, and it is the one thing whose presence a future
44+
* chokidar default cannot silently override.
45+
*
46+
* ⛔ No wall-clock wait anywhere in this file, deliberately (see
47+
* `watch-dot-root.test.ts` for the standing prohibition and its history of
48+
* merge-queue ejections). None is needed: the root is created by `mkdtemp()`
49+
* before `start()` runs, so `start()` arms the watcher SYNCHRONOUSLY inside
50+
* its own call (see its comment on #7000/#9339) — `chokidar.watch()` is
51+
* invoked, and the spy has recorded the call, by the time `start()`'s
52+
* promise resolves. This case never touches the 100ms unlink window itself
53+
* (out of scope for #12696 — see the card's "carry forward" section).
54+
*/
55+
56+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
57+
import fs from 'node:fs/promises';
58+
import path from 'node:path';
59+
import os from 'node:os';
60+
import chokidar from 'chokidar';
61+
import { FileSystemRepository } from '../src/index.js';
62+
63+
describe('FileSystemRepository — startWatcher() declares `atomic` explicitly (#12696)', () => {
64+
let root: string;
65+
let repo: FileSystemRepository | undefined;
66+
let watchSpy: ReturnType<typeof vi.spyOn>;
67+
68+
beforeEach(async () => {
69+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-fsatomic-'));
70+
// Let the real call through — nothing here is faked, so every other
71+
// watcher behaviour this case exercises stays exactly as it runs in
72+
// production.
73+
watchSpy = vi.spyOn(chokidar, 'watch');
74+
});
75+
76+
afterEach(async () => {
77+
if (repo) await repo.close().catch(() => undefined);
78+
repo = undefined;
79+
watchSpy.mockRestore();
80+
await fs.rm(root, { recursive: true, force: true });
81+
});
82+
83+
it('passes `atomic: true` as an OWN key of the options object handed to chokidar.watch()', async () => {
84+
repo = new FileSystemRepository({ root, org: 'system', disableWatch: false });
85+
await repo.start();
86+
87+
expect(watchSpy).toHaveBeenCalledTimes(1);
88+
const [, options] = watchSpy.mock.calls[0] as [string, Record<string, unknown>];
89+
90+
// The behaviour this pin exists to protect: `atomic` must be an OWN key
91+
// of the call-site options object, not merely absent-and-coincidentally
92+
// `true` after chokidar's internal merge (see file header).
93+
expect(Object.prototype.hasOwnProperty.call(options, 'atomic')).toBe(true);
94+
expect(options.atomic).toBe(true);
95+
});
96+
97+
// Positive control (#12696 ablation) — a SEPARATE case so it runs to
98+
// completion, and so its verdict, on its own assertions, is independent of
99+
// whatever the case above does. `usePolling` is unconditionally declared
100+
// today and untouched by this card's fix; it must stay green under the
101+
// ablation that removes the explicit `atomic`. If it ever went red too,
102+
// the spy/harness would be the suspect, not the `atomic` declaration.
103+
it('[control] passes `usePolling: true` as an OWN key of the same options object', async () => {
104+
repo = new FileSystemRepository({ root, org: 'system', disableWatch: false });
105+
await repo.start();
106+
107+
expect(watchSpy).toHaveBeenCalledTimes(1);
108+
const [, options] = watchSpy.mock.calls[0] as [string, Record<string, unknown>];
109+
110+
expect(Object.prototype.hasOwnProperty.call(options, 'usePolling')).toBe(true);
111+
expect(options.usePolling).toBe(true);
112+
});
113+
});

0 commit comments

Comments
 (0)