@nimbus-sh/platform 0.7.1 → 0.8.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/README.md +1 -1
- package/dist/heavy-alloc-coord.d.ts +5 -0
- package/dist/heavy-alloc-coord.d.ts.map +1 -1
- package/dist/heavy-alloc-coord.js +12 -1
- package/dist/limits.d.ts +3 -8
- package/dist/limits.d.ts.map +1 -1
- package/dist/limits.js +9 -8
- package/dist/lost-call.d.ts +62 -0
- package/dist/lost-call.d.ts.map +1 -0
- package/dist/lost-call.js +67 -0
- package/dist/module-source.d.ts +31 -0
- package/dist/module-source.d.ts.map +1 -0
- package/dist/module-source.js +52 -0
- package/dist/oom-classify.d.ts +14 -0
- package/dist/oom-classify.d.ts.map +1 -1
- package/dist/oom-classify.js +30 -0
- package/dist/retry.d.ts +49 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +59 -0
- package/dist/utf8.d.ts +11 -0
- package/dist/utf8.d.ts.map +1 -0
- package/dist/utf8.js +35 -0
- package/dist/w7-frame.d.ts +278 -6
- package/dist/w7-frame.d.ts.map +1 -1
- package/dist/w7-frame.js +657 -134
- package/dist/wave-writer.d.ts +392 -0
- package/dist/wave-writer.d.ts.map +1 -0
- package/dist/wave-writer.js +921 -0
- package/package.json +1 -1
- package/src/heavy-alloc-coord.ts +15 -1
- package/src/limits.ts +10 -9
- package/src/lost-call.ts +80 -0
- package/src/module-source.ts +57 -0
- package/src/oom-classify.ts +32 -0
- package/src/retry.ts +74 -0
- package/src/utf8.ts +31 -0
- package/src/w7-frame.ts +780 -149
- package/src/wave-writer.ts +1108 -0
package/dist/w7-frame.js
CHANGED
|
@@ -1,18 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* W7
|
|
3
|
-
*
|
|
2
|
+
* W7 v4 — incremental typed records for streamed filesystem writes, in
|
|
3
|
+
* program order: the records are the operations a writer made, in the order
|
|
4
|
+
* it made them, and a stream that stops commits a prefix of them (each group
|
|
5
|
+
* whole). A path may be named by several operations (a file written, renamed,
|
|
6
|
+
* written again). Every producer speaks v4; the format is internal, and every
|
|
7
|
+
* producer and consumer deploys together. v3 (no rename, truncate or setattr,
|
|
8
|
+
* one operation per path, deletes then directories then files) is still
|
|
9
|
+
* decoded for the one release that rolls v4 out: delete it with W7_MAGIC_V3.
|
|
10
|
+
* Fields added to v4 since (each deploys with both ends, so the magic stays):
|
|
11
|
+
* a create call's `umask`; the `open` and `close` calls (a write
|
|
12
|
+
* description's), an `open`'s `ino`, and a description's `description` on
|
|
13
|
+
* its write, append and ftruncate calls.
|
|
4
14
|
*/
|
|
5
15
|
import { crc32 } from './crc32.js';
|
|
6
16
|
import { CHUNK_SIZE } from './limits.js';
|
|
7
|
-
|
|
17
|
+
import { utf8Length } from './utf8.js';
|
|
18
|
+
export const W7_MAGIC = new Uint8Array([0x4e, 0x57, 0x37, 0x04]);
|
|
19
|
+
/** v3's magic: decoded, never encoded, for the release that rolls v4 out. Delete with v3 decoding. */
|
|
20
|
+
const W7_MAGIC_V3 = new Uint8Array([0x4e, 0x57, 0x37, 0x03]);
|
|
8
21
|
const ENCODER_QUEUE_HWM = 0;
|
|
22
|
+
const ENCODER_PULL_BYTES = 256 * 1024;
|
|
23
|
+
/** What one stream read asks for when a record's small fields are wanted. */
|
|
24
|
+
const READ_AHEAD_BYTES = 64 * 1024;
|
|
9
25
|
const MAX_METADATA_BYTES = 64 * 1024;
|
|
10
26
|
const MAX_PATH_BYTES = 64 * 1024;
|
|
11
27
|
const MAX_BATCH_ID_BYTES = 128;
|
|
12
28
|
const MAX_CONTENT_ID_BYTES = 256;
|
|
13
|
-
|
|
14
|
-
|
|
29
|
+
/**
|
|
30
|
+
* A batch's owned paths. Each stream costs the receiver a round trip and a
|
|
31
|
+
* publication's fixed work, so a batch is as wide as its byte budget allows;
|
|
32
|
+
* ownership is a set of names, small beside the bytes.
|
|
33
|
+
*/
|
|
34
|
+
export const W7_MAX_PATHS_PER_BATCH = 1024;
|
|
35
|
+
export const W7_MAX_OWNED_PATH_BYTES = 256 * 1024;
|
|
15
36
|
export const W7_MAX_RECORD_BYTES = 5 + 4 + MAX_CONTENT_ID_BYTES + 8 + CHUNK_SIZE;
|
|
37
|
+
/** The wire chunks a file or link of `size` bytes travels as: CHUNK_SIZE each, the last short, none when empty. */
|
|
38
|
+
export function w7ChunkCount(size) {
|
|
39
|
+
return size === 0 ? 0 : Math.ceil(size / CHUNK_SIZE);
|
|
40
|
+
}
|
|
41
|
+
/** `data`, the content at `path`, as its wire chunks (w7ChunkCount): views of it, not copies. */
|
|
42
|
+
export function w7Chunks(path, data) {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
for (let chunkId = 0, count = w7ChunkCount(data.byteLength); chunkId < count; chunkId++) {
|
|
45
|
+
chunks.push({ path, chunkId, data: data.subarray(chunkId * CHUNK_SIZE, (chunkId + 1) * CHUNK_SIZE) });
|
|
46
|
+
}
|
|
47
|
+
return chunks;
|
|
48
|
+
}
|
|
16
49
|
var RecordTag;
|
|
17
50
|
(function (RecordTag) {
|
|
18
51
|
RecordTag[RecordTag["BatchBegin"] = 1] = "BatchBegin";
|
|
@@ -22,34 +55,59 @@ var RecordTag;
|
|
|
22
55
|
RecordTag[RecordTag["FileChunk"] = 5] = "FileChunk";
|
|
23
56
|
RecordTag[RecordTag["FileEnd"] = 6] = "FileEnd";
|
|
24
57
|
RecordTag[RecordTag["BatchEnd"] = 7] = "BatchEnd";
|
|
58
|
+
RecordTag[RecordTag["Rename"] = 8] = "Rename";
|
|
59
|
+
RecordTag[RecordTag["Truncate"] = 9] = "Truncate";
|
|
60
|
+
RecordTag[RecordTag["SetAttr"] = 10] = "SetAttr";
|
|
61
|
+
/** A W7PathCall (v4). A data call is a FileBegin whose metadata names its call. */
|
|
62
|
+
RecordTag[RecordTag["Call"] = 11] = "Call";
|
|
25
63
|
})(RecordTag || (RecordTag = {}));
|
|
26
|
-
const MODE = '
|
|
27
|
-
/**
|
|
64
|
+
const MODE = 'program-order-committed-prefix';
|
|
65
|
+
/** v3's batch mode. Delete with v3 decoding. */
|
|
66
|
+
const MODE_V3 = 'path-atomic-committed-prefix';
|
|
67
|
+
/**
|
|
68
|
+
* Encode the records a pull reaches into one enqueued chunk of about
|
|
69
|
+
* ENCODER_PULL_BYTES (a record never splits; a file's chunk is at most
|
|
70
|
+
* CHUNK_SIZE), so a wave crosses the RPC boundary in a few writes rather than
|
|
71
|
+
* one per record. The bytes are the same records either way; no batch-sized
|
|
72
|
+
* metadata header exists.
|
|
73
|
+
*/
|
|
28
74
|
export function encodeWriteBatchStream(payload) {
|
|
29
75
|
const batchId = crypto.randomUUID();
|
|
30
|
-
const
|
|
31
|
-
const iterator = encodeRecords(batchId, deletes, directories, files);
|
|
76
|
+
const iterator = encodeRecords(batchId, preparePayload(payload, batchId));
|
|
32
77
|
let closed = false;
|
|
33
78
|
let magicEmitted = false;
|
|
34
79
|
const source = {
|
|
35
80
|
type: 'bytes',
|
|
36
|
-
pull(controller) {
|
|
81
|
+
async pull(controller) {
|
|
37
82
|
if (closed)
|
|
38
83
|
return;
|
|
39
84
|
try {
|
|
85
|
+
const parts = [];
|
|
86
|
+
let bytes = 0;
|
|
40
87
|
if (!magicEmitted) {
|
|
41
88
|
magicEmitted = true;
|
|
42
|
-
|
|
43
|
-
|
|
89
|
+
parts.push(W7_MAGIC.slice());
|
|
90
|
+
bytes += W7_MAGIC.byteLength;
|
|
44
91
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
92
|
+
while (bytes < ENCODER_PULL_BYTES) {
|
|
93
|
+
const next = await iterator.next();
|
|
94
|
+
if (next.done) {
|
|
95
|
+
closed = true;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
for (const part of next.value) {
|
|
99
|
+
parts.push(part);
|
|
100
|
+
bytes += part.byteLength;
|
|
101
|
+
}
|
|
50
102
|
}
|
|
51
|
-
|
|
52
|
-
|
|
103
|
+
// Every chunk enqueued is one the encoder built: a lone part is the
|
|
104
|
+
// magic or a metadata record, and a file chunk's record is always
|
|
105
|
+
// two parts, so its bytes are copied here. A caller's buffers are
|
|
106
|
+
// never transferred, and a payload may be encoded again.
|
|
107
|
+
if (bytes > 0)
|
|
108
|
+
controller.enqueue(parts.length === 1 ? parts[0] : concatBytes(...parts));
|
|
109
|
+
if (closed)
|
|
110
|
+
controller.close();
|
|
53
111
|
}
|
|
54
112
|
catch (error) {
|
|
55
113
|
closed = true;
|
|
@@ -65,6 +123,31 @@ export function encodeWriteBatchStream(payload) {
|
|
|
65
123
|
highWaterMark: ENCODER_QUEUE_HWM,
|
|
66
124
|
});
|
|
67
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* The bytes encodeWriteBatchStream would stream for `payload`, as one buffer:
|
|
128
|
+
* for a payload held in memory (no streamed sources), the same records in
|
|
129
|
+
* the same order, copied once.
|
|
130
|
+
*/
|
|
131
|
+
export async function encodeWriteBatch(payload) {
|
|
132
|
+
if ((payload.streams?.length ?? 0) > 0)
|
|
133
|
+
throw new Error('w7-frame: a payload with streamed sources is never encoded whole');
|
|
134
|
+
const batchId = crypto.randomUUID();
|
|
135
|
+
const parts = [W7_MAGIC];
|
|
136
|
+
let length = W7_MAGIC.byteLength;
|
|
137
|
+
for await (const record of encodeRecords(batchId, preparePayload(payload, batchId))) {
|
|
138
|
+
for (const part of record) {
|
|
139
|
+
parts.push(part);
|
|
140
|
+
length += part.byteLength;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const out = new Uint8Array(length);
|
|
144
|
+
let at = 0;
|
|
145
|
+
for (const part of parts) {
|
|
146
|
+
out.set(part, at);
|
|
147
|
+
at += part.byteLength;
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
68
151
|
/**
|
|
69
152
|
* Parse the v3 preamble eagerly, then expose validated operation records
|
|
70
153
|
* incrementally. Chunk credit is acquired after its bounded header validates
|
|
@@ -83,15 +166,16 @@ export async function decodeWriteBatchStream(stream, options = {}) {
|
|
|
83
166
|
try {
|
|
84
167
|
throwIfAborted(options.signal);
|
|
85
168
|
const magic = await buffer.readExact(W7_MAGIC.length, 'magic');
|
|
86
|
-
|
|
169
|
+
const v3 = bytesEqual(magic, W7_MAGIC_V3);
|
|
170
|
+
if (!v3 && !bytesEqual(magic, W7_MAGIC)) {
|
|
87
171
|
const version = magic.length === 4
|
|
88
172
|
&& magic[0] === 0x4e && magic[1] === 0x57 && magic[2] === 0x37
|
|
89
173
|
? magic[3]
|
|
90
174
|
: null;
|
|
91
175
|
if (version !== null) {
|
|
92
|
-
throw new Error(`w7-frame: unsupported protocol version ${version}; expected
|
|
176
|
+
throw new Error(`w7-frame: unsupported protocol version ${version}; expected 4`);
|
|
93
177
|
}
|
|
94
|
-
throw new Error(`w7-frame: bad magic, expected NW7\\
|
|
178
|
+
throw new Error(`w7-frame: bad magic, expected NW7\\x04, got ${hex(magic)}`);
|
|
95
179
|
}
|
|
96
180
|
const beginEnvelope = await readEnvelope(buffer, 'batch-begin');
|
|
97
181
|
if (beginEnvelope.tag !== RecordTag.BatchBegin) {
|
|
@@ -101,13 +185,13 @@ export async function decodeWriteBatchStream(stream, options = {}) {
|
|
|
101
185
|
throw new Error(`w7-frame: batch-begin length ${beginEnvelope.length} exceeds ${MAX_METADATA_BYTES}`);
|
|
102
186
|
}
|
|
103
187
|
const beginPayload = await buffer.readExact(beginEnvelope.length, 'batch-begin payload');
|
|
104
|
-
const begin = parseBatchBegin(beginPayload);
|
|
188
|
+
const begin = parseBatchBegin(beginPayload, v3 ? MODE_V3 : MODE);
|
|
105
189
|
const initialCheck = updateRecordCheck(0, beginEnvelope.header, beginPayload);
|
|
106
190
|
handedOff = true;
|
|
107
191
|
return {
|
|
108
192
|
batchId: begin.id,
|
|
109
193
|
mode: begin.mode,
|
|
110
|
-
records: decodeRecords(stream, reader, buffer, options, initialCheck),
|
|
194
|
+
records: decodeRecords(stream, reader, buffer, options, initialCheck, v3),
|
|
111
195
|
};
|
|
112
196
|
}
|
|
113
197
|
catch (error) {
|
|
@@ -116,8 +200,10 @@ export async function decodeWriteBatchStream(stream, options = {}) {
|
|
|
116
200
|
throw error;
|
|
117
201
|
}
|
|
118
202
|
}
|
|
119
|
-
async function* decodeRecords(stream, reader, buffer, options, initialCheck
|
|
120
|
-
|
|
203
|
+
async function* decodeRecords(stream, reader, buffer, options, initialCheck,
|
|
204
|
+
/** A v3 stream: no renames, truncates or attribute changes, and each path once. */
|
|
205
|
+
v3) {
|
|
206
|
+
const ownedPaths = new PathOwnership(!v3);
|
|
121
207
|
const contentIds = new Set();
|
|
122
208
|
let active = null;
|
|
123
209
|
let batchCheck = initialCheck;
|
|
@@ -129,6 +215,7 @@ async function* decodeRecords(stream, reader, buffer, options, initialCheck) {
|
|
|
129
215
|
fileCount: 0,
|
|
130
216
|
chunkCount: 0,
|
|
131
217
|
byteCount: 0,
|
|
218
|
+
opCount: 0,
|
|
132
219
|
};
|
|
133
220
|
let completed = false;
|
|
134
221
|
let failure = new DOMException('W7 consumer cancelled', 'AbortError');
|
|
@@ -142,26 +229,28 @@ async function* decodeRecords(stream, reader, buffer, options, initialCheck) {
|
|
|
142
229
|
if (envelope.tag === RecordTag.FileChunk) {
|
|
143
230
|
if (!active)
|
|
144
231
|
throw new Error('w7-frame: file-chunk without active file');
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
232
|
+
// The chunk must name its file's content id, so its prefix is that
|
|
233
|
+
// id's length plus the id-length, chunk-id and data-length fields:
|
|
234
|
+
// read whole, then checked field by field.
|
|
235
|
+
const expectedId = active.contentIdBytes;
|
|
236
|
+
const prefixLength = 4 + expectedId.byteLength + 8;
|
|
237
|
+
if (prefixLength > envelope.length) {
|
|
238
|
+
throw new Error('w7-frame: malformed file-chunk record length');
|
|
239
|
+
}
|
|
240
|
+
const headerPayload = await buffer.readExact(prefixLength, 'file-chunk header');
|
|
241
|
+
const idLength = readU32LE(headerPayload, 0);
|
|
148
242
|
if (idLength === 0 || idLength > MAX_CONTENT_ID_BYTES) {
|
|
149
243
|
throw new Error(`w7-frame: invalid file-chunk content-id length ${idLength}`);
|
|
150
244
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
throw new Error('w7-frame: malformed file-chunk record length');
|
|
245
|
+
if (idLength !== expectedId.byteLength || !sameBytes(headerPayload.subarray(4, 4 + idLength), expectedId)) {
|
|
246
|
+
throw new Error(`w7-frame: file-chunk content id does not own ${active.inode.path}`);
|
|
154
247
|
}
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
const chunkId = readU32LE(rest, idLength);
|
|
158
|
-
const dataLength = readU32LE(rest, idLength + 4);
|
|
248
|
+
const chunkId = readU32LE(headerPayload, 4 + idLength);
|
|
249
|
+
const dataLength = readU32LE(headerPayload, 8 + idLength);
|
|
159
250
|
if (envelope.length !== 4 + idLength + 8 + dataLength) {
|
|
160
251
|
throw new Error('w7-frame: file-chunk payload length mismatch');
|
|
161
252
|
}
|
|
162
|
-
|
|
163
|
-
throw new Error(`w7-frame: file-chunk content id ${contentId} does not own ${active.inode.path}`);
|
|
164
|
-
}
|
|
253
|
+
const contentId = active.metadata.contentId;
|
|
165
254
|
if (chunkId !== active.nextChunkId) {
|
|
166
255
|
throw new Error(`w7-frame: ${active.inode.path}: expected chunk ${active.nextChunkId}, got ${chunkId}`);
|
|
167
256
|
}
|
|
@@ -172,7 +261,6 @@ async function* decodeRecords(stream, reader, buffer, options, initialCheck) {
|
|
|
172
261
|
if (dataLength !== expectedBytes || dataLength > CHUNK_SIZE) {
|
|
173
262
|
throw new Error(`w7-frame: ${active.inode.path}: chunk ${chunkId} has ${dataLength} bytes; expected ${expectedBytes}`);
|
|
174
263
|
}
|
|
175
|
-
const headerPayload = concatBytes(idLengthBytes, rest);
|
|
176
264
|
let retention = null;
|
|
177
265
|
try {
|
|
178
266
|
retention = options.retainChunk
|
|
@@ -214,23 +302,23 @@ async function* decodeRecords(stream, reader, buffer, options, initialCheck) {
|
|
|
214
302
|
switch (envelope.tag) {
|
|
215
303
|
case RecordTag.Delete: {
|
|
216
304
|
const metadata = parseDelete(payload);
|
|
217
|
-
|
|
305
|
+
ownedPaths.claim(metadata.path);
|
|
218
306
|
summary.pathCount++;
|
|
219
307
|
summary.deleteCount++;
|
|
220
308
|
yield { type: 'delete', path: metadata.path };
|
|
221
309
|
break;
|
|
222
310
|
}
|
|
223
311
|
case RecordTag.Directory: {
|
|
224
|
-
const metadata = parseDirectory(payload);
|
|
225
|
-
|
|
312
|
+
const metadata = parseDirectory(payload, v3);
|
|
313
|
+
ownedPaths.claim(metadata.path);
|
|
226
314
|
summary.pathCount++;
|
|
227
315
|
summary.directoryCount++;
|
|
228
316
|
yield { type: 'directory', inode: directoryInode(metadata) };
|
|
229
317
|
break;
|
|
230
318
|
}
|
|
231
319
|
case RecordTag.FileBegin: {
|
|
232
|
-
const metadata = parseFileBegin(payload);
|
|
233
|
-
|
|
320
|
+
const metadata = parseFileBegin(payload, v3);
|
|
321
|
+
ownedPaths.claim(metadata.path);
|
|
234
322
|
if (contentIds.has(metadata.contentId)) {
|
|
235
323
|
throw new Error(`w7-frame: duplicate stream content id ${metadata.contentId}`);
|
|
236
324
|
}
|
|
@@ -240,6 +328,7 @@ async function* decodeRecords(stream, reader, buffer, options, initialCheck) {
|
|
|
240
328
|
summary.fileCount++;
|
|
241
329
|
active = {
|
|
242
330
|
metadata,
|
|
331
|
+
contentIdBytes: TEXT_ENCODER.encode(metadata.contentId),
|
|
243
332
|
inode,
|
|
244
333
|
nextChunkId: 0,
|
|
245
334
|
receivedBytes: 0,
|
|
@@ -278,10 +367,44 @@ async function* decodeRecords(stream, reader, buffer, options, initialCheck) {
|
|
|
278
367
|
yield record;
|
|
279
368
|
break;
|
|
280
369
|
}
|
|
370
|
+
case RecordTag.Call: {
|
|
371
|
+
if (v3)
|
|
372
|
+
throw new Error(`w7-frame: unknown record tag ${envelope.tag}`);
|
|
373
|
+
summary.pathCount++;
|
|
374
|
+
summary.opCount++;
|
|
375
|
+
const value = parseObject(payload, 'call', ['call', 'path'], ['mode', 'target', 'ino', 'size', 'existing', 'recursive', 'force', 'uid', 'gid', 'atime', 'mtime', 'umask', 'read', 'create', 'truncate', 'exclusive', 'nofollow', 'description']);
|
|
376
|
+
yield { type: 'call', call: parsePathCall(value, (path, label) => ownedPaths.claim(canonicalPath(path, label))) };
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
case RecordTag.Rename:
|
|
380
|
+
case RecordTag.Truncate:
|
|
381
|
+
case RecordTag.SetAttr: {
|
|
382
|
+
if (v3)
|
|
383
|
+
throw new Error(`w7-frame: unknown record tag ${envelope.tag}`);
|
|
384
|
+
summary.pathCount++;
|
|
385
|
+
summary.opCount++;
|
|
386
|
+
if (envelope.tag === RecordTag.Rename) {
|
|
387
|
+
const value = parseObject(payload, 'rename', ['from', 'to']);
|
|
388
|
+
const from = ownedPaths.claim(canonicalPath(value.from, 'rename source'));
|
|
389
|
+
const to = ownedPaths.claim(canonicalPath(value.to, 'rename target'));
|
|
390
|
+
yield { type: 'rename', from, to };
|
|
391
|
+
}
|
|
392
|
+
else if (envelope.tag === RecordTag.Truncate) {
|
|
393
|
+
const value = parseObject(payload, 'truncate', ['path', 'size']);
|
|
394
|
+
yield { type: 'truncate', path: ownedPaths.claim(canonicalPath(value.path, 'truncate path')), size: safeInteger(value.size, 'truncate size') };
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
const value = parseObject(payload, 'setattr', ['path'], ['mode', 'uid', 'gid', 'atime', 'mtime']);
|
|
398
|
+
const path = ownedPaths.claim(canonicalPath(value.path, 'setattr path'));
|
|
399
|
+
const { path: _path, ...attrs } = value;
|
|
400
|
+
yield { type: 'setattr', path, attrs: parseAttrs(attrs, 'setattr') };
|
|
401
|
+
}
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
281
404
|
case RecordTag.BatchEnd: {
|
|
282
405
|
if (active)
|
|
283
406
|
throw new Error(`w7-frame: batch-end while file ${active.inode.path} is active`);
|
|
284
|
-
const actual = parseBatchEnd(payload);
|
|
407
|
+
const actual = parseBatchEnd(payload, v3);
|
|
285
408
|
const expected = { ...summary, check: batchCheck };
|
|
286
409
|
if (!sameSummary(actual, expected)) {
|
|
287
410
|
throw new Error(`w7-frame: batch-end summary mismatch; expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
@@ -314,7 +437,7 @@ async function* decodeRecords(stream, reader, buffer, options, initialCheck) {
|
|
|
314
437
|
}
|
|
315
438
|
}
|
|
316
439
|
}
|
|
317
|
-
function* encodeRecords(batchId,
|
|
440
|
+
async function* encodeRecords(batchId, ops) {
|
|
318
441
|
const state = {
|
|
319
442
|
batchCheck: 0,
|
|
320
443
|
summary: {
|
|
@@ -325,55 +448,46 @@ function* encodeRecords(batchId, deletes, directories, files) {
|
|
|
325
448
|
fileCount: 0,
|
|
326
449
|
chunkCount: 0,
|
|
327
450
|
byteCount: 0,
|
|
451
|
+
opCount: 0,
|
|
328
452
|
},
|
|
329
453
|
};
|
|
330
454
|
yield encodeMetadataRecord(RecordTag.BatchBegin, { id: batchId, mode: MODE }, state);
|
|
331
|
-
for (const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
state.summary.byteCount += data.byteLength;
|
|
368
|
-
fileCheck = crc32(data, fileCheck);
|
|
369
|
-
yield [concatBytes(header, prefix), data];
|
|
370
|
-
}
|
|
371
|
-
yield encodeMetadataRecord(RecordTag.FileEnd, {
|
|
372
|
-
contentId: file.contentId,
|
|
373
|
-
size: file.inode.size,
|
|
374
|
-
chunkCount: file.inode.chunkCount,
|
|
375
|
-
check: fileCheck,
|
|
376
|
-
}, state);
|
|
455
|
+
for (const op of ops) {
|
|
456
|
+
switch (op.kind) {
|
|
457
|
+
case 'delete':
|
|
458
|
+
state.summary.pathCount++;
|
|
459
|
+
state.summary.deleteCount++;
|
|
460
|
+
yield encodeMetadataRecord(RecordTag.Delete, { path: op.path }, state);
|
|
461
|
+
break;
|
|
462
|
+
case 'directory':
|
|
463
|
+
state.summary.pathCount++;
|
|
464
|
+
state.summary.directoryCount++;
|
|
465
|
+
yield encodeMetadataRecord(RecordTag.Directory, { ...inodeMetadata(op.inode), kind: op.inode.kind }, state);
|
|
466
|
+
break;
|
|
467
|
+
case 'file':
|
|
468
|
+
yield* encodeFile(op.file, state);
|
|
469
|
+
break;
|
|
470
|
+
case 'rename':
|
|
471
|
+
state.summary.pathCount++;
|
|
472
|
+
state.summary.opCount++;
|
|
473
|
+
yield encodeMetadataRecord(RecordTag.Rename, { from: op.from, to: op.to }, state);
|
|
474
|
+
break;
|
|
475
|
+
case 'truncate':
|
|
476
|
+
state.summary.pathCount++;
|
|
477
|
+
state.summary.opCount++;
|
|
478
|
+
yield encodeMetadataRecord(RecordTag.Truncate, { path: op.path, size: op.size }, state);
|
|
479
|
+
break;
|
|
480
|
+
case 'setattr':
|
|
481
|
+
state.summary.pathCount++;
|
|
482
|
+
state.summary.opCount++;
|
|
483
|
+
yield encodeMetadataRecord(RecordTag.SetAttr, { path: op.path, ...op.attrs }, state);
|
|
484
|
+
break;
|
|
485
|
+
case 'call':
|
|
486
|
+
state.summary.pathCount++;
|
|
487
|
+
state.summary.opCount++;
|
|
488
|
+
yield encodeMetadataRecord(RecordTag.Call, { ...op.call }, state);
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
377
491
|
}
|
|
378
492
|
const end = {
|
|
379
493
|
...state.summary,
|
|
@@ -381,6 +495,83 @@ function* encodeRecords(batchId, deletes, directories, files) {
|
|
|
381
495
|
};
|
|
382
496
|
yield encodeMetadataRecord(RecordTag.BatchEnd, end);
|
|
383
497
|
}
|
|
498
|
+
/** One file's records: its begin, its chunks, its end. */
|
|
499
|
+
async function* encodeFile(file, state) {
|
|
500
|
+
state.summary.pathCount++;
|
|
501
|
+
state.summary.fileCount++;
|
|
502
|
+
yield encodeMetadataRecord(RecordTag.FileBegin, {
|
|
503
|
+
...inodeMetadata(file.inode),
|
|
504
|
+
kind: file.inode.kind,
|
|
505
|
+
contentId: file.contentId,
|
|
506
|
+
size: file.inode.size,
|
|
507
|
+
chunkCount: file.inode.chunkCount,
|
|
508
|
+
...(file.inode.call === undefined ? {} : { call: file.inode.call }),
|
|
509
|
+
...(file.inode.offset === undefined ? {} : { offset: file.inode.offset }),
|
|
510
|
+
...(file.inode.umask === undefined ? {} : { umask: file.inode.umask }),
|
|
511
|
+
...(file.inode.description === undefined ? {} : { description: file.inode.description }),
|
|
512
|
+
}, state);
|
|
513
|
+
let fileCheck = 0;
|
|
514
|
+
let chunkId = 0;
|
|
515
|
+
const pieces = file.source === null ? givenChunks(file.chunks) : fixedChunks(file.inode, file.source);
|
|
516
|
+
for await (const data of pieces) {
|
|
517
|
+
const contentBytes = new TextEncoder().encode(file.contentId);
|
|
518
|
+
const prefix = new Uint8Array(4 + contentBytes.length + 8);
|
|
519
|
+
writeU32LE(prefix, 0, contentBytes.length);
|
|
520
|
+
prefix.set(contentBytes, 4);
|
|
521
|
+
writeU32LE(prefix, 4 + contentBytes.length, chunkId++);
|
|
522
|
+
writeU32LE(prefix, 8 + contentBytes.length, data.byteLength);
|
|
523
|
+
const header = recordHeader(RecordTag.FileChunk, prefix.byteLength + data.byteLength);
|
|
524
|
+
state.batchCheck = updateRecordCheck(state.batchCheck, header, prefix, data);
|
|
525
|
+
state.summary.recordCount++;
|
|
526
|
+
state.summary.chunkCount++;
|
|
527
|
+
state.summary.byteCount += data.byteLength;
|
|
528
|
+
fileCheck = crc32(data, fileCheck);
|
|
529
|
+
yield [concatBytes(header, prefix), data];
|
|
530
|
+
}
|
|
531
|
+
yield encodeMetadataRecord(RecordTag.FileEnd, {
|
|
532
|
+
contentId: file.contentId,
|
|
533
|
+
size: file.inode.size,
|
|
534
|
+
chunkCount: file.inode.chunkCount,
|
|
535
|
+
check: fileCheck,
|
|
536
|
+
}, state);
|
|
537
|
+
}
|
|
538
|
+
/** Each chunk's bytes, read as the encoder reaches it (a producer's `data` may copy on access). */
|
|
539
|
+
function* givenChunks(chunks) {
|
|
540
|
+
for (const chunk of chunks)
|
|
541
|
+
yield chunk.data;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* A streamed file's bytes as the wire's positional chunks: CHUNK_SIZE each but
|
|
545
|
+
* the last, every one over its own buffer (the stream transfers what it
|
|
546
|
+
* enqueues). The source must yield exactly the inode's size.
|
|
547
|
+
*/
|
|
548
|
+
async function* fixedChunks(inode, source) {
|
|
549
|
+
let pending = new Uint8Array(Math.min(CHUNK_SIZE, inode.size));
|
|
550
|
+
let filled = 0;
|
|
551
|
+
let total = 0;
|
|
552
|
+
for await (const part of source) {
|
|
553
|
+
if (!(part instanceof Uint8Array))
|
|
554
|
+
throw new Error(`w7-frame: ${inode.path}: streamed piece is not bytes`);
|
|
555
|
+
if (total + part.byteLength > inode.size) {
|
|
556
|
+
throw new Error(`w7-frame: ${inode.path}: streamed source exceeds its ${inode.size} bytes`);
|
|
557
|
+
}
|
|
558
|
+
total += part.byteLength;
|
|
559
|
+
for (let offset = 0; offset < part.byteLength;) {
|
|
560
|
+
const take = Math.min(pending.byteLength - filled, part.byteLength - offset);
|
|
561
|
+
pending.set(part.subarray(offset, offset + take), filled);
|
|
562
|
+
filled += take;
|
|
563
|
+
offset += take;
|
|
564
|
+
if (filled === pending.byteLength) {
|
|
565
|
+
yield pending;
|
|
566
|
+
pending = new Uint8Array(Math.min(CHUNK_SIZE, inode.size - total + (part.byteLength - offset)));
|
|
567
|
+
filled = 0;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (total !== inode.size) {
|
|
572
|
+
throw new Error(`w7-frame: ${inode.path}: streamed source ended at ${total} of ${inode.size} bytes`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
384
575
|
function encodeMetadataRecord(tag, value, state) {
|
|
385
576
|
const payload = new TextEncoder().encode(JSON.stringify(value));
|
|
386
577
|
if (payload.byteLength > MAX_METADATA_BYTES) {
|
|
@@ -393,14 +584,28 @@ function encodeMetadataRecord(tag, value, state) {
|
|
|
393
584
|
}
|
|
394
585
|
return [concatBytes(header, payload)];
|
|
395
586
|
}
|
|
587
|
+
/**
|
|
588
|
+
* The payload's operations in the order they are encoded: its `ops` as
|
|
589
|
+
* given, or its deletes, then its directories, then its files (one per path,
|
|
590
|
+
* since chunks are matched to their file by path).
|
|
591
|
+
*/
|
|
396
592
|
function preparePayload(payload, batchId) {
|
|
397
593
|
if (!payload || !Array.isArray(payload.inodes) || !Array.isArray(payload.chunks)) {
|
|
398
594
|
throw new Error('w7-frame: payload must contain inode and chunk arrays');
|
|
399
595
|
}
|
|
400
|
-
|
|
596
|
+
if (payload.ops !== undefined)
|
|
597
|
+
return prepareOps(payload, batchId);
|
|
598
|
+
const ownedPaths = new PathOwnership(false);
|
|
401
599
|
const deletes = [...(payload.deletePaths ?? [])];
|
|
402
600
|
for (const path of deletes)
|
|
403
|
-
|
|
601
|
+
ownedPaths.claim(canonicalPath(path, 'delete path'));
|
|
602
|
+
const streamsByPath = new Map();
|
|
603
|
+
for (const stream of payload.streams ?? []) {
|
|
604
|
+
const path = canonicalPath(stream.path, 'stream path');
|
|
605
|
+
if (streamsByPath.has(path))
|
|
606
|
+
throw new Error(`w7-frame: duplicate stream for ${path}`);
|
|
607
|
+
streamsByPath.set(path, stream.source);
|
|
608
|
+
}
|
|
404
609
|
const chunksByPath = new Map();
|
|
405
610
|
for (const chunk of payload.chunks) {
|
|
406
611
|
const path = canonicalPath(chunk.path, 'chunk path');
|
|
@@ -417,49 +622,134 @@ function preparePayload(payload, batchId) {
|
|
|
417
622
|
const path = canonicalPath(inode.path, 'inode path');
|
|
418
623
|
if (path !== inode.path)
|
|
419
624
|
throw new Error(`w7-frame: noncanonical inode path ${inode.path}`);
|
|
420
|
-
|
|
625
|
+
ownedPaths.claim(path);
|
|
421
626
|
const normalizedInode = normalizeInode(inode);
|
|
422
627
|
const fileChunks = chunksByPath.get(path) ?? [];
|
|
628
|
+
const streamed = streamsByPath.get(path) ?? null;
|
|
423
629
|
if (normalizedInode.kind === 'directory') {
|
|
424
|
-
if (fileChunks.length > 0)
|
|
630
|
+
if (fileChunks.length > 0 || streamed !== null)
|
|
425
631
|
throw new Error(`w7-frame: directory ${path} has chunks`);
|
|
426
|
-
directories.push(normalizedInode);
|
|
632
|
+
directories.push({ kind: 'directory', inode: normalizedInode });
|
|
427
633
|
}
|
|
428
634
|
else {
|
|
429
|
-
|
|
635
|
+
if (streamed === null)
|
|
636
|
+
validateChunks(normalizedInode, fileChunks);
|
|
637
|
+
else if (fileChunks.length > 0)
|
|
638
|
+
throw new Error(`w7-frame: streamed file ${path} also has chunks`);
|
|
430
639
|
files.push({
|
|
431
|
-
|
|
432
|
-
contentId: `${batchId}:${fileIndex++}`,
|
|
433
|
-
chunks: fileChunks,
|
|
640
|
+
kind: 'file',
|
|
641
|
+
file: { inode: normalizedInode, contentId: `${batchId}:${fileIndex++}`, chunks: fileChunks, source: streamed },
|
|
434
642
|
});
|
|
435
643
|
}
|
|
436
644
|
chunksByPath.delete(path);
|
|
645
|
+
streamsByPath.delete(path);
|
|
437
646
|
}
|
|
438
647
|
if (chunksByPath.size > 0) {
|
|
439
648
|
throw new Error(`w7-frame: chunk has no inode: ${chunksByPath.keys().next().value}`);
|
|
440
649
|
}
|
|
441
|
-
|
|
650
|
+
if (streamsByPath.size > 0) {
|
|
651
|
+
throw new Error(`w7-frame: stream has no inode: ${streamsByPath.keys().next().value}`);
|
|
652
|
+
}
|
|
653
|
+
return [...deletes.map((path) => ({ kind: 'delete', path })), ...directories, ...files];
|
|
654
|
+
}
|
|
655
|
+
/** A program-order payload's operations, each checked as the decoder will check it. */
|
|
656
|
+
function prepareOps(payload, batchId) {
|
|
657
|
+
if (payload.inodes.length > 0 || payload.chunks.length > 0 || (payload.deletePaths?.length ?? 0) > 0 || (payload.streams?.length ?? 0) > 0) {
|
|
658
|
+
throw new Error('w7-frame: a payload of ops carries nothing else');
|
|
659
|
+
}
|
|
660
|
+
const ownedPaths = new PathOwnership(true);
|
|
661
|
+
let fileIndex = 0;
|
|
662
|
+
return payload.ops.map((op) => {
|
|
663
|
+
switch (op.type) {
|
|
664
|
+
case 'delete':
|
|
665
|
+
return { kind: 'delete', path: ownedPaths.claim(canonicalPath(op.path, 'delete path')) };
|
|
666
|
+
case 'directory': {
|
|
667
|
+
const inode = normalizeInode({ ...op.inode, path: ownedPaths.claim(canonicalPath(op.inode.path, 'inode path')) });
|
|
668
|
+
if (inode.kind !== 'directory')
|
|
669
|
+
throw new Error(`w7-frame: directory op ${inode.path} is a ${inode.kind}`);
|
|
670
|
+
return { kind: 'directory', inode };
|
|
671
|
+
}
|
|
672
|
+
case 'file': {
|
|
673
|
+
const inode = normalizeInode({ ...op.inode, path: ownedPaths.claim(canonicalPath(op.inode.path, 'inode path')) });
|
|
674
|
+
if (inode.kind === 'directory')
|
|
675
|
+
throw new Error(`w7-frame: file op ${inode.path} is a directory`);
|
|
676
|
+
const contentId = `${batchId}:${fileIndex++}`;
|
|
677
|
+
if ('source' in op)
|
|
678
|
+
return { kind: 'file', file: { inode, contentId, chunks: [], source: op.source } };
|
|
679
|
+
const chunks = w7Chunks(inode.path, op.data);
|
|
680
|
+
validateChunks(inode, chunks);
|
|
681
|
+
return { kind: 'file', file: { inode, contentId, chunks, source: null } };
|
|
682
|
+
}
|
|
683
|
+
case 'rename':
|
|
684
|
+
return {
|
|
685
|
+
kind: 'rename',
|
|
686
|
+
from: ownedPaths.claim(canonicalPath(op.from, 'rename source')),
|
|
687
|
+
to: ownedPaths.claim(canonicalPath(op.to, 'rename target')),
|
|
688
|
+
};
|
|
689
|
+
case 'truncate':
|
|
690
|
+
return { kind: 'truncate', path: ownedPaths.claim(canonicalPath(op.path, 'truncate path')), size: safeInteger(op.size, 'truncate size') };
|
|
691
|
+
case 'setattr':
|
|
692
|
+
return { kind: 'setattr', path: ownedPaths.claim(canonicalPath(op.path, 'setattr path')), attrs: parseAttrs(op.attrs, 'setattr') };
|
|
693
|
+
case 'call': {
|
|
694
|
+
const call = op.call;
|
|
695
|
+
if ('data' in call) {
|
|
696
|
+
const path = ownedPaths.claim(canonicalPath(call.path, `${call.call} path`));
|
|
697
|
+
// A call's file stamps no time of its own: the session's operation does.
|
|
698
|
+
const inode = normalizeInode({
|
|
699
|
+
path, parentPath: parentPath(path), kind: 'file', isDir: false,
|
|
700
|
+
size: call.data.byteLength, mtime: 0, mode: 'mode' in call ? u32(call.mode, `${call.call} mode`) : 0, chunkCount: w7ChunkCount(call.data.byteLength),
|
|
701
|
+
...('ino' in call && call.ino !== undefined ? { ino: inodeNumber(call.ino, `${call.call} ino`) } : {}),
|
|
702
|
+
});
|
|
703
|
+
inode.call = call.call;
|
|
704
|
+
if (call.call === 'write')
|
|
705
|
+
inode.offset = safeInteger(call.offset, 'write offset');
|
|
706
|
+
if ('umask' in call && call.umask !== undefined)
|
|
707
|
+
inode.umask = umaskOf(call.umask, `${call.call} umask`);
|
|
708
|
+
if ('description' in call && call.description !== undefined)
|
|
709
|
+
inode.description = descriptionId(call.description, `${call.call} description`);
|
|
710
|
+
const chunks = w7Chunks(path, call.data);
|
|
711
|
+
return { kind: 'file', file: { inode, contentId: `${batchId}:${fileIndex++}`, chunks, source: null } };
|
|
712
|
+
}
|
|
713
|
+
return { kind: 'call', call: parsePathCall({ ...call }, (path, label) => ownedPaths.claim(canonicalPath(path, label))) };
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
});
|
|
442
717
|
}
|
|
443
|
-
function parseBatchBegin(bytes) {
|
|
718
|
+
function parseBatchBegin(bytes, mode) {
|
|
444
719
|
const value = parseObject(bytes, 'batch-begin', ['id', 'mode']);
|
|
445
720
|
const id = boundedString(value.id, 'batch id', MAX_BATCH_ID_BYTES);
|
|
446
|
-
if (value.mode !==
|
|
721
|
+
if (value.mode !== mode)
|
|
447
722
|
throw new Error(`w7-frame: unsupported batch mode ${String(value.mode)}`);
|
|
448
|
-
return { id, mode
|
|
723
|
+
return { id, mode };
|
|
724
|
+
}
|
|
725
|
+
/** One attribute change: exactly the mode, the owner (uid and gid), or the times (atime and mtime). */
|
|
726
|
+
function parseAttrs(value, label) {
|
|
727
|
+
const keys = Object.keys(value).sort().join(',');
|
|
728
|
+
if (keys === 'mode')
|
|
729
|
+
return { mode: u32(value.mode, `${label} mode`) };
|
|
730
|
+
if (keys === 'gid,uid')
|
|
731
|
+
return { uid: u32(value.uid, `${label} uid`), gid: u32(value.gid, `${label} gid`) };
|
|
732
|
+
if (keys === 'atime,mtime')
|
|
733
|
+
return { atime: safeInteger(value.atime, `${label} atime`), mtime: safeInteger(value.mtime, `${label} mtime`) };
|
|
734
|
+
throw new Error(`w7-frame: ${label} changes the mode, the owner or the times, one of them: got ${keys || 'nothing'}`);
|
|
449
735
|
}
|
|
450
736
|
function parseDelete(bytes) {
|
|
451
737
|
const value = parseObject(bytes, 'delete', ['path']);
|
|
452
738
|
return { path: canonicalPath(value.path, 'delete path') };
|
|
453
739
|
}
|
|
454
|
-
|
|
455
|
-
|
|
740
|
+
/** A v3 record names no inode number; a v4 one may. */
|
|
741
|
+
function inodeOptional(v3) {
|
|
742
|
+
return v3 ? ['atime'] : ['atime', 'ino'];
|
|
743
|
+
}
|
|
744
|
+
function parseDirectory(bytes, v3) {
|
|
745
|
+
const value = parseObject(bytes, 'directory', ['path', 'kind', 'mtime', 'mode'], inodeOptional(v3));
|
|
456
746
|
if (value.kind !== 'directory') {
|
|
457
747
|
throw new Error(`w7-frame: unsupported directory kind ${String(value.kind)}`);
|
|
458
748
|
}
|
|
459
749
|
return { ...parseInodeMetadata(value, 'directory'), kind: 'directory' };
|
|
460
750
|
}
|
|
461
|
-
function parseFileBegin(bytes) {
|
|
462
|
-
const value = parseObject(bytes, 'file-begin', ['path', 'kind', 'contentId', 'size', 'chunkCount', 'mtime', 'mode'], ['
|
|
751
|
+
function parseFileBegin(bytes, v3) {
|
|
752
|
+
const value = parseObject(bytes, 'file-begin', ['path', 'kind', 'contentId', 'size', 'chunkCount', 'mtime', 'mode'], v3 ? inodeOptional(v3) : [...inodeOptional(v3), 'call', 'offset', 'umask', 'description']);
|
|
463
753
|
const base = parseInodeMetadata(value, 'file-begin');
|
|
464
754
|
if (value.kind !== 'file' && value.kind !== 'symlink') {
|
|
465
755
|
throw new Error(`w7-frame: unsupported file-begin kind ${String(value.kind)}`);
|
|
@@ -470,11 +760,124 @@ function parseFileBegin(bytes) {
|
|
|
470
760
|
}
|
|
471
761
|
const size = safeInteger(value.size, 'file size');
|
|
472
762
|
const chunkCount = u32(value.chunkCount, 'file chunk count');
|
|
473
|
-
const expected =
|
|
763
|
+
const expected = w7ChunkCount(size);
|
|
474
764
|
if (chunkCount !== expected) {
|
|
475
765
|
throw new Error(`w7-frame: ${base.path}: expected ${expected} chunks, got ${chunkCount}`);
|
|
476
766
|
}
|
|
477
|
-
|
|
767
|
+
const call = value.call;
|
|
768
|
+
if (call !== undefined && call !== 'writeFile' && call !== 'appendFile' && call !== 'write' && call !== 'append') {
|
|
769
|
+
throw new Error(`w7-frame: ${base.path}: unknown data call ${String(call)}`);
|
|
770
|
+
}
|
|
771
|
+
if (call !== undefined && value.kind !== 'file')
|
|
772
|
+
throw new Error(`w7-frame: ${base.path}: a ${String(call)} writes a file`);
|
|
773
|
+
// An offset is a write's, and only a description's writes name an inode.
|
|
774
|
+
if ((call === 'write') !== (value.offset !== undefined))
|
|
775
|
+
throw new Error(`w7-frame: ${base.path}: a write, and only a write, has an offset`);
|
|
776
|
+
// A umask is a call's that makes a name with a mode; a description, a description's write's.
|
|
777
|
+
if (value.umask !== undefined && call !== 'writeFile' && call !== 'appendFile')
|
|
778
|
+
throw new Error(`w7-frame: ${base.path}: only a writeFile or appendFile has a umask`);
|
|
779
|
+
if (value.description !== undefined && call !== 'write' && call !== 'append')
|
|
780
|
+
throw new Error(`w7-frame: ${base.path}: only a write or append names a description`);
|
|
781
|
+
return {
|
|
782
|
+
...base, kind: value.kind, contentId, size, chunkCount,
|
|
783
|
+
...(call === undefined ? {} : { call }),
|
|
784
|
+
...(value.offset === undefined ? {} : { offset: safeInteger(value.offset, 'write offset') }),
|
|
785
|
+
...(value.umask === undefined ? {} : { umask: umaskOf(value.umask, 'file-begin umask') }),
|
|
786
|
+
...(value.description === undefined ? {} : { description: descriptionId(value.description, 'file-begin description') }),
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
/** A path call's fields, exactly: its paths made canonical (and claimed) by `path`. */
|
|
790
|
+
function parsePathCall(value, path) {
|
|
791
|
+
const keys = Object.keys(value).sort().join(',');
|
|
792
|
+
switch (value.call) {
|
|
793
|
+
case 'mkdir': {
|
|
794
|
+
const optional = keys.replace(',existing', '').replace(',ino', '').replace(',umask', '');
|
|
795
|
+
if (optional !== 'call,mode,path')
|
|
796
|
+
break;
|
|
797
|
+
if (value.existing !== undefined && value.existing !== 'ok')
|
|
798
|
+
throw new Error(`w7-frame: mkdir existing is 'ok' or absent, not ${String(value.existing)}`);
|
|
799
|
+
return {
|
|
800
|
+
call: 'mkdir', path: path(value.path, 'mkdir path'), mode: u32(value.mode, 'mkdir mode'),
|
|
801
|
+
...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'mkdir ino') }),
|
|
802
|
+
...(value.umask === undefined ? {} : { umask: umaskOf(value.umask, 'mkdir umask') }),
|
|
803
|
+
...(value.existing === undefined ? {} : { existing: 'ok' }),
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
case 'unlink':
|
|
807
|
+
case 'rmdir':
|
|
808
|
+
if (keys !== 'call,path')
|
|
809
|
+
break;
|
|
810
|
+
return { call: value.call, path: path(value.path, `${value.call} path`) };
|
|
811
|
+
case 'symlink':
|
|
812
|
+
if (keys !== 'call,path,target' && keys !== 'call,ino,path,target')
|
|
813
|
+
break;
|
|
814
|
+
return {
|
|
815
|
+
call: 'symlink', path: path(value.path, 'symlink path'), target: boundedString(value.target, 'symlink target', MAX_PATH_BYTES),
|
|
816
|
+
...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'symlink ino') }),
|
|
817
|
+
};
|
|
818
|
+
case 'ftruncate':
|
|
819
|
+
if (keys.replace(',description', '').replace(',ino', '') !== 'call,path,size')
|
|
820
|
+
break;
|
|
821
|
+
return {
|
|
822
|
+
call: 'ftruncate', path: path(value.path, 'ftruncate path'), size: safeInteger(value.size, 'ftruncate size'),
|
|
823
|
+
...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'ftruncate ino') }),
|
|
824
|
+
...(value.description === undefined ? {} : { description: descriptionId(value.description, 'ftruncate description') }),
|
|
825
|
+
};
|
|
826
|
+
case 'close':
|
|
827
|
+
if (keys !== 'call,description,path')
|
|
828
|
+
break;
|
|
829
|
+
return { call: 'close', path: path(value.path, 'close path'), description: descriptionId(value.description, 'close description') };
|
|
830
|
+
case 'rm': {
|
|
831
|
+
if (keys.replace(',force', '').replace(',recursive', '') !== 'call,path')
|
|
832
|
+
break;
|
|
833
|
+
for (const flag of ['recursive', 'force']) {
|
|
834
|
+
if (value[flag] !== undefined && value[flag] !== true)
|
|
835
|
+
throw new Error(`w7-frame: rm ${flag} is true or absent`);
|
|
836
|
+
}
|
|
837
|
+
return {
|
|
838
|
+
call: 'rm', path: path(value.path, 'rm path'),
|
|
839
|
+
...(value.recursive === true ? { recursive: true } : {}),
|
|
840
|
+
...(value.force === true ? { force: true } : {}),
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
case 'lchown':
|
|
844
|
+
if (keys !== 'call,gid,path,uid')
|
|
845
|
+
break;
|
|
846
|
+
return { call: 'lchown', path: path(value.path, 'lchown path'), uid: u32(value.uid, 'lchown uid'), gid: u32(value.gid, 'lchown gid') };
|
|
847
|
+
case 'open': {
|
|
848
|
+
const flags = ['create', 'exclusive', 'nofollow', 'read', 'truncate'];
|
|
849
|
+
let required = keys;
|
|
850
|
+
for (const flag of ['create', 'description', 'exclusive', 'ino', 'nofollow', 'read', 'truncate', 'umask'])
|
|
851
|
+
required = required.replace(`,${flag}`, '');
|
|
852
|
+
if (required !== 'call,mode,path')
|
|
853
|
+
break;
|
|
854
|
+
for (const flag of flags) {
|
|
855
|
+
if (value[flag] !== undefined && value[flag] !== true)
|
|
856
|
+
throw new Error(`w7-frame: open ${flag} is true or absent`);
|
|
857
|
+
}
|
|
858
|
+
return {
|
|
859
|
+
call: 'open', path: path(value.path, 'open path'), mode: u32(value.mode, 'open mode'),
|
|
860
|
+
...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, 'open ino') }),
|
|
861
|
+
...(value.umask === undefined ? {} : { umask: umaskOf(value.umask, 'open umask') }),
|
|
862
|
+
...(value.create === true ? { create: true } : {}),
|
|
863
|
+
...(value.truncate === true ? { truncate: true } : {}),
|
|
864
|
+
...(value.exclusive === true ? { exclusive: true } : {}),
|
|
865
|
+
...(value.nofollow === true ? { nofollow: true } : {}),
|
|
866
|
+
...(value.read === true ? { read: true } : {}),
|
|
867
|
+
...(value.description === undefined ? {} : { description: descriptionId(value.description, 'open description') }),
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
case 'lutimes':
|
|
871
|
+
if (keys !== 'atime,call,mtime,path')
|
|
872
|
+
break;
|
|
873
|
+
return {
|
|
874
|
+
call: 'lutimes', path: path(value.path, 'lutimes path'),
|
|
875
|
+
atime: safeInteger(value.atime, 'lutimes atime'), mtime: safeInteger(value.mtime, 'lutimes mtime'),
|
|
876
|
+
};
|
|
877
|
+
default:
|
|
878
|
+
throw new Error(`w7-frame: unknown call ${String(value.call)}`);
|
|
879
|
+
}
|
|
880
|
+
throw new Error(`w7-frame: ${String(value.call)} takes other fields: got ${keys}`);
|
|
478
881
|
}
|
|
479
882
|
function parseFileEnd(bytes) {
|
|
480
883
|
const value = parseObject(bytes, 'file-end', ['contentId', 'size', 'chunkCount', 'check']);
|
|
@@ -485,10 +888,10 @@ function parseFileEnd(bytes) {
|
|
|
485
888
|
check: u32(value.check, 'file-end check'),
|
|
486
889
|
};
|
|
487
890
|
}
|
|
488
|
-
function parseBatchEnd(bytes) {
|
|
891
|
+
function parseBatchEnd(bytes, v3) {
|
|
489
892
|
const keys = [
|
|
490
893
|
'recordCount', 'pathCount', 'deleteCount', 'directoryCount',
|
|
491
|
-
'fileCount', 'chunkCount', 'byteCount', 'check',
|
|
894
|
+
'fileCount', 'chunkCount', 'byteCount', 'check', ...(v3 ? [] : ['opCount']),
|
|
492
895
|
];
|
|
493
896
|
const value = parseObject(bytes, 'batch-end', keys);
|
|
494
897
|
return {
|
|
@@ -499,6 +902,7 @@ function parseBatchEnd(bytes) {
|
|
|
499
902
|
fileCount: safeInteger(value.fileCount, 'batch file count'),
|
|
500
903
|
chunkCount: safeInteger(value.chunkCount, 'batch chunk count'),
|
|
501
904
|
byteCount: safeInteger(value.byteCount, 'batch byte count'),
|
|
905
|
+
opCount: v3 ? 0 : safeInteger(value.opCount, 'batch op count'),
|
|
502
906
|
check: u32(value.check, 'batch check'),
|
|
503
907
|
};
|
|
504
908
|
}
|
|
@@ -508,8 +912,30 @@ function parseInodeMetadata(value, label) {
|
|
|
508
912
|
...(value.atime === undefined ? {} : { atime: safeInteger(value.atime, `${label} atime`) }),
|
|
509
913
|
mtime: safeInteger(value.mtime, `${label} mtime`),
|
|
510
914
|
mode: u32(value.mode, `${label} mode`),
|
|
915
|
+
...(value.ino === undefined ? {} : { ino: inodeNumber(value.ino, `${label} ino`) }),
|
|
511
916
|
};
|
|
512
917
|
}
|
|
918
|
+
/** An open description's id, as a process chose it: up to 64 characters of [A-Za-z0-9_-]. */
|
|
919
|
+
function descriptionId(value, label) {
|
|
920
|
+
const id = boundedString(value, label, 64);
|
|
921
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id))
|
|
922
|
+
throw new Error(`w7-frame: ${label} is not a description id`);
|
|
923
|
+
return id;
|
|
924
|
+
}
|
|
925
|
+
/** A umask: permission bits, 0 to 0o777. */
|
|
926
|
+
function umaskOf(value, label) {
|
|
927
|
+
const mask = u32(value, label);
|
|
928
|
+
if (mask > 0o777)
|
|
929
|
+
throw new Error(`w7-frame: ${label} must be at most 0o777`);
|
|
930
|
+
return mask;
|
|
931
|
+
}
|
|
932
|
+
/** An inode number: an integer from 2 (1 is the root's). */
|
|
933
|
+
function inodeNumber(value, label) {
|
|
934
|
+
const ino = safeInteger(value, label);
|
|
935
|
+
if (ino < 2)
|
|
936
|
+
throw new Error(`w7-frame: ${label} must be at least 2`);
|
|
937
|
+
return ino;
|
|
938
|
+
}
|
|
513
939
|
function parseObject(bytes, label, required, optional = []) {
|
|
514
940
|
let value;
|
|
515
941
|
try {
|
|
@@ -543,6 +969,8 @@ function normalizeInode(inode) {
|
|
|
543
969
|
safeInteger(inode.mtime, `${inode.path} mtime`);
|
|
544
970
|
if (inode.atime !== undefined)
|
|
545
971
|
safeInteger(inode.atime, `${inode.path} atime`);
|
|
972
|
+
if (inode.ino !== undefined)
|
|
973
|
+
inodeNumber(inode.ino, `${inode.path} ino`);
|
|
546
974
|
u32(inode.mode, `${inode.path} mode`);
|
|
547
975
|
const rawKind = inode.kind ?? (inode.isDir ? 'directory' : 'file');
|
|
548
976
|
if (rawKind !== 'file' && rawKind !== 'directory' && rawKind !== 'symlink') {
|
|
@@ -559,7 +987,7 @@ function normalizeInode(inode) {
|
|
|
559
987
|
throw new Error(`w7-frame: directory ${inode.path} must have zero size and chunks`);
|
|
560
988
|
}
|
|
561
989
|
if (kind !== 'directory') {
|
|
562
|
-
const expected =
|
|
990
|
+
const expected = w7ChunkCount(inode.size);
|
|
563
991
|
if (inode.chunkCount !== expected) {
|
|
564
992
|
throw new Error(`w7-frame: ${inode.path}: expected ${expected} chunks, got ${inode.chunkCount}`);
|
|
565
993
|
}
|
|
@@ -594,6 +1022,7 @@ function directoryInode(metadata) {
|
|
|
594
1022
|
mtime: metadata.mtime,
|
|
595
1023
|
mode: metadata.mode,
|
|
596
1024
|
chunkCount: 0,
|
|
1025
|
+
...(metadata.ino === undefined ? {} : { ino: metadata.ino }),
|
|
597
1026
|
};
|
|
598
1027
|
}
|
|
599
1028
|
function fileInode(metadata) {
|
|
@@ -607,6 +1036,11 @@ function fileInode(metadata) {
|
|
|
607
1036
|
mtime: metadata.mtime,
|
|
608
1037
|
mode: metadata.mode,
|
|
609
1038
|
chunkCount: metadata.chunkCount,
|
|
1039
|
+
...(metadata.ino === undefined ? {} : { ino: metadata.ino }),
|
|
1040
|
+
...(metadata.call === undefined ? {} : { call: metadata.call }),
|
|
1041
|
+
...(metadata.offset === undefined ? {} : { offset: metadata.offset }),
|
|
1042
|
+
...(metadata.umask === undefined ? {} : { umask: metadata.umask }),
|
|
1043
|
+
...(metadata.description === undefined ? {} : { description: metadata.description }),
|
|
610
1044
|
};
|
|
611
1045
|
}
|
|
612
1046
|
function inodeMetadata(inode) {
|
|
@@ -615,6 +1049,7 @@ function inodeMetadata(inode) {
|
|
|
615
1049
|
...(inode.atime === undefined ? {} : { atime: inode.atime }),
|
|
616
1050
|
mtime: inode.mtime,
|
|
617
1051
|
mode: inode.mode,
|
|
1052
|
+
...(inode.ino === undefined ? {} : { ino: inode.ino }),
|
|
618
1053
|
};
|
|
619
1054
|
}
|
|
620
1055
|
function canonicalPath(value, label) {
|
|
@@ -626,26 +1061,36 @@ function canonicalPath(value, label) {
|
|
|
626
1061
|
throw new Error(`w7-frame: noncanonical ${label}: ${path}`);
|
|
627
1062
|
return path;
|
|
628
1063
|
}
|
|
1064
|
+
/**
|
|
1065
|
+
* The distinct paths a batch names, within W7's bounds. A program-order
|
|
1066
|
+
* batch (v4) may name a path in several operations; a v3 batch, and a payload
|
|
1067
|
+
* matched to its chunks by path, names each once.
|
|
1068
|
+
*/
|
|
629
1069
|
class PathOwnership {
|
|
1070
|
+
repeats;
|
|
630
1071
|
paths = new Set();
|
|
631
1072
|
pathBytes = 0;
|
|
1073
|
+
constructor(repeats) {
|
|
1074
|
+
this.repeats = repeats;
|
|
1075
|
+
}
|
|
632
1076
|
claim(path) {
|
|
633
|
-
if (this.paths.has(path))
|
|
1077
|
+
if (this.paths.has(path)) {
|
|
1078
|
+
if (this.repeats)
|
|
1079
|
+
return path;
|
|
634
1080
|
throw new Error(`w7-frame: duplicate path ownership: ${path}`);
|
|
1081
|
+
}
|
|
635
1082
|
if (this.paths.size >= W7_MAX_PATHS_PER_BATCH) {
|
|
636
1083
|
throw new Error(`w7-frame: batch exceeds ${W7_MAX_PATHS_PER_BATCH} owned paths`);
|
|
637
1084
|
}
|
|
638
|
-
const nextPathBytes = this.pathBytes +
|
|
1085
|
+
const nextPathBytes = this.pathBytes + utf8Length(path);
|
|
639
1086
|
if (nextPathBytes > W7_MAX_OWNED_PATH_BYTES) {
|
|
640
1087
|
throw new Error(`w7-frame: owned path bytes exceed ${W7_MAX_OWNED_PATH_BYTES}`);
|
|
641
1088
|
}
|
|
642
1089
|
this.paths.add(path);
|
|
643
1090
|
this.pathBytes = nextPathBytes;
|
|
1091
|
+
return path;
|
|
644
1092
|
}
|
|
645
1093
|
}
|
|
646
|
-
function claimPath(paths, path) {
|
|
647
|
-
paths.claim(path);
|
|
648
|
-
}
|
|
649
1094
|
function normalizePath(path) {
|
|
650
1095
|
const out = [];
|
|
651
1096
|
for (const segment of path.split('/')) {
|
|
@@ -667,8 +1112,7 @@ function boundedString(value, label, maxBytes) {
|
|
|
667
1112
|
if (typeof value !== 'string' || value.length === 0) {
|
|
668
1113
|
throw new Error(`w7-frame: ${label} must be a non-empty string`);
|
|
669
1114
|
}
|
|
670
|
-
|
|
671
|
-
if (length > maxBytes)
|
|
1115
|
+
if (utf8Length(value) > maxBytes)
|
|
672
1116
|
throw new Error(`w7-frame: ${label} exceeds ${maxBytes} bytes`);
|
|
673
1117
|
return value;
|
|
674
1118
|
}
|
|
@@ -692,6 +1136,7 @@ function sameSummary(left, right) {
|
|
|
692
1136
|
&& left.fileCount === right.fileCount
|
|
693
1137
|
&& left.chunkCount === right.chunkCount
|
|
694
1138
|
&& left.byteCount === right.byteCount
|
|
1139
|
+
&& left.opCount === right.opCount
|
|
695
1140
|
&& left.check === right.check;
|
|
696
1141
|
}
|
|
697
1142
|
async function readEnvelope(buffer, label) {
|
|
@@ -725,16 +1170,29 @@ function updateRecordCheck(seed, ...parts) {
|
|
|
725
1170
|
function noopRetention(bytes) {
|
|
726
1171
|
return { bytes, release() { } };
|
|
727
1172
|
}
|
|
1173
|
+
// One decoder for every record: a non-streaming decode() starts from a
|
|
1174
|
+
// clean state each call, so sharing it changes nothing but the cost of
|
|
1175
|
+
// constructing one per record (three per small file).
|
|
1176
|
+
// Both options stated: workers-types declares TextDecoderConstructorOptions
|
|
1177
|
+
// with every property required, and ignoreBOM's default is false anyway.
|
|
1178
|
+
const UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false });
|
|
728
1179
|
function decodeText(bytes, label) {
|
|
729
1180
|
try {
|
|
730
|
-
|
|
731
|
-
// with every property required, and ignoreBOM's default is false anyway.
|
|
732
|
-
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes);
|
|
1181
|
+
return UTF8.decode(bytes);
|
|
733
1182
|
}
|
|
734
1183
|
catch (error) {
|
|
735
1184
|
throw new Error(`w7-frame: invalid UTF-8 in ${label}: ${errorMessage(error)}`);
|
|
736
1185
|
}
|
|
737
1186
|
}
|
|
1187
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
1188
|
+
function sameBytes(left, right) {
|
|
1189
|
+
if (left.byteLength !== right.byteLength)
|
|
1190
|
+
return false;
|
|
1191
|
+
for (let index = 0; index < left.byteLength; index++)
|
|
1192
|
+
if (left[index] !== right[index])
|
|
1193
|
+
return false;
|
|
1194
|
+
return true;
|
|
1195
|
+
}
|
|
738
1196
|
function concatBytes(...parts) {
|
|
739
1197
|
const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
|
|
740
1198
|
let offset = 0;
|
|
@@ -771,10 +1229,19 @@ async function cancelReader(reader, reason) {
|
|
|
771
1229
|
}
|
|
772
1230
|
catch { /* already released */ }
|
|
773
1231
|
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Exact reads over a byte stream, served from a block read ahead of them: a
|
|
1234
|
+
* stream read is an await through the stream machinery (and, across RPC, its
|
|
1235
|
+
* pump), so the small reads a record's header and metadata take come out of
|
|
1236
|
+
* one READ_AHEAD_BYTES block instead of one read each. A read larger than
|
|
1237
|
+
* the block (a chunk's data) fills its own buffer directly.
|
|
1238
|
+
*/
|
|
774
1239
|
class ExactByteReader {
|
|
775
1240
|
reader;
|
|
776
1241
|
signal;
|
|
777
1242
|
done = false;
|
|
1243
|
+
block = new Uint8Array(0);
|
|
1244
|
+
offset = 0;
|
|
778
1245
|
constructor(reader, signal) {
|
|
779
1246
|
this.reader = reader;
|
|
780
1247
|
this.signal = signal;
|
|
@@ -792,26 +1259,82 @@ class ExactByteReader {
|
|
|
792
1259
|
this.signal?.removeEventListener('abort', abort);
|
|
793
1260
|
}
|
|
794
1261
|
}
|
|
1262
|
+
/** One stream read into `view`; zero bytes at the end of the stream. */
|
|
1263
|
+
async fill(view) {
|
|
1264
|
+
const next = await this.read(() => this.reader.read(view), () => this.reader.cancel(this.signal?.reason));
|
|
1265
|
+
if (next.done) {
|
|
1266
|
+
this.done = true;
|
|
1267
|
+
return new Uint8Array(0);
|
|
1268
|
+
}
|
|
1269
|
+
return next.value;
|
|
1270
|
+
}
|
|
795
1271
|
async readExact(length, label) {
|
|
796
1272
|
if (length === 0)
|
|
797
1273
|
return new Uint8Array(0);
|
|
1274
|
+
const buffered = this.block.byteLength - this.offset;
|
|
1275
|
+
if (buffered >= length) {
|
|
1276
|
+
const out = this.block.slice(this.offset, this.offset + length);
|
|
1277
|
+
this.offset += length;
|
|
1278
|
+
return out;
|
|
1279
|
+
}
|
|
798
1280
|
const output = new Uint8Array(length);
|
|
799
|
-
let
|
|
800
|
-
|
|
1281
|
+
let filled = 0;
|
|
1282
|
+
if (buffered > 0) {
|
|
1283
|
+
output.set(this.block.subarray(this.offset), 0);
|
|
1284
|
+
filled = buffered;
|
|
1285
|
+
}
|
|
1286
|
+
this.offset = this.block.byteLength;
|
|
1287
|
+
while (filled < length) {
|
|
801
1288
|
if (this.done) {
|
|
802
|
-
throw new Error(`w7-frame: stream ended ${
|
|
1289
|
+
throw new Error(`w7-frame: stream ended ${filled} bytes into expected ${length}-byte ${label}`);
|
|
803
1290
|
}
|
|
804
|
-
const
|
|
805
|
-
if (
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
output.
|
|
809
|
-
|
|
1291
|
+
const remaining = length - filled;
|
|
1292
|
+
if (remaining >= READ_AHEAD_BYTES) {
|
|
1293
|
+
const got = await this.fill(output.subarray(filled));
|
|
1294
|
+
filled += got.byteLength;
|
|
1295
|
+
// The read took output's buffer and handed it back under a new view.
|
|
1296
|
+
if (got.byteLength > 0)
|
|
1297
|
+
return this.finish(got, length);
|
|
1298
|
+
continue;
|
|
810
1299
|
}
|
|
1300
|
+
// A BYOB read transfers the buffer it fills and returns it: the block's
|
|
1301
|
+
// storage is reused from read to read, never reallocated.
|
|
1302
|
+
const got = await this.fill(new Uint8Array(this.spare(), 0, READ_AHEAD_BYTES));
|
|
1303
|
+
const take = Math.min(remaining, got.byteLength);
|
|
1304
|
+
output.set(got.subarray(0, take), filled);
|
|
1305
|
+
filled += take;
|
|
1306
|
+
this.block = got;
|
|
1307
|
+
this.offset = take;
|
|
811
1308
|
}
|
|
812
1309
|
return output;
|
|
813
1310
|
}
|
|
1311
|
+
/** The read-ahead storage, taken back from the block once it is drained. */
|
|
1312
|
+
spare() {
|
|
1313
|
+
const storage = this.block.buffer;
|
|
1314
|
+
return storage.byteLength >= READ_AHEAD_BYTES ? storage : new ArrayBuffer(READ_AHEAD_BYTES);
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* A large read lands in place: what one read returned is a view over the
|
|
1318
|
+
* output's own (transferred) buffer, so the remainder fills the same one.
|
|
1319
|
+
*/
|
|
1320
|
+
async finish(view, length) {
|
|
1321
|
+
let filled = view.byteOffset + view.byteLength;
|
|
1322
|
+
let buffer = view.buffer;
|
|
1323
|
+
while (filled < length) {
|
|
1324
|
+
if (this.done) {
|
|
1325
|
+
throw new Error(`w7-frame: stream ended ${filled} bytes into expected ${length}-byte file-chunk data`);
|
|
1326
|
+
}
|
|
1327
|
+
const got = await this.fill(new Uint8Array(buffer, filled, length - filled));
|
|
1328
|
+
buffer = got.buffer;
|
|
1329
|
+
filled += got.byteLength;
|
|
1330
|
+
}
|
|
1331
|
+
return new Uint8Array(buffer, 0, length);
|
|
1332
|
+
}
|
|
814
1333
|
async ensureEof(stream) {
|
|
1334
|
+
if (this.offset < this.block.byteLength) {
|
|
1335
|
+
await this.reader.cancel(new Error('w7-frame: trailing bytes after batch-end'));
|
|
1336
|
+
throw new Error('w7-frame: trailing bytes after batch-end');
|
|
1337
|
+
}
|
|
815
1338
|
if (this.done)
|
|
816
1339
|
return;
|
|
817
1340
|
this.reader.releaseLock();
|