@outputai/core 0.12.0 → 0.12.1-next.1243f78.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.
@@ -69,8 +69,8 @@ describe( 'worker/configs', () => {
69
69
  expect( configs.workerTelemetryIntervalMs ).toBe( 0 );
70
70
  expect( configs.activityHeartbeatIntervalMs ).toBe( 2 * 60 * 1000 );
71
71
  expect( configs.activityHeartbeatEnabled ).toBe( true );
72
- expect( configs.shutdownForceTime ).toBeUndefined();
73
- expect( configs.shutdownGraceTime ).toBeUndefined();
72
+ expect( configs.shutdownForceTime ).toBe( '25s' );
73
+ expect( configs.shutdownGraceTime ).toBe( '20s' );
74
74
  expect( configs.taskQueue ).toBe( 'test-catalog' );
75
75
  expect( configs.catalogId ).toBe( 'test-catalog' );
76
76
  } );
@@ -136,15 +136,15 @@ describe( 'worker/configs', () => {
136
136
  expect( configs.shutdownGraceTime ).toBe( '15s' );
137
137
  } );
138
138
 
139
- it( 'treats empty Temporal shutdown durations as unset', async () => {
139
+ it( 'falls back to the defaults when Temporal shutdown durations are empty', async () => {
140
140
  setEnv( {
141
141
  TEMPORAL_SHUTDOWN_FORCE_TIME: '',
142
142
  TEMPORAL_SHUTDOWN_GRACE_TIME: ''
143
143
  } );
144
144
  const configs = await loadConfigs();
145
145
 
146
- expect( configs.shutdownForceTime ).toBeUndefined();
147
- expect( configs.shutdownGraceTime ).toBeUndefined();
146
+ expect( configs.shutdownForceTime ).toBe( '25s' );
147
+ expect( configs.shutdownGraceTime ).toBe( '20s' );
148
148
  } );
149
149
 
150
150
  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
  };
@@ -1,44 +1,51 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { TemporalConnectionMonitor } from './connection_monitor.js';
2
+ import { ConnectionLostError, TemporalConnectionMonitor } from './connection_monitor.js';
3
3
 
4
4
  const CHECK_TIMEOUT_MS = 50;
5
5
  const CHECK_INTERVAL_MS = 100;
6
6
 
7
- const { scheduledDelays, delayMock, mockLogger } = vi.hoisted( () => {
7
+ const { scheduledDelays, delayMock, mockLog } = vi.hoisted( () => {
8
8
  const scheduledDelays = [];
9
- const delayMock = vi.fn( ( ms, value, options ) => new Promise( resolve => {
9
+ const abortError = () => Object.assign( new Error( 'The operation was aborted' ), { name: 'AbortError' } );
10
+
11
+ // Mirrors node:timers/promises: rejects with an AbortError when the signal aborts, or if already aborted.
12
+ const delayMock = vi.fn( ( ms, value, options ) => new Promise( ( resolve, reject ) => {
13
+ const signal = options?.signal;
14
+
15
+ if ( signal?.aborted ) {
16
+ reject( abortError() );
17
+ return;
18
+ }
19
+
10
20
  scheduledDelays.push( { ms, value, options, resolve } );
21
+ signal?.addEventListener( 'abort', () => reject( abortError() ), { once: true } );
11
22
  } ) );
12
23
 
13
24
  return {
14
25
  scheduledDelays,
15
26
  delayMock,
16
- mockLogger: {
17
- info: vi.fn(),
18
- warn: vi.fn()
19
- }
27
+ mockLog: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }
20
28
  };
21
29
  } );
22
30
 
23
31
  vi.mock( 'node:timers/promises', () => ( { setTimeout: delayMock } ) );
24
- vi.mock( '#logger', () => ( { createChildLogger: vi.fn( () => mockLogger ) } ) );
32
+ vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
25
33
 
26
- const createConnection = check => ( {
27
- workflowService: { getSystemInfo: check }
28
- } );
34
+ const delayOptions = { ref: false, signal: expect.any( AbortSignal ) };
29
35
 
30
- const createMonitor = ( check, overrides = {} ) => new TemporalConnectionMonitor( createConnection( check ), {
31
- checkIntervalMs: CHECK_INTERVAL_MS,
32
- checkTimeoutMs: CHECK_TIMEOUT_MS,
33
- ...overrides
34
- } );
36
+ const createMonitor = ( check, overrides = {}, signal = new AbortController().signal ) =>
37
+ new TemporalConnectionMonitor( {
38
+ connection: { workflowService: { getSystemInfo: check } },
39
+ signal,
40
+ overrides: { checkIntervalMs: CHECK_INTERVAL_MS, checkTimeoutMs: CHECK_TIMEOUT_MS, ...overrides }
41
+ } );
35
42
 
36
43
  const flushPromises = async () => Array
37
44
  .from( { length: 10 } )
38
45
  .reduce( promise => promise.then( () => Promise.resolve() ), Promise.resolve() );
39
46
 
40
47
  const resolveNextDelay = ms => {
41
- const index = scheduledDelays.findIndex( delay => delay.ms === ms );
48
+ const index = scheduledDelays.findIndex( scheduled => scheduled.ms === ms );
42
49
  expect( index ).not.toBe( -1 );
43
50
  const [ scheduled ] = scheduledDelays.splice( index, 1 );
44
51
  scheduled.resolve( scheduled.value );
@@ -50,133 +57,272 @@ describe( 'TemporalConnectionMonitor', () => {
50
57
  scheduledDelays.length = 0;
51
58
  } );
52
59
 
53
- it( 'logs healthy when the workflow service is reachable', async () => {
54
- const check = vi.fn().mockResolvedValue( {} );
55
- const monitor = createMonitor( check );
60
+ describe( 'health checks', () => {
61
+ it( 'logs healthy and schedules the next check when the workflow service is reachable', async () => {
62
+ const check = vi.fn().mockResolvedValue( {} );
63
+ const monitor = createMonitor( check );
56
64
 
57
- const run = monitor.start();
58
- await flushPromises();
65
+ const run = monitor.start();
66
+ await flushPromises();
59
67
 
60
- expect( check ).toHaveBeenCalledWith( {} );
61
- expect( mockLogger.info ).toHaveBeenCalledWith( 'Healthy' );
62
- expect( delayMock ).toHaveBeenCalledWith( CHECK_TIMEOUT_MS, 0, { ref: false } );
63
- expect( delayMock ).toHaveBeenCalledWith( CHECK_INTERVAL_MS, 0, { ref: false } );
64
- expect( monitor.running ).toBe( true );
68
+ expect( check ).toHaveBeenCalledWith( {} );
69
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Healthy' );
70
+ expect( delayMock ).toHaveBeenCalledWith( CHECK_TIMEOUT_MS, 0, delayOptions );
71
+ expect( delayMock ).toHaveBeenCalledWith( CHECK_INTERVAL_MS, true, delayOptions );
72
+ expect( monitor.running ).toBe( true );
65
73
 
66
- await monitor.stop();
67
- await run;
74
+ await monitor.stop();
75
+ await run;
68
76
 
69
- expect( monitor.running ).toBe( false );
70
- } );
77
+ expect( monitor.running ).toBe( false );
78
+ } );
71
79
 
72
- it( 'logs transient timeout failures before retrying', async () => {
73
- const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
74
- const monitor = createMonitor( check );
80
+ it( 'counts a timed out check as a transient failure', async () => {
81
+ const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
82
+ const monitor = createMonitor( check );
75
83
 
76
- monitor.start();
77
- resolveNextDelay( CHECK_TIMEOUT_MS );
78
- await flushPromises();
84
+ const run = monitor.start();
85
+ resolveNextDelay( CHECK_TIMEOUT_MS );
86
+ await flushPromises();
79
87
 
80
- expect( mockLogger.warn ).toHaveBeenCalledWith( 'Connection unhealthy', {
81
- error: 'Connection health check timed out',
82
- failures: 1
88
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Connection unhealthy', {
89
+ error: 'Connection health check timed out',
90
+ failures: 1
91
+ } );
92
+
93
+ await monitor.stop();
94
+ await run;
83
95
  } );
84
96
 
85
- await monitor.stop();
86
- } );
97
+ it( 'logs recovered without a healthy line when the first check fails', async () => {
98
+ const check = vi.fn()
99
+ .mockRejectedValueOnce( new Error( 'temporary outage' ) )
100
+ .mockResolvedValue( {} );
101
+ const monitor = createMonitor( check );
87
102
 
88
- it( 'logs recovered after a transient failure succeeds', async () => {
89
- const check = vi.fn()
90
- .mockRejectedValueOnce( new Error( 'temporary outage' ) )
91
- .mockResolvedValueOnce( {} );
92
- const monitor = createMonitor( check );
103
+ const run = monitor.start();
104
+ await flushPromises();
93
105
 
94
- monitor.start();
95
- await flushPromises();
106
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Connection unhealthy', { error: 'temporary outage', failures: 1 } );
96
107
 
97
- expect( mockLogger.warn ).toHaveBeenCalledWith( 'Connection unhealthy', {
98
- error: 'temporary outage',
99
- failures: 1
108
+ resolveNextDelay( CHECK_INTERVAL_MS );
109
+ await flushPromises();
110
+
111
+ expect( mockLog.info ).toHaveBeenCalledOnce();
112
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Recovered' );
113
+
114
+ await monitor.stop();
115
+ await run;
100
116
  } );
101
117
 
102
- resolveNextDelay( CHECK_INTERVAL_MS );
103
- await flushPromises();
118
+ it( 'logs healthy once while the connection stays reachable', async () => {
119
+ const check = vi.fn().mockResolvedValue( {} );
120
+ const monitor = createMonitor( check );
104
121
 
105
- expect( mockLogger.info ).toHaveBeenCalledWith( 'Recovered' );
122
+ const run = monitor.start();
123
+ await flushPromises();
124
+ resolveNextDelay( CHECK_INTERVAL_MS );
125
+ await flushPromises();
126
+ resolveNextDelay( CHECK_INTERVAL_MS );
127
+ await flushPromises();
106
128
 
107
- await monitor.stop();
108
- } );
129
+ expect( check ).toHaveBeenCalledTimes( 3 );
130
+ expect( mockLog.info ).toHaveBeenCalledOnce();
131
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Healthy' );
109
132
 
110
- it( 'stores connection loss error and calls callback after max consecutive failures', async () => {
111
- const error = new Error( 'connection refused' );
112
- const check = vi.fn().mockRejectedValue( error );
113
- const connectionLost = vi.fn();
114
- const monitor = createMonitor( check );
115
-
116
- monitor.onConnectionLost( connectionLost );
117
- monitor.start();
118
-
119
- await flushPromises();
120
- resolveNextDelay( CHECK_INTERVAL_MS );
121
- await flushPromises();
122
- resolveNextDelay( CHECK_INTERVAL_MS );
123
- await flushPromises();
124
-
125
- expect( mockLogger.warn ).toHaveBeenCalledTimes( 3 );
126
- expect( mockLogger.warn ).toHaveBeenCalledWith( 'Connection lost', {
127
- error: 'connection refused',
128
- failures: 3
129
- } );
130
- expect( connectionLost ).toHaveBeenCalledWith( error );
131
- expect( monitor.connectionLossError ).toBe( error );
132
- expect( monitor.running ).toBe( false );
133
+ await monitor.stop();
134
+ await run;
135
+ } );
136
+
137
+ it( 'logs recovered on a later failure without repeating healthy', async () => {
138
+ const check = vi.fn()
139
+ .mockResolvedValueOnce( {} )
140
+ .mockRejectedValueOnce( new Error( 'temporary outage' ) )
141
+ .mockResolvedValue( {} );
142
+ const monitor = createMonitor( check );
143
+
144
+ const run = monitor.start();
145
+ await flushPromises();
146
+ resolveNextDelay( CHECK_INTERVAL_MS );
147
+ await flushPromises();
148
+ resolveNextDelay( CHECK_INTERVAL_MS );
149
+ await flushPromises();
150
+
151
+ expect( mockLog.info ).toHaveBeenCalledTimes( 2 );
152
+ expect( mockLog.info ).toHaveBeenNthCalledWith( 1, 'Healthy' );
153
+ expect( mockLog.info ).toHaveBeenNthCalledWith( 2, 'Recovered' );
154
+
155
+ await monitor.stop();
156
+ await run;
157
+ } );
158
+
159
+ it( 'describes non error rejections with their string value', async () => {
160
+ const check = vi.fn().mockRejectedValue( 'socket hang up' );
161
+ const monitor = createMonitor( check, { maxFailures: 2 } );
162
+
163
+ const run = monitor.start();
164
+ const rejection = expect( run ).rejects.toThrow( ConnectionLostError );
165
+
166
+ await flushPromises();
167
+ resolveNextDelay( CHECK_INTERVAL_MS );
168
+ await flushPromises();
169
+ await rejection;
170
+
171
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Connection unhealthy', { error: 'socket hang up', failures: 1 } );
172
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Connection lost', { error: 'socket hang up', failures: 2 } );
173
+ } );
133
174
  } );
134
175
 
135
- it( 'returns the same lifecycle promise when started more than once', async () => {
136
- const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
137
- const monitor = createMonitor( check );
176
+ describe( 'connection loss', () => {
177
+ it( 'rejects after the maximum consecutive failures', async () => {
178
+ const check = vi.fn().mockRejectedValue( new Error( 'connection refused' ) );
179
+ const monitor = createMonitor( check, { maxFailures: 3 } );
180
+
181
+ const run = monitor.start();
182
+ const rejection = expect( run ).rejects.toThrow( ConnectionLostError );
183
+
184
+ await flushPromises();
185
+ resolveNextDelay( CHECK_INTERVAL_MS );
186
+ await flushPromises();
187
+ resolveNextDelay( CHECK_INTERVAL_MS );
188
+ await flushPromises();
189
+ await rejection;
190
+
191
+ expect( check ).toHaveBeenCalledTimes( 3 );
192
+ expect( mockLog.warn ).toHaveBeenCalledTimes( 3 );
193
+ expect( mockLog.warn ).toHaveBeenCalledWith( 'Connection lost', { error: 'connection refused', failures: 3 } );
194
+ expect( monitor.running ).toBe( false );
195
+ } );
196
+
197
+ it( 'preserves the original failure as the error cause', async () => {
198
+ const error = new Error( 'connection refused' );
199
+ const check = vi.fn().mockRejectedValue( error );
200
+ const monitor = createMonitor( check, { maxFailures: 1 } );
138
201
 
139
- const firstRun = monitor.start();
140
- const secondRun = monitor.start();
202
+ await expect( monitor.start() ).rejects.toMatchObject( {
203
+ name: 'ConnectionLostError',
204
+ message: 'Connection lost',
205
+ cause: error
206
+ } );
207
+ } );
141
208
 
142
- expect( secondRun ).toBe( firstRun );
143
- expect( check ).toHaveBeenCalledOnce();
209
+ it( 'stops watching once the connection is lost', async () => {
210
+ const check = vi.fn().mockRejectedValue( new Error( 'connection refused' ) );
211
+ const monitor = createMonitor( check, { maxFailures: 1 } );
212
+
213
+ await expect( monitor.start() ).rejects.toThrow( ConnectionLostError );
214
+ await flushPromises();
215
+
216
+ expect( check ).toHaveBeenCalledOnce();
217
+ expect( monitor.running ).toBe( false );
218
+ } );
144
219
 
145
- await monitor.stop();
220
+ it( 'reports the loss through stop so it is never silent', async () => {
221
+ const check = vi.fn().mockRejectedValue( new Error( 'connection refused' ) );
222
+ const monitor = createMonitor( check, { maxFailures: 1 } );
223
+
224
+ await expect( monitor.start() ).rejects.toThrow( ConnectionLostError );
225
+ await expect( monitor.stop() ).rejects.toThrow( ConnectionLostError );
226
+ } );
146
227
  } );
147
228
 
148
- it( 'stops without calling connection lost callback for in-flight health checks', async () => {
149
- const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
150
- const connectionLost = vi.fn();
151
- const monitor = createMonitor( check, { maxFailures: 1 } );
229
+ describe( 'abort', () => {
230
+ it( 'never checks when the signal is already aborted', async () => {
231
+ const controller = new AbortController();
232
+ controller.abort();
233
+ const check = vi.fn();
234
+ const monitor = createMonitor( check, {}, controller.signal );
235
+
236
+ await expect( monitor.start() ).resolves.toBeUndefined();
237
+
238
+ expect( check ).not.toHaveBeenCalled();
239
+ expect( monitor.running ).toBe( false );
240
+ } );
241
+
242
+ it( 'stops without reporting when aborted during a check', async () => {
243
+ const controller = new AbortController();
244
+ const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
245
+ const monitor = createMonitor( check, {}, controller.signal );
246
+
247
+ const run = monitor.start();
248
+ await flushPromises();
152
249
 
153
- monitor.onConnectionLost( connectionLost );
154
- monitor.start();
250
+ expect( monitor.running ).toBe( true );
155
251
 
156
- expect( monitor.running ).toBe( true );
252
+ controller.abort();
157
253
 
158
- await monitor.stop();
254
+ await expect( run ).resolves.toBeUndefined();
255
+ expect( mockLog.warn ).not.toHaveBeenCalled();
256
+ expect( monitor.running ).toBe( false );
257
+ } );
258
+
259
+ it( 'stops without reporting when aborted between checks', async () => {
260
+ const controller = new AbortController();
261
+ const check = vi.fn().mockResolvedValue( {} );
262
+ const monitor = createMonitor( check, {}, controller.signal );
263
+
264
+ const run = monitor.start();
265
+ await flushPromises();
266
+
267
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Healthy' );
159
268
 
160
- expect( connectionLost ).not.toHaveBeenCalled();
161
- expect( monitor.connectionLossError ).toBeNull();
162
- expect( monitor.running ).toBe( false );
269
+ controller.abort();
270
+
271
+ await expect( run ).resolves.toBeUndefined();
272
+ expect( check ).toHaveBeenCalledOnce();
273
+ expect( mockLog.warn ).not.toHaveBeenCalled();
274
+ } );
163
275
  } );
164
276
 
165
- it( 'applies timing and failure threshold overrides', async () => {
166
- const error = new Error( 'fast failure' );
167
- const check = vi.fn().mockRejectedValue( error );
168
- const connectionLost = vi.fn();
169
- const monitor = createMonitor( check, {
170
- maxFailures: 1,
171
- checkIntervalMs: 7,
172
- checkTimeoutMs: 3
277
+ describe( 'lifecycle', () => {
278
+ it( 'returns the same watch when started more than once', async () => {
279
+ const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
280
+ const monitor = createMonitor( check );
281
+
282
+ const first = monitor.start();
283
+ const second = monitor.start();
284
+
285
+ expect( second ).toBe( first );
286
+ expect( check ).toHaveBeenCalledOnce();
287
+
288
+ await monitor.stop();
289
+ await first;
290
+ } );
291
+
292
+ it( 'stops an in flight check without reporting a failure', async () => {
293
+ const check = vi.fn().mockReturnValue( new Promise( () => {} ) );
294
+ const monitor = createMonitor( check, { maxFailures: 1 } );
295
+
296
+ const run = monitor.start();
297
+
298
+ expect( monitor.running ).toBe( true );
299
+
300
+ await monitor.stop();
301
+ await run;
302
+
303
+ expect( mockLog.warn ).not.toHaveBeenCalled();
304
+ expect( monitor.running ).toBe( false );
173
305
  } );
174
306
 
175
- monitor.onConnectionLost( connectionLost );
176
- await monitor.start();
307
+ it( 'never watches when stopped before starting', async () => {
308
+ const check = vi.fn();
309
+ const monitor = createMonitor( check );
310
+
311
+ await expect( monitor.stop() ).resolves.toBeUndefined();
312
+ await expect( monitor.start() ).resolves.toBeUndefined();
313
+
314
+ expect( check ).not.toHaveBeenCalled();
315
+ expect( monitor.running ).toBe( false );
316
+ } );
177
317
 
178
- expect( delayMock ).toHaveBeenCalledWith( 3, 0, { ref: false } );
179
- expect( delayMock ).not.toHaveBeenCalledWith( 7, 0, { ref: false } );
180
- expect( connectionLost ).toHaveBeenCalledWith( error );
318
+ it( 'applies the timing and failure threshold overrides', async () => {
319
+ const check = vi.fn().mockRejectedValue( new Error( 'fast failure' ) );
320
+ const monitor = createMonitor( check, { maxFailures: 1, checkIntervalMs: 7, checkTimeoutMs: 3 } );
321
+
322
+ await expect( monitor.start() ).rejects.toThrow( ConnectionLostError );
323
+
324
+ expect( delayMock ).toHaveBeenCalledWith( 3, 0, delayOptions );
325
+ expect( delayMock ).not.toHaveBeenCalledWith( 7, true, delayOptions );
326
+ } );
181
327
  } );
182
328
  } );