@dxos/async 0.8.4-staging.ac66bdf99f → 0.9.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.
Files changed (32) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/index.mjs +28 -85
  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 +28 -85
  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.map +1 -1
  11. package/dist/types/src/debounce.d.ts.map +1 -1
  12. package/dist/types/src/errors.d.ts.map +1 -1
  13. package/dist/types/src/event-emitter.d.ts.map +1 -1
  14. package/dist/types/src/events.d.ts.map +1 -1
  15. package/dist/types/src/mutex.d.ts.map +1 -1
  16. package/dist/types/src/observable-value.d.ts.map +1 -1
  17. package/dist/types/src/observable.d.ts.map +1 -1
  18. package/dist/types/src/persistent-lifecycle.d.ts +1 -0
  19. package/dist/types/src/persistent-lifecycle.d.ts.map +1 -1
  20. package/dist/types/src/stream-to-array.d.ts.map +1 -1
  21. package/dist/types/src/task-scheduling.d.ts.map +1 -1
  22. package/dist/types/src/test-stream.d.ts.map +1 -1
  23. package/dist/types/src/testing.d.ts.map +1 -1
  24. package/dist/types/src/timeout.d.ts.map +1 -1
  25. package/dist/types/src/timer.d.ts.map +1 -1
  26. package/dist/types/src/track-leaks.d.ts.map +1 -1
  27. package/dist/types/src/trigger.d.ts.map +1 -1
  28. package/dist/types/src/update-scheduler.d.ts.map +1 -1
  29. package/dist/types/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +8 -11
  31. package/src/persistent-lifecycle.test.ts +36 -0
  32. package/src/persistent-lifecycle.ts +31 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/async",
3
- "version": "0.8.4-staging.ac66bdf99f",
3
+ "version": "0.9.0",
4
4
  "description": "Async utilities.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -8,7 +8,7 @@
8
8
  "type": "git",
9
9
  "url": "https://github.com/dxos/dxos"
10
10
  },
11
- "license": "MIT",
11
+ "license": "FSL-1.1-Apache-2.0",
12
12
  "author": "DXOS.org",
13
13
  "sideEffects": true,
14
14
  "type": "module",
@@ -23,9 +23,6 @@
23
23
  }
24
24
  },
25
25
  "types": "dist/types/src/index.d.ts",
26
- "typesVersions": {
27
- "*": {}
28
- },
29
26
  "files": [
30
27
  "dist",
31
28
  "src"
@@ -33,12 +30,12 @@
33
30
  "dependencies": {
34
31
  "zen-observable": "^0.10.0",
35
32
  "zen-push": "^0.3.1",
36
- "@dxos/context": "0.8.4-staging.ac66bdf99f",
37
- "@dxos/invariant": "0.8.4-staging.ac66bdf99f",
38
- "@dxos/debug": "0.8.4-staging.ac66bdf99f",
39
- "@dxos/log": "0.8.4-staging.ac66bdf99f",
40
- "@dxos/node-std": "0.8.4-staging.ac66bdf99f",
41
- "@dxos/util": "0.8.4-staging.ac66bdf99f"
33
+ "@dxos/context": "0.9.0",
34
+ "@dxos/log": "0.9.0",
35
+ "@dxos/invariant": "0.9.0",
36
+ "@dxos/debug": "0.9.0",
37
+ "@dxos/node-std": "0.9.0",
38
+ "@dxos/util": "0.9.0"
42
39
  },
43
40
  "devDependencies": {
44
41
  "@types/zen-observable": "^0.8.3"
@@ -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,6 +13,13 @@ import { sleep } from './timeout';
13
13
  const INIT_RESTART_DELAY = 100;
14
14
  const DEFAULT_MAX_RESTART_DELAY = 5000;
15
15
 
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
+
16
23
  export type PersistentLifecycleProps<T> = {
17
24
  /**
18
25
  * Create connection.
@@ -50,6 +57,7 @@ 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
62
  constructor({ start, stop, onRestart, maxRestartDelay = DEFAULT_MAX_RESTART_DELAY }: PersistentLifecycleProps<T>) {
55
63
  super();
@@ -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