@rljson/fs-agent 0.0.22 → 0.0.23

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.
@@ -131,6 +131,21 @@ export declare const ATOMIC_TMP_PREFIX = ".fsagent-tmp-";
131
131
  /**
132
132
  * Orchestrates filesystem operations with tree structures and blob storage
133
133
  */
134
+ /**
135
+ * Thrown when a restore wrote everything it could but at least one file was
136
+ * held open by another process.
137
+ *
138
+ * Not a failure of the restore so much as a "not yet": the bytes are still
139
+ * available, the file is simply busy. It is an error rather than a silent
140
+ * partial success because the folder does NOT match the tree afterwards, and
141
+ * anything that treats it as if it did — advertising the state, recording the
142
+ * ref as applied — would make one locked file look like an edit that everyone
143
+ * else must adopt.
144
+ */
145
+ export declare class PartialRestoreError extends Error {
146
+ readonly lockedPaths: string[];
147
+ constructor(lockedPaths: string[]);
148
+ }
134
149
  export declare class FsAgent {
135
150
  private _scanner;
136
151
  private _adapter;
@@ -162,6 +177,8 @@ export declare class FsAgent {
162
177
  /** Files written vs left alone by the current {@link restore}. */
163
178
  private _restoreWritten;
164
179
  private _restoreSkipped;
180
+ /** Paths the current {@link restore} could not write because they were held open. */
181
+ private _restoreLocked;
165
182
  /**
166
183
  * What this agent last wrote to each absolute path, so a repeat restore can
167
184
  * recognise its own work without re-reading the file.
@@ -304,6 +321,17 @@ export declare class FsAgent {
304
321
  * is the only case where the scanner's view describes these files
305
322
  */
306
323
  private _restoreTree;
324
+ /**
325
+ * Whether a caught value means "another process is holding this file".
326
+ *
327
+ * Windows reports a locked file as EPERM or EBUSY; EACCES covers the
328
+ * permission-denied shape. Deliberately narrow — anything else is a real
329
+ * write failure and must still abort, because a restore that shrugged off
330
+ * every error would report success while leaving the folder wrong.
331
+ * @param err - The caught value.
332
+ * @returns `true` for a lock-shaped error.
333
+ */
334
+ private static _isLocked;
307
335
  /**
308
336
  * The content identity this agent believes is on disk at `filePath`, or
309
337
  * `undefined` when it has no basis for an opinion.
package/dist/fs-agent.js CHANGED
@@ -1022,6 +1022,15 @@ const DEFAULT_TIMEOUTS = {
1022
1022
  const DISCONNECT_PAUSE_MAX_MS = 3e4;
1023
1023
  const SYNC_ERROR_FILE = ".sync-errors.log";
1024
1024
  const ATOMIC_TMP_PREFIX = ".fsagent-tmp-";
1025
+ class PartialRestoreError extends Error {
1026
+ constructor(lockedPaths) {
1027
+ super(
1028
+ `restore could not write ${lockedPaths.length} locked file${lockedPaths.length === 1 ? "" : "s"}: ${lockedPaths.join(", ")}`
1029
+ );
1030
+ this.lockedPaths = lockedPaths;
1031
+ this.name = "PartialRestoreError";
1032
+ }
1033
+ }
1025
1034
  class FsAgent {
1026
1035
  _scanner;
1027
1036
  _adapter;
@@ -1053,6 +1062,8 @@ class FsAgent {
1053
1062
  /** Files written vs left alone by the current {@link restore}. */
1054
1063
  _restoreWritten = 0;
1055
1064
  _restoreSkipped = 0;
1065
+ /** Paths the current {@link restore} could not write because they were held open. */
1066
+ _restoreLocked = [];
1056
1067
  /**
1057
1068
  * What this agent last wrote to each absolute path, so a repeat restore can
1058
1069
  * recognise its own work without re-reading the file.
@@ -1323,6 +1334,7 @@ ${err.stack}` : String(err);
1323
1334
  const preRestore = options?.cleanTarget ? await this._collectAllFiles(target) : /* @__PURE__ */ new Set();
1324
1335
  this._restoreWritten = 0;
1325
1336
  this._restoreSkipped = 0;
1337
+ this._restoreLocked = [];
1326
1338
  await this._restoreTree(
1327
1339
  tree.rootHash,
1328
1340
  tree.trees,
@@ -1342,6 +1354,9 @@ ${err.stack}` : String(err);
1342
1354
  preRestore
1343
1355
  );
1344
1356
  }
1357
+ if (this._restoreLocked.length > 0) {
1358
+ throw new PartialRestoreError([...this._restoreLocked]);
1359
+ }
1345
1360
  }
1346
1361
  /**
1347
1362
  * Recursively collects the absolute paths of all files under `currentDir`.
@@ -1406,18 +1421,26 @@ ${err.stack}` : String(err);
1406
1421
  );
1407
1422
  }
1408
1423
  await mkdir(dirname(filePath), { recursive: true });
1409
- await FsAgent._atomicWriteFile(filePath, fileBlob.content);
1410
- this._restoreWritten++;
1411
- if (meta.mtime) {
1412
- const mtime = new Date(meta.mtime);
1413
- await utimes(filePath, mtime, mtime);
1414
- }
1415
- if (meta.size !== void 0 && meta.mtime !== void 0) {
1416
- this._restoredBlobs.set(filePath, {
1417
- blobId: meta.blobId,
1418
- size: meta.size,
1419
- mtime: meta.mtime
1420
- });
1424
+ try {
1425
+ await FsAgent._atomicWriteFile(filePath, fileBlob.content);
1426
+ this._restoreWritten++;
1427
+ if (meta.mtime) {
1428
+ const mtime = new Date(meta.mtime);
1429
+ await utimes(filePath, mtime, mtime);
1430
+ }
1431
+ if (meta.size !== void 0 && meta.mtime !== void 0) {
1432
+ this._restoredBlobs.set(filePath, {
1433
+ blobId: meta.blobId,
1434
+ size: meta.size,
1435
+ mtime: meta.mtime
1436
+ });
1437
+ }
1438
+ } catch (error) {
1439
+ if (!FsAgent._isLocked(error)) throw error;
1440
+ console.warn(
1441
+ `[FsAgent] restore: "${meta.relativePath}" is held open by another process (${error.code}) — skipped, will retry`
1442
+ );
1443
+ this._restoreLocked.push(meta.relativePath);
1421
1444
  }
1422
1445
  }
1423
1446
  } else if (meta.type === "directory") {
@@ -1430,6 +1453,20 @@ ${err.stack}` : String(err);
1430
1453
  }
1431
1454
  }
1432
1455
  }
1456
+ /**
1457
+ * Whether a caught value means "another process is holding this file".
1458
+ *
1459
+ * Windows reports a locked file as EPERM or EBUSY; EACCES covers the
1460
+ * permission-denied shape. Deliberately narrow — anything else is a real
1461
+ * write failure and must still abort, because a restore that shrugged off
1462
+ * every error would report success while leaving the folder wrong.
1463
+ * @param err - The caught value.
1464
+ * @returns `true` for a lock-shaped error.
1465
+ */
1466
+ static _isLocked(err) {
1467
+ const code = err?.code;
1468
+ return code === "EPERM" || code === "EBUSY" || code === "EACCES";
1469
+ }
1433
1470
  /**
1434
1471
  * The content identity this agent believes is on disk at `filePath`, or
1435
1472
  * `undefined` when it has no basis for an opinion.
@@ -2119,6 +2156,13 @@ ${err.stack}` : String(err);
2119
2156
  this._currentRef = postRestoreRef;
2120
2157
  return;
2121
2158
  } catch (err) {
2159
+ if (err instanceof PartialRestoreError) {
2160
+ try {
2161
+ const halfApplied = await this._scanner.scan();
2162
+ this._lastSentContentKey = this._contentKeyFromTree(halfApplied);
2163
+ } catch {
2164
+ }
2165
+ }
2122
2166
  if (attempt === maxAttempts) {
2123
2167
  if (recoveryAttempt >= this._timeouts.recoveryRetries) {
2124
2168
  console.error(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rljson/fs-agent",
3
- "version": "0.0.22",
3
+ "version": "0.0.23",
4
4
  "description": "Rljson fs-agent description",
5
5
  "homepage": "https://github.com/rljson/fs-agent",
6
6
  "bugs": "https://github.com/rljson/fs-agent/issues",