@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.
@@ -1,13 +1,19 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { BusEventType } from '#consts';
2
3
 
3
4
  const {
4
- catalogJobInstance,
5
5
  bindGlobalFunctionsMock,
6
+ catalogPublisherInstance,
6
7
  configValues,
7
8
  connectionMonitorInstance,
8
9
  createCatalogMock,
9
10
  flushPendingHooksMock,
11
+ hashSourceCodeMock,
10
12
  initInterceptorsMock,
13
+ interruption,
14
+ loadActivitiesMock,
15
+ loadHooksMock,
16
+ loadWorkflowsMock,
11
17
  mainEventBusMock,
12
18
  mockConnection,
13
19
  mockLog,
@@ -17,8 +23,10 @@ const {
17
23
  serializeErrorMock,
18
24
  setupClientConfigMock,
19
25
  setupInterruptionHandlerMock,
26
+ setupTelemetryMock,
20
27
  setupTemporalLoggerMock,
21
- setupTelemetryMock
28
+ shutdownServicesMock,
29
+ workerRunnerInstance
22
30
  } = vi.hoisted( () => {
23
31
  const createDeferred = () => {
24
32
  const state = {};
@@ -31,9 +39,8 @@ const {
31
39
 
32
40
  const promises = {};
33
41
  const resetPromises = () => {
34
- promises.workerRun = createDeferred();
42
+ promises.workerRunner = createDeferred();
35
43
  promises.connectionMonitor = createDeferred();
36
- promises.catalogJob = createDeferred();
37
44
  };
38
45
  resetPromises();
39
46
 
@@ -54,122 +61,139 @@ const {
54
61
  shutdownGraceTime: undefined
55
62
  };
56
63
 
57
- const connectionMonitorInstance = {
64
+ /**
65
+ * The real runner only settles once the worker finished draining, which is triggered by stop().
66
+ * Aborting alone leaves it running, so the shutdown sequence is the one awaiting the drain.
67
+ */
68
+ const workerRunnerInstance = {
58
69
  running: false,
59
- connectionLossError: null,
60
- onConnectionLost: vi.fn( cb => {
61
- connectionMonitorInstance.connectionLostCb = cb;
70
+ options: null,
71
+ start: vi.fn( () => {
72
+ workerRunnerInstance.running = true;
73
+ return promises.workerRunner.promise.finally( () => {
74
+ workerRunnerInstance.running = false;
75
+ } );
62
76
  } ),
77
+ stop: vi.fn( () => {
78
+ promises.workerRunner.resolve();
79
+ return promises.workerRunner.promise.catch( () => {} );
80
+ } )
81
+ };
82
+
83
+ /** The real monitor exits its loop as soon as the signal aborts, so it settles on its own. */
84
+ const connectionMonitorInstance = {
85
+ running: false,
86
+ options: null,
63
87
  start: vi.fn( () => {
64
88
  connectionMonitorInstance.running = true;
89
+ connectionMonitorInstance.options?.signal.addEventListener(
90
+ 'abort',
91
+ () => promises.connectionMonitor.resolve(),
92
+ { once: true }
93
+ );
65
94
  return promises.connectionMonitor.promise.finally( () => {
66
95
  connectionMonitorInstance.running = false;
67
96
  } );
68
97
  } ),
69
98
  stop: vi.fn( () => {
70
- connectionMonitorInstance.running = false;
71
99
  promises.connectionMonitor.resolve();
72
- return promises.connectionMonitor.promise;
73
- } ),
74
- connectionLostCb: null
100
+ return promises.connectionMonitor.promise.catch( () => {} );
101
+ } )
75
102
  };
76
103
 
77
- const catalogJobInstance = {
104
+ const catalogPublisherInstance = {
78
105
  running: false,
79
- error: null,
80
- onError: vi.fn( cb => {
81
- catalogJobInstance.errorCb = cb;
82
- } ),
83
- run: vi.fn( () => {
84
- catalogJobInstance.running = true;
85
- return promises.catalogJob.promise.finally( () => {
86
- catalogJobInstance.running = false;
87
- } );
88
- } ),
89
- interrupt: vi.fn( () => {
90
- catalogJobInstance.running = false;
91
- promises.catalogJob.resolve();
92
- return promises.catalogJob.promise;
93
- } ),
94
- errorCb: null
106
+ options: null,
107
+ run: vi.fn().mockResolvedValue( undefined ),
108
+ interrupt: vi.fn().mockResolvedValue( undefined )
109
+ };
110
+
111
+ class KillSignError extends Error {
112
+ name = 'KillSignError';
95
113
  };
96
114
 
97
- const mockWorker = {
98
- getStatus: vi.fn( () => ( { runState: mockWorker.runState } ) ),
99
- run: vi.fn( () => promises.workerRun.promise ),
100
- runState: 'RUNNING',
101
- shutdown: vi.fn()
115
+ class UncaughtError extends Error {
116
+ name = 'UncaughtError';
102
117
  };
103
118
 
119
+ const interruption = { controller: null, KillSignError, UncaughtError };
120
+
104
121
  return {
105
- catalogJobInstance,
106
122
  bindGlobalFunctionsMock: vi.fn(),
123
+ catalogPublisherInstance,
107
124
  configValues,
108
125
  connectionMonitorInstance,
109
- createCatalogMock: vi.fn().mockReturnValue( { workflows: [], activities: {} } ),
110
- createWorkflowsEntryPointMock: vi.fn().mockReturnValue( '/fake/workflows/path.js' ),
126
+ createCatalogMock: vi.fn().mockReturnValue( { workflowNames: [ 'demo' ] } ),
111
127
  flushPendingHooksMock: vi.fn().mockResolvedValue( undefined ),
112
128
  hashSourceCodeMock: vi.fn().mockResolvedValue( 'catalog-hash' ),
113
129
  initInterceptorsMock: vi.fn().mockReturnValue( [] ),
114
- loadActivitiesMock: vi.fn().mockResolvedValue( {} ),
130
+ interruption,
131
+ loadActivitiesMock: vi.fn().mockResolvedValue( { activities: {} } ),
115
132
  loadHooksMock: vi.fn().mockResolvedValue( undefined ),
116
- loadWorkflowsMock: vi.fn().mockResolvedValue( [] ),
133
+ loadWorkflowsMock: vi.fn().mockResolvedValue( { workflows: [], entrypoint: '/fake/workflows/path.js' } ),
117
134
  mainEventBusMock: { emit: vi.fn(), on: vi.fn() },
118
135
  mockConnection: { close: vi.fn().mockResolvedValue( undefined ) },
119
136
  mockLog: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
120
- mockWorker,
137
+ mockWorker: { name: 'temporal-worker' },
121
138
  promises,
122
139
  resetPromises,
123
- serializeErrorMock: vi.fn( error => ( {
124
- name: error.name,
125
- message: error.message,
126
- stack: error.stack
127
- } ) ),
140
+ serializeErrorMock: vi.fn( error => ( { name: error.name, message: error.message } ) ),
128
141
  setupClientConfigMock: vi.fn(),
129
- setupInterruptionHandlerMock: vi.fn(),
142
+ setupInterruptionHandlerMock: vi.fn( controller => {
143
+ interruption.controller = controller;
144
+ } ),
145
+ setupTelemetryMock: vi.fn(),
130
146
  setupTemporalLoggerMock: vi.fn(),
131
- setupTelemetryMock: vi.fn()
147
+ shutdownServicesMock: vi.fn().mockResolvedValue( [] ),
148
+ workerRunnerInstance
132
149
  };
133
150
  } );
134
151
 
152
+ const initTracing = vi.fn().mockResolvedValue( undefined );
153
+
135
154
  vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
136
155
  vi.mock( '#helpers/error_serializer', () => ( { serializeError: serializeErrorMock } ) );
137
- const initTracing = vi.fn().mockResolvedValue( undefined );
138
156
  vi.mock( '#tracing', () => ( { init: initTracing } ) );
139
157
  vi.mock( '#bus', () => ( { mainEventBus: mainEventBusMock } ) );
140
158
  vi.mock( '#hooks/pending_hooks', () => ( { flushPendingHooks: flushPendingHooksMock } ) );
141
-
142
- const loadWorkflowsMock = vi.fn().mockResolvedValue( { workflows: [], entrypoint: '/fake/workflows/path.js' } );
143
- const loadActivitiesMock = vi.fn().mockResolvedValue( { activities: {} } );
144
- const loadHooksMock = vi.fn().mockResolvedValue( undefined );
159
+ vi.mock( '#temporal/client', () => ( { setupClientConfig: setupClientConfigMock } ) );
160
+ vi.mock( './configs.js', () => configValues );
145
161
  vi.mock( './loader/workflows.js', () => ( { loadWorkflows: loadWorkflowsMock } ) );
146
162
  vi.mock( './loader/activities.js', () => ( { loadActivities: loadActivitiesMock } ) );
147
163
  vi.mock( './loader/hooks.js', () => ( { loadHooks: loadHooksMock } ) );
148
- vi.mock( './configs.js', () => configValues );
149
-
150
- const hashSourceCodeMock = vi.fn().mockResolvedValue( 'catalog-hash' );
151
164
  vi.mock( './loader/tools.js', () => ( { hashSourceCode: hashSourceCodeMock } ) );
152
-
153
165
  vi.mock( './sinks.js', () => ( { sinks: {} } ) );
154
166
  vi.mock( './catalog_workflow/index.js', () => ( { createCatalog: createCatalogMock } ) );
155
167
  vi.mock( './bundler_options.js', () => ( { webpackConfigHook: vi.fn() } ) );
156
168
  vi.mock( './interceptors/index.js', () => ( { initInterceptors: initInterceptorsMock } ) );
157
169
  vi.mock( './telemetry.js', () => ( { setupTelemetry: setupTelemetryMock } ) );
158
- vi.mock( './interruption.js', () => ( { setupInterruptionHandler: setupInterruptionHandlerMock } ) );
159
170
  vi.mock( './global_functions.js', () => ( { bindGlobalFunctions: bindGlobalFunctionsMock } ) );
171
+ vi.mock( './temporal_logger.js', () => ( { setupTemporalLogger: setupTemporalLoggerMock } ) );
172
+ vi.mock( './log_hooks.js', () => ( {} ) );
173
+ vi.mock( './interruption.js', () => ( {
174
+ setupInterruptionHandler: setupInterruptionHandlerMock,
175
+ KillSignError: interruption.KillSignError,
176
+ UncaughtError: interruption.UncaughtError
177
+ } ) );
178
+ vi.mock( './worker_runner.js', () => ( {
179
+ WorkerRunner: vi.fn( function ( options ) {
180
+ workerRunnerInstance.options = options;
181
+ return workerRunnerInstance;
182
+ } )
183
+ } ) );
160
184
  vi.mock( './connection_monitor.js', () => ( {
161
- TemporalConnectionMonitor: vi.fn( function () {
185
+ TemporalConnectionMonitor: vi.fn( function ( options ) {
186
+ connectionMonitorInstance.options = options;
162
187
  return connectionMonitorInstance;
163
188
  } )
164
189
  } ) );
165
- vi.mock( './catalog_workflow/catalog_job.js', () => ( {
166
- CatalogJob: vi.fn( function () {
167
- return catalogJobInstance;
190
+ vi.mock( './catalog_workflow/catalog_publisher.js', () => ( {
191
+ CatalogPublisher: vi.fn( function ( options ) {
192
+ catalogPublisherInstance.options = options;
193
+ return catalogPublisherInstance;
168
194
  } )
169
195
  } ) );
170
- vi.mock( './log_hooks.js', () => ( {} ) );
171
- vi.mock( './temporal_logger.js', () => ( { setupTemporalLogger: setupTemporalLoggerMock } ) );
172
- vi.mock( '#temporal/client', () => ( { setupClientConfig: setupClientConfigMock } ) );
196
+ vi.mock( './shutdown.js', () => ( { shutdownServices: shutdownServicesMock } ) );
173
197
  vi.mock( '@temporalio/worker', () => ( {
174
198
  NativeConnection: { connect: vi.fn().mockResolvedValue( mockConnection ) },
175
199
  Worker: { create: vi.fn().mockResolvedValue( mockWorker ) }
@@ -180,15 +204,18 @@ const importWorker = async () => {
180
204
  await import( './index.js' );
181
205
  };
182
206
 
183
- const settleWorker = async () => {
184
- promises.catalogJob.resolve();
185
- promises.connectionMonitor.resolve();
186
- promises.workerRun.resolve();
187
- await vi.waitFor( () => expect( mockConnection.close ).toHaveBeenCalled() );
207
+ /** Imports the worker and waits until it is polling, which is where a healthy boot settles. */
208
+ const bootWorker = async () => {
209
+ await importWorker();
210
+ await vi.waitFor( () => {
211
+ expect( workerRunnerInstance.start ).toHaveBeenCalled();
212
+ expect( connectionMonitorInstance.start ).toHaveBeenCalled();
213
+ } );
188
214
  };
189
215
 
216
+ const waitForExit = () => vi.waitFor( () => expect( process.exit ).toHaveBeenCalled() );
217
+
190
218
  describe( 'worker/index', () => {
191
- const exitMock = vi.fn();
192
219
  const originalArgv = process.argv;
193
220
 
194
221
  beforeEach( () => {
@@ -199,16 +226,16 @@ describe( 'worker/index', () => {
199
226
  configValues.workerTuner = undefined;
200
227
  configValues.shutdownForceTime = undefined;
201
228
  configValues.shutdownGraceTime = undefined;
202
- catalogJobInstance.error = null;
203
- catalogJobInstance.errorCb = null;
204
- catalogJobInstance.running = false;
205
- connectionMonitorInstance.connectionLossError = null;
206
- connectionMonitorInstance.connectionLostCb = null;
229
+ workerRunnerInstance.running = false;
230
+ workerRunnerInstance.options = null;
207
231
  connectionMonitorInstance.running = false;
232
+ connectionMonitorInstance.options = null;
233
+ catalogPublisherInstance.running = false;
234
+ catalogPublisherInstance.options = null;
235
+ interruption.controller = null;
208
236
  mockConnection.close.mockResolvedValue( undefined );
209
- mockWorker.runState = 'RUNNING';
210
237
  process.argv = [ ...originalArgv.slice( 0, 2 ), '/test/caller/dir' ];
211
- vi.spyOn( process, 'exit' ).mockImplementation( exitMock );
238
+ vi.spyOn( process, 'exit' ).mockImplementation( () => undefined );
212
239
  } );
213
240
 
214
241
  afterEach( () => {
@@ -216,225 +243,284 @@ describe( 'worker/index', () => {
216
243
  vi.restoreAllMocks();
217
244
  } );
218
245
 
219
- it( 'creates the worker lifecycle jobs with expected dependencies', async () => {
220
- const { NativeConnection, Worker } = await import( '@temporalio/worker' );
221
- const { TemporalConnectionMonitor } = await import( './connection_monitor.js' );
222
- const { CatalogJob } = await import( './catalog_workflow/catalog_job.js' );
223
-
224
- await importWorker();
225
-
226
- await vi.waitFor( () => expect( Worker.create ).toHaveBeenCalled() );
227
-
228
- expect( loadHooksMock ).toHaveBeenCalledWith( '/test/caller/dir' );
229
- expect( setupTemporalLoggerMock ).toHaveBeenCalledOnce();
230
- expect( loadWorkflowsMock ).toHaveBeenCalledWith( '/test/caller/dir' );
231
- expect( loadActivitiesMock ).toHaveBeenCalledWith( '/test/caller/dir', [] );
232
- expect( initTracing ).toHaveBeenCalled();
233
- expect( createCatalogMock ).toHaveBeenCalledWith( { workflows: [], activities: {} } );
234
- expect( hashSourceCodeMock ).toHaveBeenCalledWith( '/test/caller/dir' );
235
- expect( NativeConnection.connect ).toHaveBeenCalledWith( {
236
- address: configValues.address,
237
- tls: false,
238
- apiKey: undefined,
239
- proxy: undefined
246
+ describe( 'startup', () => {
247
+ it( 'wires every lifecycle component with the shared abort signal', async () => {
248
+ const { NativeConnection, Worker } = await import( '@temporalio/worker' );
249
+ const { TemporalConnectionMonitor } = await import( './connection_monitor.js' );
250
+ const { CatalogPublisher } = await import( './catalog_workflow/catalog_publisher.js' );
251
+ const { WorkerRunner } = await import( './worker_runner.js' );
252
+
253
+ await bootWorker();
254
+
255
+ const { signal } = interruption.controller;
256
+
257
+ expect( setupInterruptionHandlerMock ).toHaveBeenCalledWith( expect.any( AbortController ) );
258
+ expect( setupTemporalLoggerMock ).toHaveBeenCalledOnce();
259
+ expect( loadHooksMock ).toHaveBeenCalledWith( '/test/caller/dir' );
260
+ expect( loadWorkflowsMock ).toHaveBeenCalledWith( '/test/caller/dir' );
261
+ expect( loadActivitiesMock ).toHaveBeenCalledWith( '/test/caller/dir', [] );
262
+ expect( mainEventBusMock.emit ).toHaveBeenCalledWith( BusEventType.WORKER_BEFORE_START );
263
+ expect( initTracing ).toHaveBeenCalledOnce();
264
+ expect( createCatalogMock ).toHaveBeenCalledWith( { workflows: [], activities: {} } );
265
+ expect( hashSourceCodeMock ).toHaveBeenCalledWith( '/test/caller/dir' );
266
+ expect( NativeConnection.connect ).toHaveBeenCalledWith( {
267
+ address: configValues.address,
268
+ tls: false,
269
+ apiKey: undefined,
270
+ proxy: undefined
271
+ } );
272
+ expect( setupClientConfigMock ).toHaveBeenCalledWith( {
273
+ connection: mockConnection,
274
+ namespace: configValues.namespace
275
+ } );
276
+ expect( CatalogPublisher ).toHaveBeenCalledWith( {
277
+ connection: mockConnection,
278
+ namespace: configValues.namespace,
279
+ catalog: { workflowNames: [ 'demo' ] },
280
+ catalogHash: 'catalog-hash',
281
+ signal
282
+ } );
283
+ expect( catalogPublisherInstance.run ).toHaveBeenCalledOnce();
284
+ expect( TemporalConnectionMonitor ).toHaveBeenCalledWith( { connection: mockConnection, signal } );
285
+ expect( Worker.create ).toHaveBeenCalledWith( expect.objectContaining( {
286
+ connection: mockConnection,
287
+ namespace: configValues.namespace,
288
+ taskQueue: configValues.taskQueue,
289
+ workflowsPath: '/fake/workflows/path.js',
290
+ activities: {},
291
+ maxConcurrentWorkflowTaskExecutions: configValues.maxConcurrentWorkflowTaskExecutions,
292
+ maxConcurrentActivityTaskExecutions: configValues.maxConcurrentActivityTaskExecutions,
293
+ maxCachedWorkflows: configValues.maxCachedWorkflows,
294
+ maxConcurrentActivityTaskPolls: configValues.maxConcurrentActivityTaskPolls,
295
+ maxConcurrentWorkflowTaskPolls: configValues.maxConcurrentWorkflowTaskPolls
296
+ } ) );
297
+ expect( Worker.create.mock.calls[0][0] ).not.toHaveProperty( 'shutdownForceTime' );
298
+ expect( Worker.create.mock.calls[0][0] ).not.toHaveProperty( 'shutdownGraceTime' );
299
+ expect( initInterceptorsMock ).toHaveBeenCalledWith( { activities: {}, workflows: [] } );
300
+ expect( setupTelemetryMock ).toHaveBeenCalledWith( { worker: mockWorker } );
301
+ expect( WorkerRunner ).toHaveBeenCalledWith( { worker: mockWorker, signal } );
302
+ } );
303
+
304
+ it( 'publishes the catalog before creating the worker', async () => {
305
+ const { Worker } = await import( '@temporalio/worker' );
306
+
307
+ await bootWorker();
308
+
309
+ // The worker never exists unless its catalog matches its own source code.
310
+ expect( catalogPublisherInstance.run.mock.invocationCallOrder[0] )
311
+ .toBeLessThan( Worker.create.mock.invocationCallOrder[0] );
240
312
  } );
241
- expect( setupClientConfigMock ).toHaveBeenCalledWith( {
242
- connection: mockConnection,
243
- namespace: configValues.namespace
313
+
314
+ it( 'passes worker tuner instead of incompatible execution concurrency options', async () => {
315
+ configValues.workerTuner = { tunerOptions: { targetMemoryUsage: 0.8, targetCpuUsage: 0.9 } };
316
+ const { Worker } = await import( '@temporalio/worker' );
317
+
318
+ await bootWorker();
319
+
320
+ const workerOptions = Worker.create.mock.calls[0][0];
321
+ expect( workerOptions ).toEqual( expect.objectContaining( { tuner: configValues.workerTuner } ) );
322
+ expect( workerOptions ).not.toHaveProperty( 'maxConcurrentWorkflowTaskExecutions' );
323
+ expect( workerOptions ).not.toHaveProperty( 'maxConcurrentActivityTaskExecutions' );
244
324
  } );
245
- expect( bindGlobalFunctionsMock ).toHaveBeenCalledTimes( 1 );
246
- expect( bindGlobalFunctionsMock.mock.invocationCallOrder[0] ).toBeLessThan(
247
- NativeConnection.connect.mock.invocationCallOrder[0]
248
- );
249
- expect( TemporalConnectionMonitor ).toHaveBeenCalledWith( mockConnection );
250
- expect( CatalogJob ).toHaveBeenCalledWith( {
251
- connection: mockConnection,
252
- namespace: configValues.namespace,
253
- catalog: { workflows: [], activities: {} },
254
- catalogHash: 'catalog-hash'
325
+
326
+ it( 'enables TLS when an apiKey is set', async () => {
327
+ configValues.apiKey = 'secret';
328
+ const { NativeConnection } = await import( '@temporalio/worker' );
329
+
330
+ await bootWorker();
331
+
332
+ expect( NativeConnection.connect ).toHaveBeenCalledWith( expect.objectContaining( { apiKey: 'secret', tls: true } ) );
255
333
  } );
256
- expect( Worker.create ).toHaveBeenCalledWith( expect.objectContaining( {
257
- connection: mockConnection,
258
- namespace: configValues.namespace,
259
- taskQueue: configValues.taskQueue,
260
- workflowsPath: '/fake/workflows/path.js',
261
- activities: {},
262
- maxConcurrentWorkflowTaskExecutions: configValues.maxConcurrentWorkflowTaskExecutions,
263
- maxConcurrentActivityTaskExecutions: configValues.maxConcurrentActivityTaskExecutions,
264
- maxCachedWorkflows: configValues.maxCachedWorkflows,
265
- maxConcurrentActivityTaskPolls: configValues.maxConcurrentActivityTaskPolls,
266
- maxConcurrentWorkflowTaskPolls: configValues.maxConcurrentWorkflowTaskPolls
267
- } ) );
268
- expect( Worker.create.mock.calls[0][0] ).not.toHaveProperty( 'shutdownForceTime' );
269
- expect( Worker.create.mock.calls[0][0] ).not.toHaveProperty( 'shutdownGraceTime' );
270
- expect( initInterceptorsMock ).toHaveBeenCalledWith( { activities: {}, workflows: [] } );
271
- expect( setupTelemetryMock ).toHaveBeenCalledWith( { worker: mockWorker } );
272
- expect( setupInterruptionHandlerMock ).toHaveBeenCalledWith( expect.any( Function ) );
273
- expect( connectionMonitorInstance.onConnectionLost ).toHaveBeenCalledWith( expect.any( Function ) );
274
- expect( catalogJobInstance.onError ).toHaveBeenCalledWith( expect.any( Function ) );
275
- expect( mockWorker.run ).toHaveBeenCalled();
276
- expect( connectionMonitorInstance.start ).toHaveBeenCalled();
277
- expect( catalogJobInstance.run ).toHaveBeenCalled();
278
334
 
279
- await settleWorker();
280
- await vi.waitFor( () => expect( flushPendingHooksMock ).toHaveBeenCalledOnce() );
281
- expect( mockLog.info ).toHaveBeenCalledWith( 'Bye' );
282
- } );
335
+ it( 'routes the connection through the configured gRPC proxy', async () => {
336
+ configValues.grpcProxy = 'proxy:1234';
337
+ const { NativeConnection } = await import( '@temporalio/worker' );
283
338
 
284
- it( 'passes worker tuner instead of incompatible execution concurrency options', async () => {
285
- configValues.workerTuner = {
286
- tunerOptions: {
287
- targetMemoryUsage: 0.8,
288
- targetCpuUsage: 0.9
289
- }
290
- };
291
- const { Worker } = await import( '@temporalio/worker' );
292
-
293
- await importWorker();
294
-
295
- await vi.waitFor( () => expect( Worker.create ).toHaveBeenCalled() );
296
-
297
- const workerOptions = Worker.create.mock.calls[0][0];
298
- expect( workerOptions ).toEqual( expect.objectContaining( {
299
- tuner: configValues.workerTuner,
300
- maxCachedWorkflows: configValues.maxCachedWorkflows,
301
- maxConcurrentActivityTaskPolls: configValues.maxConcurrentActivityTaskPolls,
302
- maxConcurrentWorkflowTaskPolls: configValues.maxConcurrentWorkflowTaskPolls
303
- } ) );
304
- expect( workerOptions ).not.toHaveProperty( 'maxConcurrentWorkflowTaskExecutions' );
305
- expect( workerOptions ).not.toHaveProperty( 'maxConcurrentActivityTaskExecutions' );
306
-
307
- await settleWorker();
308
- } );
339
+ await bootWorker();
309
340
 
310
- it( 'enables TLS when apiKey is set', async () => {
311
- configValues.apiKey = 'secret';
312
- const { NativeConnection } = await import( '@temporalio/worker' );
341
+ expect( NativeConnection.connect ).toHaveBeenCalledWith( expect.objectContaining( {
342
+ proxy: { type: 'http-connect', targetHost: 'proxy:1234' }
343
+ } ) );
344
+ } );
313
345
 
314
- await importWorker();
346
+ it( 'passes configured shutdown durations to the worker', async () => {
347
+ configValues.shutdownForceTime = '30s';
348
+ configValues.shutdownGraceTime = '10s';
349
+ const { Worker } = await import( '@temporalio/worker' );
315
350
 
316
- await vi.waitFor( () => expect( NativeConnection.connect ).toHaveBeenCalledWith( expect.objectContaining( {
317
- apiKey: 'secret',
318
- tls: true
319
- } ) ) );
351
+ await bootWorker();
320
352
 
321
- await settleWorker();
353
+ expect( Worker.create ).toHaveBeenCalledWith( expect.objectContaining( {
354
+ shutdownForceTime: '30s',
355
+ shutdownGraceTime: '10s'
356
+ } ) );
357
+ } );
358
+
359
+ it( 'stops advancing through startup once the signal aborts', async () => {
360
+ const { NativeConnection } = await import( '@temporalio/worker' );
361
+ loadHooksMock.mockImplementationOnce( async () => {
362
+ interruption.controller.abort( new interruption.KillSignError( 'SIGTERM' ) );
363
+ } );
364
+
365
+ await importWorker();
366
+ await waitForExit();
367
+
368
+ expect( loadWorkflowsMock ).not.toHaveBeenCalled();
369
+ expect( NativeConnection.connect ).not.toHaveBeenCalled();
370
+ expect( mockLog.error ).not.toHaveBeenCalled();
371
+ expect( process.exit ).toHaveBeenCalledWith( 0 );
372
+ } );
322
373
  } );
323
374
 
324
- it( 'passes configured shutdown durations to the worker', async () => {
325
- configValues.shutdownForceTime = '30s';
326
- configValues.shutdownGraceTime = '10s';
327
- const { Worker } = await import( '@temporalio/worker' );
375
+ describe( 'clean termination', () => {
376
+ it( 'shuts every service down and exits successfully on a kill signal', async () => {
377
+ await bootWorker();
328
378
 
329
- await importWorker();
379
+ interruption.controller.abort( new interruption.KillSignError( 'SIGTERM' ) );
380
+ await waitForExit();
330
381
 
331
- await vi.waitFor( () => expect( Worker.create ).toHaveBeenCalledWith( expect.objectContaining( {
332
- shutdownForceTime: '30s',
333
- shutdownGraceTime: '10s'
334
- } ) ) );
382
+ expect( shutdownServicesMock ).toHaveBeenCalledOnce();
383
+ expect( shutdownServicesMock ).toHaveBeenCalledWith( {
384
+ workerRunner: workerRunnerInstance,
385
+ connectionMonitor: connectionMonitorInstance,
386
+ catalogPublisher: catalogPublisherInstance,
387
+ connection: mockConnection
388
+ } );
389
+ expect( mockLog.error ).not.toHaveBeenCalled();
390
+ expect( mainEventBusMock.emit ).not.toHaveBeenCalledWith( BusEventType.RUNTIME_ERROR, expect.anything() );
391
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Bye' );
392
+ expect( process.exit ).toHaveBeenCalledWith( 0 );
393
+ } );
335
394
 
336
- await settleWorker();
337
- } );
395
+ it( 'still exits cleanly when a service other than the worker failed to stop', async () => {
396
+ await bootWorker();
397
+ shutdownServicesMock.mockResolvedValueOnce( [ { service: 'connection', error: new Error( 'close failed' ) } ] );
338
398
 
339
- it( 'runs graceful shutdown when interrupted', async () => {
340
- await importWorker();
399
+ interruption.controller.abort( new interruption.KillSignError( 'SIGTERM' ) );
400
+ await waitForExit();
341
401
 
342
- await vi.waitFor( () => expect( setupInterruptionHandlerMock ).toHaveBeenCalled() );
343
- const [ shutdown ] = setupInterruptionHandlerMock.mock.calls[0];
402
+ expect( mockLog.error ).not.toHaveBeenCalled();
403
+ expect( process.exit ).toHaveBeenCalledWith( 0 );
404
+ } );
344
405
 
345
- shutdown();
406
+ it( 'does not report the worker as terminated when a signal ended the run', async () => {
407
+ await bootWorker();
346
408
 
347
- expect( mockWorker.shutdown ).toHaveBeenCalledOnce();
348
- expect( connectionMonitorInstance.stop ).toHaveBeenCalledOnce();
349
- expect( catalogJobInstance.interrupt ).toHaveBeenCalledOnce();
409
+ interruption.controller.abort( new interruption.KillSignError( 'SIGTERM' ) );
410
+ await waitForExit();
350
411
 
351
- promises.workerRun.resolve();
352
- await vi.waitFor( () => expect( mockConnection.close ).toHaveBeenCalled() );
353
- expect( mockLog.info ).toHaveBeenCalledWith( 'Bye' );
354
- } );
412
+ expect( mockLog.info ).not.toHaveBeenCalledWith( 'Worker terminated' );
413
+ } );
355
414
 
356
- it( 'does not call worker.shutdown when worker has already failed', async () => {
357
- const error = new Error( 'Big Failure' );
415
+ it( 'reports a terminated worker when the run ends without an abort', async () => {
416
+ await bootWorker();
358
417
 
359
- await importWorker();
360
- await vi.waitFor( () => expect( mockWorker.run ).toHaveBeenCalled() );
418
+ promises.connectionMonitor.resolve();
419
+ await waitForExit();
361
420
 
362
- mockWorker.runState = 'FAILED';
363
- promises.workerRun.reject( error );
421
+ expect( mockLog.info ).toHaveBeenCalledWith( 'Worker terminated' );
422
+ expect( process.exit ).toHaveBeenCalledWith( 0 );
423
+ } );
364
424
 
365
- await vi.waitFor( () => expect( connectionMonitorInstance.stop ).toHaveBeenCalled() );
366
- expect( mockWorker.shutdown ).not.toHaveBeenCalled();
425
+ it( 'flushes pending hooks before exiting', async () => {
426
+ await bootWorker();
367
427
 
368
- promises.connectionMonitor.resolve();
369
- promises.catalogJob.resolve();
428
+ interruption.controller.abort( new interruption.KillSignError( 'SIGTERM' ) );
429
+ await waitForExit();
370
430
 
371
- await vi.waitFor( () => {
372
- expect( mockLog.error ).toHaveBeenCalledWith( 'Fatal error', expect.objectContaining( {
373
- error: expect.objectContaining( { message: 'Big Failure' } )
374
- } ) );
431
+ expect( flushPendingHooksMock ).toHaveBeenCalledOnce();
432
+ expect( shutdownServicesMock.mock.invocationCallOrder[0] )
433
+ .toBeLessThan( flushPendingHooksMock.mock.invocationCallOrder[0] );
434
+ expect( flushPendingHooksMock.mock.invocationCallOrder[0] )
435
+ .toBeLessThan( process.exit.mock.invocationCallOrder[0] );
375
436
  } );
376
- expect( serializeErrorMock ).toHaveBeenCalledWith( error );
377
- expect( mainEventBusMock.emit ).toHaveBeenCalledWith( expect.any( String ), { error } );
378
- await vi.waitFor( () => expect( exitMock ).toHaveBeenCalledWith( 1 ) );
379
- expect( flushPendingHooksMock ).toHaveBeenCalledOnce();
380
- expect( mainEventBusMock.emit.mock.invocationCallOrder[0] ).toBeLessThan( flushPendingHooksMock.mock.invocationCallOrder[0] );
381
- expect( flushPendingHooksMock.mock.invocationCallOrder[0] ).toBeLessThan( exitMock.mock.invocationCallOrder[0] );
382
437
  } );
383
438
 
384
- it( 'throws connection monitor errors after graceful shutdown', async () => {
385
- const error = new Error( 'connection lost' );
439
+ describe( 'failures', () => {
440
+ it( 'reports an uncaught error captured by the interruption handler', async () => {
441
+ const cause = new Error( 'boom' );
442
+ await bootWorker();
443
+
444
+ const fault = new interruption.UncaughtError( 'uncaughtException', { cause } );
445
+ interruption.controller.abort( fault );
446
+ await waitForExit();
447
+
448
+ expect( mockLog.error ).toHaveBeenCalledWith( 'Worker error', {
449
+ error: expect.objectContaining( { name: 'UncaughtError', message: 'uncaughtException' } )
450
+ } );
451
+ expect( mainEventBusMock.emit ).toHaveBeenCalledWith( BusEventType.RUNTIME_ERROR, { error: fault } );
452
+ expect( process.exit ).toHaveBeenCalledWith( 1 );
453
+ } );
454
+
455
+ it( 'exits with a failure when the worker never finished draining', async () => {
456
+ await bootWorker();
457
+ shutdownServicesMock.mockResolvedValueOnce( [ { service: 'worker', error: new Error( 'drain failed' ) } ] );
458
+
459
+ interruption.controller.abort( new interruption.KillSignError( 'SIGTERM' ) );
460
+ await waitForExit();
461
+
462
+ expect( process.exit ).toHaveBeenCalledWith( 1 );
463
+ } );
386
464
 
387
- await importWorker();
388
- await vi.waitFor( () => expect( connectionMonitorInstance.onConnectionLost ).toHaveBeenCalled() );
465
+ it( 'reports a lost connection and shuts down', async () => {
466
+ const error = new Error( 'connection lost' );
467
+ await bootWorker();
389
468
 
390
- connectionMonitorInstance.connectionLossError = error;
391
- connectionMonitorInstance.connectionLostCb( error );
392
- promises.workerRun.resolve();
469
+ promises.connectionMonitor.reject( error );
470
+ await waitForExit();
393
471
 
394
- await vi.waitFor( () => {
395
- expect( mockLog.error ).toHaveBeenCalledWith( 'Fatal error', expect.objectContaining( {
472
+ expect( shutdownServicesMock ).toHaveBeenCalledOnce();
473
+ expect( serializeErrorMock ).toHaveBeenCalledWith( error );
474
+ expect( mockLog.error ).toHaveBeenCalledWith( 'Worker error', {
396
475
  error: expect.objectContaining( { message: 'connection lost' } )
397
- } ) );
476
+ } );
477
+ expect( mainEventBusMock.emit ).toHaveBeenCalledWith( BusEventType.RUNTIME_ERROR, { error } );
478
+ expect( process.exit ).toHaveBeenCalledWith( 1 );
398
479
  } );
399
- expect( mockWorker.shutdown ).toHaveBeenCalledOnce();
400
- expect( catalogJobInstance.interrupt ).toHaveBeenCalledOnce();
401
- expect( mainEventBusMock.emit ).toHaveBeenCalledWith( expect.any( String ), { error } );
402
- } );
403
480
 
404
- it( 'throws catalog job errors after graceful shutdown', async () => {
405
- const error = new Error( 'catalog failed' );
481
+ it( 'reports a failed worker run', async () => {
482
+ const error = new Error( 'worker crashed' );
483
+ await bootWorker();
406
484
 
407
- await importWorker();
408
- await vi.waitFor( () => expect( catalogJobInstance.onError ).toHaveBeenCalled() );
485
+ promises.workerRunner.reject( error );
486
+ await waitForExit();
409
487
 
410
- catalogJobInstance.error = error;
411
- catalogJobInstance.errorCb( error );
412
- promises.workerRun.resolve();
488
+ expect( shutdownServicesMock ).toHaveBeenCalledOnce();
489
+ expect( mockLog.error ).toHaveBeenCalledWith( 'Worker error', {
490
+ error: expect.objectContaining( { message: 'worker crashed' } )
491
+ } );
492
+ expect( process.exit ).toHaveBeenCalledWith( 1 );
493
+ } );
413
494
 
414
- await vi.waitFor( () => {
415
- expect( mockLog.error ).toHaveBeenCalledWith( 'Fatal error', expect.objectContaining( {
495
+ it( 'never creates the worker when publishing the catalog fails', async () => {
496
+ const { Worker } = await import( '@temporalio/worker' );
497
+ catalogPublisherInstance.run.mockRejectedValueOnce( new Error( 'catalog failed' ) );
498
+
499
+ await importWorker();
500
+ await waitForExit();
501
+
502
+ expect( Worker.create ).not.toHaveBeenCalled();
503
+ expect( workerRunnerInstance.start ).not.toHaveBeenCalled();
504
+ expect( shutdownServicesMock ).toHaveBeenCalledOnce();
505
+ expect( mockLog.error ).toHaveBeenCalledWith( 'Worker error', {
416
506
  error: expect.objectContaining( { message: 'catalog failed' } )
417
- } ) );
507
+ } );
508
+ expect( process.exit ).toHaveBeenCalledWith( 1 );
418
509
  } );
419
- expect( mockWorker.shutdown ).toHaveBeenCalledOnce();
420
- expect( connectionMonitorInstance.stop ).toHaveBeenCalledOnce();
421
- expect( mainEventBusMock.emit ).toHaveBeenCalledWith( expect.any( String ), { error } );
422
- } );
423
510
 
424
- it( 'cleans up partial startup failures after connecting', async () => {
425
- const { Worker } = await import( '@temporalio/worker' );
426
- const error = new Error( 'worker create failed' );
427
- Worker.create.mockRejectedValueOnce( error );
511
+ it( 'hands the partially built services to shutdown when startup fails', async () => {
512
+ const { Worker } = await import( '@temporalio/worker' );
513
+ Worker.create.mockRejectedValueOnce( new Error( 'worker create failed' ) );
428
514
 
429
- await importWorker();
515
+ await importWorker();
516
+ await waitForExit();
430
517
 
431
- await vi.waitFor( () => expect( mockConnection.close ).toHaveBeenCalled() );
432
- expect( connectionMonitorInstance.stop ).not.toHaveBeenCalled();
433
- expect( catalogJobInstance.interrupt ).not.toHaveBeenCalled();
434
- await vi.waitFor( () => {
435
- expect( mockLog.error ).toHaveBeenCalledWith( 'Fatal error', expect.objectContaining( {
436
- error: expect.objectContaining( { message: 'worker create failed' } )
518
+ expect( workerRunnerInstance.start ).not.toHaveBeenCalled();
519
+ expect( shutdownServicesMock ).toHaveBeenCalledWith( expect.objectContaining( {
520
+ workerRunner: null,
521
+ connection: mockConnection
437
522
  } ) );
523
+ expect( process.exit ).toHaveBeenCalledWith( 1 );
438
524
  } );
439
525
  } );
440
526
  } );