@outputai/core 0.12.1-next.a4a41b6.0 → 0.12.1-next.d1d27f7.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.
@@ -0,0 +1,317 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { WorkflowNotFoundError } from '@temporalio/client';
3
+ import { WorkflowExecutionAlreadyStartedError, WorkflowIdConflictPolicy } from '@temporalio/common';
4
+ import { CatalogPublisher } from './catalog_publisher.js';
5
+
6
+ const {
7
+ catalogId,
8
+ describeMock,
9
+ getHandleMock,
10
+ mockLog,
11
+ serializeErrorMock,
12
+ sleepMock,
13
+ startMock,
14
+ taskQueue,
15
+ terminateMock
16
+ } = vi.hoisted( () => ( {
17
+ catalogId: 'test-catalog',
18
+ describeMock: vi.fn(),
19
+ getHandleMock: vi.fn(),
20
+ mockLog: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
21
+ serializeErrorMock: vi.fn( error => ( { message: error.message } ) ),
22
+ sleepMock: vi.fn(),
23
+ startMock: vi.fn(),
24
+ taskQueue: 'test-queue',
25
+ terminateMock: vi.fn()
26
+ } ) );
27
+
28
+ vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
29
+ vi.mock( '#helpers/error_serializer', () => ( { serializeError: serializeErrorMock } ) );
30
+ vi.mock( '#consts', () => ( { WORKFLOW_CATALOG: 'catalog' } ) );
31
+ vi.mock( '#helpers/promise', () => ( { sleepCancellable: sleepMock } ) );
32
+ vi.mock( '../configs.js', () => ( { catalogId, taskQueue } ) );
33
+ vi.mock( '@temporalio/client', async importOriginal => {
34
+ const actual = await importOriginal();
35
+ return {
36
+ ...actual,
37
+ Client: vi.fn().mockImplementation( function () {
38
+ return { workflow: { start: startMock, getHandle: getHandleMock } };
39
+ } )
40
+ };
41
+ } );
42
+
43
+ const RETRY_DELAY = 10_000;
44
+ const namespace = 'default';
45
+ const catalogHash = 'catalog-hash';
46
+ const catalog = { workflows: [], workflowNames: { workflow: 'workflow' }, activities: {} };
47
+
48
+ const startArguments = {
49
+ taskQueue,
50
+ workflowId: catalogId,
51
+ workflowIdConflictPolicy: WorkflowIdConflictPolicy.FAIL,
52
+ workflowTaskTimeout: 30_000,
53
+ args: [ catalog ],
54
+ memo: {
55
+ workflowNames: catalog.workflowNames,
56
+ hash: catalogHash
57
+ }
58
+ };
59
+
60
+ const compactError = message => ( { error: { message } } );
61
+
62
+ const running = hash => ( { closeTime: undefined, runId: 'run-1', memo: { hash } } );
63
+ const notFound = () => new WorkflowNotFoundError( 'not found' );
64
+ const alreadyStarted = () => new WorkflowExecutionAlreadyStartedError( 'already started', catalogId, 'catalog' );
65
+
66
+ const createPublisher = ( signal = new AbortController().signal ) =>
67
+ new CatalogPublisher( { connection: {}, namespace, catalog, catalogHash, signal } );
68
+
69
+ const flushPromises = async () => Array
70
+ .from( { length: 10 } )
71
+ .reduce( promise => promise.then( () => Promise.resolve() ), Promise.resolve() );
72
+
73
+ describe( 'CatalogPublisher', () => {
74
+ beforeEach( () => {
75
+ vi.clearAllMocks();
76
+ getHandleMock.mockReturnValue( { describe: describeMock, terminate: terminateMock } );
77
+ describeMock.mockRejectedValue( notFound() );
78
+ terminateMock.mockResolvedValue( undefined );
79
+ startMock.mockResolvedValue( undefined );
80
+ sleepMock.mockResolvedValue( undefined );
81
+ } );
82
+
83
+ describe( 'publish paths', () => {
84
+ it( 'starts the catalog when nothing is running', async () => {
85
+ const publisher = createPublisher();
86
+
87
+ await publisher.run();
88
+
89
+ expect( terminateMock ).not.toHaveBeenCalled();
90
+ expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
91
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Catalog workflow started' );
92
+ expect( mockLog.warn ).not.toHaveBeenCalled();
93
+ expect( publisher.running ).toBe( false );
94
+ } );
95
+
96
+ it( 'starts the catalog when the previous one is closed', async () => {
97
+ describeMock.mockResolvedValue( { closeTime: '2024-01-01T00:00:00Z' } );
98
+
99
+ await createPublisher().run();
100
+
101
+ expect( terminateMock ).not.toHaveBeenCalled();
102
+ expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
103
+ } );
104
+
105
+ it( 'skips the restart when the running hash matches', async () => {
106
+ describeMock.mockResolvedValue( running( catalogHash ) );
107
+
108
+ await createPublisher().run();
109
+
110
+ expect( terminateMock ).not.toHaveBeenCalled();
111
+ expect( startMock ).not.toHaveBeenCalled();
112
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Current catalog workflow hash matches worker, restart skipped' );
113
+ } );
114
+
115
+ it( 'terminates and starts when the running catalog carries no memo', async () => {
116
+ describeMock.mockResolvedValue( { closeTime: undefined, runId: 'run-1', memo: undefined } );
117
+
118
+ await createPublisher().run();
119
+
120
+ expect( mockLog.warn ).not.toHaveBeenCalled();
121
+ expect( terminateMock ).toHaveBeenCalledOnce();
122
+ expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
123
+ } );
124
+
125
+ it( 'terminates the pinned run then starts when the running hash differs', async () => {
126
+ describeMock.mockResolvedValue( running( 'old-hash' ) );
127
+
128
+ await createPublisher().run();
129
+
130
+ expect( getHandleMock ).toHaveBeenCalledWith( catalogId, 'run-1' );
131
+ expect( terminateMock ).toHaveBeenCalledWith( `Systematic terminating. Superseded by catalog ${catalogHash}` );
132
+ expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
133
+ } );
134
+ } );
135
+
136
+ describe( 'race paths', () => {
137
+ it( 'ignores an already started error when the published hash matches', async () => {
138
+ describeMock
139
+ .mockRejectedValueOnce( notFound() )
140
+ .mockResolvedValueOnce( running( catalogHash ) );
141
+ startMock.mockRejectedValue( alreadyStarted() );
142
+
143
+ await createPublisher().run();
144
+
145
+ expect( startMock ).toHaveBeenCalledOnce();
146
+ expect( terminateMock ).not.toHaveBeenCalled();
147
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Found a catalog workflow with matching hashes, start skipped' );
148
+ expect( mockLog.info ).not.toHaveBeenCalledWith( 'Catalog workflow started' );
149
+ } );
150
+
151
+ it( 'retires the winner on retry when an already started error exposes a different hash', async () => {
152
+ describeMock
153
+ .mockRejectedValueOnce( notFound() )
154
+ .mockResolvedValue( running( 'other-hash' ) );
155
+ startMock
156
+ .mockRejectedValueOnce( alreadyStarted() )
157
+ .mockResolvedValue( undefined );
158
+
159
+ await createPublisher().run();
160
+
161
+ expect( sleepMock ).toHaveBeenCalledOnce();
162
+ expect( terminateMock ).toHaveBeenCalledOnce();
163
+ expect( startMock ).toHaveBeenCalledTimes( 2 );
164
+ expect( mockLog.warn ).toHaveBeenCalledWith(
165
+ 'Error while starting catalog workflow, retrying...',
166
+ compactError( 'already started' )
167
+ );
168
+ } );
169
+
170
+ it( 'warns and attempts a start when describing fails', async () => {
171
+ const error = new Error( 'describe failed' );
172
+ describeMock.mockRejectedValue( error );
173
+
174
+ await createPublisher().run();
175
+
176
+ expect( serializeErrorMock ).toHaveBeenCalledWith( error, { dropKeys: [ 'stack' ] } );
177
+ expect( mockLog.warn ).toHaveBeenCalledWith(
178
+ 'Ignoring error while describing catalog workflow',
179
+ compactError( 'describe failed' )
180
+ );
181
+ expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
182
+ } );
183
+
184
+ it( 'warns and still starts when terminating the previous catalog fails', async () => {
185
+ describeMock.mockResolvedValue( running( 'old-hash' ) );
186
+ terminateMock.mockRejectedValue( new Error( 'terminate failed' ) );
187
+
188
+ await createPublisher().run();
189
+
190
+ expect( mockLog.warn ).toHaveBeenCalledWith(
191
+ 'Ignoring error while terminating previous catalog workflow',
192
+ compactError( 'terminate failed' )
193
+ );
194
+ expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
195
+ } );
196
+ } );
197
+
198
+ describe( 'failure', () => {
199
+ it( 'rejects with the original error after exhausting the retries', async () => {
200
+ const error = new Error( 'start failed' );
201
+ startMock.mockRejectedValue( error );
202
+ const publisher = createPublisher();
203
+
204
+ await expect( publisher.run() ).rejects.toBe( error );
205
+
206
+ expect( startMock ).toHaveBeenCalledTimes( 3 );
207
+ expect( sleepMock ).toHaveBeenCalledTimes( 2 );
208
+ expect( publisher.running ).toBe( false );
209
+ } );
210
+
211
+ it( 'waits the retry delay with the abort signal between attempts', async () => {
212
+ startMock.mockRejectedValue( new Error( 'start failed' ) );
213
+
214
+ await expect( createPublisher().run() ).rejects.toThrow( 'start failed' );
215
+
216
+ expect( sleepMock ).toHaveBeenCalledWith( RETRY_DELAY, expect.any( AbortSignal ) );
217
+ } );
218
+ } );
219
+
220
+ describe( 'abort paths', () => {
221
+ it( 'publishes nothing when already aborted', async () => {
222
+ const controller = new AbortController();
223
+ controller.abort();
224
+
225
+ await expect( createPublisher( controller.signal ).run() ).resolves.toBeUndefined();
226
+
227
+ expect( describeMock ).not.toHaveBeenCalled();
228
+ expect( startMock ).not.toHaveBeenCalled();
229
+ } );
230
+
231
+ it( 'stops retrying when the signal aborts during the backoff', async () => {
232
+ const controller = new AbortController();
233
+ startMock.mockRejectedValue( new Error( 'start failed' ) );
234
+ sleepMock.mockImplementation( async () => controller.abort() );
235
+
236
+ await expect( createPublisher( controller.signal ).run() ).resolves.toBeUndefined();
237
+
238
+ expect( startMock ).toHaveBeenCalledOnce();
239
+ expect( sleepMock ).toHaveBeenCalledOnce();
240
+ } );
241
+
242
+ it( 'stops retrying when interrupted during the backoff', async () => {
243
+ startMock.mockRejectedValue( new Error( 'start failed' ) );
244
+ const publisher = createPublisher();
245
+ sleepMock.mockImplementation( async () => {
246
+ publisher.interrupt();
247
+ } );
248
+
249
+ await expect( publisher.run() ).resolves.toBeUndefined();
250
+
251
+ expect( startMock ).toHaveBeenCalledOnce();
252
+ } );
253
+
254
+ it( 'still starts when the abort lands between terminating and starting', async () => {
255
+ const controller = new AbortController();
256
+ describeMock.mockResolvedValue( running( 'old-hash' ) );
257
+ terminateMock.mockImplementation( async () => controller.abort() );
258
+
259
+ await createPublisher( controller.signal ).run();
260
+
261
+ expect( terminateMock ).toHaveBeenCalledOnce();
262
+ expect( startMock ).toHaveBeenCalledWith( 'catalog', startArguments );
263
+ } );
264
+ } );
265
+
266
+ describe( 'lifecycle', () => {
267
+ it( 'reports running while the sequence is pending', async () => {
268
+ const deferred = {};
269
+ describeMock.mockReturnValue( new Promise( resolve => {
270
+ deferred.resolve = resolve;
271
+ } ) );
272
+ const publisher = createPublisher();
273
+
274
+ const run = publisher.run();
275
+ await flushPromises();
276
+
277
+ expect( publisher.running ).toBe( true );
278
+
279
+ deferred.resolve( running( catalogHash ) );
280
+ await run;
281
+
282
+ expect( publisher.running ).toBe( false );
283
+ } );
284
+
285
+ it( 'returns the same execution and publishes once when run more than once', async () => {
286
+ const publisher = createPublisher();
287
+
288
+ const first = publisher.run();
289
+ const second = publisher.run();
290
+
291
+ expect( second ).toBe( first );
292
+
293
+ await first;
294
+
295
+ expect( describeMock ).toHaveBeenCalledOnce();
296
+ expect( startMock ).toHaveBeenCalledOnce();
297
+ } );
298
+
299
+ it( 'resolves when interrupted before running', async () => {
300
+ const publisher = createPublisher();
301
+
302
+ await expect( publisher.interrupt() ).resolves.toBeUndefined();
303
+
304
+ expect( publisher.running ).toBe( false );
305
+ expect( describeMock ).not.toHaveBeenCalled();
306
+ } );
307
+
308
+ it( 'reports the failure when interrupting after a rejected run', async () => {
309
+ startMock.mockRejectedValue( new Error( 'start failed' ) );
310
+ const publisher = createPublisher();
311
+
312
+ await expect( publisher.run() ).rejects.toThrow( 'start failed' );
313
+
314
+ await expect( publisher.interrupt() ).rejects.toThrow( 'start failed' );
315
+ } );
316
+ } );
317
+ } );
@@ -36,10 +36,13 @@ const envVarSchema = z.object( {
36
36
  OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 2 * 60 * 1000 ) ), // 2min
37
37
  // Whether to send activity heartbeats (enabled by default)
38
38
  OUTPUT_ACTIVITY_HEARTBEAT_ENABLED: z.transform( v => v === undefined ? true : isStringboolTrue( v ) ),
39
- // Set temporal worker shutdown force time
40
- TEMPORAL_SHUTDOWN_FORCE_TIME: durationSchema,
41
- // Set temporal worker shutdown grace time
42
- TEMPORAL_SHUTDOWN_GRACE_TIME: durationSchema,
39
+ // Set temporal worker shutdown force time. Defaults budget a shutdown against the 30s most
40
+ // platforms allow before SIGKILL: force time + OUTPUT_HOOK_FLUSH_TIMEOUT_MS + 5s of margin.
41
+ TEMPORAL_SHUTDOWN_FORCE_TIME: durationSchema.default( '20s' ),
42
+ // Set temporal worker shutdown grace time, when in-flight activities are asked to cancel
43
+ TEMPORAL_SHUTDOWN_GRACE_TIME: durationSchema.default( '15s' ),
44
+ // How long the worker awaits pending hook callbacks once the drain is over, before exiting anyway
45
+ OUTPUT_HOOK_FLUSH_TIMEOUT_MS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 5 * 1000 ) ), // 5s
43
46
  // HTTP CONNECT proxy for Temporal gRPC connections (e.g. "proxy-host:8080").
44
47
  // Must be a bare host:port — no scheme (Temporal's native HTTP CONNECT
45
48
  // option is not a URL).
@@ -70,4 +73,5 @@ export const activityHeartbeatIntervalMs = envVars.OUTPUT_ACTIVITY_HEARTBEAT_INT
70
73
  export const activityHeartbeatEnabled = envVars.OUTPUT_ACTIVITY_HEARTBEAT_ENABLED;
71
74
  export const shutdownForceTime = envVars.TEMPORAL_SHUTDOWN_FORCE_TIME;
72
75
  export const shutdownGraceTime = envVars.TEMPORAL_SHUTDOWN_GRACE_TIME;
76
+ export const hookFlushTimeoutMs = envVars.OUTPUT_HOOK_FLUSH_TIMEOUT_MS;
73
77
  export const grpcProxy = envVars.TEMPORAL_GRPC_PROXY;
@@ -15,7 +15,8 @@ const CONFIG_KEYS = [
15
15
  'OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS',
16
16
  'OUTPUT_ACTIVITY_HEARTBEAT_ENABLED',
17
17
  'TEMPORAL_SHUTDOWN_FORCE_TIME',
18
- 'TEMPORAL_SHUTDOWN_GRACE_TIME'
18
+ 'TEMPORAL_SHUTDOWN_GRACE_TIME',
19
+ 'OUTPUT_HOOK_FLUSH_TIMEOUT_MS'
19
20
  ];
20
21
 
21
22
  const setEnv = ( overrides = {} ) => {
@@ -69,8 +70,9 @@ describe( 'worker/configs', () => {
69
70
  expect( configs.workerTelemetryIntervalMs ).toBe( 0 );
70
71
  expect( configs.activityHeartbeatIntervalMs ).toBe( 2 * 60 * 1000 );
71
72
  expect( configs.activityHeartbeatEnabled ).toBe( true );
72
- expect( configs.shutdownForceTime ).toBeUndefined();
73
- expect( configs.shutdownGraceTime ).toBeUndefined();
73
+ expect( configs.shutdownForceTime ).toBe( '20s' );
74
+ expect( configs.shutdownGraceTime ).toBe( '15s' );
75
+ expect( configs.hookFlushTimeoutMs ).toBe( 5000 );
74
76
  expect( configs.taskQueue ).toBe( 'test-catalog' );
75
77
  expect( configs.catalogId ).toBe( 'test-catalog' );
76
78
  } );
@@ -136,15 +138,17 @@ describe( 'worker/configs', () => {
136
138
  expect( configs.shutdownGraceTime ).toBe( '15s' );
137
139
  } );
138
140
 
139
- it( 'treats empty Temporal shutdown durations as unset', async () => {
141
+ it( 'falls back to the defaults when Temporal shutdown durations are empty', async () => {
140
142
  setEnv( {
141
143
  TEMPORAL_SHUTDOWN_FORCE_TIME: '',
142
- TEMPORAL_SHUTDOWN_GRACE_TIME: ''
144
+ TEMPORAL_SHUTDOWN_GRACE_TIME: '',
145
+ OUTPUT_HOOK_FLUSH_TIMEOUT_MS: ''
143
146
  } );
144
147
  const configs = await loadConfigs();
145
148
 
146
- expect( configs.shutdownForceTime ).toBeUndefined();
147
- expect( configs.shutdownGraceTime ).toBeUndefined();
149
+ expect( configs.shutdownForceTime ).toBe( '20s' );
150
+ expect( configs.shutdownGraceTime ).toBe( '15s' );
151
+ expect( configs.hookFlushTimeoutMs ).toBe( 5000 );
148
152
  } );
149
153
 
150
154
  it( 'throws when Temporal shutdown durations are invalid', async () => {
@@ -1,101 +1,129 @@
1
+ import { sleepCancellable } from '#helpers/promise';
1
2
  import { createChildLogger } from '#logger';
2
3
  import { setTimeout as delay } from 'node:timers/promises';
3
- import { CancellablePromise } from '#helpers/promise';
4
4
 
5
5
  const log = createChildLogger( 'Connection' );
6
6
 
7
+ /** Raised when the connection health check fails repeatedly. */
8
+ export class ConnectionLostError extends Error {
9
+ name = 'ConnectionLostError';
10
+ };
11
+
12
+ const MAX_FAILURES = 3;
13
+ const CHECK_INTERVAL_MS = 60_000;
14
+ const CHECK_TIMEOUT_MS = 5_000;
15
+ const HEALTHY_LOG_EVERY_LOOPS = 60;
16
+
17
+ /**
18
+ * Watches the Temporal connection health.
19
+ * Rejects with a ConnectionLostError once the connection is considered lost.
20
+ */
7
21
  export class TemporalConnectionMonitor {
8
- #MAX_FAILURES = 3;
9
- #CHECK_INTERVAL_MS = 60_000;
10
- #CHECK_TIMEOUT_MS = 5_000;
22
+ #maxFailures = MAX_FAILURES;
23
+ #checkIntervalMs = CHECK_INTERVAL_MS;
24
+ #checkTimeoutMs = CHECK_TIMEOUT_MS;
11
25
 
12
- #cancellation = new CancellablePromise();
26
+ #abortCtrl = new AbortController();
13
27
  #failures = 0;
14
- #error = null;
28
+ #loops = 0;
15
29
  #running = false;
16
- #watchPromise = null;
30
+ #execution = null;
17
31
  #connection = null;
18
- #connectionLostCb = null;
19
-
20
- #getTimeout = async () => delay( this.#CHECK_TIMEOUT_MS, 0, { ref: false } ).then( () => {
21
- throw new Error( 'Connection health check timed out' );
22
- } );
23
-
24
- #healthcheck = async () => this.#connection.workflowService.getSystemInfo( {} );
32
+ #signal = null;
25
33
 
26
- #sleep = async () => delay( this.#CHECK_INTERVAL_MS, 0, { ref: false } );
27
-
28
- #watch = async () => {
29
- while ( !this.#cancellation.completed ) {
34
+ #watch = async signal => {
35
+ while ( !signal.aborted ) {
30
36
  try {
31
- await Promise.race( [ this.#healthcheck(), this.#getTimeout(), this.#cancellation.promise ] );
32
-
33
- // cancellation won the race
34
- if ( this.#cancellation.completed ) {
35
- break;
37
+ await Promise.race( [
38
+ this.#connection.workflowService.getSystemInfo( {} ),
39
+ delay( this.#checkTimeoutMs, 0, { ref: false, signal } ).then( () => {
40
+ throw new Error( 'Connection health check timed out' );
41
+ } )
42
+ ] );
43
+
44
+ if ( this.#failures > 0 ) {
45
+ log.info( 'Recovered' );
46
+ }
47
+ if ( this.#loops === 0 || this.#loops % HEALTHY_LOG_EVERY_LOOPS === 0 ) {
48
+ log.info( 'Healthy' );
36
49
  }
37
-
38
- log.info( this.#failures === 0 ? 'Healthy' : 'Recovered' );
39
50
  this.#failures = 0;
51
+
40
52
  } catch ( error ) {
41
- // cancellation will ignore warnings and not throw errors;
42
- if ( this.#cancellation.completed ) {
43
- break;
53
+ // aborting will ignore warnings and not report errors
54
+ if ( signal.aborted ) {
55
+ return;
44
56
  }
45
57
 
46
- if ( ++this.#failures >= this.#MAX_FAILURES ) {
47
- log.warn( 'Connection lost', { error: error.message, failures: this.#failures } );
48
- this.#error = error;
49
- this.#connectionLostCb?.( error );
50
- this.#cancellation.complete();
51
- break;
52
- } else {
53
- log.warn( 'Connection unhealthy', { error: error.message, failures: this.#failures } );
58
+ const failureMessage = error?.message ?? String( error );
59
+ if ( ++this.#failures >= this.#maxFailures ) {
60
+ log.warn( 'Connection lost', { error: failureMessage, failures: this.#failures } );
61
+ this.#abortCtrl.abort();
62
+ throw new ConnectionLostError( 'Connection lost', { cause: error } );
54
63
  }
64
+
65
+ log.warn( 'Connection unhealthy', { error: failureMessage, failures: this.#failures } );
55
66
  }
56
67
 
57
- await Promise.race( [ this.#sleep(), this.#cancellation.promise ] );
68
+ this.#loops++;
69
+ await sleepCancellable( this.#checkIntervalMs, signal );
58
70
  }
59
71
  };
60
72
 
61
- constructor( connection, overrides = {} ) {
73
+ /**
74
+ * @param {object} options
75
+ * @param {import('@temporalio/worker').NativeConnection} options.connection - Temporal connection
76
+ * @param {AbortSignal} options.signal - stops watching when aborted
77
+ * @param {object} [options.overrides] - health check tuning, mostly for tests
78
+ */
79
+ constructor( { connection, signal, overrides = {} } ) {
62
80
  this.#connection = connection;
81
+ this.#signal = signal;
63
82
  if ( Number.isFinite( overrides?.maxFailures ) ) {
64
- this.#MAX_FAILURES = overrides.maxFailures;
83
+ this.#maxFailures = overrides.maxFailures;
65
84
  }
66
85
  if ( Number.isFinite( overrides?.checkIntervalMs ) ) {
67
- this.#CHECK_INTERVAL_MS = overrides.checkIntervalMs;
86
+ this.#checkIntervalMs = overrides.checkIntervalMs;
68
87
  }
69
88
  if ( Number.isFinite( overrides?.checkTimeoutMs ) ) {
70
- this.#CHECK_TIMEOUT_MS = overrides.checkTimeoutMs;
89
+ this.#checkTimeoutMs = overrides.checkTimeoutMs;
71
90
  }
72
91
  }
73
92
 
74
- onConnectionLost( cb ) {
75
- this.#connectionLostCb = cb;
76
- }
77
-
93
+ /** Returns whether the monitor is watching */
78
94
  get running() {
79
95
  return this.#running;
80
96
  }
81
97
 
98
+ /**
99
+ * Starts watching the connection. Aborting the signal stops the watch.
100
+ * @returns {Promise<void>} resolves when the watch has stopped, rejects when the connection is lost
101
+ */
82
102
  start() {
83
- if ( this.#watchPromise ) {
84
- return this.#watchPromise;
103
+ if ( this.#execution ) {
104
+ return this.#execution;
105
+ }
106
+
107
+ const signal = AbortSignal.any( [ this.#signal, this.#abortCtrl.signal ] );
108
+
109
+ if ( signal.aborted ) {
110
+ return Promise.resolve();
85
111
  }
112
+
86
113
  this.#running = true;
87
- this.#watchPromise = this.#watch().finally( () => {
114
+ this.#execution = this.#watch( signal ).finally( () => {
88
115
  this.#running = false;
89
116
  } );
90
- return this.#watchPromise;
91
- }
92
117
 
93
- stop() {
94
- this.#cancellation.complete();
95
- return this.#watchPromise ?? Promise.resolve();
118
+ return this.#execution;
96
119
  }
97
120
 
98
- get connectionLossError() {
99
- return this.#error;
121
+ /**
122
+ * Stops watching, without reporting a connection loss.
123
+ * @returns {Promise<void>} resolves when the watch has fully stopped, rejects when it failed
124
+ */
125
+ stop() {
126
+ this.#abortCtrl.abort();
127
+ return this.#execution ?? Promise.resolve();
100
128
  }
101
129
  };