@platformatic/runtime 3.71.1-alpha.0 → 4.0.0-alpha.1

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.
@@ -6,14 +6,14 @@ import {
6
6
  getOnHttpCacheMiss,
7
7
  getOnHttpCacheRequest
8
8
  } from '@platformatic/globals'
9
- import { createTelemetryThreadInterceptorHooks } from '@platformatic/telemetry'
9
+ import { createTelemetryThreadInterceptorHooks } from '@platformatic/tracing'
10
10
  import { createRequire } from 'node:module'
11
11
  import { join } from 'node:path'
12
12
  import { pathToFileURL } from 'node:url'
13
- import { parentPort, workerData } from 'node:worker_threads'
14
- import { Agent, Client, Pool } from 'undici'
15
- import { wire } from 'undici-thread-interceptor'
16
- import { createChannelCreationHook } from '../policies.js'
13
+ import { workerData } from 'node:worker_threads'
14
+ import { Agent, Client, Pool, setGlobalDispatcher } from 'undici'
15
+ import { createInterceptor } from 'undici-thread-interceptor'
16
+ import { createTargetPermissionHook } from '../policies.js'
17
17
  import { RemoteCacheStore, httpCacheInterceptor } from './http-cache.js'
18
18
 
19
19
  const kPlatformaticGlobalDispatcher = Symbol.for('platformatic.undici.globalDispatcher')
@@ -43,8 +43,11 @@ export function refreshGlobalDispatcher () {
43
43
  }
44
44
 
45
45
  export async function setDispatcher (runtimeConfig) {
46
- const threadDispatcher = createThreadInterceptor(runtimeConfig)
47
- const threadInterceptor = threadDispatcher.interceptor
46
+ const tracingHooks = runtimeConfig.tracing ? createTelemetryThreadInterceptorHooks() : {}
47
+ const threadDispatcher = createThreadInterceptor(runtimeConfig, tracingHooks)
48
+ const threadInterceptor = threadDispatcher
49
+
50
+ await threadDispatcher.ready
48
51
 
49
52
  let cacheInterceptor = null
50
53
  if (runtimeConfig.httpCache) {
@@ -65,30 +68,29 @@ export async function setDispatcher (runtimeConfig) {
65
68
  [threadInterceptor, ...userInterceptors, cacheInterceptor].filter(Boolean)
66
69
  )
67
70
 
68
- markAsPlatformaticDispatcher(dispatcher)
69
- mirrorGlobalDispatcherForBuiltinFetch(dispatcher, createLegacyDispatcher(dispatcher))
71
+ installGlobalDispatcher(dispatcher)
70
72
  return dispatcher
71
73
  }
72
74
 
73
75
  composeRuntimeDispatcher = installDispatcher
74
76
  installDispatcher(new Agent(dispatcherOpts))
75
77
 
76
- return { threadDispatcher }
77
- }
78
-
79
- function createLegacyDispatcher (dispatcher) {
80
- const legacyDispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')]
81
- const currentDispatcher = globalThis[Symbol.for('undici.globalDispatcher.2')]
82
-
83
- if (legacyDispatcher && legacyDispatcher !== currentDispatcher && legacyDispatcher.constructor?.name === 'Dispatcher1Wrapper') {
84
- try {
85
- return new legacyDispatcher.constructor(dispatcher)
86
- } catch {
87
- return dispatcher
78
+ return {
79
+ threadDispatcher: {
80
+ interceptor: threadDispatcher,
81
+ server: null,
82
+ serverHooks: tracingHooks.serverHooks
88
83
  }
89
84
  }
85
+ }
90
86
 
91
- return dispatcher
87
+ export function installGlobalDispatcher (dispatcher) {
88
+ markAsPlatformaticDispatcher(dispatcher)
89
+ setGlobalDispatcher(dispatcher)
90
+ mirrorGlobalDispatcherForBuiltinFetch(
91
+ dispatcher,
92
+ globalThis[Symbol.for('undici.globalDispatcher.1')]
93
+ )
92
94
  }
93
95
 
94
96
  export async function updateUndiciInterceptors (undiciConfig) {
@@ -215,19 +217,17 @@ async function getDispatcherOpts (undiciConfig) {
215
217
  return dispatcherOpts
216
218
  }
217
219
 
218
- function createThreadInterceptor (runtimeConfig) {
219
- const telemetry = runtimeConfig.telemetry
220
-
221
- const telemetryHooks = telemetry ? createTelemetryThreadInterceptorHooks() : {}
222
-
223
- const threadDispatcher = wire({
224
- // Specifying the domain is critical to avoid flooding the DNS
225
- // with requests for a domain that's never going to exist.
220
+ function createThreadInterceptor (runtimeConfig, tracingHooks) {
221
+ const threadDispatcher = createInterceptor({
222
+ meshId: workerData.meshId,
226
223
  domain: '.plt.local',
227
- port: parentPort,
228
- timeout: runtimeConfig.applicationTimeout,
229
- onChannelCreation: createChannelCreationHook(runtimeConfig),
230
- ...telemetryHooks
224
+ connectTimeout: runtimeConfig.applicationTimeout,
225
+ bootstrapTimeout: runtimeConfig.applicationTimeout,
226
+ allowTarget: createTargetPermissionHook({
227
+ ...runtimeConfig,
228
+ applicationId: workerData.applicationConfig.id
229
+ }),
230
+ ...tracingHooks
231
231
  })
232
232
  return threadDispatcher
233
233
  }
package/lib/worker/itc.js CHANGED
@@ -1,13 +1,22 @@
1
- import { ensureLoggableError, executeInParallel, executeWithTimeout, kTimeout } from '@platformatic/foundation'
2
- import { getEvents, getLogger, getMessaging, updateGlobals } from '@platformatic/globals'
1
+ import { servingState } from '@platformatic/basic'
2
+ import {
3
+ ensureError,
4
+ ensureLoggableError,
5
+ executeInParallel,
6
+ executeWithTimeout,
7
+ kTimeout
8
+ } from '@platformatic/foundation'
9
+ import { consumeCloseCallbacks, getEvents, getLogger, getMessaging, updateGlobals } from '@platformatic/globals'
3
10
  import { ITC, initializeITCTelemetry } from '@platformatic/itc'
4
11
  import { Unpromise } from '@watchable/unpromise'
5
12
  import { once } from 'node:events'
6
13
  import { createRequire } from 'node:module'
7
14
  import { Duplex } from 'node:stream'
8
15
  import { parentPort, workerData } from 'node:worker_threads'
16
+ import { createServer } from 'undici-thread-interceptor'
9
17
  import {
10
18
  ApplicationExitedError,
19
+ ApplicationShutdownError,
11
20
  FailedToPerformCustomHealthCheckError,
12
21
  FailedToPerformCustomReadinessCheckError,
13
22
  FailedToRetrieveGraphQLSchemaError,
@@ -15,12 +24,13 @@ import {
15
24
  FailedToRetrieveMetaError,
16
25
  FailedToRetrieveMetricsError,
17
26
  FailedToRetrieveOpenAPISchemaError,
27
+ RuntimeAbortedError,
18
28
  WorkerExitedError,
19
29
  exitCodes
20
30
  } from '../errors.js'
21
31
  import { updateUndiciInterceptors } from './interceptors.js'
22
32
  import { MessagingITC } from './messaging.js'
23
- import { kApplicationId, kITC, kId, kWorkerId } from './symbols.js'
33
+ import { kApplicationId, kITC, kId, kWorkerExitCode, kWorkerId } from './symbols.js'
24
34
 
25
35
  function startSubprocessRepl (port, childManager, clientWs, controller) {
26
36
  // Start the REPL in the child process
@@ -78,14 +88,18 @@ async function safeHandleInITC (worker, fn) {
78
88
  try {
79
89
  // Make sure to catch when the worker exits, otherwise we're stuck forever
80
90
  const ac = new AbortController()
81
- let exitCode
82
-
83
- const response = await Unpromise.race([
84
- fn(),
85
- once(worker, 'exit', { signal: ac.signal }).then(([code]) => {
86
- exitCode = code
87
- })
88
- ])
91
+ // A worker which already exited will not emit the exit event again
92
+ let exitCode = worker[kWorkerExitCode]
93
+ let response
94
+
95
+ if (typeof exitCode !== 'number') {
96
+ response = await Unpromise.race([
97
+ fn(),
98
+ once(worker, 'exit', { signal: ac.signal }).then(([code]) => {
99
+ exitCode = code
100
+ })
101
+ ])
102
+ }
89
103
 
90
104
  if (typeof exitCode === 'number') {
91
105
  if (typeof worker[kWorkerId] !== 'undefined' && exitCode !== exitCodes.PROCESS_UNHANDLED_ERROR) {
@@ -103,22 +117,142 @@ async function safeHandleInITC (worker, fn) {
103
117
  throw error
104
118
  }
105
119
 
106
- if (error.handlerErrorCode && !error.handlerError.code) {
107
- error.handlerError.code = error.handlerErrorCode
120
+ const handlerError = ensureError(error.handlerError)
121
+ if (error.handlerErrorCode && !handlerError.code) {
122
+ handlerError.code = error.handlerErrorCode
108
123
  }
109
124
 
110
- throw error.handlerError
125
+ throw handlerError
111
126
  }
112
127
  }
113
128
 
114
- async function closeITC (dispatcher, itc, messaging) {
129
+ async function closeResource (close, errors) {
115
130
  try {
116
- await dispatcher.interceptor.close()
117
- itc.close()
118
- messaging.close()
131
+ await close()
132
+ } catch (error) {
133
+ errors.push(error)
134
+ }
135
+ }
136
+
137
+ async function stopApplication (context, force, dependents, shutdownTimeout) {
138
+ const { controller, dispatcher, itc, messaging } = context
139
+ const errors = []
140
+ let controllerError
141
+
142
+ if (force || controller.getStatus().startsWith('start')) {
143
+ try {
144
+ getEvents().emit('stop')
145
+ } catch (error) {
146
+ errors.push(error)
147
+ }
148
+
149
+ try {
150
+ await controller.stop(force, dependents, shutdownTimeout)
151
+ } catch (error) {
152
+ controllerError = error
153
+ errors.push(error)
154
+ }
155
+ }
156
+
157
+ errors.push(...(await runShutdownCallbacks()))
158
+ // Keep communication usable while signal handlers drain. Only idle channels
159
+ // are unreferenced; pending mesh and messaging operations keep the worker alive.
160
+ const drained = once(process, 'beforeExit')
161
+ dispatcher.interceptor.unref()
162
+ dispatcher.server?.unref()
163
+ messaging.unref()
164
+ itc.port.unref()
165
+ await drained
166
+ itc.port.ref()
167
+ await closeResource(() => closeApplicationChannels(dispatcher, messaging), errors)
168
+
169
+ if (errors.length > 0) {
170
+ if (errors.length === 1 && errors[0] === controllerError && controllerError?.code) {
171
+ throw controllerError
172
+ }
173
+ throw new ApplicationShutdownError(errors)
174
+ }
175
+ }
176
+
177
+ async function stopController (context, force, dependents, shutdownTimeout, stopProcessed) {
178
+ try {
179
+ if (context.controller.getStatus() === 'starting') {
180
+ // The start handler reports its own error and shares cleanup with us.
181
+ await context.controllerStartPromise.catch(() => {})
182
+ }
183
+ if (!context.applicationStopPromise) {
184
+ const { promise, resolve, reject } = Promise.withResolvers()
185
+ context.applicationStopPromise = promise
186
+ stopApplication(context, force, dependents, shutdownTimeout).then(resolve, reject)
187
+ }
188
+ await context.applicationStopPromise
119
189
  } finally {
190
+ // The acknowledgement only gates transport closure. Application work has already
191
+ // drained before the response, including work scheduled by SIGINT after failed startup.
192
+ stopProcessed.then(() => {
193
+ context.internalClosePromise ??= closeITC(context.itc)
194
+ context.internalClosePromise.catch(err => {
195
+ context.logger.error({ err: ensureLoggableError(err) }, 'Failed to close the worker ITC after stop.')
196
+ })
197
+ })
198
+ }
199
+ }
200
+
201
+ async function runShutdownCallbacks () {
202
+ const errors = []
203
+ const callbacks = consumeCloseCallbacks().reverse()
204
+
205
+ for (const callback of callbacks) {
206
+ try {
207
+ await callback()
208
+ } catch (error) {
209
+ errors.push(error)
210
+ }
211
+ }
212
+
213
+ const signalListeners = process.listeners('SIGINT')
214
+ for (const listener of signalListeners) {
215
+ process.removeListener('SIGINT', listener)
216
+ }
217
+
218
+ for (const listener of signalListeners) {
219
+ try {
220
+ // Like a signal emission, ignore return values without consuming rejected promises.
221
+ listener.call(process, 'SIGINT')
222
+ } catch (error) {
223
+ errors.push(error)
224
+ }
225
+ }
226
+
227
+ return errors
228
+ }
229
+
230
+ export async function closeApplicationChannels (dispatcher, messaging) {
231
+ const errors = []
232
+
233
+ await closeResource(() => dispatcher.interceptor.close(), errors)
234
+ await closeResource(() => dispatcher.server?.close(), errors)
235
+ await closeResource(() => messaging.close(), errors)
236
+
237
+ if (errors.length > 0) {
238
+ throw new ApplicationShutdownError(errors)
239
+ }
240
+ }
241
+
242
+ export async function closeITC (itc) {
243
+ const errors = []
244
+ // ITC closes the transport after all active handlers have sent their responses.
245
+ await closeResource(() => itc.close(), errors)
246
+
247
+ try {
120
248
  const events = getEvents()
121
249
  events.emit('exit')
250
+ } catch (error) {
251
+ errors.push(error)
252
+ }
253
+
254
+ if (errors.length > 0) {
255
+ throw new ApplicationShutdownError(errors)
122
256
  }
123
257
  }
124
258
 
@@ -169,7 +303,18 @@ export async function setupITC (controller, application, dispatcher, sharedConte
169
303
 
170
304
  // ITC handlers run concurrently, so later start/stop requests must wait for
171
305
  // the actual in-flight start operation rather than a controller event.
172
- let controllerStartPromise
306
+ const context = {
307
+ controller,
308
+ dispatcher,
309
+ itc: null,
310
+ messaging,
311
+ logger,
312
+ controllerStartPromise: null,
313
+ controllerStopPromise: null,
314
+ abortStart: false,
315
+ applicationStopPromise: null,
316
+ internalClosePromise: null
317
+ }
173
318
 
174
319
  const itc = new ITC({
175
320
  name: controller.applicationConfig.id + '-worker',
@@ -179,37 +324,58 @@ export async function setupITC (controller, application, dispatcher, sharedConte
179
324
  const status = controller.getStatus()
180
325
 
181
326
  if (status === 'starting') {
182
- await controllerStartPromise
327
+ await context.controllerStartPromise
183
328
  } else {
184
329
  // This gives a chance to a capability to perform custom logic
185
330
  const events = getEvents()
186
331
  events.emit('start')
187
332
 
188
333
  try {
189
- controllerStartPromise = controller.start()
190
- await controllerStartPromise
334
+ context.controllerStartPromise = controller.start()
335
+ await context.controllerStartPromise
336
+ if (context.abortStart) {
337
+ throw new RuntimeAbortedError()
338
+ }
191
339
  } catch (e) {
192
- await controller.stop(true)
193
-
194
- // Reply to the runtime that the start failed, so it can cleanup
195
- once(itc, 'application:worker:start:processed').then(() => {
196
- closeITC(dispatcher, itc, messaging).catch(err => {
197
- logger.error(
198
- { err: ensureLoggableError(err) },
199
- 'Failed to close the worker ITC after a failed start.'
200
- )
201
- })
202
- })
203
-
204
- throw ensureLoggableError(e)
340
+ const startProcessed = once(itc, 'application:worker:start:processed')
341
+ try {
342
+ await stopController(context, true, [], undefined, startProcessed)
343
+ } catch (error) {
344
+ logger.error(
345
+ { err: ensureLoggableError(error) },
346
+ 'Errors occurred while cleaning up a failed application start.'
347
+ )
348
+ }
349
+
350
+ // Errors are structured cloned when sent to the runtime, which drops all their custom properties (like the
351
+ // port and the address of listen errors): send a plain object instead so that the runtime can inspect them.
352
+ // eslint-disable-next-line no-throw-literal
353
+ throw { name: e.name, ...ensureLoggableError(e) }
205
354
  }
206
355
  }
207
356
 
208
- if (application.entrypoint) {
209
- await controller.listen()
357
+ const dispatchTarget = await controller.capability.getDispatchTarget()
358
+ if (dispatchTarget == null) {
359
+ await new Promise(() => {})
210
360
  }
211
361
 
212
- dispatcher.replaceServer(await controller.capability.getDispatchTarget())
362
+ const serverTarget = dispatcher.serverHooks?.run
363
+ ? wrapDispatchTarget(dispatchTarget, dispatcher.serverHooks.run)
364
+ : dispatchTarget
365
+
366
+ dispatcher.server = createServer({
367
+ meshId: workerData.meshId,
368
+ serverId: workerData.worker.id,
369
+ domain: `${application.id}.plt.local`,
370
+ server: serverTarget,
371
+ bootstrapTimeout: workerData.config.applicationTimeout,
372
+ metadata: {
373
+ applicationId: application.id,
374
+ workerId: workerData.worker.id
375
+ },
376
+ ...dispatcher.serverHooks
377
+ })
378
+ await dispatcher.server.ready
213
379
 
214
380
  const scheduledTasks =
215
381
  typeof controller.capability.getScheduledTasks === 'function'
@@ -217,38 +383,28 @@ export async function setupITC (controller, application, dispatcher, sharedConte
217
383
  : []
218
384
 
219
385
  return {
220
- url: application.entrypoint ? controller.capability.getUrl() : null,
386
+ url: controller.getUrl(),
221
387
  scheduledTasks
222
388
  }
223
389
  },
224
390
 
225
- async stop ({ force, dependents }) {
226
- try {
227
- const status = controller.getStatus()
391
+ async stop ({ force, dependents, shutdownTimeout }) {
392
+ if (context.controllerStopPromise) {
393
+ return context.controllerStopPromise
394
+ }
395
+ context.abortStart = !!force
228
396
 
229
- if (!force && status === 'starting') {
230
- await controllerStartPromise
231
- }
397
+ const stopProcessed = once(itc, 'application:worker:stop:processed')
232
398
 
233
- if (force || status.startsWith('start')) {
234
- // This gives a chance to a capability to perform custom logic
235
- const events = getEvents()
236
- events.emit('stop')
399
+ context.controllerStopPromise = stopController(
400
+ context,
401
+ force,
402
+ dependents,
403
+ shutdownTimeout,
404
+ stopProcessed
405
+ )
237
406
 
238
- await controller.stop(force, dependents)
239
- }
240
- } finally {
241
- // Always schedule cleanup, even when stop throws. Otherwise the worker
242
- // keeps open handles and runtime shutdown hangs until the grace timeout.
243
- once(itc, 'application:worker:stop:processed').then(() => {
244
- closeITC(dispatcher, itc, messaging).catch(err => {
245
- logger.error(
246
- { err: ensureLoggableError(err) },
247
- 'Failed to close the worker ITC after stop.'
248
- )
249
- })
250
- })
251
- }
407
+ return context.controllerStopPromise
252
408
  },
253
409
 
254
410
  async getDependencies () {
@@ -260,7 +416,8 @@ export async function setupITC (controller, application, dispatcher, sharedConte
260
416
  },
261
417
 
262
418
  async removeFromMesh () {
263
- return dispatcher.interceptor.close()
419
+ await dispatcher.server?.close()
420
+ dispatcher.server = null
264
421
  },
265
422
 
266
423
  inject (injectParams) {
@@ -292,6 +449,12 @@ export async function setupITC (controller, application, dispatcher, sharedConte
292
449
  return controller.capability.getInfo()
293
450
  },
294
451
 
452
+ // How this worker serves, which only the worker can answer: for a worker-classified
453
+ // capability it depends on what the application's factory returned in this worker.
454
+ getServingState () {
455
+ return controller.capability?.getServingState?.() ?? servingState.inactive
456
+ },
457
+
295
458
  async getApplicationConfig () {
296
459
  const current = await controller.capability.getConfig()
297
460
  // Remove all undefined keys from the config
@@ -401,11 +564,11 @@ export async function setupITC (controller, application, dispatcher, sharedConte
401
564
  const session = new Session()
402
565
  session.connect()
403
566
 
404
- session.on('HeapProfiler.addHeapSnapshotChunk', (m) => {
567
+ session.on('HeapProfiler.addHeapSnapshotChunk', m => {
405
568
  port.postMessage({ type: 'chunk', chunk: m.params.chunk })
406
569
  })
407
570
 
408
- session.post('HeapProfiler.takeHeapSnapshot', null, (err) => {
571
+ session.post('HeapProfiler.takeHeapSnapshot', null, err => {
409
572
  session.disconnect()
410
573
  if (err) {
411
574
  port.postMessage({ type: 'error', message: err.message })
@@ -496,6 +659,25 @@ export async function setupITC (controller, application, dispatcher, sharedConte
496
659
  itc.notify('changed')
497
660
  })
498
661
 
662
+ context.itc = itc
663
+
499
664
  itc.listen()
500
665
  return itc
501
666
  }
667
+
668
+ function wrapDispatchTarget (target, run) {
669
+ if (typeof target.inject === 'function') {
670
+ return {
671
+ inject: (req, callback) => run(req, () => target.inject(req, callback)),
672
+ server: target.server,
673
+ emit: target.emit?.bind(target),
674
+ listenerCount: target.listenerCount?.bind(target)
675
+ }
676
+ }
677
+
678
+ if (typeof target.emit === 'function') {
679
+ return target
680
+ }
681
+
682
+ return target
683
+ }