@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.
- package/dist/backends/cow.d.ts +4 -0
- package/dist/backends/cow.js +24 -14
- package/dist/backends/fetch.d.ts +4 -0
- package/dist/backends/fetch.js +7 -3
- package/dist/vfs/file.js +5 -5
- package/dist/vfs/shared.js +4 -3
- package/dist/vfs/vcache.js +2 -2
- package/dist/vfs/vnode.d.ts +6 -0
- package/dist/vfs/vnode.js +15 -0
- package/package.json +1 -1
- package/scripts/make-index.js +36 -15
- package/tests/backend/fetch.test.ts +24 -1
- package/tests/common.ts +0 -2
- package/tests/fs/concurrency.test.ts +38 -0
package/dist/backends/cow.d.ts
CHANGED
|
@@ -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;
|
package/dist/backends/cow.js
CHANGED
|
@@ -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
|
|
398
|
-
|
|
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
|
-
|
|
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
|
|
413
|
-
|
|
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
|
-
|
|
425
|
+
if (size)
|
|
426
|
+
await this.writable.write(path, data, 0);
|
|
417
427
|
}
|
|
418
428
|
}
|
|
419
429
|
const _CopyOnWrite = {
|
package/dist/backends/fetch.d.ts
CHANGED
|
@@ -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>;
|
package/dist/backends/fetch.js
CHANGED
|
@@ -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('
|
|
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('
|
|
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('
|
|
656
|
+
const _ = __addDisposableResource(env_17, await vnode.lock('rw'), false);
|
|
657
657
|
await vnode.sync();
|
|
658
658
|
}
|
|
659
659
|
catch (e_17) {
|
package/dist/vfs/shared.js
CHANGED
|
@@ -52,17 +52,18 @@ export function umount(mountPoint) {
|
|
|
52
52
|
}
|
|
53
53
|
const _done = () => {
|
|
54
54
|
caches.delete(fs.uuid);
|
|
55
|
-
|
|
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
|
|
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
|
package/dist/vfs/vcache.js
CHANGED
|
@@ -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('
|
|
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('
|
|
163
|
+
const _ = __addDisposableResource(env_2, node.lockSync('rw'), false);
|
|
164
164
|
node.syncSync();
|
|
165
165
|
if (!node.refs)
|
|
166
166
|
this.evict(node);
|
package/dist/vfs/vnode.d.ts
CHANGED
|
@@ -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
package/scripts/make-index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { matchesGlob, relative,
|
|
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 =
|
|
88
|
+
const stats = lstatSync(path);
|
|
61
89
|
|
|
62
|
-
|
|
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
|
-
|
|
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
|
});
|