pixivflow 2.19.1 → 2.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/ExecuteSlotCommand.js +1 -1
- package/dist/commands/SchedulerCommand.js +3 -1
- package/dist/commands/SchedulerRunOnceCommand.js +1 -1
- package/dist/commands/scheduler-runtime.d.ts +31 -2
- package/dist/commands/scheduler-runtime.js +63 -9
- package/dist/delivery/DeliveryLedgerPort.d.ts +14 -0
- package/dist/delivery/DeliveryLedgerPort.js +43 -0
- package/dist/delivery/OutboxWorker.d.ts +2 -0
- package/dist/delivery/OutboxWorker.js +25 -13
- package/dist/delivery/errorClass.d.ts +1 -1
- package/dist/delivery/errorClass.js +7 -0
- package/dist/download/DownloadManager.d.ts +5 -0
- package/dist/download/DownloadManager.js +12 -2
- package/dist/download/handlers/IllustrationTargetHandler.d.ts +27 -1
- package/dist/download/handlers/IllustrationTargetHandler.js +105 -7
- package/dist/download/handlers/NovelTargetHandler.d.ts +27 -1
- package/dist/download/handlers/NovelTargetHandler.js +107 -3
- package/dist/notification/NotificationPolicy.d.ts +11 -0
- package/dist/notification/NotificationPolicy.js +22 -5
- package/dist/package.json +1 -1
- package/dist/pixiv-client/TargetSearchRunner.js +24 -8
- package/dist/scheduler/SlotCoordinator.d.ts +74 -1
- package/dist/scheduler/SlotCoordinator.js +108 -1
- package/dist/scheduler/WorkIdentity.d.ts +49 -0
- package/dist/scheduler/WorkIdentity.js +31 -0
- package/dist/storage/repositories/DeliveryRepository.d.ts +9 -0
- package/dist/storage/repositories/DeliveryRepository.js +16 -0
- package/dist/storage/repositories/OutboxRepository.d.ts +15 -0
- package/dist/storage/repositories/OutboxRepository.js +31 -0
- package/dist/storage/repositories/SlotRepository.d.ts +27 -0
- package/dist/storage/repositories/SlotRepository.js +45 -0
- package/dist/topic/TopicPipeline.js +4 -0
- package/dist/utils/errors.d.ts +9 -0
- package/dist/utils/errors.js +14 -1
- package/dist/version.js +1 -1
- package/dist/webui/package.json +1 -1
- package/node_modules/@redtidev/pixiv-client/dist/transport/transport.d.ts +9 -0
- package/node_modules/@redtidev/pixiv-client/dist/transport/transport.d.ts.map +1 -1
- package/node_modules/@redtidev/pixiv-client/dist/transport/transport.js +66 -14
- package/node_modules/@redtidev/pixiv-client/dist/transport/transport.js.map +1 -1
- package/node_modules/@redtidev/pixiv-client/src/transport/__tests__/transport-cancellation.test.ts +257 -0
- package/node_modules/@redtidev/pixiv-client/src/transport/transport.ts +81 -18
- package/package.json +1 -1
package/node_modules/@redtidev/pixiv-client/src/transport/__tests__/transport-cancellation.test.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request timeout *composition* with a caller/run AbortSignal.
|
|
3
|
+
*
|
|
4
|
+
* Contract: the signal handed to `fetch` must abort when EITHER
|
|
5
|
+
* - the caller's run signal aborts (scheduler cancellation), OR
|
|
6
|
+
* - the per-request timeout elapses,
|
|
7
|
+
* whichever fires first. A caller signal must never *disable* the timeout.
|
|
8
|
+
*
|
|
9
|
+
* `TargetSearchRunner` threads the scheduler run signal into the kit search
|
|
10
|
+
* calls on the documented assumption that "the transport already combines a
|
|
11
|
+
* signal with its per-request timeout". These tests pin that assumption down
|
|
12
|
+
* with zero network access: the fake fetch below never settles on its own.
|
|
13
|
+
*
|
|
14
|
+
* Fidelity note (Node 24 / undici, verified empirically):
|
|
15
|
+
* - `controller.abort(reason)` makes fetch reject with `reason` itself;
|
|
16
|
+
* - `controller.abort()` makes fetch reject with a DOMException AbortError.
|
|
17
|
+
* The fake fetch mirrors both shapes so the transport classifies what it
|
|
18
|
+
* actually sees in production.
|
|
19
|
+
*/
|
|
20
|
+
import { PixivClient } from '../../client';
|
|
21
|
+
import { StaticTokenProvider } from '../../auth/types';
|
|
22
|
+
import type { FetchLike } from '../../types';
|
|
23
|
+
import { PixivAbortError, PixivNetworkError, PixivTimeoutError } from '../../errors/errors';
|
|
24
|
+
|
|
25
|
+
const PER_REQUEST_TIMEOUT_MS = 80;
|
|
26
|
+
/** Generous relative to PER_REQUEST_TIMEOUT_MS, tiny relative to jest's 15s. */
|
|
27
|
+
const SETTLE_BUDGET_MS = 1_200;
|
|
28
|
+
|
|
29
|
+
type SettleOutcome = { settled: true; error?: unknown } | { settled: false };
|
|
30
|
+
|
|
31
|
+
/** Resolves as soon as `p` settles; reports `settled: false` on the deadline. */
|
|
32
|
+
function settleWithin(p: Promise<unknown>, ms: number): Promise<SettleOutcome> {
|
|
33
|
+
return new Promise<SettleOutcome>((resolve) => {
|
|
34
|
+
const timer = setTimeout(() => resolve({ settled: false }), ms);
|
|
35
|
+
p.then(
|
|
36
|
+
() => {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
resolve({ settled: true });
|
|
39
|
+
},
|
|
40
|
+
(error) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
resolve({ settled: true, error });
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A fetch that hangs until its signal aborts, then rejects exactly the way
|
|
50
|
+
* undici rejects (with the signal reason, or a DOMException named AbortError).
|
|
51
|
+
* It never applies a timeout of its own: the transport must supply one.
|
|
52
|
+
*/
|
|
53
|
+
function hangingFetch(counter: { calls: number }): FetchLike {
|
|
54
|
+
return async (_url, init) => {
|
|
55
|
+
counter.calls++;
|
|
56
|
+
const signal = init.signal ?? undefined;
|
|
57
|
+
await new Promise<never>((_resolve, reject) => {
|
|
58
|
+
const rejectLikeUndici = (): void => {
|
|
59
|
+
const reason: unknown = signal?.reason;
|
|
60
|
+
if (reason !== undefined && reason !== null) {
|
|
61
|
+
reject(reason);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const e = new Error('This operation was aborted');
|
|
65
|
+
e.name = 'AbortError';
|
|
66
|
+
reject(e);
|
|
67
|
+
};
|
|
68
|
+
if (signal?.aborted) {
|
|
69
|
+
rejectLikeUndici();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
signal?.addEventListener('abort', rejectLikeUndici, { once: true });
|
|
73
|
+
});
|
|
74
|
+
throw new Error('unreachable');
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function makeClient(fetchImpl: FetchLike, retries = 0): PixivClient {
|
|
79
|
+
return new PixivClient({
|
|
80
|
+
auth: new StaticTokenProvider('test-token'),
|
|
81
|
+
fetchImpl,
|
|
82
|
+
retries,
|
|
83
|
+
timeoutMs: PER_REQUEST_TIMEOUT_MS,
|
|
84
|
+
rateLimit: {
|
|
85
|
+
minIntervalMs: 0,
|
|
86
|
+
jitterRatio: 0,
|
|
87
|
+
initialCooldownMs: 60_000,
|
|
88
|
+
maxCooldownMs: 900_000,
|
|
89
|
+
openThreshold: 10,
|
|
90
|
+
decaySuccesses: 99,
|
|
91
|
+
random: () => 0,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function errorOf(outcome: SettleOutcome): unknown {
|
|
97
|
+
return outcome.settled ? outcome.error : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('per-request timeout survives a caller/run signal', () => {
|
|
101
|
+
it('a hung search request still times out while a run signal is attached', async () => {
|
|
102
|
+
const counter = { calls: 0 };
|
|
103
|
+
const client = makeClient(hangingFetch(counter));
|
|
104
|
+
const run = new AbortController(); // attached, NOT aborted
|
|
105
|
+
|
|
106
|
+
const call = client.illustrations.searchPage({ word: 'test', limit: 1, signal: run.signal }, null);
|
|
107
|
+
const outcome = await settleWithin(call, SETTLE_BUDGET_MS);
|
|
108
|
+
|
|
109
|
+
// Regression: with a caller signal the per-request timeout used to be
|
|
110
|
+
// skipped entirely, so this promise stayed pending until the whole
|
|
111
|
+
// schedule timed out.
|
|
112
|
+
expect(outcome.settled).toBe(true);
|
|
113
|
+
expect(errorOf(outcome)).toBeInstanceOf(PixivTimeoutError);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('a hung media fetch still times out while a run signal is attached', async () => {
|
|
117
|
+
const counter = { calls: 0 };
|
|
118
|
+
const client = makeClient(hangingFetch(counter));
|
|
119
|
+
|
|
120
|
+
const call = client.media.fetch('https://i.pximg.net/img-original/img/x.jpg', {
|
|
121
|
+
signal: new AbortController().signal,
|
|
122
|
+
});
|
|
123
|
+
const outcome = await settleWithin(call, SETTLE_BUDGET_MS);
|
|
124
|
+
|
|
125
|
+
expect(outcome.settled).toBe(true);
|
|
126
|
+
expect(errorOf(outcome)).toBeInstanceOf(PixivTimeoutError);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('aborting the run signal still cancels the request, with no timeout masking it', async () => {
|
|
130
|
+
const counter = { calls: 0 };
|
|
131
|
+
const client = makeClient(hangingFetch(counter));
|
|
132
|
+
const run = new AbortController();
|
|
133
|
+
|
|
134
|
+
const call = client.illustrations.searchPage({ word: 'test', limit: 1, signal: run.signal }, null);
|
|
135
|
+
setTimeout(() => run.abort(), 10);
|
|
136
|
+
|
|
137
|
+
const outcome = await settleWithin(call, SETTLE_BUDGET_MS);
|
|
138
|
+
expect(outcome.settled).toBe(true);
|
|
139
|
+
expect(errorOf(outcome)).toBeInstanceOf(PixivAbortError);
|
|
140
|
+
expect(counter.calls).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('cancels promptly when the run signal aborts during retry back-off', async () => {
|
|
144
|
+
let calls = 0;
|
|
145
|
+
const client = makeClient(
|
|
146
|
+
async () => {
|
|
147
|
+
calls++;
|
|
148
|
+
throw Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' });
|
|
149
|
+
},
|
|
150
|
+
3
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const run = new AbortController();
|
|
154
|
+
const call = client.illustrations.searchPage({ word: 'test', limit: 1, signal: run.signal }, null);
|
|
155
|
+
setTimeout(() => run.abort(), 30);
|
|
156
|
+
|
|
157
|
+
const outcome = await settleWithin(call, SETTLE_BUDGET_MS);
|
|
158
|
+
expect(outcome.settled).toBe(true);
|
|
159
|
+
expect(errorOf(outcome)).toBeInstanceOf(PixivAbortError);
|
|
160
|
+
// The back-off must not be waited out and the retry budget must not be burned.
|
|
161
|
+
expect(calls).toBe(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('keeps retrying transient network failures while the run signal stays active', async () => {
|
|
165
|
+
let calls = 0;
|
|
166
|
+
const client = makeClient(async () => {
|
|
167
|
+
calls++;
|
|
168
|
+
throw Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' });
|
|
169
|
+
}, 2);
|
|
170
|
+
|
|
171
|
+
const run = new AbortController();
|
|
172
|
+
const call = client.illustrations.searchPage({ word: 'test', limit: 1, signal: run.signal }, null);
|
|
173
|
+
const outcome = await settleWithin(call, 4_000);
|
|
174
|
+
|
|
175
|
+
expect(outcome.settled).toBe(true);
|
|
176
|
+
expect(errorOf(outcome)).toBeInstanceOf(PixivNetworkError);
|
|
177
|
+
expect(calls).toBe(3); // retries honoured: an attached signal is not a cancellation
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('removes its abort listener from the caller signal once the request settles', async () => {
|
|
181
|
+
const counter = { calls: 0 };
|
|
182
|
+
const client = makeClient(hangingFetch(counter));
|
|
183
|
+
const run = new AbortController();
|
|
184
|
+
const added = jest.spyOn(run.signal, 'addEventListener');
|
|
185
|
+
const removed = jest.spyOn(run.signal, 'removeEventListener');
|
|
186
|
+
|
|
187
|
+
const call = client.illustrations.searchPage({ word: 'test', limit: 1, signal: run.signal }, null);
|
|
188
|
+
const outcome = await settleWithin(call, SETTLE_BUDGET_MS);
|
|
189
|
+
expect(outcome.settled).toBe(true);
|
|
190
|
+
|
|
191
|
+
expect(added).toHaveBeenCalled();
|
|
192
|
+
// No listener leak: every abort listener added is taken off again.
|
|
193
|
+
expect(removed.mock.calls.length).toBe(added.mock.calls.length);
|
|
194
|
+
|
|
195
|
+
// Already settled: aborting afterwards must not reject anything a second time.
|
|
196
|
+
expect(() => run.abort()).not.toThrow();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('without a caller signal the per-request timeout is unchanged', async () => {
|
|
200
|
+
const counter = { calls: 0 };
|
|
201
|
+
const client = makeClient(hangingFetch(counter));
|
|
202
|
+
|
|
203
|
+
const call = client.illustrations.searchPage({ word: 'test', limit: 1 });
|
|
204
|
+
const outcome = await settleWithin(call, SETTLE_BUDGET_MS);
|
|
205
|
+
|
|
206
|
+
expect(outcome.settled).toBe(true);
|
|
207
|
+
expect(errorOf(outcome)).toBeInstanceOf(PixivTimeoutError);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('clears the per-request timer once the request settles', async () => {
|
|
211
|
+
jest.useFakeTimers();
|
|
212
|
+
try {
|
|
213
|
+
const counter = { calls: 0 };
|
|
214
|
+
const client = makeClient(hangingFetch(counter));
|
|
215
|
+
const baseline = jest.getTimerCount();
|
|
216
|
+
|
|
217
|
+
const call = client.illustrations.searchPage({ word: 'test', limit: 1 });
|
|
218
|
+
void call.catch(() => undefined); // observed below via rejects
|
|
219
|
+
expect(jest.getTimerCount()).toBeGreaterThan(baseline); // timeout armed
|
|
220
|
+
|
|
221
|
+
await jest.advanceTimersByTimeAsync(PER_REQUEST_TIMEOUT_MS);
|
|
222
|
+
|
|
223
|
+
await expect(call).rejects.toBeInstanceOf(PixivTimeoutError);
|
|
224
|
+
// No leaked timer: `cancel()` in the finally block cleared it.
|
|
225
|
+
expect(jest.getTimerCount()).toBe(baseline);
|
|
226
|
+
} finally {
|
|
227
|
+
jest.useRealTimers();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('settles once and rejects nothing else when a run signal aborts first', async () => {
|
|
232
|
+
const counter = { calls: 0 };
|
|
233
|
+
const client = makeClient(hangingFetch(counter));
|
|
234
|
+
const run = new AbortController();
|
|
235
|
+
const unhandled: unknown[] = [];
|
|
236
|
+
const onUnhandled = (reason: unknown): void => {
|
|
237
|
+
unhandled.push(reason);
|
|
238
|
+
};
|
|
239
|
+
process.on('unhandledRejection', onUnhandled);
|
|
240
|
+
try {
|
|
241
|
+
// A plain string reason, exactly as DownloadManager.cancel() passes it.
|
|
242
|
+
const call = client.illustrations.searchPage({ word: 'test', limit: 1, signal: run.signal }, null);
|
|
243
|
+
run.abort('run cancelled');
|
|
244
|
+
|
|
245
|
+
const outcome = await settleWithin(call, SETTLE_BUDGET_MS);
|
|
246
|
+
expect(outcome.settled).toBe(true);
|
|
247
|
+
expect(errorOf(outcome)).toBeInstanceOf(PixivAbortError);
|
|
248
|
+
|
|
249
|
+
// Outlive the per-request deadline: the cleared timer must not abort (or
|
|
250
|
+
// reject) anything a second time.
|
|
251
|
+
await new Promise((resolve) => setTimeout(resolve, PER_REQUEST_TIMEOUT_MS * 2));
|
|
252
|
+
expect(unhandled).toEqual([]);
|
|
253
|
+
} finally {
|
|
254
|
+
process.off('unhandledRejection', onUnhandled);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -46,6 +46,23 @@ const APP_HEADERS = {
|
|
|
46
46
|
'App-Version': '7.13.3',
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Marker reason for the per-request timeout abort. `withTimeout()` aborts its
|
|
51
|
+
* own controller with it so the retry path can tell our own timeout apart from
|
|
52
|
+
* a caller/run cancellation: the caller reason is opaque (the downloader passes
|
|
53
|
+
* a plain string) and must not be inspected.
|
|
54
|
+
*/
|
|
55
|
+
const TIMEOUT_REASON = '__pixiv_timeout__';
|
|
56
|
+
|
|
57
|
+
/** Structural AbortError test: also matches DOMException (not an Error subclass). */
|
|
58
|
+
function isAbortErrorLike(e: unknown): boolean {
|
|
59
|
+
return typeof e === 'object' && e !== null && (e as { name?: unknown }).name === 'AbortError';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isTimeoutReason(e: unknown): boolean {
|
|
63
|
+
return e instanceof Error && e.message === TIMEOUT_REASON;
|
|
64
|
+
}
|
|
65
|
+
|
|
49
66
|
|
|
50
67
|
function proxyUrl(p: ProxyOptions): string {
|
|
51
68
|
const protocol = (p.protocol ?? 'http').toLowerCase();
|
|
@@ -419,22 +436,36 @@ export class Transport {
|
|
|
419
436
|
return { action: 'throw', error: e };
|
|
420
437
|
}
|
|
421
438
|
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
// (
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
439
|
+
// Tell our own per-request timeout apart from a caller/run cancellation.
|
|
440
|
+
// The abort reason is authoritative, not the error shape:
|
|
441
|
+
// - our timeout aborts with TIMEOUT_REASON; undici rejects the fetch with
|
|
442
|
+
// that reason object directly (so it arrives as a plain Error, NOT an
|
|
443
|
+
// AbortError), while abort-wrapping runtimes expose it via `cause`;
|
|
444
|
+
// - the caller reason is opaque (a plain string from the downloader), so
|
|
445
|
+
// it must be classified from the signal state.
|
|
446
|
+
const abortCause = (e as { cause?: unknown } | null)?.cause;
|
|
447
|
+
if (isTimeoutReason(e) || isTimeoutReason(abortCause)) {
|
|
448
|
+
return this.maybeTransientRetry(
|
|
449
|
+
new PixivTimeoutError(`Request timeout after ${this.timeoutMs}ms`, { endpoint: url, cause: e, code: 'timeout' }),
|
|
450
|
+
attempt,
|
|
451
|
+
url
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (isAbortErrorLike(e) && !callerAborted) {
|
|
456
|
+
// An abort we cannot attribute to the caller can only be our own timeout
|
|
457
|
+
// (the only other abort source on this path).
|
|
458
|
+
return this.maybeTransientRetry(
|
|
459
|
+
new PixivTimeoutError(`Request timeout after ${this.timeoutMs}ms`, { endpoint: url, cause: e, code: 'timeout' }),
|
|
460
|
+
attempt,
|
|
461
|
+
url
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (callerAborted) {
|
|
466
|
+
// Run/scheduler cancellation is terminal by definition and must never be
|
|
467
|
+
// charged to the retry budget (waitRetry would otherwise sit out the
|
|
468
|
+
// back-off before the next attempt noticed the cancel).
|
|
438
469
|
return { action: 'throw', error: new PixivAbortError('Request aborted', { cause: e, endpoint: url }) };
|
|
439
470
|
}
|
|
440
471
|
|
|
@@ -467,6 +498,11 @@ export class Transport {
|
|
|
467
498
|
}
|
|
468
499
|
|
|
469
500
|
private async waitRetry(waitMs: number, signal?: AbortSignal): Promise<void> {
|
|
501
|
+
// An already-aborted signal never fires 'abort' again, so the sleeper would
|
|
502
|
+
// sit out the entire back-off before the next attempt noticed the cancel.
|
|
503
|
+
if (signal?.aborted) {
|
|
504
|
+
throw new PixivAbortError('aborted before retry back-off');
|
|
505
|
+
}
|
|
470
506
|
if (waitMs <= 0) return;
|
|
471
507
|
try {
|
|
472
508
|
await this.sleeper(waitMs, signal);
|
|
@@ -477,15 +513,42 @@ export class Transport {
|
|
|
477
513
|
|
|
478
514
|
// -- helpers ---------------------------------------------------------------
|
|
479
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Effective per-attempt signal = the caller's/run cancellation **OR** the
|
|
518
|
+
* per-request timeout, whichever fires first.
|
|
519
|
+
*
|
|
520
|
+
* Both sources must stay armed. A caller signal may not replace the timeout:
|
|
521
|
+
* that would let a single hung socket wait for the whole schedule timeout
|
|
522
|
+
* (the caller only cancels at run level). The timeout may not replace the
|
|
523
|
+
* caller signal either: a cancelled run must not keep its request alive.
|
|
524
|
+
*/
|
|
480
525
|
private withTimeout(timeoutMs: number, external?: AbortSignal): { signal: AbortSignal; cancel: () => void } {
|
|
481
|
-
if (external) return { signal: external, cancel: () => {} };
|
|
482
526
|
const controller = new AbortController();
|
|
483
|
-
const timer = setTimeout(() => controller.abort(new Error(
|
|
527
|
+
const timer = setTimeout(() => controller.abort(new Error(TIMEOUT_REASON)), timeoutMs);
|
|
484
528
|
if (typeof timer.unref === 'function') timer.unref();
|
|
529
|
+
|
|
530
|
+
if (!external) {
|
|
531
|
+
return {
|
|
532
|
+
signal: controller.signal,
|
|
533
|
+
cancel: () => {
|
|
534
|
+
clearTimeout(timer);
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Forward the caller reason verbatim so cancellation stays observable.
|
|
540
|
+
const onExternalAbort = (): void => controller.abort(external.reason);
|
|
541
|
+
if (external.aborted) {
|
|
542
|
+
onExternalAbort();
|
|
543
|
+
} else {
|
|
544
|
+
external.addEventListener('abort', onExternalAbort, { once: true });
|
|
545
|
+
}
|
|
546
|
+
|
|
485
547
|
return {
|
|
486
548
|
signal: controller.signal,
|
|
487
549
|
cancel: () => {
|
|
488
550
|
clearTimeout(timer);
|
|
551
|
+
external.removeEventListener('abort', onExternalAbort);
|
|
489
552
|
},
|
|
490
553
|
};
|
|
491
554
|
}
|
package/package.json
CHANGED