@relayfile/local-mount 0.7.16 → 0.7.18

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.
package/README.md CHANGED
@@ -74,8 +74,10 @@ By default, `launchOnMount` keeps the mount and project directory in sync contin
74
74
 
75
75
  ```ts
76
76
  interface AutoSyncOptions {
77
- /** Full-reconcile interval as a safety net. Default: 10_000 ms. */
77
+ /** Degraded-watcher full-reconcile interval. Default: 10_000 ms. 0/Infinity disables periodic scans. */
78
78
  scanIntervalMs?: number;
79
+ /** Healthy-watcher full-reconcile interval. Default: 60_000 ms, or scanIntervalMs when set. */
80
+ healthyScanIntervalMs?: number;
79
81
  /** Per-path event debounce in ms. Default: 50 ms. */
80
82
  debounceMs?: number;
81
83
  /** Invoked on sync errors. Defaults to swallowing them. */
@@ -100,12 +102,16 @@ Control it from `launchOnMount`:
100
102
  launchOnMount({ /* ... */, autoSync: false });
101
103
 
102
104
  // Tune it.
103
- launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 5_000, debounceMs: 100 } });
105
+ launchOnMount({ /* ... */, autoSync: { healthyScanIntervalMs: 120_000, debounceMs: 100 } });
106
+
107
+ // Disable periodic full reconciles and rely on watcher events.
108
+ launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 0 } });
104
109
  ```
105
110
 
106
111
  How it works:
107
112
  - [@parcel/watcher](https://www.npmjs.com/package/@parcel/watcher) watches both the mount and the project tree using native FSEvents/inotify/ReadDirectoryChangesW
108
- - every `scanIntervalMs`, a full reconcile walks both trees as a safety net for missed events
113
+ - every `healthyScanIntervalMs` while watchers are healthy, a full reconcile walks both trees as a low-frequency safety net for missed events
114
+ - if watcher setup fails or a watcher reports an error, full reconciles fall back to `scanIntervalMs`
109
115
  - watcher events are tracked as dirty paths, so shutdown can flush pending path-level work and make the final sync-back proportional to the number of mount-side changes when the watcher state stayed healthy
110
116
  - `stop({ signal })` still closes watchers if aborted, but skips the final draining reconcile
111
117
  - per-file `mtime` is tracked at the last sync, so the scan skips files that haven't changed
@@ -32,8 +32,17 @@ export interface AutoSyncContext {
32
32
  isReservedFile: (relPosix: string) => boolean;
33
33
  }
34
34
  export interface AutoSyncOptions {
35
- /** Full-reconcile interval as a safety net. Default: 10_000ms. */
35
+ /**
36
+ * Degraded-watcher full-reconcile interval as a safety net. Default: 10_000ms.
37
+ * Set to 0 or Infinity to disable periodic full reconciles.
38
+ */
36
39
  scanIntervalMs?: number;
40
+ /**
41
+ * Full-reconcile interval while both watcher subscriptions are healthy.
42
+ * Default: 60_000ms, or `scanIntervalMs` when that option is explicitly set.
43
+ * Set to 0 or Infinity to disable healthy-watcher full reconciles.
44
+ */
45
+ healthyScanIntervalMs?: number;
37
46
  /**
38
47
  * Per-path event debounce in ms. Rapid watcher events for the same path
39
48
  * are coalesced into a single sync. Default: 50.
package/dist/auto-sync.js CHANGED
@@ -2,8 +2,21 @@ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync,
2
2
  import path from 'node:path';
3
3
  import watcher from '@parcel/watcher';
4
4
  const STOP_EVENT_SETTLE_MS = 250;
5
+ const DEFAULT_SCAN_INTERVAL_MS = 10_000;
6
+ const DEFAULT_HEALTHY_SCAN_INTERVAL_MS = 60_000;
7
+ const MAX_SCAN_INTERVAL_MS = 2_147_483_647;
8
+ function normalizeScanInterval(name, value, fallback) {
9
+ const interval = value ?? fallback;
10
+ if (interval === 0 || interval === Infinity)
11
+ return null;
12
+ if (!Number.isFinite(interval) || interval < 0 || interval > MAX_SCAN_INTERVAL_MS) {
13
+ throw new RangeError(`${name} must be between 0 and ${MAX_SCAN_INTERVAL_MS}, or Infinity`);
14
+ }
15
+ return interval;
16
+ }
5
17
  export function startAutoSync(ctx, opts = {}) {
6
- const scanIntervalMs = opts.scanIntervalMs ?? 10_000;
18
+ const scanIntervalMs = normalizeScanInterval('scanIntervalMs', opts.scanIntervalMs, DEFAULT_SCAN_INTERVAL_MS);
19
+ const healthyScanIntervalMs = normalizeScanInterval('healthyScanIntervalMs', opts.healthyScanIntervalMs, opts.scanIntervalMs === undefined ? DEFAULT_HEALTHY_SCAN_INTERVAL_MS : opts.scanIntervalMs);
7
20
  const debounceMs = opts.debounceMs ?? 50;
8
21
  const onError = opts.onError ?? (() => { });
9
22
  const state = new Map();
@@ -19,10 +32,6 @@ export function startAutoSync(ctx, opts = {}) {
19
32
  const pendingDebounces = new Map();
20
33
  const dirtyMountPaths = new Set();
21
34
  const watchersHealthy = () => watchersReadySettled && !watcherDegraded;
22
- const markWatcherDegraded = (err) => {
23
- watcherDegraded = true;
24
- onError(err);
25
- };
26
35
  const clearPendingDebounces = () => {
27
36
  for (const t of pendingDebounces.values())
28
37
  clearTimeout(t);
@@ -104,6 +113,47 @@ export function startAutoSync(ctx, opts = {}) {
104
113
  totalChanges += count;
105
114
  return count;
106
115
  };
116
+ let periodicTimer;
117
+ let periodicReconcileRunning = false;
118
+ const clearPeriodicReconcile = () => {
119
+ if (periodicTimer) {
120
+ clearTimeout(periodicTimer);
121
+ periodicTimer = undefined;
122
+ }
123
+ };
124
+ const nextScanInterval = () => {
125
+ return watchersHealthy() ? healthyScanIntervalMs : scanIntervalMs;
126
+ };
127
+ const schedulePeriodicReconcile = () => {
128
+ clearPeriodicReconcile();
129
+ if (stopping || stopped)
130
+ return;
131
+ const delay = nextScanInterval();
132
+ if (delay === null)
133
+ return;
134
+ periodicTimer = setTimeout(() => {
135
+ periodicTimer = undefined;
136
+ periodicReconcileRunning = true;
137
+ void runReconcile().finally(() => {
138
+ periodicReconcileRunning = false;
139
+ schedulePeriodicReconcile();
140
+ });
141
+ }, delay);
142
+ periodicTimer.unref?.();
143
+ };
144
+ const reschedulePeriodicReconcile = () => {
145
+ if (periodicReconcileRunning)
146
+ return;
147
+ schedulePeriodicReconcile();
148
+ };
149
+ const markWatcherDegraded = (err) => {
150
+ const alreadyDegraded = watcherDegraded;
151
+ watcherDegraded = true;
152
+ if (!alreadyDegraded) {
153
+ reschedulePeriodicReconcile();
154
+ }
155
+ onError(err);
156
+ };
107
157
  const flushPending = async (opts) => {
108
158
  if (opts?.signal?.aborted) {
109
159
  return 0;
@@ -178,10 +228,12 @@ export function startAutoSync(ctx, opts = {}) {
178
228
  projectSub = projectResult.value;
179
229
  if (mountResult.status === 'fulfilled' && projectResult.status === 'fulfilled') {
180
230
  watchersReadySettled = true;
231
+ reschedulePeriodicReconcile();
181
232
  return;
182
233
  }
183
234
  watchersReadySettled = true;
184
235
  watcherDegraded = true;
236
+ reschedulePeriodicReconcile();
185
237
  await Promise.allSettled([
186
238
  mountSub?.unsubscribe(),
187
239
  projectSub?.unsubscribe(),
@@ -196,11 +248,7 @@ export function startAutoSync(ctx, opts = {}) {
196
248
  // rejection. stop() still awaits the same promise and will observe the
197
249
  // rejection after the cleanup above has already run.
198
250
  watchersReady.catch((err) => markWatcherDegraded(err));
199
- const interval = setInterval(() => {
200
- void runReconcile();
201
- }, scanIntervalMs);
202
- // Do not keep the event loop alive just because of our scan timer.
203
- interval.unref?.();
251
+ schedulePeriodicReconcile();
204
252
  return {
205
253
  async stop(opts) {
206
254
  try {
@@ -214,7 +262,7 @@ export function startAutoSync(ctx, opts = {}) {
214
262
  await new Promise((resolve) => setTimeout(resolve, STOP_EVENT_SETTLE_MS));
215
263
  }
216
264
  stopping = true;
217
- clearInterval(interval);
265
+ clearPeriodicReconcile();
218
266
  clearPendingDebounces();
219
267
  await Promise.allSettled([
220
268
  mountSub?.unsubscribe(),
package/dist/mount.d.ts CHANGED
@@ -36,8 +36,9 @@ export interface MountHandle {
36
36
  }): Promise<number>;
37
37
  /**
38
38
  * Start bidirectional auto-sync: watches both the mount and project trees
39
- * via @parcel/watcher and runs a full reconcile every `scanIntervalMs`
40
- * as a safety net. Returns a handle you must `stop()` before teardown.
39
+ * via @parcel/watcher and runs periodic full reconciles as a safety net,
40
+ * with a slower cadence while watchers are healthy. Returns a handle you
41
+ * must `stop()` before teardown.
41
42
  */
42
43
  startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle;
43
44
  cleanup(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/local-mount",
3
- "version": "0.7.16",
3
+ "version": "0.7.18",
4
4
  "description": "Create a symlink/copy mount of a project directory with .agentignore/.agentreadonly semantics, then launch a CLI inside it and sync writable changes back on exit",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",