@dxos/async 0.8.4-main.fffef41 → 0.8.4-staging.60fe92afc8

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.
Files changed (42) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/index.mjs +111 -88
  3. package/dist/lib/browser/index.mjs.map +3 -3
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/node-esm/index.mjs +111 -88
  6. package/dist/lib/node-esm/index.mjs.map +3 -3
  7. package/dist/lib/node-esm/meta.json +1 -1
  8. package/dist/types/src/callback.d.ts.map +1 -1
  9. package/dist/types/src/chain.d.ts.map +1 -1
  10. package/dist/types/src/cleanup.d.ts +2 -2
  11. package/dist/types/src/cleanup.d.ts.map +1 -1
  12. package/dist/types/src/debounce.d.ts +15 -10
  13. package/dist/types/src/debounce.d.ts.map +1 -1
  14. package/dist/types/src/errors.d.ts.map +1 -1
  15. package/dist/types/src/event-emitter.d.ts.map +1 -1
  16. package/dist/types/src/events.d.ts.map +1 -1
  17. package/dist/types/src/mutex.d.ts.map +1 -1
  18. package/dist/types/src/observable-value.d.ts.map +1 -1
  19. package/dist/types/src/observable.d.ts.map +1 -1
  20. package/dist/types/src/persistent-lifecycle.d.ts +3 -2
  21. package/dist/types/src/persistent-lifecycle.d.ts.map +1 -1
  22. package/dist/types/src/stream-to-array.d.ts.map +1 -1
  23. package/dist/types/src/task-scheduling.d.ts +29 -1
  24. package/dist/types/src/task-scheduling.d.ts.map +1 -1
  25. package/dist/types/src/test-stream.d.ts.map +1 -1
  26. package/dist/types/src/testing.d.ts.map +1 -1
  27. package/dist/types/src/timeout.d.ts +1 -1
  28. package/dist/types/src/timeout.d.ts.map +1 -1
  29. package/dist/types/src/timer.d.ts.map +1 -1
  30. package/dist/types/src/track-leaks.d.ts.map +1 -1
  31. package/dist/types/src/trigger.d.ts.map +1 -1
  32. package/dist/types/src/update-scheduler.d.ts.map +1 -1
  33. package/dist/types/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +12 -11
  35. package/src/cleanup.ts +7 -4
  36. package/src/debounce.ts +19 -14
  37. package/src/event-emitter.test.ts +0 -1
  38. package/src/observable-value.ts +4 -2
  39. package/src/persistent-lifecycle.test.ts +36 -0
  40. package/src/persistent-lifecycle.ts +33 -6
  41. package/src/task-scheduling.ts +95 -1
  42. package/src/timeout.ts +6 -9
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "@dxos/async",
3
- "version": "0.8.4-main.fffef41",
3
+ "version": "0.8.4-staging.60fe92afc8",
4
4
  "description": "Async utilities.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
7
- "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
11
+ "license": "FSL-1.1-Apache-2.0",
8
12
  "author": "DXOS.org",
9
13
  "sideEffects": true,
10
14
  "type": "module",
@@ -19,9 +23,6 @@
19
23
  }
20
24
  },
21
25
  "types": "dist/types/src/index.d.ts",
22
- "typesVersions": {
23
- "*": {}
24
- },
25
26
  "files": [
26
27
  "dist",
27
28
  "src"
@@ -29,12 +30,12 @@
29
30
  "dependencies": {
30
31
  "zen-observable": "^0.10.0",
31
32
  "zen-push": "^0.3.1",
32
- "@dxos/context": "0.8.4-main.fffef41",
33
- "@dxos/log": "0.8.4-main.fffef41",
34
- "@dxos/debug": "0.8.4-main.fffef41",
35
- "@dxos/node-std": "0.8.4-main.fffef41",
36
- "@dxos/util": "0.8.4-main.fffef41",
37
- "@dxos/invariant": "0.8.4-main.fffef41"
33
+ "@dxos/debug": "0.8.4-staging.60fe92afc8",
34
+ "@dxos/context": "0.8.4-staging.60fe92afc8",
35
+ "@dxos/invariant": "0.8.4-staging.60fe92afc8",
36
+ "@dxos/log": "0.8.4-staging.60fe92afc8",
37
+ "@dxos/util": "0.8.4-staging.60fe92afc8",
38
+ "@dxos/node-std": "0.8.4-staging.60fe92afc8"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@types/zen-observable": "^0.8.3"
package/src/cleanup.ts CHANGED
@@ -10,9 +10,12 @@ export type CleanupFn = () => void;
10
10
  * Combine multiple cleanup functions into a single cleanup function.
11
11
  * Can be used in effect hooks in conjunction with `addEventListener`.
12
12
  */
13
- export const combine = (...cleanupFns: (CleanupFn | CleanupFn[])[]): CleanupFn => {
13
+ export const combine = (...cleanupFns: (boolean | undefined | CleanupFn | CleanupFn[])[]): CleanupFn => {
14
14
  return () => {
15
- cleanupFns.flat().forEach((cleanupFn) => cleanupFn());
15
+ cleanupFns
16
+ .flat()
17
+ .filter((f): f is CleanupFn => typeof f === 'function')
18
+ .forEach((cleanupFn) => cleanupFn());
16
19
  };
17
20
  };
18
21
 
@@ -51,8 +54,8 @@ export const addEventListener = <T extends EventTarget, K extends keyof EventMap
51
54
  export class SubscriptionList {
52
55
  private readonly _cleanups: CleanupFn[] = [];
53
56
 
54
- add(cb: CleanupFn): this {
55
- this._cleanups.push(cb);
57
+ add(...cb: CleanupFn[]): this {
58
+ this._cleanups.push(...cb);
56
59
  return this;
57
60
  }
58
61
 
package/src/debounce.ts CHANGED
@@ -12,7 +12,7 @@ type Callback = (...args: any[]) => void;
12
12
  * @param delay Time to wait before invoking the callback.
13
13
  * @returns A new function that schedules the callback once and ignores subsequent calls until executed.
14
14
  */
15
- export const delay = <CB extends Callback>(cb: CB, delay = 100): CB => {
15
+ export const delay = <F extends Callback>(cb: F, delay = 100): F => {
16
16
  let pending = false;
17
17
  return ((...args: any[]) => {
18
18
  if (pending) {
@@ -27,32 +27,37 @@ export const delay = <CB extends Callback>(cb: CB, delay = 100): CB => {
27
27
  pending = false;
28
28
  }
29
29
  }, delay);
30
- }) as CB;
30
+ }) as F;
31
31
  };
32
32
 
33
33
  /**
34
- * Debounce callback.
34
+ * Debounce callback: delays execution until calls stop.
35
+ * Each new call resets the timer, so the callback fires only after the delay elapses with no further calls (trailing-edge).
36
+ * Use when you want to react to the end of a burst of events (e.g. user stops typing).
35
37
  *
36
38
  * @param cb Callback to invoke.
37
- * @param delay Time window to wait before allowing calls.
38
- * @returns A new function that wraps the callback and ensures that the callback is only invoked after the time window has passed and no new calls have been made.
39
+ * @param delay Idle time (ms) to wait after the last call before invoking.
40
+ * @returns Wrapped function that postpones invocation until activity ceases.
39
41
  */
40
- export const debounce = <CB extends Callback>(cb: CB, delay = 100): CB => {
42
+ export const debounce = <F extends Callback>(cb: F, delay = 100): F => {
41
43
  let t: ReturnType<typeof setTimeout>;
42
44
  return ((...args: any[]) => {
43
45
  clearTimeout(t);
44
46
  t = setTimeout(() => cb(...args), delay);
45
- }) as CB;
47
+ }) as F;
46
48
  };
47
49
 
48
50
  /**
49
- * Throttle callback.
51
+ * Throttle callback: limits execution to at most once per interval.
52
+ * The callback fires immediately on the first call;
53
+ * subsequent calls within the same interval are dropped (leading-edge).
54
+ * Use when you need regular updates at a bounded rate (e.g. scroll or resize handlers).
50
55
  *
51
56
  * @param cb Callback to invoke.
52
- * @param delay Time window to allow calls in.
53
- * @returns A new function that wraps the callback and prevents multiple invocations within the time window.
57
+ * @param delay Minimum interval (ms) between successive invocations.
58
+ * @returns Wrapped function that rate-limits invocations.
54
59
  */
55
- export const throttle = <CB extends Callback>(cb: CB, delay = 100): CB => {
60
+ export const throttle = <F extends Callback>(cb: F, delay = 100): F => {
56
61
  let lastCall = 0;
57
62
  return ((...args: any[]) => {
58
63
  const now = Date.now();
@@ -60,7 +65,7 @@ export const throttle = <CB extends Callback>(cb: CB, delay = 100): CB => {
60
65
  cb(...args);
61
66
  lastCall = now;
62
67
  }
63
- }) as CB;
68
+ }) as F;
64
69
  };
65
70
 
66
71
  /**
@@ -72,7 +77,7 @@ export const throttle = <CB extends Callback>(cb: CB, delay = 100): CB => {
72
77
  * @param delay Time window for both throttle and debounce.
73
78
  * @returns A new function that combines throttle and debounce behavior.
74
79
  */
75
- export const debounceAndThrottle = <CB extends Callback>(cb: CB, delay = 100): CB => {
80
+ export const debounceAndThrottle = <F extends Callback>(cb: F, delay = 100): F => {
76
81
  let timeout: ReturnType<typeof setTimeout>;
77
82
  let lastCall = 0;
78
83
 
@@ -94,5 +99,5 @@ export const debounceAndThrottle = <CB extends Callback>(cb: CB, delay = 100): C
94
99
  lastCall = Date.now();
95
100
  }, delay - delta);
96
101
  }
97
- }) as CB;
102
+ }) as F;
98
103
  };
@@ -3,7 +3,6 @@
3
3
  //
4
4
 
5
5
  import { EventEmitter } from 'node:events';
6
-
7
6
  import { describe, expect, test } from 'vitest';
8
7
 
9
8
  import { onEvent, waitForEvent } from './event-emitter';
@@ -66,8 +66,10 @@ export interface CancellableObservableEvents {
66
66
  /**
67
67
  * @deprecated
68
68
  */
69
- export interface CancellableObservable<Events extends CancellableObservableEvents, Value = unknown>
70
- extends ObservableValue<Events, Value> {
69
+ export interface CancellableObservable<
70
+ Events extends CancellableObservableEvents,
71
+ Value = unknown,
72
+ > extends ObservableValue<Events, Value> {
71
73
  cancel(): Promise<void>;
72
74
  }
73
75
 
@@ -57,6 +57,42 @@ describe('ConnectionState', () => {
57
57
  expect(timeToTrigger).to.be.greaterThanOrEqual(100);
58
58
  });
59
59
 
60
+ test('connection that drops immediately backs off instead of hot-looping', async () => {
61
+ const startTimes: number[] = [];
62
+ const maxStarts = 4;
63
+ const done = new Trigger();
64
+
65
+ const persistentLifecycle = new PersistentLifecycle({
66
+ start: async () => {
67
+ startTimes.push(Date.now());
68
+ if (startTimes.length >= maxStarts) {
69
+ done.wake();
70
+ }
71
+ },
72
+ stop: async () => {},
73
+ // Simulate the connection dropping the moment it is established.
74
+ onRestart: async () => {
75
+ if (startTimes.length < maxStarts) {
76
+ void persistentLifecycle.scheduleRestart();
77
+ }
78
+ },
79
+ });
80
+
81
+ await persistentLifecycle.open();
82
+ onTestFinished(async () => {
83
+ await persistentLifecycle.close();
84
+ });
85
+
86
+ // The initial open already performed the first start; simulate its immediate drop.
87
+ void persistentLifecycle.scheduleRestart();
88
+ await done.wait({ timeout: 5000 });
89
+
90
+ // Successive reconnects must back off (~0, ~100, ~200ms), not fire back-to-back.
91
+ const gaps = startTimes.slice(1).map((time, index) => time - startTimes[index]);
92
+ expect(gaps[1]).to.be.greaterThanOrEqual(90);
93
+ expect(gaps[2]).to.be.greaterThanOrEqual(180);
94
+ });
95
+
60
96
  test('finish `restart` before close', async () => {
61
97
  let restarted = false;
62
98
  const persistentLifecycle = new PersistentLifecycle({
@@ -13,7 +13,14 @@ import { sleep } from './timeout';
13
13
  const INIT_RESTART_DELAY = 100;
14
14
  const DEFAULT_MAX_RESTART_DELAY = 5000;
15
15
 
16
- export type PersistentLifecycleParams<T> = {
16
+ /**
17
+ * Minimum duration a connection must stay up before it is considered stable and the backoff is
18
+ * reset. A connection that drops sooner keeps escalating the delay, so an endpoint that accepts
19
+ * then immediately closes the connection cannot produce a zero-delay reconnect loop.
20
+ */
21
+ const STABLE_CONNECTION_THRESHOLD = 5000;
22
+
23
+ export type PersistentLifecycleProps<T> = {
17
24
  /**
18
25
  * Create connection.
19
26
  * If promise resolves successfully, connection is considered established.
@@ -50,8 +57,9 @@ export class PersistentLifecycle<T> extends Resource {
50
57
  private _currentState: T | undefined = undefined;
51
58
  private _restartTask?: DeferredTask = undefined;
52
59
  private _restartAfter = 0;
60
+ private _connectedAt: number | undefined = undefined;
53
61
 
54
- constructor({ start, stop, onRestart, maxRestartDelay = DEFAULT_MAX_RESTART_DELAY }: PersistentLifecycleParams<T>) {
62
+ constructor({ start, stop, onRestart, maxRestartDelay = DEFAULT_MAX_RESTART_DELAY }: PersistentLifecycleProps<T>) {
55
63
  super();
56
64
  this._start = start;
57
65
  this._stop = stop;
@@ -69,16 +77,26 @@ export class PersistentLifecycle<T> extends Resource {
69
77
  try {
70
78
  await this._restart();
71
79
  } catch (err) {
80
+ // Suppress noise from restarts that race with shutdown.
81
+ if (this._ctx?.disposed) {
82
+ return;
83
+ }
72
84
  log.warn('Restart failed', { err });
73
85
  this._restartTask?.schedule();
74
86
  }
75
87
  });
76
88
 
77
- this._currentState = await this._start().catch((err) => {
89
+ try {
90
+ this._currentState = await this._start();
91
+ this._connectedAt = Date.now();
92
+ } catch (err) {
93
+ // Suppress noise when shutdown was requested while the initial start was in flight.
94
+ if (this._ctx?.disposed) {
95
+ return;
96
+ }
78
97
  log.warn('Start failed', { err });
79
98
  this._restartTask?.schedule();
80
- return undefined;
81
- });
99
+ }
82
100
  }
83
101
 
84
102
  protected override async _close(): Promise<void> {
@@ -89,6 +107,15 @@ export class PersistentLifecycle<T> extends Resource {
89
107
 
90
108
  private async _restart(): Promise<void> {
91
109
  log(`restarting in ${this._restartAfter}ms`, { state: this._lifecycleState });
110
+
111
+ // Reset the backoff only if the previous connection stayed up long enough to be considered
112
+ // stable. A connection that drops shortly after starting must keep escalating the delay,
113
+ // otherwise reconnects degenerate into a hot loop.
114
+ if (this._connectedAt !== undefined && Date.now() - this._connectedAt >= STABLE_CONNECTION_THRESHOLD) {
115
+ this._restartAfter = 0;
116
+ }
117
+ this._connectedAt = undefined;
118
+
92
119
  await this._stopCurrentState();
93
120
  if (this._lifecycleState !== LifecycleState.OPEN) {
94
121
  return;
@@ -101,7 +128,7 @@ export class PersistentLifecycle<T> extends Resource {
101
128
  this._currentState = await this._start();
102
129
  });
103
130
 
104
- this._restartAfter = 0;
131
+ this._connectedAt = Date.now();
105
132
  await this._onRestart?.();
106
133
  }
107
134
 
@@ -2,7 +2,7 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
5
- import { type Context, ContextDisposedError } from '@dxos/context';
5
+ import { Context, ContextDisposedError } from '@dxos/context';
6
6
  import { StackTrace } from '@dxos/debug';
7
7
  import { type MaybePromise } from '@dxos/util';
8
8
 
@@ -77,6 +77,100 @@ export class DeferredTask {
77
77
  }
78
78
  }
79
79
 
80
+ // TODO(dmaretskyi): Protyping this an alternative API to DeferredTask.
81
+ export class AsyncTask {
82
+ #callback: () => Promise<void>;
83
+ #ctx?: Context = undefined;
84
+
85
+ #scheduled = false;
86
+ #currentTask: Promise<void> | null = null; // Can't be rejected.
87
+ #nextTask = new Trigger();
88
+
89
+ constructor(callback: () => Promise<void>) {
90
+ this.#callback = callback;
91
+ }
92
+
93
+ get scheduled() {
94
+ return this.#scheduled;
95
+ }
96
+
97
+ /**
98
+ * Context of the resource that owns the task.
99
+ * When the context is disposed, the task is cancelled and cannot be scheduled again.
100
+ */
101
+ open(): void {
102
+ this.#ctx = new Context();
103
+ }
104
+
105
+ /**
106
+ * Closes the task and waits for it to finish if it is running.
107
+ */
108
+ async close(): Promise<void> {
109
+ await this.#ctx?.dispose();
110
+ await this.join();
111
+ this.#ctx = undefined;
112
+ }
113
+
114
+ [Symbol.asyncDispose](): Promise<void> {
115
+ return this.close();
116
+ }
117
+
118
+ /**
119
+ * Schedule the task to run asynchronously.
120
+ */
121
+ // TODO(dmaretskyi): Add scheduleAt. Where the earlier time will override the later one.
122
+ schedule(): void {
123
+ if (!this.#ctx || this.#ctx.disposed) {
124
+ throw new Error('AsyncTask not open');
125
+ }
126
+
127
+ if (this.#scheduled) {
128
+ return; // Already scheduled.
129
+ }
130
+
131
+ scheduleTask(this.#ctx, async () => {
132
+ // The previous task might still be running, so we need to wait for it to finish.
133
+ await this.#currentTask; // Can't be rejected.
134
+
135
+ if (!this.#ctx || this.#ctx.disposed) {
136
+ return;
137
+ }
138
+
139
+ // Reset the flag. New tasks can now be scheduled. They would wait for the callback to finish.
140
+ this.#scheduled = false;
141
+ const completionTrigger = this.#nextTask;
142
+ this.#nextTask = new Trigger(); // Re-create the trigger as opposed to resetting it since there might be listeners waiting for it.
143
+
144
+ // Store the promise so that new tasks could wait for this one to finish.
145
+ this.#currentTask = runInContextAsync(this.#ctx, () => this.#callback()).then(() => {
146
+ completionTrigger.wake();
147
+ });
148
+ });
149
+
150
+ this.#scheduled = true;
151
+ }
152
+
153
+ /**
154
+ * Schedule the task to run and wait for it to finish.
155
+ */
156
+ async runBlocking(): Promise<void> {
157
+ if (this.#ctx?.disposed) {
158
+ throw new ContextDisposedError();
159
+ }
160
+
161
+ this.schedule();
162
+ await this.#nextTask.wait();
163
+ }
164
+
165
+ /**
166
+ * Waits for the current task to finish if it is running.
167
+ * Does not schedule a new task.
168
+ */
169
+ async join(): Promise<void> {
170
+ await this.#currentTask;
171
+ }
172
+ }
173
+
80
174
  export const runInContext = (ctx: Context, fn: () => void) => {
81
175
  try {
82
176
  fn();
package/src/timeout.ts CHANGED
@@ -4,7 +4,6 @@
4
4
 
5
5
  import { type Context, ContextDisposedError } from '@dxos/context';
6
6
 
7
- import { promiseFromCallback } from './callback';
8
7
  import { TimeoutError } from './errors';
9
8
 
10
9
  /**
@@ -57,12 +56,11 @@ export const asyncReturn = () => sleep(0);
57
56
  /**
58
57
  * Wait for promise or throw error.
59
58
  */
60
- export const asyncTimeout = async <T>(
61
- // TODO(dmaretskyi): This callback API is unintuitive and leads to bugs.
62
- promise: Promise<T> | (() => Promise<T>),
63
- timeout: number,
64
- err?: Error | string,
65
- ): Promise<T> => {
59
+ export const asyncTimeout = async <T>(promise: Promise<T>, timeout: number, err?: Error | string): Promise<T> => {
60
+ if (typeof promise === 'function') {
61
+ throw new Error('First argument must be a promise.');
62
+ }
63
+
66
64
  let timeoutId: NodeJS.Timeout;
67
65
  const throwable = err === undefined || typeof err === 'string' ? new TimeoutError(timeout, err) : err;
68
66
  const timeoutPromise = new Promise<T>((resolve, reject) => {
@@ -73,8 +71,7 @@ export const asyncTimeout = async <T>(
73
71
  unrefTimeout(timeoutId);
74
72
  });
75
73
 
76
- const conditionTimeout = typeof promise === 'function' ? promiseFromCallback<T>(promise) : promise;
77
- return await Promise.race([conditionTimeout, timeoutPromise]).finally(() => {
74
+ return await Promise.race([promise, timeoutPromise]).finally(() => {
78
75
  clearTimeout(timeoutId);
79
76
  });
80
77
  };