@mrjacket/ahko 0.3.0 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-09-22 — Timeout & Robust Cancellation
9
+
10
+ ### Added
11
+ - Execution timeout control via `timeoutMs` in `IScheduleOptions`.
12
+ - Rejection with `AhkoTimeoutError` containing exceeded `timeoutMs` threshold and descriptive message.
13
+ - Immediate rejection and slot recovery for hanging, uncooperative tasks via `Promise.race`.
14
+ - Active execution timeout isolation (queued waiting time and backoff delays do not consume execution timeout).
15
+ - Fresh `timeoutMs` window allocation on retry attempts.
16
+ - Unified signal coordination via `combineSignals` utility with deterministic listener detachment.
17
+ - Strict configuration validation for `timeoutMs` (positive finite numbers).
18
+ - Comprehensive unit test suite covering execution timeouts, cancellation precedence, uncooperative tasks, retries, and signal combination.
19
+
20
+ ---
21
+
8
22
  ## [0.3.0] - 2026-09-22 — Retry & Backoff
9
23
 
10
24
  ### Added
package/README.md CHANGED
@@ -160,7 +160,26 @@ const taskPromise = ahko.schedule(
160
160
  controller.abort();
161
161
  ```
162
162
 
163
- ### 6. Telemetry (`stats`)
163
+ ### 6. Execution Deadlines & Timeouts (`timeoutMs`)
164
+
165
+ Enforce deadlines per attempt. If a task exceeds `timeoutMs`, its context signal is aborted and the task rejects with `AhkoTimeoutError`:
166
+
167
+ ```typescript
168
+ try {
169
+ await ahko.schedule(
170
+ async ({ signal }) => {
171
+ return callLongRunningApi({ signal });
172
+ },
173
+ { timeoutMs: 3000 } // aborts and rejects if running longer than 3 seconds
174
+ );
175
+ } catch (error) {
176
+ if (error instanceof AhkoTimeoutError) {
177
+ console.error(`Timed out after ${error.timeoutMs}ms`);
178
+ }
179
+ }
180
+ ```
181
+
182
+ ### 7. Telemetry (`stats`)
164
183
 
165
184
  Inspect real-time scheduler state without synthetic metrics:
166
185
 
@@ -174,7 +193,7 @@ console.log(stats);
174
193
  // completedTasks: 42,
175
194
  // failedTasks: 1,
176
195
  // cancelledTasks: 2,
177
- // timedOutTasks: 0,
196
+ // timedOutTasks: 1,
178
197
  // capacity: 3
179
198
  // }
180
199
  ```
@@ -214,6 +233,7 @@ Schedules an asynchronous task with full return type inference.
214
233
  | `delay` | `number` | `0` | Delay in milliseconds when strategy is `"delay"`. |
215
234
  | `idleTimeout` | `number` | `undefined` | Maximum time to wait for idle window before forcing queue entry. |
216
235
  | `retry` | `IRetryOptions` | `undefined` | Automatic retry policy (attempts, backoff, jitter, predicate). |
236
+ | `timeoutMs` | `number` | `undefined` | Maximum execution duration in milliseconds per attempt before aborting with `AhkoTimeoutError`. |
217
237
  | `signal` | `AbortSignal` | `undefined` | Optional external `AbortSignal` for cooperative cancellation. |
218
238
 
219
239
  ### `ahko.idle<T>(task: ITask<T>, options?: Omit<IScheduleOptions, "strategy">): Promise<T>`
@@ -1,13 +1,26 @@
1
1
  import { AhkoError } from "./ahko.error.js";
2
+ /**
3
+ * Options for constructing an AhkoTimeoutError.
4
+ */
5
+ export interface IAhkoTimeoutErrorOptions extends ErrorOptions {
6
+ /**
7
+ * The timeout threshold in milliseconds that was exceeded.
8
+ */
9
+ timeoutMs?: number;
10
+ }
2
11
  /**
3
12
  * Thrown when a task exceeds its allotted timeout duration.
4
13
  */
5
14
  export declare class AhkoTimeoutError extends AhkoError {
15
+ /**
16
+ * The timeout threshold in milliseconds that was exceeded, if configured.
17
+ */
18
+ readonly timeoutMs?: number;
6
19
  /**
7
20
  * Creates a new AhkoTimeoutError.
8
21
  *
9
22
  * @param message - Explanation of timeout expiry.
10
- * @param options - Standard Error options including cause.
23
+ * @param options - Standard Error options including optional timeoutMs and cause.
11
24
  */
12
- constructor(message?: string, options?: ErrorOptions);
25
+ constructor(message?: string, options?: IAhkoTimeoutErrorOptions);
13
26
  }
package/dist/index.cjs CHANGED
@@ -31,7 +31,8 @@ __export(src_exports, {
31
31
  EScheduleStrategy: () => EScheduleStrategy,
32
32
  ETaskState: () => ETaskState,
33
33
  VERSION: () => VERSION,
34
- calculateBackoff: () => calculateBackoff
34
+ calculateBackoff: () => calculateBackoff,
35
+ combineSignals: () => combineSignals
35
36
  });
36
37
  module.exports = __toCommonJS(src_exports);
37
38
 
@@ -73,6 +74,26 @@ var EScheduleStrategy = /* @__PURE__ */ ((EScheduleStrategy2) => {
73
74
  return EScheduleStrategy2;
74
75
  })(EScheduleStrategy || {});
75
76
 
77
+ // src/errors/timeout.error.ts
78
+ var AhkoTimeoutError = class extends AhkoError {
79
+ /**
80
+ * The timeout threshold in milliseconds that was exceeded, if configured.
81
+ */
82
+ timeoutMs;
83
+ /**
84
+ * Creates a new AhkoTimeoutError.
85
+ *
86
+ * @param message - Explanation of timeout expiry.
87
+ * @param options - Standard Error options including optional timeoutMs and cause.
88
+ */
89
+ constructor(message = "Task execution timed out", options) {
90
+ super(message, options);
91
+ this.name = "AhkoTimeoutError";
92
+ this.timeoutMs = options?.timeoutMs;
93
+ Object.setPrototypeOf(this, new.target.prototype);
94
+ }
95
+ };
96
+
76
97
  // src/models/state.model.ts
77
98
  var ETaskState = /* @__PURE__ */ ((ETaskState2) => {
78
99
  ETaskState2["PENDING"] = "pending";
@@ -219,6 +240,13 @@ var TaskQueue = class {
219
240
  );
220
241
  }
221
242
  }
243
+ if (options?.timeoutMs !== void 0) {
244
+ if (typeof options.timeoutMs !== "number" || Number.isNaN(options.timeoutMs) || !Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
245
+ throw new AhkoConfigurationError(
246
+ `Invalid timeoutMs "${options.timeoutMs}". timeoutMs must be a positive finite number greater than 0.`
247
+ );
248
+ }
249
+ }
222
250
  if (options) {
223
251
  this.runnerOptions.set(runner, options);
224
252
  }
@@ -361,7 +389,7 @@ var TaskQueue = class {
361
389
  this.scheduleRetry(runner, options);
362
390
  return;
363
391
  }
364
- if (runner.state === "timed_out" /* TIMED_OUT */) {
392
+ if (runner.state === "timed_out" /* TIMED_OUT */ || error instanceof AhkoTimeoutError) {
365
393
  this.timedOutTasks++;
366
394
  } else {
367
395
  this.failedTasks++;
@@ -464,6 +492,10 @@ var TaskRunner = class {
464
492
  task;
465
493
  /** User-supplied AbortSignal for external cancellation */
466
494
  externalSignal;
495
+ /** Maximum execution duration allowed in milliseconds */
496
+ timeoutMs;
497
+ /** Active timeout timer identifier */
498
+ timeoutTimerId;
467
499
  /** Abort event listener reference for clean detachment */
468
500
  abortListener;
469
501
  /** Promise resolve handler */
@@ -474,16 +506,20 @@ var TaskRunner = class {
474
506
  promise;
475
507
  /** Callback invoked when runner is cancelled while pending */
476
508
  onCancel;
509
+ /** Current execution attempt count (1-indexed) */
510
+ attempt = 1;
477
511
  /**
478
512
  * Creates a new TaskRunner instance.
479
513
  *
480
514
  * @param task - The asynchronous work unit to run.
481
515
  * @param externalSignal - Optional external AbortSignal to propagate.
516
+ * @param timeoutMs - Optional maximum execution time in milliseconds.
482
517
  */
483
- constructor(task, externalSignal) {
518
+ constructor(task, externalSignal, timeoutMs) {
484
519
  this.taskId = `task_${Date.now().toString(36)}_${(++taskIdCounter).toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
485
520
  this.task = task;
486
521
  this.externalSignal = externalSignal;
522
+ this.timeoutMs = timeoutMs;
487
523
  this.abortController = new AbortController();
488
524
  this.promise = new Promise((resolve, reject) => {
489
525
  this.resolvePromise = resolve;
@@ -497,6 +533,7 @@ var TaskRunner = class {
497
533
  typeof reason === "string" ? reason : "Task was cancelled prior to execution",
498
534
  { cause: reason instanceof Error ? reason : void 0 }
499
535
  );
536
+ this.abortController.abort(cancelError);
500
537
  this.rejectPromise(cancelError);
501
538
  } else {
502
539
  this.abortListener = () => {
@@ -512,8 +549,6 @@ var TaskRunner = class {
512
549
  get state() {
513
550
  return this._state;
514
551
  }
515
- /** Current execution attempt count (1-indexed) */
516
- attempt = 1;
517
552
  /**
518
553
  * Resolves the deferred promise.
519
554
  *
@@ -533,14 +568,14 @@ var TaskRunner = class {
533
568
  this.rejectPromise(reason);
534
569
  }
535
570
  /**
536
- * Evaluates if the task should be retried following an execution failure.
571
+ * Evaluates if the task should be retried following an execution failure or timeout.
537
572
  *
538
573
  * @param error - The error encountered during the attempt.
539
574
  * @param retryOptions - Configured retry policy.
540
575
  * @returns A promise resolving to true if retry should proceed, false otherwise.
541
576
  */
542
577
  async canRetry(error, retryOptions) {
543
- if (this._state === "cancelled" /* CANCELLED */ || this.abortController.signal.aborted) {
578
+ if (this._state === "cancelled" /* CANCELLED */ || (this.externalSignal?.aborted ?? false)) {
544
579
  return false;
545
580
  }
546
581
  if (!retryOptions || typeof retryOptions.attempts !== "number") {
@@ -561,12 +596,13 @@ var TaskRunner = class {
561
596
  }
562
597
  this.attempt++;
563
598
  this._state = "pending" /* PENDING */;
599
+ this.abortController = new AbortController();
564
600
  return true;
565
601
  }
566
602
  /**
567
603
  * Executes the task within an allocated concurrency slot.
568
604
  *
569
- * @returns A promise resolving to the task result or rejecting on failure/cancellation.
605
+ * @returns A promise resolving to the task result or rejecting on failure/cancellation/timeout.
570
606
  */
571
607
  async run() {
572
608
  if (this._state === "cancelled" /* CANCELLED */) {
@@ -577,14 +613,100 @@ var TaskRunner = class {
577
613
  signal: this.abortController.signal,
578
614
  taskId: this.taskId
579
615
  };
616
+ let abortListener;
617
+ const abortPromise = new Promise((_, reject) => {
618
+ abortListener = () => {
619
+ if (this._state === "timed_out" /* TIMED_OUT */) {
620
+ reject(
621
+ new AhkoTimeoutError(
622
+ `Task execution timed out after ${this.timeoutMs}ms`,
623
+ { timeoutMs: this.timeoutMs }
624
+ )
625
+ );
626
+ } else {
627
+ const reason = this.abortController.signal.reason;
628
+ reject(
629
+ new AhkoCancellationError("Task was cancelled during execution", {
630
+ cause: reason instanceof Error ? reason : void 0
631
+ })
632
+ );
633
+ }
634
+ };
635
+ this.abortController.signal.addEventListener("abort", abortListener, { once: true });
636
+ });
637
+ let timeoutPromise;
638
+ if (this.timeoutMs !== void 0) {
639
+ timeoutPromise = new Promise((_, reject) => {
640
+ this.timeoutTimerId = setTimeout(() => {
641
+ if (this._state !== "running" /* RUNNING */) {
642
+ return;
643
+ }
644
+ this._state = "timed_out" /* TIMED_OUT */;
645
+ const timeoutError = new AhkoTimeoutError(
646
+ `Task execution timed out after ${this.timeoutMs}ms`,
647
+ { timeoutMs: this.timeoutMs }
648
+ );
649
+ this.abortController.abort(timeoutError);
650
+ reject(timeoutError);
651
+ }, this.timeoutMs);
652
+ });
653
+ }
654
+ let taskExecutionPromise;
655
+ try {
656
+ taskExecutionPromise = Promise.resolve(this.task(context));
657
+ } catch (syncError) {
658
+ taskExecutionPromise = Promise.reject(syncError);
659
+ }
660
+ taskExecutionPromise.catch(() => {
661
+ });
662
+ const racePromises = [
663
+ taskExecutionPromise,
664
+ abortPromise
665
+ ];
666
+ if (timeoutPromise) {
667
+ racePromises.push(timeoutPromise);
668
+ }
580
669
  try {
581
- const result = await this.task(context);
670
+ const result = await Promise.race(racePromises);
671
+ this.clearTimeoutTimer();
672
+ if (abortListener) {
673
+ this.abortController.signal.removeEventListener("abort", abortListener);
674
+ }
675
+ if (this._state === "timed_out" /* TIMED_OUT */) {
676
+ throw new AhkoTimeoutError(
677
+ `Task execution timed out after ${this.timeoutMs}ms`,
678
+ { timeoutMs: this.timeoutMs }
679
+ );
680
+ }
681
+ if (this._state === "cancelled" /* CANCELLED */) {
682
+ throw new AhkoCancellationError("Task was cancelled during execution");
683
+ }
582
684
  this._state = "completed" /* COMPLETED */;
583
685
  return result;
584
686
  } catch (error) {
585
- const isCancelled = this._state === "cancelled" /* CANCELLED */ || this.abortController.signal.aborted;
687
+ this.clearTimeoutTimer();
688
+ if (abortListener) {
689
+ this.abortController.signal.removeEventListener("abort", abortListener);
690
+ }
691
+ if (this._state === "timed_out" /* TIMED_OUT */ || error instanceof AhkoTimeoutError) {
692
+ this._state = "timed_out" /* TIMED_OUT */;
693
+ if (error instanceof AhkoTimeoutError) {
694
+ throw error;
695
+ }
696
+ throw new AhkoTimeoutError(
697
+ `Task execution timed out after ${this.timeoutMs}ms`,
698
+ {
699
+ timeoutMs: this.timeoutMs,
700
+ cause: error instanceof Error ? error : void 0
701
+ }
702
+ );
703
+ }
704
+ const isCancelled = this._state === "cancelled" /* CANCELLED */ || this.abortController.signal.aborted || (this.externalSignal?.aborted ?? false);
586
705
  if (isCancelled) {
587
706
  this._state = "cancelled" /* CANCELLED */;
707
+ if (error instanceof AhkoCancellationError) {
708
+ throw error;
709
+ }
588
710
  throw new AhkoCancellationError("Task was cancelled during execution", {
589
711
  cause: error instanceof Error ? error : void 0
590
712
  });
@@ -593,6 +715,15 @@ var TaskRunner = class {
593
715
  throw error;
594
716
  }
595
717
  }
718
+ /**
719
+ * Clears the active timeout timer.
720
+ */
721
+ clearTimeoutTimer() {
722
+ if (this.timeoutTimerId !== void 0) {
723
+ clearTimeout(this.timeoutTimerId);
724
+ this.timeoutTimerId = void 0;
725
+ }
726
+ }
596
727
  /**
597
728
  * Cancels the task, aborting pending or running execution.
598
729
  *
@@ -604,6 +735,7 @@ var TaskRunner = class {
604
735
  }
605
736
  const wasPending = this._state === "pending" /* PENDING */;
606
737
  this._state = "cancelled" /* CANCELLED */;
738
+ this.clearTimeoutTimer();
607
739
  this.abortController.abort(reason);
608
740
  this.cleanup();
609
741
  if (wasPending) {
@@ -625,6 +757,7 @@ var TaskRunner = class {
625
757
  * Detaches event listeners from external signal to guarantee memory safety.
626
758
  */
627
759
  cleanup() {
760
+ this.clearTimeoutTimer();
628
761
  if (this.externalSignal && this.abortListener) {
629
762
  this.externalSignal.removeEventListener("abort", this.abortListener);
630
763
  }
@@ -676,7 +809,7 @@ var Ahko = class {
676
809
  if (typeof task !== "function") {
677
810
  throw new AhkoConfigurationError("Task must be a valid function.");
678
811
  }
679
- const runner = new TaskRunner(task, options?.signal);
812
+ const runner = new TaskRunner(task, options?.signal, options?.timeoutMs);
680
813
  return this.queue.enqueue(runner, options);
681
814
  }
682
815
  /**
@@ -725,7 +858,7 @@ var Ahko = class {
725
858
  };
726
859
 
727
860
  // src/version.ts
728
- var VERSION = "0.3.0";
861
+ var VERSION = "0.4.0";
729
862
 
730
863
  // src/errors/queue.error.ts
731
864
  var AhkoQueueError = class extends AhkoError {
@@ -742,20 +875,60 @@ var AhkoQueueError = class extends AhkoError {
742
875
  }
743
876
  };
744
877
 
745
- // src/errors/timeout.error.ts
746
- var AhkoTimeoutError = class extends AhkoError {
747
- /**
748
- * Creates a new AhkoTimeoutError.
749
- *
750
- * @param message - Explanation of timeout expiry.
751
- * @param options - Standard Error options including cause.
752
- */
753
- constructor(message = "Task execution timed out", options) {
754
- super(message, options);
755
- this.name = "AhkoTimeoutError";
756
- Object.setPrototypeOf(this, new.target.prototype);
878
+ // src/scheduler/signal.ts
879
+ function combineSignals(signals) {
880
+ const activeSignals = signals.filter(
881
+ (signal) => signal !== void 0
882
+ );
883
+ if (activeSignals.length === 0) {
884
+ const controller2 = new AbortController();
885
+ return {
886
+ signal: controller2.signal,
887
+ cleanup: () => {
888
+ }
889
+ };
757
890
  }
758
- };
891
+ const alreadyAborted = activeSignals.find((s) => s.aborted);
892
+ if (alreadyAborted) {
893
+ const controller2 = new AbortController();
894
+ controller2.abort(alreadyAborted.reason);
895
+ return {
896
+ signal: controller2.signal,
897
+ cleanup: () => {
898
+ }
899
+ };
900
+ }
901
+ if (activeSignals.length === 1) {
902
+ return {
903
+ signal: activeSignals[0],
904
+ cleanup: () => {
905
+ }
906
+ };
907
+ }
908
+ const controller = new AbortController();
909
+ const cleanupFns = [];
910
+ const onAbort = (event) => {
911
+ const target = event.target;
912
+ cleanup();
913
+ controller.abort(target.reason);
914
+ };
915
+ for (const sig of activeSignals) {
916
+ sig.addEventListener("abort", onAbort, { once: true });
917
+ cleanupFns.push(() => {
918
+ sig.removeEventListener("abort", onAbort);
919
+ });
920
+ }
921
+ const cleanup = () => {
922
+ for (const fn of cleanupFns) {
923
+ fn();
924
+ }
925
+ cleanupFns.length = 0;
926
+ };
927
+ return {
928
+ signal: controller.signal,
929
+ cleanup
930
+ };
931
+ }
759
932
  // Annotate the CommonJS export names for ESM import in node:
760
933
  0 && (module.exports = {
761
934
  Ahko,
@@ -769,6 +942,7 @@ var AhkoTimeoutError = class extends AhkoError {
769
942
  EScheduleStrategy,
770
943
  ETaskState,
771
944
  VERSION,
772
- calculateBackoff
945
+ calculateBackoff,
946
+ combineSignals
773
947
  });
774
948
  //# sourceMappingURL=index.cjs.map