@openclaw/fs-safe 0.21.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/archive-zip-directory.js +4 -0
  3. package/dist/archive-zip-loader.js +3 -1
  4. package/dist/archive-zip-manifest.js +3 -1
  5. package/dist/copy-publication.d.ts +1 -3
  6. package/dist/copy-publication.js +2 -6
  7. package/dist/deny-mutation-match.d.ts +2 -0
  8. package/dist/deny-mutation-match.js +71 -0
  9. package/dist/deny-mutations.js +4 -3
  10. package/dist/file-identity.js +10 -2
  11. package/dist/file-lock-sync-admission.js +5 -1
  12. package/dist/file-lock-sync-root-io.d.ts +2 -2
  13. package/dist/file-lock-sync-root-io.js +1 -1
  14. package/dist/file-lock-sync.js +2 -2
  15. package/dist/file-store-prune.js +4 -4
  16. package/dist/mutation-authority.js +5 -0
  17. package/dist/native-binding.d.ts +11 -0
  18. package/dist/native-pinned-write.js +8 -2
  19. package/dist/pinned-mutation-admission.js +4 -2
  20. package/dist/replace-file-copy-fallback.js +4 -4
  21. package/dist/replace-file-destination.d.ts +4 -0
  22. package/dist/replace-file-destination.js +67 -5
  23. package/dist/replace-file.js +2 -1
  24. package/dist/retained-file-types.d.ts +3 -14
  25. package/dist/root-directory-list.d.ts +2 -0
  26. package/dist/root-directory-list.js +46 -20
  27. package/dist/root-move-noreplace.js +3 -3
  28. package/dist/sidecar-lock-acquire.js +3 -3
  29. package/dist/sidecar-lock-reclaim.d.ts +2 -2
  30. package/dist/sidecar-lock-reclaim.js +6 -6
  31. package/dist/sidecar-lock.js +4 -4
  32. package/dist/staged-symlink-types.d.ts +4 -14
  33. package/dist/test-hooks.d.ts +1 -0
  34. package/dist/watch-alias.js +11 -3
  35. package/dist/watch-hints.d.ts +2 -1
  36. package/dist/watch-hints.js +17 -1
  37. package/dist/watch-native.d.ts +4 -0
  38. package/dist/watch-native.js +18 -1
  39. package/dist/watch-scan.d.ts +5 -1
  40. package/dist/watch-scan.js +37 -6
  41. package/dist/watch-stream.d.ts +8 -0
  42. package/dist/watch-stream.js +32 -0
  43. package/dist/watch-types.d.ts +2 -0
  44. package/dist/watch.js +35 -7
  45. package/docs/archive.md +6 -0
  46. package/docs/atomic.md +23 -2
  47. package/docs/contributing.md +69 -6
  48. package/docs/install.md +2 -0
  49. package/docs/native.md +24 -6
  50. package/docs/public-api.md +23 -2
  51. package/docs/retained-file.md +4 -2
  52. package/docs/root.md +19 -7
  53. package/docs/sidecar-lock.md +1 -1
  54. package/docs/staged-symlink.md +2 -1
  55. package/docs/testing.md +132 -10
  56. package/docs/watch.md +77 -10
  57. package/package.json +8 -8
package/docs/testing.md CHANGED
@@ -1,6 +1,29 @@
1
1
  # Testing
2
2
 
3
- ## Manual watch stress campaign
3
+ ## Coverage gates
4
+
5
+ The coverage workflow measures `src/**/*.ts` with V8 on Linux, macOS, and
6
+ Windows, then merges their counters before enforcing the thresholds in
7
+ `vitest.config.ts`: lines 94%, statements 92%, functions 95%, and branches 89%.
8
+ `pnpm test:coverage:collect` disables per-OS thresholds so platform-only paths
9
+ are credited by their own OS; `pnpm test:coverage:merge` requires all three
10
+ reports and enforces the combined gate. The DACL batch child entrypoint also
11
+ runs in-process in a unit test so its protocol and budget handling are measured.
12
+
13
+ The Linux Rust job uses pinned `cargo-llvm-cov` and a pinned nightly compiler
14
+ for line and branch instrumentation. It combines native crate unit tests with
15
+ TS native suites running the instrumented addon, and exports LCOV plus separate
16
+ unit-only and combined summaries. The Rust line threshold is 71%; addon execution
17
+ must also increase covered Rust lines beyond the unit tests. Only native crate sources compiled on Linux are measured;
18
+ macOS/Windows Rust implementations and the archive WASM crate are outside this
19
+ report. Run `pnpm build` followed by `bash scripts/native-coverage.sh` on Linux
20
+ after installing the toolchain versions specified in `coverage.yml`.
21
+
22
+ Percentages complement behavioral gates: mutation-policy proof, nightly watch
23
+ stress, and platform lanes are equally important. High coverage cannot establish
24
+ root confinement, race safety, event delivery, or bounded resource retirement.
25
+
26
+ ## Watch stress campaign
4
27
 
5
28
  Build from the exact revision being qualified with `pnpm install --frozen-lockfile`,
6
29
  `pnpm native:build`, and `pnpm build`, then run on a disposable machine:
@@ -13,7 +36,8 @@ Individual scenario names are `scale`, `fanout`, `churn`, `lifecycle`,
13
36
  `adversarial`, `limits`, `idle`, and `soak`. The runner uses plain Node and no
14
37
  additional dependencies. Each scenario prints one JSON result line; progress
15
38
  goes to stderr. `all` isolates scenarios in child processes and stops at the
16
- first failure. This suite is manual and is not part of per-PR CI.
39
+ first failure. The full qualification remains manual. A smaller nightly campaign runs outside
40
+ per-PR CI; it can also be dispatched with `watch-stress.yml`.
17
41
 
18
42
  Run `node scripts/watch-stress.mjs --scenario oracle-selftest` first to check
19
43
  that a poisoned cache fails comparison and can recover only after invalidation.
@@ -33,14 +57,18 @@ differences across 10,000-mutation batches and isolated edit latency measurement
33
57
  Lifecycle performs 10,000 ready/close cycles plus admission
34
58
  cancellation, close-during-ready, 1,000 scope replacements, and callback-close.
35
59
  Adversarial cases exercise Root swaps, outside symlinks, recursive deletion and
36
- 10,000 same-name create/delete pairs. Soak runs 30 minutes, checking the oracle
60
+ 10,000 same-name create/delete pairs. Soak runs 60 minutes, checking the oracle
37
61
  each minute. Linux needs passwordless `sudo` for the limits scenario; it lowers
38
62
  `fs.inotify.max_user_watches` to 1 in a child shell with a restoration trap and
39
63
  verifies restoration. Never run that scenario on a shared production host.
40
64
 
41
65
  RSS limits are fixed before execution: 512 MiB peak for churn/soak, at most
42
- 64 MiB growth after warmup, and at most 32 MiB lifecycle growth after 3,000
43
- cycles. Reports include samples and fitted slopes. Idle runs for ten minutes
66
+ 64 MiB churn growth after warmup, and at most 32 MiB lifecycle growth after 3,000
67
+ cycles. Soak collects garbage twice at every checkpoint, limits collected-heap
68
+ and external-memory growth to 8 MiB after minute five, and requires the fitted
69
+ RSS slope over minutes 31–60 to stay at or below 1 MiB/minute. The runner launches
70
+ soak with `--expose-gc` automatically, including through `--scenario all`.
71
+ Reports include samples and fitted slopes. Idle runs for ten minutes
44
72
  with 16 subscriptions and a one-hour reconciliation interval to isolate native
45
73
  hub wakeups, requiring less than 1% of one CPU and, on Linux, at most 30 hub
46
74
  context switches. macOS captures `ps -M`; Windows captures PowerShell thread
@@ -53,6 +81,38 @@ records native overflow and recovery, but the shared batch does not distinguish
53
81
  RDCW kernel-buffer loss from bounded native queue loss; a Windows qualification
54
82
  must retain that limitation rather than call it proved kernel overflow.
55
83
 
84
+ ### Nightly workload
85
+
86
+ The nightly workflow runs on `ubuntu-latest` (x64), `ubuntu-24.04-arm`,
87
+ `macos-15` (arm64), `macos-15-intel`, and the Windows latest 16-core runner,
88
+ using Node 24 and freshly built native bindings. Each job has a 25-minute
89
+ budget, uploads one JSON result per scenario (including failures), and fails
90
+ if any scenario fails. Remaining scenarios still run after a failure. Only
91
+ Linux runs the kernel-limits scenario automatically.
92
+
93
+ The same harness accepts these environment overrides; defaults remain the
94
+ full qualification workload. Child processes inherit the settings, which are
95
+ recorded in every JSON result.
96
+
97
+ | Environment variable | Default | Nightly |
98
+ | --- | ---: | ---: |
99
+ | `FS_SAFE_STRESS_SCALE_DIRECTORIES` (25 files each) | 2,000 | 400 (10,000 files) |
100
+ | `FS_SAFE_STRESS_FANOUT` (maximum subscriptions) | 256 | 64 |
101
+ | `FS_SAFE_STRESS_CHURN_SECONDS` | 300 | 60 |
102
+ | `FS_SAFE_STRESS_LIFECYCLE_CYCLES` | 10,000 | 1,000 |
103
+ | `FS_SAFE_STRESS_IDLE_SECONDS` | 600 | 120 |
104
+ | `FS_SAFE_STRESS_SOAK_MINUTES` (minimum 10) | 60 | 10 |
105
+
106
+ Soaks shorter than 30 minutes keep the hard peak-RSS, collected-heap, and external-memory
107
+ growth gates, but report the second-half RSS slope and its limit without using it
108
+ to pass or fail (`memory.rssSlopeGated: false`): [#701](https://github.com/openclaw/fs-safe/pull/701)
109
+ found that V8 capacity expansion and allocator retention can raise RSS while live memory stays flat.
110
+ Runs of 30 minutes or longer enforce the same second-half slope limit; only runs of
111
+ at least 60 minutes report `memory.qualification: true`.
112
+ Lifecycle memory is sampled across ten intervals, with
113
+ the first two excluded as warm-up; idle's Linux wakeup bound scales with the
114
+ requested duration (three per minute). Adversarial cases remain unchanged.
115
+
56
116
  ## Linux openat2 fallback
57
117
 
58
118
  Build the host addon and package first. The test hook is cached with the native
@@ -62,7 +122,7 @@ between tests in one process:
62
122
  ```bash
63
123
  pnpm native:build
64
124
  pnpm build
65
- FS_SAFE_TEST_NO_OPENAT2=1 FS_SAFE_NATIVE_MODE=require pnpm test test/linux-openat2-fallback.test.ts test/root-move-noreplace.test.ts test/root-move-native-integration.test.ts test/native-write-containment.test.ts
125
+ FS_SAFE_TEST_NO_OPENAT2=1 FS_SAFE_NATIVE_MODE=require pnpm test test/linux-openat2-parity.test.ts test/linux-openat2-fallback.test.ts test/root-move-noreplace.test.ts test/root-move-native-integration.test.ts test/native-write-containment.test.ts
66
126
  ```
67
127
 
68
128
  On Linux, the seccomp harness also exercises the real syscall failure without
@@ -73,13 +133,21 @@ unprivileged seccomp filter; it affects only its child process:
73
133
  cc test/fixtures/deny-openat2.c -o /tmp/fs-safe-deny-openat2
74
134
  /tmp/fs-safe-deny-openat2 ENOSYS node test/fixtures/linux-openat2-fallback.mjs "$PWD/native/fs-safe-native.linux-x64-gnu.node"
75
135
  /tmp/fs-safe-deny-openat2 EPERM node test/fixtures/linux-openat2-fallback.mjs "$PWD/native/fs-safe-native.linux-x64-gnu.node"
136
+ FS_SAFE_TEST_OPENAT2_FILTER=/tmp/fs-safe-deny-openat2 FS_SAFE_NATIVE_MODE=require pnpm test test/linux-openat2-parity.test.ts
76
137
  ```
77
138
 
78
139
  Use the matching native artifact filename on other Linux architectures/libcs.
79
- The fixture proves nested moves, collisions, read/write, traversal, symlink and
80
- hardlink rejection, cached selection, and `helper-unavailable` for strict
140
+ The fixtures prove in-root alias operations and policy parity, nested moves,
141
+ collisions, read/write, traversal and escaping-link rejection, hardlink rejection,
142
+ cached selection, and `helper-unavailable` for strict
81
143
  bounded cleanup. Bounded-cleanup success tests require real `openat2`; run the
82
- full suite with the environment hook unset.
144
+ full suite with the environment hook unset. PR CI's `Native check
145
+ (linux-x64-no-openat2)` runs the native Node suites and watch proofs with the
146
+ hook set. It replaces the four bounded-cleanup success suites and quarantine
147
+ success proof with the explicit refusal fixture above. XFS tree-clone proof
148
+ requires the same openat2/NO_XDEV primitive and runs in the ordinary Linux
149
+ lanes. Bun's full compatibility
150
+ suite remains in the normal native lanes, because it includes bounded cleanup.
83
151
 
84
152
  `@openclaw/fs-safe/test-hooks` exposes test-only injection points. Registration
85
153
  is allowed only when `process.env.NODE_ENV === "test"` or
@@ -244,7 +312,8 @@ regression with `MallocStackLogging=1 node --expose-gc scripts/watch-cleanup-lea
244
312
  It compares `leaks` results before and after 100 and 1,000 subscription cycles,
245
313
  requiring zero growth in leaked allocations. Allocation stacks are saved under
246
314
  `.artifacts/watch-cleanup-leaks`. The native macOS CI lane runs this short proof;
247
- the full stress campaign remains manual.
315
+ the full stress qualification remains manual, with the smaller nightly
316
+ campaign above.
248
317
 
249
318
  Run the full local gate before handoff:
250
319
 
@@ -270,6 +339,59 @@ workspace reads that bypass pinned file descriptors.
270
339
 
271
340
  `pnpm check` also runs `pnpm lint:file-size`. New source and test files should stay under 500 lines. Existing larger files have explicit budgets in `scripts/check-file-size.mjs`; do not increase those budgets as part of unrelated work.
272
341
 
342
+ ## Watch memory diagnosis
343
+
344
+ The original soak rule rejected more than 64 MiB RSS growth after minute five.
345
+ That outcome remains in `memory.legacyRss`, with its original limit and pass/fail
346
+ value; it is no longer the soak pass criterion. Static guarded poll scans alone
347
+ reproduced growth from 72 to 181 MiB RSS while collected heap stayed near 7–8 MiB:
348
+ V8 expanded its almost-empty young generation to 128 MiB, with 103 MiB physically
349
+ committed. A diagnostic run limiting that space ended at 85 MiB RSS with the
350
+ same live heap. The normal harness keeps Node's default nursery sizing.
351
+
352
+ The 60-minute qualification separates this capacity warm-up from the later RSS
353
+ trend. The measured Linux event run had under 1 MiB collected-heap drift and a
354
+ 0.55 MiB/minute second-half RSS slope; its final ten-minute RSS range was 1.60 MiB.
355
+ The 8 MiB live-memory allowance and 1 MiB/minute RSS slope leave measurement
356
+ margin while rejecting retained growth and continued rapid RSS growth. The
357
+ 512 MiB peak ceiling is unchanged. Native allocation leaks need independent
358
+ accounting/profiling too: the investigation found a much smaller cleanup-hook
359
+ context leak even when registrations, pending sets, and TSFN counters retired.
360
+
361
+ Build the native addon and package from the same revision, then run each control
362
+ in a fresh Node process on a disposable machine:
363
+
364
+ ```sh
365
+ node --expose-gc scripts/watch-memory.mjs --arm events --minutes 60 --output .artifacts/events
366
+ node --expose-gc scripts/watch-memory.mjs --arm none --minutes 30 --output .artifacts/none
367
+ node --expose-gc scripts/watch-memory.mjs --arm poll --minutes 30 --output .artifacts/poll
368
+ node --expose-gc scripts/watch-memory.mjs --arm lifecycle --minutes 30 --output .artifacts/lifecycle
369
+ node --expose-gc scripts/watch-memory.mjs --arm steady --minutes 30 --output .artifacts/steady
370
+ ```
371
+
372
+ `events` and `poll` combine low-rate edits, a 10,000-operation burst every fifth
373
+ minute, and subscription cycling. `steady` omits cycling; `lifecycle` omits
374
+ writes. `none` drives the same writer and consumer cache using explicit synthetic
375
+ invalidations, without constructing subscriptions. That arm is an allocation
376
+ control, not evidence of watcher correctness. Every arm checks its consumer
377
+ cache against an independent filesystem walk at each checkpoint.
378
+
379
+ The diagnostic runner emits JSONL to stdout and `memory.jsonl` in its output
380
+ directory. Each minute includes all five `process.memoryUsage()` fields before
381
+ and after collection, V8 heap-space capacity, Linux `smaps_rollup`, operation and
382
+ guarded-read counts, and live/created/destroyed native watch allocations.
383
+ `--gc none` measures the same workload without forced collection. `--snapshots`
384
+ writes V8 snapshots at minutes 5 and 30; use a separate run because snapshots
385
+ perturb memory usage. On macOS, `--tools` saves `vmmap --summary` and `leaks`
386
+ reports at minutes 5, 30, and 60. `--smoke` is a short calibration, explicitly
387
+ marked in output; it is not a duration-qualified soak.
388
+
389
+ The native allocation getter is internal and requires `NODE_ENV=test` or
390
+ `VITEST=true`; the runner sets the former. After close, it requires no live
391
+ registrations, pending sets, callback payloads, or thread-safe functions.
392
+ Diagnostic success establishes correctness and retirement; it does not classify
393
+ an RSS curve as a leak or automatically approve a memory budget.
394
+
273
395
  ## See also
274
396
 
275
397
  - [Security model](security-model.md) — what the boundary is supposed to defend; design tests around the same threats.
package/docs/watch.md CHANGED
@@ -56,11 +56,20 @@ means invalidate **every configured scope**. Initial admission and every
56
56
  successful `setScopes` publish one undetailed `reconcile` invalidation. A rename
57
57
  or identity replacement is structural; metadata changes to the same ordinary
58
58
  file may be content changes. Neither means the file is settled or readable.
59
+ An admitted native hint can invalidate an entry even when its before/after scan
60
+ metadata is identical: the path may have changed and been restored between scans
61
+ (ABA), or content may have changed without a distinguishable metadata change.
62
+ Structural hints remain conservative in that case.
63
+ This does not promise delivery for a differently spelled alias that appears and
64
+ disappears entirely between scans: without an observed identity, it cannot be
65
+ admitted as the selected path. Observation is not a complete transient history.
59
66
  Raw event names remain private: detail comes from guarded scans, prior guarded
60
- snapshots, or explicitly configured targets. Unknown names and overflow lose
61
- detail. Exclusion callbacks are synchronous; excluded directories are not
62
- scanned. Exclusions are a scan policy, not a promise that overflow cannot wake
63
- the application.
67
+ snapshots, or explicitly configured targets. Unclassifiable hints inside selected,
68
+ non-excluded territory lose detail. Hints for excluded or unselected paths are
69
+ ignored. Exclusion callbacks are synchronous; excluded directories are recorded
70
+ without descent. Bounded exclusion records recognize late deletion hints.
71
+ Genuine backend event loss and detail-budget exhaustion still invalidate every
72
+ scope, including when an excluded subtree caused the underlying event pressure.
64
73
 
65
74
  ## Transport and mode
66
75
 
@@ -96,6 +105,14 @@ pending detail and queued batches are bounded. The last removal stops and joins
96
105
  the native thread. No Worker threads, eval programs, or JS `fs.watch` are used.
97
106
 
98
107
  macOS uses FileEvents, NoDefer and WatchRoot with a 30 ms FSEvents latency.
108
+ After guarded admission, streams use selected tree anchors and entry parents,
109
+ falling back to the nearest admitted ancestor for missing paths. Nested anchors
110
+ are deduplicated, with at most 128 paths. The eight shallowest non-overlapping
111
+ excluded directories are also passed to `FSEventStreamSetExclusionPaths`.
112
+ When the stream paths change, the old stream is stopped, invalidated and released
113
+ on its dispatch queue, then its replacement starts before another guarded pass
114
+ covers the handover. Native exclusions reduce traffic but cannot eliminate real
115
+ FSEvents drops, including during recursive deletion.
99
116
  Absolute hints are reduced lexically against the admitted canonical Root;
100
117
  outside paths never become detail. Dropped/wrapped streams, RootChanged and
101
118
  Unmount trigger guarded reconciliation. Pathname hints can reflect activity
@@ -107,11 +124,18 @@ READ/WRITE/DELETE sharing, backup semantics and overlapped I/O. Each handle
107
124
  observes the entire subtree; guarded scans filter hints to configured scopes.
108
125
  No descendant watch handles are retained, so directories inside the Root can be
109
126
  renamed while watching, including directories containing selected scopes.
110
- Root-wide noise can coalesce into whole-scope invalidation. Completed 64 KiB buffers are copied and the
127
+ Completed buffers are copied and the
111
128
  read re-armed before names are examined. Zero-byte / enumeration-loss completions
112
- invalidate every scope. Cancellation waits for IOCP completion before closing
129
+ invalidate every scope. Buffers are 1 MiB on confirmed local volumes, and 64 KiB
130
+ on network/UNC or unclassified volumes. Cancellation waits for IOCP completion before closing
113
131
  the handle or freeing its buffer; there are no detached retirement waits.
114
132
 
133
+ On Windows, an open file handle—including fs-safe's pinned reads, editors, and
134
+ antivirus—blocks renaming that file's ancestor directories, even with delete
135
+ sharing. The watch backend retains only the Root's `ReadDirectoryChangesW` handle,
136
+ which permits renames beneath the Root. Callers renaming directories concurrently
137
+ with reads should use bounded retries, as Windows tools do.
138
+
115
139
  Windows prevents ordinary renames of the Root's own ancestors while its directory
116
140
  handle is open, even with DELETE sharing. Use `mode: "poll"` when callers must not
117
141
  retain that handle. Renaming or replacing the Root still fails guarded observation;
@@ -125,6 +149,7 @@ handles and delivery queues, and closing one does not retire another's observati
125
149
  | `scopes` | At most 128 literal scopes |
126
150
  | `persistent` | `true`; `false` lets Node exit with the subscription still open |
127
151
  | `intervalMs` | 30000 with events; 1000 with poll; minimum 20 ms |
152
+ | `pollIntervalMs` | Optional polling override; minimum 20 ms, maximum 2147483647 ms (same as `intervalMs`) |
128
153
  | `maxDirectories` | 4096 observed directories, including scope ancestors |
129
154
  | `maxEntries` | 100000 examined entries per pass, including excluded entries |
130
155
  | `maxPendingPaths` | 256; maximum 4096 |
@@ -132,6 +157,12 @@ handles and delivery queues, and closing one does not retire another's observati
132
157
 
133
158
  Periodic guarded reconciliation runs without needing an event. It catches
134
159
  missed events and works on filesystems where native hints are incomplete.
160
+ When polling is selected, the interval is `pollIntervalMs`, then `intervalMs`,
161
+ then 1000 ms, in that order. This applies to explicit `mode: "poll"`, `auto`
162
+ selecting polling, and `auto` falling back after an unsupported event backend.
163
+ `pollIntervalMs` does not change the events reconciliation interval, which
164
+ remains `intervalMs` or 30000 ms. For example, `mode: "auto", pollIntervalMs: 25`
165
+ uses 25 ms polling when needed and retains the 30-second events reconciliation.
135
166
  Scans are metadata comparisons: content changes preserving all compared
136
167
  metadata may be missed in polling mode. No mode promises transactional
137
168
  snapshots, complete history, or hard real-time delivery.
@@ -143,12 +174,20 @@ registration/listing identity is retried up to three times per directory; furthe
143
174
  churn invalidates that subtree. Poll mode starts with its first scan and detects
144
175
  changes during the crawl on the next comparison. Neither mode waits for two
145
176
  agreeing scans.
177
+ `ready` and `reconcile()` do not drain the operating system's event queue.
178
+ For example, FSEvents can deliver coalesced setup creation activity after `ready`,
179
+ even with its stream starting at the current event ID. Consumers must tolerate
180
+ these advisory invalidations; tests measuring a quiet interval should first
181
+ observe a selected sentinel edit and drain its trailing events.
146
182
 
147
183
  Each later pass compares with the previous snapshot, publishes bounded differences,
148
184
  and adopts its result as the next snapshot. Vanishing entries, kind changes, and
149
185
  transient descendant scan errors produce structural invalidations, preserving the
150
- Root identity checks. Events during a pass coalesce into one pending pass; detail
151
- overflow or catching up beyond the 25 ms coalescing window emits `overflow` without
186
+ Root identity checks. Events during a pass coalesce into one pending pass and
187
+ retain bounded detail regardless of how long the scan takes. The 25 ms hint
188
+ coalescing window does not impose a scan deadline. A full native callback queue
189
+ retains its bounded pending batch for retry. Genuine backend loss, exhausted
190
+ detail capacity, or an unclassifiable selected hint emits `overflow` without
152
191
  detail. Sustained writes cannot exhaust a pass budget or disable observation.
153
192
 
154
193
  `reconcile()` resolves after a complete pass that **started after the call**. Calls
@@ -174,11 +213,39 @@ or inaccessible), fatal native backend/registration failures such as `watch-limi
174
213
  deterministic size limits (`too-large`, operation `scan`), or callback contract
175
214
  violations. Invalid scope admission, including symbolic parents, still rejects;
176
215
  it never grants authority through a link. Transient descendant churn does not
177
- make an admitted subscription unavailable. Callbacks may synchronously retire the owner; returning a thenable from
178
- `onInvalidate`, `onHealth`, or `exclude` rejects observation. Application async
216
+ make an admitted subscription unavailable. Callbacks may synchronously retire
217
+ the owner. Returning a thenable or a synchronous or asynchronous generator object
218
+ from `onInvalidate`, `onHealth`, or `exclude` rejects observation without advancing
219
+ generators. Application async
179
220
  work remains application-owned and is not joined by the subscription.
180
221
 
181
222
  `close()` is terminal, idempotent, and joined. Observation failures remain in
182
223
  health but do not make successful retirement reject. Retirement failures do
183
224
  reject, with a `SuppressedError` retaining an earlier observation failure when
184
225
  both exist. No new generation or callback can be admitted after close.
226
+
227
+ The stress harness also provides `node scripts/watch-stress.mjs --scenario soak-short`,
228
+ a two-minute lifecycle and collected-memory smoke. It is separate from the
229
+ full one-hour `soak` scenario and its stronger memory-growth qualification.
230
+
231
+ ## Seeded consumer-cache stress test
232
+
233
+ After building the package and host binding, run the model against real temporary
234
+ Roots in both modes:
235
+
236
+ ```sh
237
+ node scripts/watch-stress/model-runner.mjs --seeds 2000 --mode both --output watch-model-results.json
238
+ ```
239
+
240
+ Each seed generates file and directory edits, renames, replacements, deep trees,
241
+ symlink retargets, bursts, scope changes, and subscription retirement. A consumer
242
+ queues refreshes only from `onInvalidate`; checkpoints drain those requests after
243
+ quiescence and `reconcile()`, then compare its cache with a guarded Root walk and
244
+ an independent mutation model. Native hints never authorize consumer reads.
245
+
246
+ `--seed`, `--steps`, `--settle` (milliseconds), and `--concurrency` control a run.
247
+ Failures are shrunk by fast-check and saved beside the report as `.<mode>-<seed>.failure.json`;
248
+ replay one with `--replay <failure.json>`. Event mode requires a working native
249
+ binding and never silently falls back to polling. The small
250
+ `test/watch-model.test.ts` corpus runs in ordinary CI; native-event cases also run
251
+ when `FS_SAFE_TEST_WATCH_EVENTS=1`. Keep fixtures on normal `os.tmpdir()` storage.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -173,13 +173,13 @@
173
173
  "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
174
174
  },
175
175
  "optionalDependencies": {
176
- "@openclaw/fs-safe-darwin-arm64": "0.21.0",
177
- "@openclaw/fs-safe-darwin-x64": "0.21.0",
178
- "@openclaw/fs-safe-linux-arm64-gnu": "0.21.0",
179
- "@openclaw/fs-safe-linux-arm64-musl": "0.21.0",
180
- "@openclaw/fs-safe-linux-x64-gnu": "0.21.0",
181
- "@openclaw/fs-safe-linux-x64-musl": "0.21.0",
182
- "@openclaw/fs-safe-win32-x64-msvc": "0.21.0",
176
+ "@openclaw/fs-safe-darwin-arm64": "0.21.1",
177
+ "@openclaw/fs-safe-darwin-x64": "0.21.1",
178
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.21.1",
179
+ "@openclaw/fs-safe-linux-arm64-musl": "0.21.1",
180
+ "@openclaw/fs-safe-linux-x64-gnu": "0.21.1",
181
+ "@openclaw/fs-safe-linux-x64-musl": "0.21.1",
182
+ "@openclaw/fs-safe-win32-x64-msvc": "0.21.1",
183
183
  "jszip": "^3.10.2"
184
184
  },
185
185
  "devDependencies": {