clawmatrix 0.2.8 → 0.2.11

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.
@@ -0,0 +1,671 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, writeFile, stat, mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { debug } from "./debug.ts";
5
+ import type { PeerManager } from "./peer-manager.ts";
6
+ import type { ClawMatrixConfig } from "./config.ts";
7
+ import type {
8
+ FileTransferInit,
9
+ FileTransferAck,
10
+ FileTransferChunk,
11
+ FileTransferChunkAck,
12
+ FileTransferComplete,
13
+ } from "./types.ts";
14
+
15
+ interface FileTransferConfig {
16
+ enabled: boolean;
17
+ chunkSize: number;
18
+ maxFileSize: number;
19
+ timeout: number;
20
+ allowedPaths: string[];
21
+ }
22
+
23
+ interface PendingTransfer {
24
+ sessionId: string;
25
+ direction: "push" | "pull";
26
+ filePath: string;
27
+ targetPath: string;
28
+ remoteNode: string;
29
+ resolve: (result: TransferResult) => void;
30
+ reject: (err: Error) => void;
31
+ timer: ReturnType<typeof setTimeout>;
32
+ // Push state
33
+ fileData?: Buffer;
34
+ chunkSize?: number;
35
+ totalChunks?: number;
36
+ sentChunks?: number;
37
+ // Pull state
38
+ chunks?: Map<number, Buffer>;
39
+ expectedSize?: number;
40
+ expectedChunks?: number;
41
+ expectedChecksum?: string;
42
+ receivedChunks?: number;
43
+ }
44
+
45
+ interface ReceivingTransfer {
46
+ sessionId: string;
47
+ direction: "push" | "pull";
48
+ filePath: string;
49
+ targetPath: string;
50
+ fromNode: string;
51
+ fileSize: number;
52
+ totalChunks: number;
53
+ chunkSize: number;
54
+ checksum: string;
55
+ chunks: Map<number, Buffer>;
56
+ receivedChunks: number;
57
+ timer: ReturnType<typeof setTimeout>;
58
+ /** Cached file data for pull-mode serving to avoid re-reading per chunk */
59
+ cachedData?: Buffer;
60
+ }
61
+
62
+ export interface TransferResult {
63
+ success: boolean;
64
+ bytesTransferred: number;
65
+ error?: string;
66
+ }
67
+
68
+ export class FileTransferManager {
69
+ private config: FileTransferConfig;
70
+ private nodeId: string;
71
+ private peerManager: PeerManager;
72
+ private pending = new Map<string, PendingTransfer>();
73
+ private receiving = new Map<string, ReceivingTransfer>();
74
+
75
+ constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
76
+ this.nodeId = config.nodeId;
77
+ this.peerManager = peerManager;
78
+ const ft = config.fileTransfer;
79
+ this.config = {
80
+ enabled: ft?.enabled ?? false,
81
+ chunkSize: ft?.chunkSize ?? 262_144,
82
+ maxFileSize: ft?.maxFileSize ?? 104_857_600,
83
+ timeout: ft?.timeout ?? 300_000,
84
+ allowedPaths: ft?.allowedPaths ?? [],
85
+ };
86
+ }
87
+
88
+ // ── Public API ──────────────────────────────────────────────────
89
+
90
+ async pushFile(remoteNode: string, localPath: string, remotePath: string): Promise<TransferResult> {
91
+ this.ensureEnabled();
92
+ const resolvedPath = path.resolve(localPath);
93
+ this.validatePath(resolvedPath);
94
+
95
+ const fileData = await readFile(resolvedPath);
96
+ if (fileData.length > this.config.maxFileSize) {
97
+ throw new Error(`File too large: ${fileData.length} bytes (max ${this.config.maxFileSize})`);
98
+ }
99
+
100
+ const checksum = createHash("sha256").update(fileData).digest("hex");
101
+ const totalChunks = Math.ceil(fileData.length / this.config.chunkSize) || 1;
102
+ const sessionId = crypto.randomUUID();
103
+
104
+ return new Promise<TransferResult>((resolve, reject) => {
105
+ const timer = this.createTimer(sessionId, "pending");
106
+
107
+ this.pending.set(sessionId, {
108
+ sessionId,
109
+ direction: "push",
110
+ filePath: resolvedPath,
111
+ targetPath: remotePath,
112
+ remoteNode,
113
+ resolve,
114
+ reject,
115
+ timer,
116
+ fileData,
117
+ chunkSize: this.config.chunkSize,
118
+ totalChunks,
119
+ sentChunks: 0,
120
+ });
121
+
122
+ this.peerManager.sendTo(remoteNode, {
123
+ type: "file_transfer_init",
124
+ id: sessionId,
125
+ from: this.nodeId,
126
+ to: remoteNode,
127
+ timestamp: Date.now(),
128
+ payload: {
129
+ sessionId,
130
+ direction: "push",
131
+ filePath: resolvedPath,
132
+ targetPath: remotePath,
133
+ fileSize: fileData.length,
134
+ totalChunks,
135
+ chunkSize: this.config.chunkSize,
136
+ checksum,
137
+ },
138
+ } as FileTransferInit);
139
+ });
140
+ }
141
+
142
+ async pullFile(remoteNode: string, remotePath: string, localPath: string): Promise<TransferResult> {
143
+ this.ensureEnabled();
144
+ const resolvedPath = path.resolve(localPath);
145
+ this.validatePath(resolvedPath);
146
+
147
+ const sessionId = crypto.randomUUID();
148
+
149
+ return new Promise<TransferResult>((resolve, reject) => {
150
+ const timer = this.createTimer(sessionId, "pending");
151
+
152
+ this.pending.set(sessionId, {
153
+ sessionId,
154
+ direction: "pull",
155
+ filePath: remotePath,
156
+ targetPath: resolvedPath,
157
+ remoteNode,
158
+ resolve,
159
+ reject,
160
+ timer,
161
+ chunks: new Map(),
162
+ receivedChunks: 0,
163
+ });
164
+
165
+ this.peerManager.sendTo(remoteNode, {
166
+ type: "file_transfer_init",
167
+ id: sessionId,
168
+ from: this.nodeId,
169
+ to: remoteNode,
170
+ timestamp: Date.now(),
171
+ payload: {
172
+ sessionId,
173
+ direction: "pull",
174
+ filePath: remotePath,
175
+ targetPath: resolvedPath,
176
+ fileSize: 0,
177
+ totalChunks: 0,
178
+ chunkSize: this.config.chunkSize,
179
+ checksum: "",
180
+ },
181
+ } as FileTransferInit);
182
+ });
183
+ }
184
+
185
+ // ── Frame handlers (called by cluster-service dispatch) ─────────
186
+
187
+ async handleInit(frame: FileTransferInit): Promise<void> {
188
+ const { sessionId, direction, filePath, targetPath, fileSize, totalChunks, chunkSize, checksum } = frame.payload;
189
+
190
+ // Validate enabled
191
+ if (!this.config.enabled) {
192
+ this.sendAck(frame.from, sessionId, frame.id, false, "File transfer not enabled on this node");
193
+ return;
194
+ }
195
+
196
+ if (direction === "push") {
197
+ // Remote wants to push a file to us
198
+ const resolvedTarget = path.resolve(targetPath);
199
+ if (!this.isPathAllowed(resolvedTarget)) {
200
+ this.sendAck(frame.from, sessionId, frame.id, false, "Target path not allowed");
201
+ return;
202
+ }
203
+ if (fileSize > this.config.maxFileSize) {
204
+ this.sendAck(frame.from, sessionId, frame.id, false, `File too large: ${fileSize} bytes (max ${this.config.maxFileSize})`);
205
+ return;
206
+ }
207
+
208
+ // Accept and prepare to receive chunks
209
+ const timer = this.createTimer(sessionId, "receiving");
210
+ this.receiving.set(sessionId, {
211
+ sessionId,
212
+ direction: "push",
213
+ filePath,
214
+ targetPath: resolvedTarget,
215
+ fromNode: frame.from,
216
+ fileSize,
217
+ totalChunks,
218
+ chunkSize,
219
+ checksum,
220
+ chunks: new Map(),
221
+ receivedChunks: 0,
222
+ timer,
223
+ });
224
+
225
+ this.sendAck(frame.from, sessionId, frame.id, true);
226
+ } else {
227
+ // Remote wants to pull a file from us
228
+ const resolvedSource = path.resolve(filePath);
229
+ if (!this.isPathAllowed(resolvedSource)) {
230
+ this.sendAck(frame.from, sessionId, frame.id, false, "Source path not allowed");
231
+ return;
232
+ }
233
+
234
+ let fileData: Buffer;
235
+ try {
236
+ fileData = await readFile(resolvedSource);
237
+ } catch (err) {
238
+ this.sendAck(frame.from, sessionId, frame.id, false, `Cannot read file: ${err instanceof Error ? err.message : String(err)}`);
239
+ return;
240
+ }
241
+
242
+ if (fileData.length > this.config.maxFileSize) {
243
+ this.sendAck(frame.from, sessionId, frame.id, false, `File too large: ${fileData.length} bytes`);
244
+ return;
245
+ }
246
+
247
+ const fileChecksum = createHash("sha256").update(fileData).digest("hex");
248
+ const fileTotalChunks = Math.ceil(fileData.length / this.config.chunkSize) || 1;
249
+
250
+ // Send ack with file metadata
251
+ this.peerManager.sendTo(frame.from, {
252
+ type: "file_transfer_ack",
253
+ id: frame.id,
254
+ from: this.nodeId,
255
+ to: frame.from,
256
+ timestamp: Date.now(),
257
+ payload: {
258
+ sessionId,
259
+ accepted: true,
260
+ fileSize: fileData.length,
261
+ totalChunks: fileTotalChunks,
262
+ checksum: fileChecksum,
263
+ },
264
+ } as FileTransferAck);
265
+
266
+ // Store as a "receiving" entry for tracking (we are the sender in pull mode)
267
+ const timer = this.createTimer(sessionId, "receiving");
268
+ this.receiving.set(sessionId, {
269
+ sessionId,
270
+ direction: "pull",
271
+ filePath: resolvedSource,
272
+ targetPath,
273
+ fromNode: frame.from,
274
+ fileSize: fileData.length,
275
+ totalChunks: fileTotalChunks,
276
+ chunkSize: this.config.chunkSize,
277
+ checksum: fileChecksum,
278
+ chunks: new Map(),
279
+ receivedChunks: 0,
280
+ timer,
281
+ cachedData: fileData,
282
+ });
283
+
284
+ // Start sending chunks (stop-and-wait: send first, wait for ack)
285
+ this.sendChunkFromReceiving(sessionId, 0, fileData);
286
+ }
287
+ }
288
+
289
+ handleAck(frame: FileTransferAck): void {
290
+ const { sessionId, accepted, error, fileSize, totalChunks, checksum } = frame.payload;
291
+ const transfer = this.pending.get(sessionId);
292
+ if (!transfer) return;
293
+
294
+ this.resetTimer(sessionId, "pending");
295
+
296
+ if (!accepted) {
297
+ this.completePending(sessionId, { success: false, bytesTransferred: 0, error: error || "Transfer rejected" });
298
+ return;
299
+ }
300
+
301
+ if (transfer.direction === "push") {
302
+ // Start sending chunks
303
+ this.sendNextChunk(sessionId);
304
+ } else {
305
+ // Pull mode: store expected metadata
306
+ transfer.expectedSize = fileSize;
307
+ transfer.expectedChunks = totalChunks;
308
+ transfer.expectedChecksum = checksum;
309
+ // Wait for chunks from remote
310
+ }
311
+ }
312
+
313
+ handleChunk(frame: FileTransferChunk): void {
314
+ const { sessionId, chunkIndex, data } = frame.payload;
315
+
316
+ // Check if this is a pull we initiated (we're receiving)
317
+ const pending = this.pending.get(sessionId);
318
+ if (pending && pending.direction === "pull") {
319
+ this.resetTimer(sessionId, "pending");
320
+ const buf = Buffer.from(data, "base64");
321
+ pending.chunks!.set(chunkIndex, buf);
322
+ pending.receivedChunks = (pending.receivedChunks ?? 0) + 1;
323
+
324
+ // Send chunk ack
325
+ this.peerManager.sendTo(pending.remoteNode, {
326
+ type: "file_transfer_chunk_ack",
327
+ id: frame.id,
328
+ from: this.nodeId,
329
+ to: pending.remoteNode,
330
+ timestamp: Date.now(),
331
+ payload: { sessionId, chunkIndex, success: true },
332
+ } as FileTransferChunkAck);
333
+
334
+ // Check if all chunks received
335
+ if (pending.receivedChunks === pending.expectedChunks) {
336
+ this.finalizePull(sessionId).catch((err) => {
337
+ this.completePending(sessionId, {
338
+ success: false,
339
+ bytesTransferred: 0,
340
+ error: err instanceof Error ? err.message : String(err),
341
+ });
342
+ });
343
+ }
344
+ return;
345
+ }
346
+
347
+ // Otherwise, this is a push from remote (we're receiving)
348
+ const receiving = this.receiving.get(sessionId);
349
+ if (!receiving || receiving.direction !== "push") return;
350
+
351
+ this.resetTimer(sessionId, "receiving");
352
+ const buf = Buffer.from(data, "base64");
353
+ receiving.chunks.set(chunkIndex, buf);
354
+ receiving.receivedChunks++;
355
+
356
+ // Send chunk ack
357
+ this.peerManager.sendTo(receiving.fromNode, {
358
+ type: "file_transfer_chunk_ack",
359
+ id: frame.id,
360
+ from: this.nodeId,
361
+ to: receiving.fromNode,
362
+ timestamp: Date.now(),
363
+ payload: { sessionId, chunkIndex, success: true },
364
+ } as FileTransferChunkAck);
365
+
366
+ // Check if all chunks received
367
+ if (receiving.receivedChunks === receiving.totalChunks) {
368
+ this.finalizePushReceive(sessionId).catch((err) => {
369
+ debug("file-transfer", `finalizePushReceive error: ${err}`);
370
+ });
371
+ }
372
+ }
373
+
374
+ handleChunkAck(frame: FileTransferChunkAck): void {
375
+ const { sessionId, chunkIndex, success, error } = frame.payload;
376
+
377
+ // Push mode: we sent a chunk, got ack, send next
378
+ const pending = this.pending.get(sessionId);
379
+ if (pending && pending.direction === "push") {
380
+ this.resetTimer(sessionId, "pending");
381
+ if (!success) {
382
+ this.completePending(sessionId, {
383
+ success: false,
384
+ bytesTransferred: chunkIndex * (pending.chunkSize ?? this.config.chunkSize),
385
+ error: error || `Chunk ${chunkIndex} rejected`,
386
+ });
387
+ return;
388
+ }
389
+ pending.sentChunks = chunkIndex + 1;
390
+ if (pending.sentChunks! < pending.totalChunks!) {
391
+ this.sendNextChunk(sessionId);
392
+ }
393
+ // If all sent, wait for file_transfer_complete from receiver
394
+ return;
395
+ }
396
+
397
+ // Pull mode (we are serving): got chunk ack, send next
398
+ const receiving = this.receiving.get(sessionId);
399
+ if (receiving && receiving.direction === "pull") {
400
+ this.resetTimer(sessionId, "receiving");
401
+ if (!success) {
402
+ this.cleanupReceiving(sessionId);
403
+ return;
404
+ }
405
+ const nextIndex = chunkIndex + 1;
406
+ if (nextIndex < receiving.totalChunks) {
407
+ if (receiving.cachedData) {
408
+ this.sendChunkFromReceiving(sessionId, nextIndex, receiving.cachedData);
409
+ } else {
410
+ // Fallback: read file if cached data is missing
411
+ readFile(receiving.filePath).then((data) => {
412
+ receiving.cachedData = data;
413
+ this.sendChunkFromReceiving(sessionId, nextIndex, data);
414
+ }).catch(() => {
415
+ this.cleanupReceiving(sessionId);
416
+ });
417
+ }
418
+ }
419
+ // If all chunks sent, wait for file_transfer_complete from puller
420
+ }
421
+ }
422
+
423
+ handleComplete(frame: FileTransferComplete): void {
424
+ const { sessionId, success, error, bytesTransferred } = frame.payload;
425
+
426
+ // Pending transfer completed
427
+ const pending = this.pending.get(sessionId);
428
+ if (pending) {
429
+ this.completePending(sessionId, {
430
+ success,
431
+ bytesTransferred: bytesTransferred ?? 0,
432
+ error: error || undefined,
433
+ });
434
+ return;
435
+ }
436
+
437
+ // Receiving transfer completed (pull mode serving — remote confirms)
438
+ const receiving = this.receiving.get(sessionId);
439
+ if (receiving) {
440
+ this.cleanupReceiving(sessionId);
441
+ }
442
+ }
443
+
444
+ destroy(): void {
445
+ for (const [id, transfer] of this.pending) {
446
+ clearTimeout(transfer.timer);
447
+ transfer.reject(new Error("FileTransferManager destroyed"));
448
+ }
449
+ this.pending.clear();
450
+
451
+ for (const [id, transfer] of this.receiving) {
452
+ clearTimeout(transfer.timer);
453
+ }
454
+ this.receiving.clear();
455
+ }
456
+
457
+ // ── Internal helpers ────────────────────────────────────────────
458
+
459
+ private ensureEnabled(): void {
460
+ if (!this.config.enabled) {
461
+ throw new Error("File transfer is not enabled");
462
+ }
463
+ }
464
+
465
+ private validatePath(resolvedPath: string): void {
466
+ if (!this.isPathAllowed(resolvedPath)) {
467
+ throw new Error(`Path not allowed: ${resolvedPath}`);
468
+ }
469
+ }
470
+
471
+ private isPathAllowed(resolvedPath: string): boolean {
472
+ // TODO(security): allowedPaths 为空时默认允许所有路径,当前仅用于受信任网络。
473
+ // 开放到非受信环境前需改为默认拒绝,或要求显式配置 allowedPaths。
474
+ if (this.config.allowedPaths.length === 0) return true;
475
+ const normalized = path.resolve(resolvedPath);
476
+ return this.config.allowedPaths.some((allowed) => {
477
+ const resolvedAllowed = path.resolve(allowed);
478
+ return normalized === resolvedAllowed || normalized.startsWith(resolvedAllowed + path.sep);
479
+ });
480
+ }
481
+
482
+ private sendAck(to: string, sessionId: string, frameId: string, accepted: boolean, error?: string): void {
483
+ this.peerManager.sendTo(to, {
484
+ type: "file_transfer_ack",
485
+ id: frameId,
486
+ from: this.nodeId,
487
+ to,
488
+ timestamp: Date.now(),
489
+ payload: { sessionId, accepted, error },
490
+ } as FileTransferAck);
491
+ }
492
+
493
+ private sendNextChunk(sessionId: string): void {
494
+ const transfer = this.pending.get(sessionId);
495
+ if (!transfer || !transfer.fileData) return;
496
+
497
+ const chunkIndex = transfer.sentChunks!;
498
+ const start = chunkIndex * transfer.chunkSize!;
499
+ const end = Math.min(start + transfer.chunkSize!, transfer.fileData.length);
500
+ const chunk = transfer.fileData.subarray(start, end);
501
+
502
+ this.peerManager.sendTo(transfer.remoteNode, {
503
+ type: "file_transfer_chunk",
504
+ id: transfer.sessionId,
505
+ from: this.nodeId,
506
+ to: transfer.remoteNode,
507
+ timestamp: Date.now(),
508
+ payload: {
509
+ sessionId,
510
+ chunkIndex,
511
+ data: chunk.toString("base64"),
512
+ },
513
+ } as FileTransferChunk);
514
+ }
515
+
516
+ private sendChunkFromReceiving(sessionId: string, chunkIndex: number, fileData: Buffer): void {
517
+ const receiving = this.receiving.get(sessionId);
518
+ if (!receiving) return;
519
+
520
+ const start = chunkIndex * receiving.chunkSize;
521
+ const end = Math.min(start + receiving.chunkSize, fileData.length);
522
+ const chunk = fileData.subarray(start, end);
523
+
524
+ this.peerManager.sendTo(receiving.fromNode, {
525
+ type: "file_transfer_chunk",
526
+ id: sessionId,
527
+ from: this.nodeId,
528
+ to: receiving.fromNode,
529
+ timestamp: Date.now(),
530
+ payload: {
531
+ sessionId,
532
+ chunkIndex,
533
+ data: chunk.toString("base64"),
534
+ },
535
+ } as FileTransferChunk);
536
+ }
537
+
538
+ private async finalizePushReceive(sessionId: string): Promise<void> {
539
+ const receiving = this.receiving.get(sessionId);
540
+ if (!receiving) return;
541
+
542
+ // Reassemble file
543
+ const buffers: Buffer[] = [];
544
+ for (let i = 0; i < receiving.totalChunks; i++) {
545
+ const chunk = receiving.chunks.get(i);
546
+ if (!chunk) {
547
+ this.sendComplete(receiving.fromNode, sessionId, false, "Missing chunk " + i);
548
+ this.cleanupReceiving(sessionId);
549
+ return;
550
+ }
551
+ buffers.push(chunk);
552
+ }
553
+
554
+ const assembled = Buffer.concat(buffers);
555
+
556
+ // Verify checksum
557
+ const actualChecksum = createHash("sha256").update(assembled).digest("hex");
558
+ if (actualChecksum !== receiving.checksum) {
559
+ this.sendComplete(receiving.fromNode, sessionId, false, `Checksum mismatch: expected ${receiving.checksum}, got ${actualChecksum}`);
560
+ this.cleanupReceiving(sessionId);
561
+ return;
562
+ }
563
+
564
+ // Write file
565
+ try {
566
+ await mkdir(path.dirname(receiving.targetPath), { recursive: true });
567
+ await writeFile(receiving.targetPath, assembled);
568
+ } catch (err) {
569
+ this.sendComplete(receiving.fromNode, sessionId, false, `Write error: ${err instanceof Error ? err.message : String(err)}`);
570
+ this.cleanupReceiving(sessionId);
571
+ return;
572
+ }
573
+
574
+ this.sendComplete(receiving.fromNode, sessionId, true, undefined, assembled.length);
575
+ this.cleanupReceiving(sessionId);
576
+ }
577
+
578
+ private async finalizePull(sessionId: string): Promise<void> {
579
+ const transfer = this.pending.get(sessionId);
580
+ if (!transfer) return;
581
+
582
+ // Reassemble file
583
+ const buffers: Buffer[] = [];
584
+ for (let i = 0; i < transfer.expectedChunks!; i++) {
585
+ const chunk = transfer.chunks!.get(i);
586
+ if (!chunk) {
587
+ throw new Error(`Missing chunk ${i}`);
588
+ }
589
+ buffers.push(chunk);
590
+ }
591
+
592
+ const assembled = Buffer.concat(buffers);
593
+
594
+ // Verify checksum
595
+ const actualChecksum = createHash("sha256").update(assembled).digest("hex");
596
+ if (actualChecksum !== transfer.expectedChecksum) {
597
+ // Notify remote of failure
598
+ this.sendComplete(transfer.remoteNode, sessionId, false, `Checksum mismatch: expected ${transfer.expectedChecksum}, got ${actualChecksum}`);
599
+ this.completePending(sessionId, {
600
+ success: false,
601
+ bytesTransferred: 0,
602
+ error: `Checksum mismatch`,
603
+ });
604
+ return;
605
+ }
606
+
607
+ // Write file
608
+ await mkdir(path.dirname(transfer.targetPath), { recursive: true });
609
+ await writeFile(transfer.targetPath, assembled);
610
+
611
+ // Notify remote of success
612
+ this.sendComplete(transfer.remoteNode, sessionId, true, undefined, assembled.length);
613
+ this.completePending(sessionId, { success: true, bytesTransferred: assembled.length });
614
+ }
615
+
616
+ private sendComplete(to: string, sessionId: string, success: boolean, error?: string, bytesTransferred?: number): void {
617
+ this.peerManager.sendTo(to, {
618
+ type: "file_transfer_complete",
619
+ id: sessionId,
620
+ from: this.nodeId,
621
+ to,
622
+ timestamp: Date.now(),
623
+ payload: { sessionId, success, error, bytesTransferred },
624
+ } as FileTransferComplete);
625
+ }
626
+
627
+ private completePending(sessionId: string, result: TransferResult): void {
628
+ const transfer = this.pending.get(sessionId);
629
+ if (!transfer) return;
630
+ clearTimeout(transfer.timer);
631
+ this.pending.delete(sessionId);
632
+ transfer.resolve(result);
633
+ }
634
+
635
+ private cleanupReceiving(sessionId: string): void {
636
+ const receiving = this.receiving.get(sessionId);
637
+ if (!receiving) return;
638
+ clearTimeout(receiving.timer);
639
+ this.receiving.delete(sessionId);
640
+ }
641
+
642
+ private createTimer(sessionId: string, mapType: "pending" | "receiving"): ReturnType<typeof setTimeout> {
643
+ return setTimeout(() => {
644
+ if (mapType === "pending") {
645
+ const transfer = this.pending.get(sessionId);
646
+ if (transfer) {
647
+ this.pending.delete(sessionId);
648
+ transfer.reject(new Error("File transfer timeout"));
649
+ }
650
+ } else {
651
+ this.receiving.delete(sessionId);
652
+ }
653
+ }, this.config.timeout);
654
+ }
655
+
656
+ private resetTimer(sessionId: string, mapType: "pending" | "receiving"): void {
657
+ if (mapType === "pending") {
658
+ const transfer = this.pending.get(sessionId);
659
+ if (transfer) {
660
+ clearTimeout(transfer.timer);
661
+ transfer.timer = this.createTimer(sessionId, "pending");
662
+ }
663
+ } else {
664
+ const receiving = this.receiving.get(sessionId);
665
+ if (receiving) {
666
+ clearTimeout(receiving.timer);
667
+ receiving.timer = this.createTimer(sessionId, "receiving");
668
+ }
669
+ }
670
+ }
671
+ }