@zenfs/core 2.6.0 → 2.6.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.
@@ -127,11 +127,15 @@ export declare class CopyOnWriteFS extends FileSystem {
127
127
  * Helper function:
128
128
  * - Ensures p is on writable before proceeding. Throws an error if it doesn't exist.
129
129
  * - Calls f to perform operation on writable.
130
+ *
131
+ * `keep` is the size the file is about to be truncated to, when the caller knows it.
132
+ * Bytes past it are discarded by the operation that follows, so they are not worth copying up.
130
133
  */
131
134
  private copyForWriteSync;
132
135
  private copyForWrite;
133
136
  /**
134
137
  * Copy from readable to writable storage.
138
+ * Only `[0, keep)` is copied; the caller is responsible for setting the final size.
135
139
  * PRECONDITION: File does not exist on writable storage.
136
140
  */
137
141
  private copyToWritableSync;
@@ -202,11 +202,11 @@ export class CopyOnWriteFS extends FileSystem {
202
202
  }
203
203
  }
204
204
  async touch(path, metadata) {
205
- await this.copyForWrite(path);
205
+ await this.copyForWrite(path, metadata.size);
206
206
  await this.writable.touch(path, metadata);
207
207
  }
208
208
  touchSync(path, metadata) {
209
- this.copyForWriteSync(path);
209
+ this.copyForWriteSync(path, metadata.size);
210
210
  this.writable.touchSync(path, metadata);
211
211
  }
212
212
  async createFile(path, options) {
@@ -360,32 +360,36 @@ export class CopyOnWriteFS extends FileSystem {
360
360
  * Helper function:
361
361
  * - Ensures p is on writable before proceeding. Throws an error if it doesn't exist.
362
362
  * - Calls f to perform operation on writable.
363
+ *
364
+ * `keep` is the size the file is about to be truncated to, when the caller knows it.
365
+ * Bytes past it are discarded by the operation that follows, so they are not worth copying up.
363
366
  */
364
- copyForWriteSync(path) {
367
+ copyForWriteSync(path, keep) {
365
368
  if (!this.existsSync(path))
366
369
  throw withErrno('ENOENT');
367
370
  if (!this.writable.existsSync(dirname(path))) {
368
371
  this.createParentDirectoriesSync(path);
369
372
  }
370
373
  if (!this.writable.existsSync(path)) {
371
- this.copyToWritableSync(path);
374
+ this.copyToWritableSync(path, keep);
372
375
  }
373
376
  }
374
- async copyForWrite(path) {
377
+ async copyForWrite(path, keep) {
375
378
  if (!(await this.exists(path)))
376
379
  throw withErrno('ENOENT');
377
380
  if (!(await this.writable.exists(dirname(path)))) {
378
381
  await this.createParentDirectories(path);
379
382
  }
380
383
  if (!(await this.writable.exists(path))) {
381
- return this.copyToWritable(path);
384
+ return this.copyToWritable(path, keep);
382
385
  }
383
386
  }
384
387
  /**
385
388
  * Copy from readable to writable storage.
389
+ * Only `[0, keep)` is copied; the caller is responsible for setting the final size.
386
390
  * PRECONDITION: File does not exist on writable storage.
387
391
  */
388
- copyToWritableSync(path) {
392
+ copyToWritableSync(path, keep) {
389
393
  const stats = this.readable.statSync(path);
390
394
  if (isDirectory(stats)) {
391
395
  this.writable.mkdirSync(path, stats);
@@ -394,13 +398,16 @@ export class CopyOnWriteFS extends FileSystem {
394
398
  }
395
399
  return;
396
400
  }
397
- const data = new Uint8Array(stats.size);
398
- this.readable.readSync(path, data, 0, data.byteLength);
401
+ const size = Math.min(stats.size, keep ?? stats.size);
402
+ const data = new Uint8Array(size);
403
+ if (size)
404
+ this.readable.readSync(path, data, 0, size);
399
405
  this.writable.createFileSync(path, stats);
400
406
  this.writable.touchSync(path, stats);
401
- this.writable.writeSync(path, data, 0);
407
+ if (size)
408
+ this.writable.writeSync(path, data, 0);
402
409
  }
403
- async copyToWritable(path) {
410
+ async copyToWritable(path, keep) {
404
411
  const stats = await this.readable.stat(path);
405
412
  if (isDirectory(stats)) {
406
413
  await this.writable.mkdir(path, stats);
@@ -409,11 +416,14 @@ export class CopyOnWriteFS extends FileSystem {
409
416
  }
410
417
  return;
411
418
  }
412
- const data = new Uint8Array(stats.size);
413
- await this.readable.read(path, data, 0, stats.size);
419
+ const size = Math.min(stats.size, keep ?? stats.size);
420
+ const data = new Uint8Array(size);
421
+ if (size)
422
+ await this.readable.read(path, data, 0, size);
414
423
  await this.writable.createFile(path, stats);
415
424
  await this.writable.touch(path, stats);
416
- await this.writable.write(path, data, 0);
425
+ if (size)
426
+ await this.writable.write(path, data, 0);
417
427
  }
418
428
  }
419
429
  const _CopyOnWrite = {
@@ -39,6 +39,10 @@ export declare class FetchFS extends IndexFS {
39
39
  * @internal @hidden
40
40
  */
41
41
  _asyncDone: Promise<unknown>;
42
+ /**
43
+ * Queue an async operation started from a sync method.
44
+ * Failures aren't rethrown to prevent further async ops from failing
45
+ */
42
46
  protected _async(p: Promise<unknown>): void;
43
47
  constructor(index: Index, baseUrl: string, requestInit?: RequestInit, remoteWrite?: boolean | undefined);
44
48
  protected remove(path: string): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: LGPL-3.0-or-later
2
2
  import { withErrno } from 'kerium';
3
- import { err, warn } from 'kerium/log';
3
+ import { debug, err, warn } from 'kerium/log';
4
4
  import { decodeUTF8 } from 'utilium';
5
5
  import * as requests from 'utilium/requests';
6
6
  import { Index } from '../internal/file_index.js';
@@ -35,8 +35,12 @@ export class FetchFS extends IndexFS {
35
35
  * @internal @hidden
36
36
  */
37
37
  _asyncDone = Promise.resolve();
38
+ /**
39
+ * Queue an async operation started from a sync method.
40
+ * Failures aren't rethrown to prevent further async ops from failing
41
+ */
38
42
  _async(p) {
39
- this._asyncDone = this._asyncDone.then(() => p);
43
+ this._asyncDone = this._asyncDone.then(() => p).catch(e => debug('Async operation failed: ' + (e.message ?? e)));
40
44
  }
41
45
  constructor(index, baseUrl, requestInit = {}, remoteWrite) {
42
46
  super(0x206e6673, 'nfs', index);
@@ -74,7 +78,7 @@ export class FetchFS extends IndexFS {
74
78
  if (!data)
75
79
  throw withErrno('ENODATA');
76
80
  if (missing.length) {
77
- this._async(requests.get(this.baseUrl + path, { start: offset, end, size: inode.size, warn }));
81
+ this._async(requests.get(this.baseUrl + path, { start: offset, end, size: inode.size, warn }, this.requestInit));
78
82
  throw withErrno('EAGAIN');
79
83
  }
80
84
  buffer.set(data);
package/dist/vfs/file.js CHANGED
@@ -178,7 +178,7 @@ let Handle = (() => {
178
178
  throw UV('EBADF', 'sync', this.path);
179
179
  if (!this.dirty)
180
180
  return;
181
- const _ = __addDisposableResource(env_1, this.vnode.lockSync('ro'), false);
181
+ const _ = __addDisposableResource(env_1, this.vnode.lockSync('rw'), false);
182
182
  this.vnode.syncSync();
183
183
  }
184
184
  catch (e_1) {
@@ -199,7 +199,7 @@ let Handle = (() => {
199
199
  if (this.closed)
200
200
  throw UV('EBADF', 'close', this.path);
201
201
  this.syncSync();
202
- this.disposeSync();
202
+ this.disposeSync(true);
203
203
  }
204
204
  /**
205
205
  * Cleans up. This will *not* sync the file data to the FS
@@ -385,7 +385,7 @@ let Handle = (() => {
385
385
  throw UV('EBADF', 'sync', this.path);
386
386
  if (!this.dirty)
387
387
  return;
388
- const _ = __addDisposableResource(env_8, await this.vnode.lock('ro'), false);
388
+ const _ = __addDisposableResource(env_8, await this.vnode.lock('rw'), false);
389
389
  await this.vnode.sync();
390
390
  }
391
391
  catch (e_8) {
@@ -406,7 +406,7 @@ let Handle = (() => {
406
406
  if (this.closed)
407
407
  throw UV('EBADF', 'close', this.path);
408
408
  await this.sync();
409
- this.dispose();
409
+ this.dispose(true);
410
410
  }
411
411
  /**
412
412
  * Cleans up. This will *not* sync the file data to the FS
@@ -653,7 +653,7 @@ let Handle = (() => {
653
653
  async close() {
654
654
  const env_17 = { stack: [], error: void 0, hasError: false };
655
655
  try {
656
- const _ = __addDisposableResource(env_17, await vnode.lock('ro'), false);
656
+ const _ = __addDisposableResource(env_17, await vnode.lock('rw'), false);
657
657
  await vnode.sync();
658
658
  }
659
659
  catch (e_17) {
@@ -52,17 +52,18 @@ export function umount(mountPoint) {
52
52
  }
53
53
  const _done = () => {
54
54
  caches.delete(fs.uuid);
55
- mounts.delete(mountPoint);
56
- notice('Unmounted ' + mountPoint);
55
+ info('Synced all changes to ' + mountPoint);
57
56
  };
58
57
  try {
59
58
  caches.get(fs.uuid)?.syncSync();
60
59
  _done();
61
60
  }
62
61
  catch {
63
- warn(`Could not flush to ${mountPoint} synchronously. Unmount is being deferred.`);
62
+ warn(`Could not flush to ${mountPoint} synchronously.`);
64
63
  void caches.get(fs.uuid)?.sync().then(_done);
65
64
  }
65
+ mounts.delete(mountPoint);
66
+ notice('Unmounted ' + mountPoint);
66
67
  }
67
68
  /**
68
69
  * Gets the internal `FileSystem` for the path, then returns it along with the path relative to the FS' root
@@ -139,7 +139,7 @@ export class VCache {
139
139
  try {
140
140
  if (!node.dirty)
141
141
  continue;
142
- const _ = __addDisposableResource(env_1, await node.lock('ro'), false);
142
+ const _ = __addDisposableResource(env_1, await node.lock('rw'), false);
143
143
  await node.sync();
144
144
  if (!node.refs)
145
145
  this.evict(node);
@@ -160,7 +160,7 @@ export class VCache {
160
160
  try {
161
161
  if (!node.dirty)
162
162
  continue;
163
- const _ = __addDisposableResource(env_2, node.lockSync('ro'), false);
163
+ const _ = __addDisposableResource(env_2, node.lockSync('rw'), false);
164
164
  node.syncSync();
165
165
  if (!node.refs)
166
166
  this.evict(node);
@@ -19,6 +19,8 @@ export declare class VNode extends RwLockable {
19
19
  readonly data: Resource<number>;
20
20
  /** Ranges of `data` that have been written but not yet synced to the backend */
21
21
  protected dirtyRanges: Range[];
22
+ /** The smallest size this vnode has been truncated to since the last sync, if it has been truncated */
23
+ protected truncatedTo?: number;
22
24
  /** Whether `inode` has changes that have not been synced to the backend */
23
25
  metadataDirty: boolean;
24
26
  /** The number of active references: open handles and in-progress VFS operations */
@@ -60,6 +62,10 @@ export declare class VNode extends RwLockable {
60
62
  * Note an extending truncate creates a hole, which reads as zeroes.
61
63
  */
62
64
  truncate(length: number): void;
65
+ /**
66
+ * Whether the backend has to be told about a truncation before the dirty data is written.
67
+ */
68
+ protected get needsPreTruncate(): boolean;
63
69
  /** Write all unsynced data to the backend, then metadata */
64
70
  sync(): Promise<void>;
65
71
  syncSync(): void;
package/dist/vfs/vnode.js CHANGED
@@ -19,6 +19,8 @@ export class VNode extends RwLockable {
19
19
  data;
20
20
  /** Ranges of `data` that have been written but not yet synced to the backend */
21
21
  dirtyRanges = [];
22
+ /** The smallest size this vnode has been truncated to since the last sync, if it has been truncated */
23
+ truncatedTo;
22
24
  /** Whether `inode` has changes that have not been synced to the backend */
23
25
  metadataDirty = false;
24
26
  /** The number of active references: open handles and in-progress VFS operations */
@@ -151,6 +153,7 @@ export class VNode extends RwLockable {
151
153
  * Note an extending truncate creates a hole, which reads as zeroes.
152
154
  */
153
155
  truncate(length) {
156
+ this.truncatedTo = Math.min(this.truncatedTo ?? length, length);
154
157
  this.data.size = length;
155
158
  this.dirtyRanges = this.dirtyRanges
156
159
  .filter(range => range.start < length)
@@ -160,8 +163,17 @@ export class VNode extends RwLockable {
160
163
  this.inode.ctimeMs = Date.now();
161
164
  this.metadataDirty = true;
162
165
  }
166
+ /**
167
+ * Whether the backend has to be told about a truncation before the dirty data is written.
168
+ */
169
+ get needsPreTruncate() {
170
+ return !this.bypassCache && this.truncatedTo !== undefined && this.truncatedTo < this.inode.size && !this.fs.attributes.has('no_write');
171
+ }
163
172
  /** Write all unsynced data to the backend, then metadata */
164
173
  async sync() {
174
+ if (this.needsPreTruncate)
175
+ await this.fs.touch(this.path, { size: this.truncatedTo });
176
+ this.truncatedTo = undefined;
165
177
  for (const range of this.dirtyRanges) {
166
178
  for (let pos = range.start; pos < range.end;) {
167
179
  const region = this.data.regionAt(pos);
@@ -180,6 +192,9 @@ export class VNode extends RwLockable {
180
192
  this.metadataDirty = false;
181
193
  }
182
194
  syncSync() {
195
+ if (this.needsPreTruncate)
196
+ this.fs.touchSync(this.path, { size: this.truncatedTo });
197
+ this.truncatedTo = undefined;
183
198
  for (const range of this.dirtyRanges) {
184
199
  for (let pos = range.start; pos < range.end;) {
185
200
  const region = this.data.regionAt(pos);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenfs/core",
3
- "version": "2.6.0",
3
+ "version": "2.6.1",
4
4
  "description": "A filesystem, anywhere",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { readdirSync, statSync, writeFileSync } from 'node:fs';
3
- import { matchesGlob, relative, join, resolve } from 'node:path/posix';
2
+ import { lstatSync, readdirSync, writeFileSync, constants as c } from 'node:fs';
3
+ import { join, matchesGlob, relative, resolve } from 'node:path/posix';
4
4
  import { parseArgs, styleText } from 'node:util';
5
5
 
6
6
  const { values: options, positionals } = parseArgs({
@@ -47,6 +47,34 @@ const resolvedRoot = root || '.';
47
47
 
48
48
  const entries = new Map();
49
49
 
50
+ /** @type {Record<string, import('node:util').InspectColor>} */
51
+ const typeMap = {
52
+ file: 'green',
53
+ ' dir': 'blue',
54
+ ' sym': 'cyan',
55
+ ' dev': 'yellow',
56
+ sock: 'magenta',
57
+ fifo: 'blue',
58
+ };
59
+
60
+ function getTypeText(/** @type {{ mode: number }} */ stats) {
61
+ switch (stats.mode & c.S_IFMT) {
62
+ case c.S_IFBLK:
63
+ case c.S_IFCHR:
64
+ return ' dev';
65
+ case c.S_IFIFO:
66
+ return 'fifo';
67
+ case c.S_IFSOCK:
68
+ return 'sock';
69
+ case c.S_IFREG:
70
+ return 'file';
71
+ case c.S_IFDIR:
72
+ return ' dir';
73
+ default:
74
+ throw `Unknown file type: 0o${(stats.mode & c.S_IFMT).toString(8)}`;
75
+ }
76
+ }
77
+
50
78
  /**
51
79
  * @param {string} path
52
80
  */
@@ -57,22 +85,15 @@ function computeEntries(path) {
57
85
  return;
58
86
  }
59
87
 
60
- const stats = statSync(path);
88
+ const stats = lstatSync(path);
61
89
 
62
- if (stats.isFile()) {
63
- entries.set('/' + relative(resolvedRoot, path), stats);
64
- if (options.verbose) {
65
- console.log(`${styleText('green', 'file')} ${path}`);
66
- }
67
- return;
68
- }
90
+ const type = getTypeText(stats);
69
91
 
70
- for (const file of readdirSync(path)) {
71
- computeEntries(join(path, file));
72
- }
73
92
  entries.set('/' + relative(resolvedRoot, path), stats);
74
- if (options.verbose) {
75
- console.log(`${styleText('greenBright', ' dir')} ${path}`);
93
+ if (options.verbose) console.log(`${styleText(typeMap[type] || 'white', type)} ${path}`);
94
+
95
+ if (stats.isDirectory()) {
96
+ for (const file of readdirSync(path)) computeEntries(join(path, file));
76
97
  }
77
98
  } catch (/** @type {any} */ e) {
78
99
  if (!options.quiet) {
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: LGPL-3.0-or-later
2
- import { Fetch, configureSingle, fs, mounts, type FetchFS } from '@zenfs/core';
2
+ import { CopyOnWrite, Fetch, InMemory, configureSingle, fs, mounts, resolveMountConfig, type FetchFS } from '@zenfs/core';
3
3
  import assert from 'node:assert/strict';
4
4
  import { join } from 'node:path';
5
5
  import { after, suite, test } from 'node:test';
@@ -47,6 +47,29 @@ suite('Fetch with `disableAsyncCache`', () => {
47
47
  });
48
48
  });
49
49
 
50
+ suite('CopyOnWrite over uncached Fetch #301', () => {
51
+ test('Overwriting a base-layer file does not need a synchronous copy-up read', async () => {
52
+ const readable = await resolveMountConfig({ backend: Fetch, disableAsyncCache: true, baseUrl, index: baseUrl + indexPath });
53
+
54
+ fs.mount('/cow', await resolveMountConfig({ backend: CopyOnWrite, readable, writable: InMemory.create({}) }));
55
+
56
+ // `w+` truncates first, so none of the base file survives and nothing has to be read from it
57
+ fs.writeFileSync('/cow/49chars.txt', 'replaced');
58
+
59
+ assert.equal(fs.readFileSync('/cow/49chars.txt', 'utf8'), 'replaced');
60
+ assert.equal(fs.statSync('/cow/49chars.txt').size, 8);
61
+ });
62
+
63
+ test('Partially overwriting an uncached base-layer file still throws', () => {
64
+ const fd = fs.openSync('/cow/a.js', 'r+');
65
+ fs.writeSync(fd, 'x', 0);
66
+
67
+ // The bytes the write leaves alone have to be copied up, which `Fetch` can not do synchronously.
68
+ // Writes go through the vnode cache, so this surfaces when the handle is flushed rather than at `write`.
69
+ assert.throws(() => fs.fsyncSync(fd), { code: 'EAGAIN' });
70
+ });
71
+ });
72
+
50
73
  after(async () => {
51
74
  await (mounts.get('/') as FetchFS)._asyncDone;
52
75
  await server.terminate();
package/tests/common.ts CHANGED
@@ -21,8 +21,6 @@ const setup = await import(setupPath).catch(error => {
21
21
  // Satisfies is used to make sure that ZenFS is fully type compatible with Node.js
22
22
  export const fs = (setup.fs || defaultFS) as typeof defaultFS satisfies NodeFS;
23
23
 
24
- export const Stats = (setup.Stats || defaultFS.Stats) as typeof defaultFS.Stats;
25
-
26
24
  /**
27
25
  * A feature or function a backend may not support.
28
26
  * Tests that need one are skipped when the backend under test does not have it.
@@ -49,4 +49,42 @@ suite('Concurrency', config('write', 'async'), () => {
49
49
  await a.close();
50
50
  await b.close();
51
51
  });
52
+
53
+ test('reads concurrent with an overwrite of the same file do not fail #303', async () => {
54
+ await fs.promises.writeFile('/issue-303.txt', 'original contents');
55
+
56
+ /* Every handle for a file shares one vnode, so closing any of these flushes whatever is dirty.
57
+ Syncing took a shared lock, letting several of these run at once and corrupt each other. */
58
+ for (let round = 0; round < 20; round++) {
59
+ const contents = `rewritten contents for round ${round}`;
60
+
61
+ const results = await Promise.allSettled([
62
+ ...Array.from({ length: 4 }, () => fs.promises.readFile('/issue-303.txt', 'utf8')),
63
+ fs.promises.writeFile('/issue-303.txt', contents),
64
+ ...Array.from({ length: 4 }, () => fs.promises.readFile('/issue-303.txt', 'utf8')),
65
+ ]);
66
+
67
+ const rejected = results.filter(r => r.status === 'rejected');
68
+ assert.deepEqual(
69
+ rejected.map(r => r.reason.code ?? r.reason.message),
70
+ []
71
+ );
72
+
73
+ assert.equal(await fs.promises.readFile('/issue-303.txt', 'utf8'), contents);
74
+ }
75
+ });
76
+
77
+ test('closing a handle does not fail because another handle has unsynced writes #303', async () => {
78
+ await fs.promises.writeFile('/issue-303-close.txt', 'original');
79
+
80
+ const reader = await fs.promises.open('/issue-303-close.txt', 'r');
81
+ const writer = await fs.promises.open('/issue-303-close.txt', 'r+');
82
+
83
+ // The reader wrote nothing, so its close must not report the writer's dirty data as EBUSY
84
+ const [closed] = await Promise.allSettled([reader.close(), writer.write(new TextEncoder().encode('DIRTY'), 0, 5, 0)]);
85
+ assert.equal(closed.status, 'fulfilled');
86
+
87
+ await writer.close();
88
+ assert.equal(await fs.promises.readFile('/issue-303-close.txt', 'utf8'), 'DIRTYnal');
89
+ });
52
90
  });