@torrent-tv/proxy 2.101.20 → 2.101.22
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/package.json +1 -1
- package/routes/stream/get.js +86 -48
- package/services/storage/files/CompletedFiles.js +90 -28
package/package.json
CHANGED
package/routes/stream/get.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { open } from "node:fs/promises";
|
|
10
|
+
import { Readable } from "node:stream";
|
|
10
11
|
import { parseRange } from "../../utils/parse-range.js";
|
|
11
12
|
import { logger } from "../../utils/logger.js";
|
|
12
13
|
|
|
@@ -109,49 +110,66 @@ function wholeFileFor(torrentPool, sourceKey, fileIndex) {
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
/**
|
|
112
|
-
*
|
|
113
|
+
* Acquire the whole file a response can continue from, if there is one.
|
|
113
114
|
*
|
|
114
|
-
* Any failure to
|
|
115
|
+
* Any failure to acquire it leaves the response on the torrent, which can still
|
|
115
116
|
* answer; a file the disk owner removed is also forgotten here, as the route
|
|
116
117
|
* does before headers.
|
|
117
118
|
*
|
|
118
|
-
* @param {{ wholeFiles?: Map<string, { path: string, length: number, name: string }> }} torrentPool
|
|
119
|
+
* @param {{ wholeFiles?: Map<string, { path: string, length: number, name: string }>, acquireWholeFile: (source: { sourceKey: string, fileIndex: number }) => Promise<{ length: number, stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> } | null> }} torrentPool
|
|
119
120
|
* @param {string} sourceKey
|
|
120
121
|
* @param {number} fileIndex
|
|
121
122
|
* @param {number} length - The length the response was promised for.
|
|
122
|
-
* @returns {Promise<import("node:
|
|
123
|
+
* @returns {Promise<{ length: number, stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> } | null>}
|
|
123
124
|
*/
|
|
124
|
-
async function
|
|
125
|
+
async function acquireWholeFileRead(torrentPool, sourceKey, fileIndex, length) {
|
|
125
126
|
const whole = wholeFileFor(torrentPool, sourceKey, fileIndex);
|
|
126
127
|
if (!whole || whole.length !== length) {
|
|
127
128
|
return null;
|
|
128
129
|
}
|
|
129
130
|
try {
|
|
130
|
-
|
|
131
|
+
const read = await torrentPool.acquireWholeFile({ sourceKey, fileIndex });
|
|
132
|
+
if (!read) {
|
|
133
|
+
forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (read.length !== length) {
|
|
137
|
+
await read.release();
|
|
138
|
+
forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return read;
|
|
131
142
|
} catch (error) {
|
|
132
143
|
if (error.code === "ENOENT") {
|
|
133
|
-
|
|
134
|
-
if (torrentPool.wholeFiles.get(key) === whole) torrentPool.wholeFiles.delete(key);
|
|
144
|
+
forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
|
|
135
145
|
}
|
|
136
146
|
return null;
|
|
137
147
|
}
|
|
138
148
|
}
|
|
139
149
|
|
|
150
|
+
function forgetWholeFile(torrentPool, sourceKey, fileIndex, whole) {
|
|
151
|
+
const key = `${sourceKey.slice("torrent:".length).toLowerCase()}/${fileIndex}`;
|
|
152
|
+
if (torrentPool.wholeFiles?.get(key) === whole) torrentPool.wholeFiles.delete(key);
|
|
153
|
+
}
|
|
154
|
+
|
|
140
155
|
/**
|
|
141
156
|
* Write bytes `from`..`end` of an open file to a response, each write waited
|
|
142
157
|
* for, and close the file.
|
|
143
158
|
*
|
|
144
159
|
* @param {import("node:http").ServerResponse} raw
|
|
145
|
-
* @param {import("node:
|
|
160
|
+
* @param {{ stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> }} read
|
|
146
161
|
* @param {number} from - Inclusive.
|
|
147
162
|
* @param {number} end - Inclusive.
|
|
148
163
|
* @param {(length: number) => void} onWritten
|
|
149
164
|
* @returns {Promise<number>} Bytes written.
|
|
150
165
|
*/
|
|
151
|
-
async function writeFromWholeFile(raw,
|
|
166
|
+
async function writeFromWholeFile(raw, read, from, end, onWritten) {
|
|
152
167
|
let written = 0;
|
|
168
|
+
let stream = null;
|
|
153
169
|
try {
|
|
154
|
-
|
|
170
|
+
stream = read.stream(from, end, () => {});
|
|
171
|
+
if (!stream) throw new Error("Completed file did not provide the admitted range.");
|
|
172
|
+
for await (const chunk of stream) {
|
|
155
173
|
if (raw.writableEnded || raw.destroyed) {
|
|
156
174
|
break;
|
|
157
175
|
}
|
|
@@ -162,7 +180,8 @@ async function writeFromWholeFile(raw, handle, from, end, onWritten) {
|
|
|
162
180
|
onWritten(chunk.length);
|
|
163
181
|
}
|
|
164
182
|
} finally {
|
|
165
|
-
|
|
183
|
+
stream?.destroy();
|
|
184
|
+
await read.release();
|
|
166
185
|
}
|
|
167
186
|
return written;
|
|
168
187
|
}
|
|
@@ -173,35 +192,41 @@ async function writeFromWholeFile(raw, handle, from, end, onWritten) {
|
|
|
173
192
|
* @param {import("fastify").FastifyRequest} req
|
|
174
193
|
* @param {import("fastify").FastifyReply} reply
|
|
175
194
|
* @param {{ path: string, length: number, name: string }} file
|
|
176
|
-
* @param {import("node:
|
|
195
|
+
* @param {{ stream: (start: number, end: number, onRead: (length: number) => void) => import("node:stream").Readable | null, release: () => Promise<unknown> }} read
|
|
177
196
|
* @returns {Promise<void> | void}
|
|
178
197
|
*/
|
|
179
|
-
function serveWholeFile(req, reply, file,
|
|
198
|
+
function serveWholeFile(req, reply, file, read) {
|
|
180
199
|
const disposition = `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`;
|
|
181
|
-
if (req.method === "HEAD") {
|
|
182
|
-
reply.hijack();
|
|
183
|
-
reply.raw.writeHead(200, {
|
|
184
|
-
"Accept-Ranges": "bytes",
|
|
185
|
-
"Content-Type": "application/octet-stream",
|
|
186
|
-
"Content-Length": String(file.length),
|
|
187
|
-
"Content-Disposition": disposition
|
|
188
|
-
});
|
|
189
|
-
reply.raw.end();
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
200
|
reply.header("Accept-Ranges", "bytes");
|
|
193
201
|
reply.header("Content-Type", "application/octet-stream");
|
|
194
202
|
reply.header("Content-Disposition", disposition);
|
|
195
203
|
const range = parseRange(req.headers.range, file.length);
|
|
204
|
+
if (file.length === 0) {
|
|
205
|
+
reply.header("Content-Length", "0");
|
|
206
|
+
void read.release().catch(() => undefined);
|
|
207
|
+
return reply.send(Readable.from([]));
|
|
208
|
+
}
|
|
209
|
+
const start = range?.start ?? 0;
|
|
210
|
+
const end = range?.end ?? file.length - 1;
|
|
211
|
+
const stream = read.stream(start, end, () => {});
|
|
212
|
+
if (!stream) {
|
|
213
|
+
void read.release().catch(() => undefined);
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
196
216
|
if (!range) {
|
|
197
217
|
reply.header("Content-Length", String(file.length));
|
|
198
|
-
|
|
218
|
+
} else {
|
|
219
|
+
reply.code(206);
|
|
220
|
+
reply.header("Content-Length", String(end - start + 1));
|
|
221
|
+
reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
|
|
222
|
+
}
|
|
223
|
+
void read.release().catch(() => undefined);
|
|
224
|
+
try {
|
|
225
|
+
return reply.send(stream);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
stream.destroy();
|
|
228
|
+
throw error;
|
|
199
229
|
}
|
|
200
|
-
const { start, end } = range;
|
|
201
|
-
reply.code(206);
|
|
202
|
-
reply.header("Content-Length", String(end - start + 1));
|
|
203
|
-
reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
|
|
204
|
-
return reply.send(handle.createReadStream({ start, end }));
|
|
205
230
|
}
|
|
206
231
|
|
|
207
232
|
export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool, noteInputBytes = null, noteInputWaiting = null }) {
|
|
@@ -224,23 +249,36 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
224
249
|
// nothing the torrent would have told us.
|
|
225
250
|
const whole = wholeFileFor(torrentPool, sourceKey, fileIndex);
|
|
226
251
|
if (whole) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
// Open before committing headers: the disk owner may have removed an
|
|
230
|
-
// announced file. An open descriptor also preserves a live POSIX read.
|
|
231
|
-
handle = await open(whole.path, "r");
|
|
232
|
-
} catch (error) {
|
|
233
|
-
if (error.code !== "ENOENT") throw error;
|
|
234
|
-
const key = `${sourceKey.slice("torrent:".length).toLowerCase()}/${fileIndex}`;
|
|
235
|
-
if (torrentPool.wholeFiles.get(key) === whole) torrentPool.wholeFiles.delete(key);
|
|
236
|
-
}
|
|
237
|
-
if (handle) {
|
|
238
|
-
if (req.method === "HEAD") await handle.close();
|
|
252
|
+
if (req.method === "HEAD") {
|
|
253
|
+
let handle;
|
|
239
254
|
try {
|
|
240
|
-
|
|
255
|
+
handle = await open(whole.path, "r");
|
|
241
256
|
} catch (error) {
|
|
242
|
-
|
|
243
|
-
|
|
257
|
+
if (error.code !== "ENOENT") throw error;
|
|
258
|
+
forgetWholeFile(torrentPool, sourceKey, fileIndex, whole);
|
|
259
|
+
}
|
|
260
|
+
if (handle) {
|
|
261
|
+
await handle.close();
|
|
262
|
+
reply.hijack();
|
|
263
|
+
reply.raw.writeHead(200, {
|
|
264
|
+
"Accept-Ranges": "bytes",
|
|
265
|
+
"Content-Type": "application/octet-stream",
|
|
266
|
+
"Content-Length": String(whole.length),
|
|
267
|
+
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(whole.name)}`
|
|
268
|
+
});
|
|
269
|
+
reply.raw.end();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
const read = await acquireWholeFileRead(torrentPool, sourceKey, fileIndex, whole.length);
|
|
274
|
+
if (read) {
|
|
275
|
+
try {
|
|
276
|
+
const sent = serveWholeFile(req, reply, whole, read);
|
|
277
|
+
if (sent) return sent;
|
|
278
|
+
} catch (error) {
|
|
279
|
+
await read.release().catch(() => undefined);
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
244
282
|
}
|
|
245
283
|
}
|
|
246
284
|
}
|
|
@@ -338,7 +376,7 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
338
376
|
fragment.release();
|
|
339
377
|
break;
|
|
340
378
|
}
|
|
341
|
-
continueFrom = await
|
|
379
|
+
continueFrom = await acquireWholeFileRead(torrentPool, sourceKey, fileIndex, file.length);
|
|
342
380
|
if (continueFrom) {
|
|
343
381
|
fragment.release();
|
|
344
382
|
break;
|
|
@@ -368,7 +406,7 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
|
|
|
368
406
|
}
|
|
369
407
|
} catch (error) {
|
|
370
408
|
if (sent >= contentLength || req.raw.aborted || reply.raw.destroyed) throw error;
|
|
371
|
-
continueFrom = await
|
|
409
|
+
continueFrom = await acquireWholeFileRead(torrentPool, sourceKey, fileIndex, file.length);
|
|
372
410
|
if (!continueFrom) throw error;
|
|
373
411
|
}
|
|
374
412
|
if (continueFrom) {
|
|
@@ -54,8 +54,13 @@ export class CompletedFiles {
|
|
|
54
54
|
|
|
55
55
|
/** Assemblies in flight, so two passes cannot write one file at once. @type {Set<string>} */
|
|
56
56
|
#writing = new Set();
|
|
57
|
+
/** Bytes promised to assemblies that have not yet become held files. @type {Map<string, number>} */
|
|
58
|
+
#reservations = new Map();
|
|
59
|
+
/** Serialize allowance changes and admission decisions. @type {Promise<unknown>} */
|
|
60
|
+
#admission = Promise.resolve();
|
|
57
61
|
#readers = new Map();
|
|
58
62
|
#removeAfterRead = new Set();
|
|
63
|
+
#removing = new Set();
|
|
59
64
|
|
|
60
65
|
/**
|
|
61
66
|
* What this store may hold, handed to it by the owner of the disk.
|
|
@@ -76,6 +81,8 @@ export class CompletedFiles {
|
|
|
76
81
|
|
|
77
82
|
/** When each held file was last asked for. @type {Map<string, number>} */
|
|
78
83
|
#lastRead = new Map();
|
|
84
|
+
/** Monotonic access order, so reads in the same clock tick remain ordered. */
|
|
85
|
+
#accessOrder = 0;
|
|
79
86
|
|
|
80
87
|
/**
|
|
81
88
|
* @param {object} params
|
|
@@ -142,17 +149,22 @@ export class CompletedFiles {
|
|
|
142
149
|
*/
|
|
143
150
|
find(infoHash, fileIndex) {
|
|
144
151
|
const key = this.#keyOf(infoHash, fileIndex);
|
|
145
|
-
if (this.#removeAfterRead.has(key)) return null;
|
|
152
|
+
if (this.#removeAfterRead.has(key) || this.#removing.has(key)) return null;
|
|
146
153
|
const file = this.#held.get(key) ?? null;
|
|
147
154
|
if (file) {
|
|
148
155
|
// WHEN IT WAS LAST WANTED, which is the order it leaves in. A whole file
|
|
149
156
|
// nobody has asked for in the longest is the one whose loss costs least:
|
|
150
157
|
// it can be downloaded again, and a read of it falls back to the pieces.
|
|
151
|
-
this.#
|
|
158
|
+
this.#touch(key);
|
|
152
159
|
}
|
|
153
160
|
return file;
|
|
154
161
|
}
|
|
155
162
|
|
|
163
|
+
/** Mark a file as more recently wanted than every earlier access. */
|
|
164
|
+
#touch(key) {
|
|
165
|
+
this.#lastRead.set(key, ++this.#accessOrder);
|
|
166
|
+
}
|
|
167
|
+
|
|
156
168
|
/** Pin a completed file against eviction until all its offset reads finish. */
|
|
157
169
|
acquire(infoHash, fileIndex) {
|
|
158
170
|
const key = this.#keyOf(infoHash, fileIndex);
|
|
@@ -186,20 +198,38 @@ export class CompletedFiles {
|
|
|
186
198
|
* many files went.
|
|
187
199
|
*/
|
|
188
200
|
async allow(bytes) {
|
|
189
|
-
this.#
|
|
190
|
-
|
|
191
|
-
|
|
201
|
+
return this.#withAdmission(async () => {
|
|
202
|
+
this.#allowanceBytes = Number.isFinite(bytes) && bytes >= 0 ? bytes : null;
|
|
203
|
+
const removed = await this.#trimToAllowance();
|
|
192
204
|
return { bytes: this.bytes, removed };
|
|
193
|
-
}
|
|
194
|
-
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Run one storage decision at a time. @param {() => Promise<unknown> | unknown} action */
|
|
209
|
+
#withAdmission(action) {
|
|
210
|
+
const result = this.#admission.then(action, action);
|
|
211
|
+
this.#admission = result.catch(() => undefined);
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Include bytes currently being assembled when enforcing the allowance. */
|
|
216
|
+
#reservedBytes() {
|
|
217
|
+
let total = 0;
|
|
218
|
+
for (const length of this.#reservations.values()) total += length;
|
|
219
|
+
return total;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Remove eligible unused files until held and reserved bytes fit. */
|
|
223
|
+
async #trimToAllowance() {
|
|
224
|
+
let removed = 0;
|
|
225
|
+
if (this.#allowanceBytes === null) return removed;
|
|
226
|
+
while (this.bytes + this.#reservedBytes() > this.#allowanceBytes) {
|
|
195
227
|
const victim = this.#longestUnread();
|
|
196
|
-
if (!victim)
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
await this.#remove(victim);
|
|
228
|
+
if (!victim) break;
|
|
229
|
+
if (!await this.#remove(victim)) break;
|
|
200
230
|
removed += 1;
|
|
201
231
|
}
|
|
202
|
-
return
|
|
232
|
+
return removed;
|
|
203
233
|
}
|
|
204
234
|
|
|
205
235
|
/**
|
|
@@ -215,7 +245,7 @@ export class CompletedFiles {
|
|
|
215
245
|
if (this.#allowanceBytes === null) {
|
|
216
246
|
return true;
|
|
217
247
|
}
|
|
218
|
-
return this.bytes + Math.max(0, Number(length) || 0) <= this.#allowanceBytes;
|
|
248
|
+
return this.bytes + this.#reservedBytes() + Math.max(0, Number(length) || 0) <= this.#allowanceBytes;
|
|
219
249
|
}
|
|
220
250
|
|
|
221
251
|
/**
|
|
@@ -227,7 +257,7 @@ export class CompletedFiles {
|
|
|
227
257
|
let oldest = null;
|
|
228
258
|
let oldestAt = Number.POSITIVE_INFINITY;
|
|
229
259
|
for (const key of this.#held.keys()) {
|
|
230
|
-
if (this.#writing.has(key) || this.#readers.has(key)) {
|
|
260
|
+
if (this.#writing.has(key) || this.#readers.has(key) || this.#removing.has(key)) {
|
|
231
261
|
continue;
|
|
232
262
|
}
|
|
233
263
|
const at = this.#lastRead.get(key) ?? 0;
|
|
@@ -243,17 +273,24 @@ export class CompletedFiles {
|
|
|
243
273
|
* Take one whole file off the disk.
|
|
244
274
|
*
|
|
245
275
|
* @param {string} key
|
|
246
|
-
* @returns {Promise<
|
|
276
|
+
* @returns {Promise<boolean>} Whether the file was removed.
|
|
247
277
|
*/
|
|
248
278
|
async #remove(key) {
|
|
249
|
-
if (this.#
|
|
279
|
+
if (this.#removing.has(key)) return false;
|
|
280
|
+
if (this.#readers.has(key)) { this.#removeAfterRead.add(key); return false; }
|
|
250
281
|
const file = this.#held.get(key);
|
|
282
|
+
if (!file) return true;
|
|
283
|
+
this.#removing.add(key);
|
|
284
|
+
try {
|
|
285
|
+
await fs.rm(file.path, REMOVAL);
|
|
286
|
+
} catch {
|
|
287
|
+
this.#removing.delete(key);
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
251
290
|
this.#held.delete(key);
|
|
252
291
|
this.#lastRead.delete(key);
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
await fs.rm(file.path, REMOVAL).catch(() => undefined);
|
|
292
|
+
this.#removing.delete(key);
|
|
293
|
+
return true;
|
|
257
294
|
}
|
|
258
295
|
|
|
259
296
|
/**
|
|
@@ -344,18 +381,30 @@ export class CompletedFiles {
|
|
|
344
381
|
async keep({ infoHash, fileIndex, length, name, open }) {
|
|
345
382
|
const key = this.#keyOf(infoHash, fileIndex);
|
|
346
383
|
const held = this.#held.get(key);
|
|
347
|
-
if (held) {
|
|
384
|
+
if (held && !this.#removing.has(key)) {
|
|
348
385
|
return held;
|
|
349
386
|
}
|
|
387
|
+
if (this.#removing.has(key)) return null;
|
|
350
388
|
if (this.#writing.has(key)) {
|
|
351
389
|
return null;
|
|
352
390
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
391
|
+
this.#writing.add(key);
|
|
392
|
+
const admitted = await this.#withAdmission(async () => {
|
|
393
|
+
if (this.#held.has(key)) return false;
|
|
394
|
+
if (this.#allowanceBytes !== null && length > this.#allowanceBytes) return false;
|
|
395
|
+
while (this.#allowanceBytes !== null &&
|
|
396
|
+
this.bytes + this.#reservedBytes() + length > this.#allowanceBytes) {
|
|
397
|
+
const victim = this.#longestUnread();
|
|
398
|
+
if (!victim) return false;
|
|
399
|
+
if (!await this.#remove(victim)) return false;
|
|
400
|
+
}
|
|
401
|
+
this.#reservations.set(key, length);
|
|
402
|
+
return true;
|
|
403
|
+
});
|
|
404
|
+
if (!admitted) {
|
|
405
|
+
await this.#withAdmission(() => { this.#writing.delete(key); });
|
|
356
406
|
return null;
|
|
357
407
|
}
|
|
358
|
-
this.#writing.add(key);
|
|
359
408
|
const directory = path.join(this.#root, String(infoHash));
|
|
360
409
|
const where = path.join(directory, String(fileIndex));
|
|
361
410
|
const partial = `${where}.partial`;
|
|
@@ -367,6 +416,9 @@ export class CompletedFiles {
|
|
|
367
416
|
let written = 0;
|
|
368
417
|
try {
|
|
369
418
|
for await (const chunk of source) {
|
|
419
|
+
if (written + chunk.length > length) {
|
|
420
|
+
throw new Error("Whole-file read exceeded its declared length");
|
|
421
|
+
}
|
|
370
422
|
await handle.write(chunk);
|
|
371
423
|
written += chunk.length;
|
|
372
424
|
}
|
|
@@ -382,14 +434,24 @@ export class CompletedFiles {
|
|
|
382
434
|
}
|
|
383
435
|
await fs.rename(partial, where);
|
|
384
436
|
const file = { path: where, length, name: String(name ?? fileIndex) };
|
|
385
|
-
this.#
|
|
437
|
+
await this.#withAdmission(async () => {
|
|
438
|
+
this.#reservations.delete(key);
|
|
439
|
+
this.#held.set(key, file);
|
|
440
|
+
this.#touch(key);
|
|
441
|
+
this.#writing.delete(key);
|
|
442
|
+
await this.#trimToAllowance();
|
|
443
|
+
});
|
|
386
444
|
await this.#writeManifest(infoHash);
|
|
387
|
-
return
|
|
445
|
+
return this.#held.get(key) ?? null;
|
|
388
446
|
} catch {
|
|
389
447
|
await fs.rm(partial, REMOVAL).catch(() => undefined);
|
|
390
448
|
return null;
|
|
391
449
|
} finally {
|
|
392
|
-
this.#
|
|
450
|
+
await this.#withAdmission(async () => {
|
|
451
|
+
this.#reservations.delete(key);
|
|
452
|
+
this.#writing.delete(key);
|
|
453
|
+
await this.#trimToAllowance();
|
|
454
|
+
});
|
|
393
455
|
}
|
|
394
456
|
}
|
|
395
457
|
|