📄 src/downloader/test/queue.test.ts
D-OPEN SOVEREIGN
1import { describe, it, expect, vi } from 'vitest';
2import { ConcurrentProcessor } from '../queue';
3
4const deferred = <T>() => {
5 let resolve!: (v: T) => void;
6 let reject!: (e: unknown) => void;
7 const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
8 return { promise, resolve, reject };
9};
10
11describe('ConcurrentProcessor', () => {
12 it('runs tasks immediately while under the concurrency limit', async () => {
13 const p = new ConcurrentProcessor<number>(2);
14 const started: string[] = [];
15 const a = p.add('a', async () => { started.push('a'); return 1; });
16 const b = p.add('b', async () => { started.push('b'); return 2; });
17 expect(await a).toBe(1);
18 expect(await b).toBe(2);
19 expect(started).toEqual(['a', 'b']);
20 });
21
22 it('queues tasks beyond the concurrency limit and runs them once a slot frees up', async () => {
23 const p = new ConcurrentProcessor<string>(1);
24 const first = deferred<string>();
25 const order: string[] = [];
26
27 const a = p.add('a', async () => { order.push('a-start'); const r = await first.promise; order.push('a-end'); return r; });
28 const b = p.add('b', async () => { order.push('b-start'); return 'b-result'; });
29
30 await new Promise(r => setTimeout(r, 0));
31 // 'b' must not have started yet — only 1 concurrent slot, held by 'a'.
32 expect(order).toEqual(['a-start']);
33
34 first.resolve('a-result');
35 expect(await a).toBe('a-result');
36 expect(await b).toBe('b-result');
37 expect(order).toEqual(['a-start', 'a-end', 'b-start']);
38 });
39
40 it('returns the same promise for duplicate keys instead of re-running the task', async () => {
41 const p = new ConcurrentProcessor<number>(2);
42 let calls = 0;
43 const task = async () => { calls++; return 42; };
44
45 const first = p.add('same-key', task);
46 const second = p.add('same-key', task);
47 expect(first).toBe(second);
48 expect(await first).toBe(42);
49 expect(calls).toBe(1);
50 });
51
52 it('allows the same key to run again once the prior task has settled', async () => {
53 const p = new ConcurrentProcessor<number>(2);
54 let calls = 0;
55 const task = async () => { calls++; return calls; };
56
57 expect(await p.add('k', task)).toBe(1);
58 // The waiters-map cleanup runs in a .finally() one microtask after the
59 // caller's await unblocks — flush the queue so 'k' is free again.
60 await new Promise(r => setTimeout(r, 0));
61 expect(await p.add('k', task)).toBe(2);
62 });
63
64 it('converts a synchronous throw inside a task into a rejection instead of hanging', async () => {
65 const p = new ConcurrentProcessor<void>(1);
66 await expect(p.add('boom', () => { throw new Error('sync fail'); })).rejects.toThrow('sync fail');
67 });
68
69 it('propagates an async rejection to the caller', async () => {
70 const p = new ConcurrentProcessor<void>(1);
71 await expect(p.add('boom', async () => { throw new Error('async fail'); })).rejects.toThrow('async fail');
72 });
73
74 it('frees the concurrency slot after a task rejects, so queued tasks still run', async () => {
75 const p = new ConcurrentProcessor<string>(1);
76 const failing = p.add('fail', async () => { throw new Error('nope'); });
77 const succeeding = p.add('ok', async () => 'done');
78
79 await expect(failing).rejects.toThrow('nope');
80 expect(await succeeding).toBe('done');
81 });
82
83 it('stop() rejects queued-but-not-started tasks and lets active ones settle', async () => {
84 const p = new ConcurrentProcessor<string>(1);
85 const first = deferred<string>();
86 const active = p.add('active', async () => first.promise);
87 const queued = p.add('queued', async () => 'never runs');
88
89 const stopping = p.stop();
90 await expect(queued).rejects.toThrow(/stopped before task started/);
91
92 first.resolve('active-result');
93 expect(await active).toBe('active-result');
94 await stopping;
95 });
96
97 it('start() is a no-op that does not throw', () => {
98 const p = new ConcurrentProcessor<void>(3);
99 expect(() => p.start()).not.toThrow();
100 });
101});
102