@platformatic/runtime 3.0.0-alpha.6 → 3.0.0-rc.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.
package/lib/runtime.js CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  deepmerge,
3
3
  ensureError,
4
4
  ensureLoggableError,
5
+ executeInParallel,
5
6
  executeWithTimeout,
6
7
  features,
7
8
  kMetadata,
@@ -22,7 +23,6 @@ import { Worker } from 'node:worker_threads'
22
23
  import SonicBoom from 'sonic-boom'
23
24
  import { Agent, request, interceptors as undiciInterceptors } from 'undici'
24
25
  import { createThreadInterceptor } from 'undici-thread-interceptor'
25
- import { checkDependencies, topologicalSort } from './dependencies.js'
26
26
  import {
27
27
  ApplicationAlreadyStartedError,
28
28
  ApplicationNotFoundError,
@@ -31,6 +31,7 @@ import {
31
31
  InvalidArgumentError,
32
32
  MessagingError,
33
33
  MissingEntrypointError,
34
+ MissingPprofCapture,
34
35
  RuntimeAbortedError,
35
36
  RuntimeExitedError,
36
37
  WorkerNotFoundError
@@ -59,18 +60,20 @@ import {
59
60
 
60
61
  const kWorkerFile = join(import.meta.dirname, 'worker/main.js')
61
62
  const kInspectorOptions = Symbol('plt.runtime.worker.inspectorOptions')
62
- const kForwardEvents = Symbol('plt.runtime.worker.forwardEvents')
63
63
 
64
64
  const MAX_LISTENERS_COUNT = 100
65
65
  const MAX_METRICS_QUEUE_LENGTH = 5 * 60 // 5 minutes in seconds
66
66
  const COLLECT_METRICS_TIMEOUT = 1000
67
67
 
68
+ const MAX_CONCURRENCY = 5
68
69
  const MAX_BOOTSTRAP_ATTEMPTS = 5
69
70
  const IMMEDIATE_RESTART_MAX_THRESHOLD = 10
70
71
  const MAX_WORKERS = 100
71
72
 
72
73
  export class Runtime extends EventEmitter {
73
74
  logger
75
+ error
76
+
74
77
  #loggerDestination
75
78
  #stdio
76
79
 
@@ -81,6 +84,7 @@ export class Runtime extends EventEmitter {
81
84
  #context
82
85
  #sharedContext
83
86
  #isProduction
87
+ #concurrency
84
88
  #entrypointId
85
89
  #url
86
90
 
@@ -112,6 +116,7 @@ export class Runtime extends EventEmitter {
112
116
  this.#env = config[kMetadata].env
113
117
  this.#context = context ?? {}
114
118
  this.#isProduction = this.#context.isProduction ?? this.#context.production ?? false
119
+ this.#concurrency = this.#context.concurrency ?? MAX_CONCURRENCY
115
120
  this.#workers = new RoundRobinMap()
116
121
  this.#url = undefined
117
122
  this.#meshInterceptor = createThreadInterceptor({ domain: '.plt.local', timeout: this.#config.applicationTimeout })
@@ -150,7 +155,6 @@ export class Runtime extends EventEmitter {
150
155
  }
151
156
 
152
157
  const config = this.#config
153
- const autoloadEnabled = config.autoload
154
158
 
155
159
  if (config.managementApi) {
156
160
  this.#managementApi = await startManagementApi(this, this.#root)
@@ -190,61 +194,7 @@ export class Runtime extends EventEmitter {
190
194
  this.#env['PLT_ENVIRONMENT'] = 'development'
191
195
  }
192
196
 
193
- // Create all applications, each in is own worker thread
194
- for (const applicationConfig of config.applications) {
195
- // If there is no application path, check if the application was resolved
196
- if (!applicationConfig.path) {
197
- if (applicationConfig.url) {
198
- // Try to backfill the path for external applications
199
- applicationConfig.path = join(this.#root, config.resolvedApplicationsBasePath, applicationConfig.id)
200
-
201
- if (!existsSync(applicationConfig.path)) {
202
- const executable = globalThis.platformatic?.executable ?? 'platformatic'
203
- this.logger.error(
204
- `The path for application "%s" does not exist. Please run "${executable} resolve" and try again.`,
205
- applicationConfig.id
206
- )
207
-
208
- await this.closeAndThrow(new RuntimeAbortedError())
209
- }
210
- } else {
211
- this.logger.error(
212
- 'The application "%s" has no path defined. Please check your configuration and try again.',
213
- applicationConfig.id
214
- )
215
-
216
- await this.closeAndThrow(new RuntimeAbortedError())
217
- }
218
- }
219
-
220
- await this.#setupApplication(applicationConfig)
221
- }
222
-
223
- try {
224
- checkDependencies(config.applications)
225
-
226
- // Make sure the list exists before computing the dependencies, otherwise some applications might not be stopped
227
- if (autoloadEnabled) {
228
- this.#workers = topologicalSort(this.#workers, config)
229
- }
230
-
231
- // When autoloading is disabled, add a warning if an application is defined before its dependencies
232
- if (!autoloadEnabled) {
233
- for (let i = 0; i < config.applications.length; i++) {
234
- const current = config.applications[i]
235
-
236
- for (const dep of current.dependencies ?? []) {
237
- if (config.applications.findIndex(s => s.id === dep.id) > i) {
238
- this.logger.warn(
239
- `Application "${current.id}" depends on application "${dep.id}", but it is defined and it will be started before it. Please check your configuration file.`
240
- )
241
- }
242
- }
243
- }
244
- }
245
- } catch (e) {
246
- await this.closeAndThrow(e)
247
- }
197
+ await this.#setupApplications()
248
198
 
249
199
  await this.#setDispatcher(config.undici)
250
200
 
@@ -266,12 +216,14 @@ export class Runtime extends EventEmitter {
266
216
  this.#updateStatus('starting')
267
217
  this.#createWorkersBroadcastChannel()
268
218
 
269
- // Important: do not use Promise.all here since it won't properly manage dependencies
270
219
  try {
220
+ const startInvocations = []
271
221
  for (const application of this.getApplicationsIds()) {
272
- await this.startApplication(application, silent)
222
+ startInvocations.push([application, silent])
273
223
  }
274
224
 
225
+ await executeInParallel(this.startApplication.bind(this), startInvocations, this.#concurrency)
226
+
275
227
  if (this.#config.inspectorOptions) {
276
228
  const { port } = this.#config.inspectorOptions
277
229
 
@@ -340,16 +292,35 @@ export class Runtime extends EventEmitter {
340
292
  await this.stopApplication(this.#entrypointId, silent)
341
293
  }
342
294
 
343
- // Stop applications in reverse order to ensure applications which depend on others are stopped first
344
- for (const application of this.getApplicationsIds().reverse()) {
295
+ const stopInvocations = []
296
+
297
+ const allApplications = await this.getApplications(true)
298
+
299
+ // Construct the reverse dependency graph
300
+ const dependents = {}
301
+ for (const application of allApplications.applications) {
302
+ for (const dependency of application.dependencies ?? []) {
303
+ let applicationDependents = dependents[dependency]
304
+ if (!applicationDependents) {
305
+ applicationDependents = new Set()
306
+ dependents[dependency] = applicationDependents
307
+ }
308
+
309
+ applicationDependents.add(application.id)
310
+ }
311
+ }
312
+
313
+ for (const application of this.getApplicationsIds()) {
345
314
  // The entrypoint has been stopped above
346
315
  if (application === this.#entrypointId) {
347
316
  continue
348
317
  }
349
318
 
350
- await this.stopApplication(application, silent)
319
+ stopInvocations.push([application, silent, Array.from(dependents[application] ?? [])])
351
320
  }
352
321
 
322
+ await executeInParallel(this.stopApplication.bind(this), stopInvocations, this.#concurrency)
323
+
353
324
  await this.#meshInterceptor.close()
354
325
  this.#workersBroadcastChannel?.close()
355
326
 
@@ -397,6 +368,7 @@ export class Runtime extends EventEmitter {
397
368
 
398
369
  async closeAndThrow (error) {
399
370
  this.#updateStatus('errored', error)
371
+ this.error = error
400
372
 
401
373
  // Wait for the next tick so that any pending logging is properly flushed
402
374
  await sleep(1)
@@ -452,9 +424,7 @@ export class Runtime extends EventEmitter {
452
424
 
453
425
  emit (event, payload) {
454
426
  for (const worker of this.#workers.values()) {
455
- if (worker[kForwardEvents]) {
456
- worker[kITC].notify('runtime:event', { event, payload })
457
- }
427
+ worker[kITC].notify('runtime:event', { event, payload })
458
428
  }
459
429
 
460
430
  this.logger.trace({ event, payload }, 'Runtime event')
@@ -502,7 +472,7 @@ export class Runtime extends EventEmitter {
502
472
  this.emit('application:started', id)
503
473
  }
504
474
 
505
- async stopApplication (id, silent = false) {
475
+ async stopApplication (id, silent = false, dependents = []) {
506
476
  const config = this.#config
507
477
  const applicationConfig = config.applications.find(s => s.id === id)
508
478
 
@@ -515,9 +485,12 @@ export class Runtime extends EventEmitter {
515
485
  this.emit('application:stopping', id)
516
486
 
517
487
  if (typeof workersCount === 'number') {
488
+ const stopInvocations = []
518
489
  for (let i = 0; i < workersCount; i++) {
519
- await this.#stopWorker(workersCount, id, i, silent)
490
+ stopInvocations.push([workersCount, id, i, silent, undefined, dependents])
520
491
  }
492
+
493
+ await executeInParallel(this.#stopWorker.bind(this), stopInvocations, this.#concurrency)
521
494
  }
522
495
 
523
496
  this.emit('application:stopped', id)
@@ -540,6 +513,20 @@ export class Runtime extends EventEmitter {
540
513
  }
541
514
  }
542
515
 
516
+ async startApplicationProfiling (id, options = {}, ensureStarted = true) {
517
+ const service = await this.#getApplicationById(id, ensureStarted)
518
+ this.#validatePprofCapturePreload()
519
+
520
+ return sendViaITC(service, 'startProfiling', options)
521
+ }
522
+
523
+ async stopApplicationProfiling (id, ensureStarted = true) {
524
+ const service = await this.#getApplicationById(id, ensureStarted)
525
+ this.#validatePprofCapturePreload()
526
+
527
+ return sendViaITC(service, 'stopProfiling')
528
+ }
529
+
543
530
  async updateUndiciInterceptors (undiciConfig) {
544
531
  this.#config.undici = undiciConfig
545
532
 
@@ -754,6 +741,10 @@ export class Runtime extends EventEmitter {
754
741
  return report
755
742
  }
756
743
 
744
+ setConcurrency (concurrency) {
745
+ this.#concurrency = concurrency
746
+ }
747
+
757
748
  async getUrl () {
758
749
  return this.#url
759
750
  }
@@ -822,7 +813,9 @@ export class Runtime extends EventEmitter {
822
813
  const label = `${application}:${i}`
823
814
  const worker = this.#workers.get(label)
824
815
 
825
- status[label] = await sendViaITC(worker, 'getCustomHealthCheck')
816
+ if (worker) {
817
+ status[label] = await sendViaITC(worker, 'getCustomHealthCheck')
818
+ }
826
819
  }
827
820
  }
828
821
 
@@ -837,7 +830,9 @@ export class Runtime extends EventEmitter {
837
830
  const label = `${application}:${i}`
838
831
  const worker = this.#workers.get(label)
839
832
 
840
- status[label] = await sendViaITC(worker, 'getCustomReadinessCheck')
833
+ if (worker) {
834
+ status[label] = await sendViaITC(worker, 'getCustomReadinessCheck')
835
+ }
841
836
  }
842
837
  }
843
838
 
@@ -1014,11 +1009,13 @@ export class Runtime extends EventEmitter {
1014
1009
  return this.#config.applications.map(application => application.id)
1015
1010
  }
1016
1011
 
1017
- async getApplications () {
1012
+ async getApplications (allowUnloaded = false) {
1018
1013
  return {
1019
1014
  entrypoint: this.#entrypointId,
1020
1015
  production: this.#isProduction,
1021
- applications: await Promise.all(this.getApplicationsIds().map(id => this.getApplicationDetails(id)))
1016
+ applications: await Promise.all(
1017
+ this.getApplicationsIds().map(id => this.getApplicationDetails(id, allowUnloaded))
1018
+ )
1022
1019
  }
1023
1020
  }
1024
1021
 
@@ -1070,19 +1067,19 @@ export class Runtime extends EventEmitter {
1070
1067
  throw e
1071
1068
  }
1072
1069
 
1073
- const { entrypoint, dependencies, localUrl } = application[kConfig]
1070
+ const { entrypoint, localUrl } = application[kConfig]
1074
1071
 
1075
1072
  const status = await sendViaITC(application, 'getStatus')
1076
- const { type, version } = await sendViaITC(application, 'getApplicationInfo')
1073
+ const { type, version, dependencies } = await sendViaITC(application, 'getApplicationInfo')
1077
1074
 
1078
1075
  const applicationDetails = {
1079
1076
  id,
1080
1077
  type,
1081
1078
  status,
1079
+ dependencies,
1082
1080
  version,
1083
1081
  localUrl,
1084
- entrypoint,
1085
- dependencies
1082
+ entrypoint
1086
1083
  }
1087
1084
 
1088
1085
  if (this.#isProduction) {
@@ -1175,17 +1172,59 @@ export class Runtime extends EventEmitter {
1175
1172
  this.logger.info(`Platformatic is now listening at ${this.#url}`)
1176
1173
  }
1177
1174
 
1175
+ async #setupApplications () {
1176
+ const config = this.#config
1177
+ const setupInvocations = []
1178
+
1179
+ // Parse all applications and verify we're not missing any path or resolved application
1180
+ for (const applicationConfig of config.applications) {
1181
+ // If there is no application path, check if the application was resolved
1182
+ if (!applicationConfig.path) {
1183
+ if (applicationConfig.url) {
1184
+ // Try to backfill the path for external applications
1185
+ applicationConfig.path = join(this.#root, config.resolvedApplicationsBasePath, applicationConfig.id)
1186
+
1187
+ if (!existsSync(applicationConfig.path)) {
1188
+ const executable = globalThis.platformatic?.executable ?? 'platformatic'
1189
+ this.logger.error(
1190
+ `The path for application "%s" does not exist. Please run "${executable} resolve" and try again.`,
1191
+ applicationConfig.id
1192
+ )
1193
+
1194
+ await this.closeAndThrow(new RuntimeAbortedError())
1195
+ }
1196
+ } else {
1197
+ this.logger.error(
1198
+ 'The application "%s" has no path defined. Please check your configuration and try again.',
1199
+ applicationConfig.id
1200
+ )
1201
+
1202
+ await this.closeAndThrow(new RuntimeAbortedError())
1203
+ }
1204
+ }
1205
+
1206
+ setupInvocations.push([applicationConfig])
1207
+ }
1208
+
1209
+ await executeInParallel(this.#setupApplication.bind(this), setupInvocations, this.#concurrency)
1210
+ }
1211
+
1178
1212
  async #setupApplication (applicationConfig) {
1179
- if (this.#status === 'stopping' || this.#status === 'closed') return
1213
+ if (this.#status === 'stopping' || this.#status === 'closed') {
1214
+ return
1215
+ }
1180
1216
 
1181
1217
  const config = this.#config
1182
1218
  const workersCount = await this.#workers.getCount(applicationConfig.id)
1183
1219
  const id = applicationConfig.id
1220
+ const setupInvocations = []
1184
1221
 
1185
1222
  for (let i = 0; i < workersCount; i++) {
1186
- await this.#setupWorker(config, applicationConfig, workersCount, id, i)
1223
+ setupInvocations.push([config, applicationConfig, workersCount, id, i])
1187
1224
  }
1188
1225
 
1226
+ await executeInParallel(this.#setupWorker.bind(this), setupInvocations, this.#concurrency)
1227
+
1189
1228
  this.emit('application:init', id)
1190
1229
  }
1191
1230
 
@@ -1287,6 +1326,7 @@ export class Runtime extends EventEmitter {
1287
1326
 
1288
1327
  // Track application exiting
1289
1328
  const eventPayload = { application: applicationId, worker: index, workersCount }
1329
+
1290
1330
  worker.once('exit', code => {
1291
1331
  if (worker[kWorkerStatus] === 'exited') {
1292
1332
  return
@@ -1338,7 +1378,6 @@ export class Runtime extends EventEmitter {
1338
1378
  worker[kApplicationId] = applicationId
1339
1379
  worker[kWorkerId] = workersCount > 1 ? index : undefined
1340
1380
  worker[kWorkerStatus] = 'boot'
1341
- worker[kForwardEvents] = false
1342
1381
 
1343
1382
  if (inspectorOptions) {
1344
1383
  worker[kInspectorOptions] = {
@@ -1352,12 +1391,7 @@ export class Runtime extends EventEmitter {
1352
1391
  worker[kITC] = new ITC({
1353
1392
  name: workerId + '-runtime',
1354
1393
  port: worker,
1355
- handlers: {
1356
- ...this.#workerITCHandlers,
1357
- setEventsForwarding (value) {
1358
- worker[kForwardEvents] = value
1359
- }
1360
- }
1394
+ handlers: this.#workerITCHandlers
1361
1395
  })
1362
1396
  worker[kITC].listen()
1363
1397
 
@@ -1403,15 +1437,13 @@ export class Runtime extends EventEmitter {
1403
1437
  this.#meshInterceptor.route(applicationId, worker)
1404
1438
  }
1405
1439
 
1406
- // Store dependencies
1407
- const [{ dependencies }] = await waitEventFromITC(worker, 'init')
1408
- applicationConfig.dependencies = dependencies
1440
+ // Wait for initialization
1441
+ await waitEventFromITC(worker, 'init')
1409
1442
 
1410
1443
  if (applicationConfig.entrypoint) {
1411
1444
  this.#entrypointId = applicationId
1412
1445
  }
1413
1446
 
1414
- // This must be done here as the dependencies are filled above
1415
1447
  worker[kConfig] = { ...applicationConfig, health, workers: workersCount }
1416
1448
  worker[kWorkerStatus] = 'init'
1417
1449
  this.emit('application:worker:init', eventPayload)
@@ -1587,6 +1619,7 @@ export class Runtime extends EventEmitter {
1587
1619
  }
1588
1620
  } catch (err) {
1589
1621
  const error = ensureError(err)
1622
+ worker[kITC].notify('application:worker:start:processed')
1590
1623
 
1591
1624
  // TODO: handle port allocation error here
1592
1625
  if (error.code === 'EADDRINUSE' || error.code === 'EACCES') throw error
@@ -1608,7 +1641,7 @@ export class Runtime extends EventEmitter {
1608
1641
  this.emit('application:worker:start:error', { ...eventPayload, error })
1609
1642
 
1610
1643
  if (error.code !== 'PLT_RUNTIME_APPLICATION_START_TIMEOUT') {
1611
- this.logger.error({ err: ensureLoggableError(error) }, `Failed to start ${label}.`)
1644
+ this.logger.error({ err: ensureLoggableError(error) }, `Failed to start ${label}: ${error.message}`)
1612
1645
  }
1613
1646
 
1614
1647
  const restartOnError = config.restartOnError
@@ -1619,6 +1652,7 @@ export class Runtime extends EventEmitter {
1619
1652
 
1620
1653
  if (bootstrapAttempt++ >= MAX_BOOTSTRAP_ATTEMPTS || restartOnError === 0) {
1621
1654
  this.logger.error(`Failed to start ${label} after ${MAX_BOOTSTRAP_ATTEMPTS} attempts.`)
1655
+ this.emit('application:worker:start:failed', { ...eventPayload, error })
1622
1656
  throw error
1623
1657
  }
1624
1658
 
@@ -1636,7 +1670,7 @@ export class Runtime extends EventEmitter {
1636
1670
  }
1637
1671
  }
1638
1672
 
1639
- async #stopWorker (workersCount, id, index, silent, worker = undefined) {
1673
+ async #stopWorker (workersCount, id, index, silent, worker, dependents) {
1640
1674
  if (!worker) {
1641
1675
  worker = await this.#getWorkerById(id, index, false, false)
1642
1676
  }
@@ -1662,16 +1696,20 @@ export class Runtime extends EventEmitter {
1662
1696
  this.logger.info(`Stopping the ${label}...`)
1663
1697
  }
1664
1698
 
1665
- const exitTimeout = this.#config.gracefulShutdown.runtime
1699
+ const exitTimeout = this.#config.gracefulShutdown.application
1666
1700
  const exitPromise = once(worker, 'exit')
1667
1701
 
1668
1702
  // Always send the stop message, it will shut down workers that only had ITC and interceptors setup
1669
1703
  try {
1670
- await executeWithTimeout(sendViaITC(worker, 'stop'), exitTimeout)
1704
+ await executeWithTimeout(sendViaITC(worker, 'stop', { force: !!this.error, dependents }), exitTimeout)
1671
1705
  } catch (error) {
1672
- this.emit('application:worker:stop:timeout', eventPayload)
1706
+ this.emit('application:worker:stop:error', eventPayload)
1673
1707
  this.logger.info({ error: ensureLoggableError(error) }, `Failed to stop ${label}. Killing a worker thread.`)
1674
1708
  } finally {
1709
+ worker[kITC].notify('application:worker:stop:processed')
1710
+ // Wait for the processed message to be received
1711
+ await sleep(1)
1712
+
1675
1713
  worker[kITC].close()
1676
1714
  }
1677
1715
 
@@ -1800,7 +1838,7 @@ export class Runtime extends EventEmitter {
1800
1838
  throw e
1801
1839
  }
1802
1840
 
1803
- await this.#stopWorker(workersCount, applicationId, index, false, worker)
1841
+ await this.#stopWorker(workersCount, applicationId, index, false, worker, [])
1804
1842
  }
1805
1843
 
1806
1844
  async #getApplicationById (applicationId, ensureStarted = false, mustExist = true) {
@@ -2323,7 +2361,7 @@ export class Runtime extends EventEmitter {
2323
2361
  for (let i = currentWorkers - 1; i >= workers; i--) {
2324
2362
  const worker = await this.#getWorkerById(applicationId, i, false, false)
2325
2363
  await sendViaITC(worker, 'removeFromMesh')
2326
- await this.#stopWorker(currentWorkers, applicationId, i, false, worker)
2364
+ await this.#stopWorker(currentWorkers, applicationId, i, false, worker, [])
2327
2365
  report.stopped.push(i)
2328
2366
  }
2329
2367
  await this.#updateApplicationConfigWorkers(applicationId, workers)
@@ -2343,4 +2381,12 @@ export class Runtime extends EventEmitter {
2343
2381
  }
2344
2382
  return report
2345
2383
  }
2384
+
2385
+ #validatePprofCapturePreload () {
2386
+ const found = this.#config.preload?.some(p => p.includes('wattpm-pprof-capture'))
2387
+
2388
+ if (!found) {
2389
+ throw new MissingPprofCapture()
2390
+ }
2391
+ }
2346
2392
  }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  ensureLoggableError,
3
3
  FileWatcher,
4
+ kHandledError,
4
5
  listRecognizedConfigurationFiles,
5
6
  loadConfiguration,
6
7
  loadConfigurationModule
@@ -9,6 +10,7 @@ import debounce from 'debounce'
9
10
  import { EventEmitter } from 'node:events'
10
11
  import { existsSync } from 'node:fs'
11
12
  import { resolve } from 'node:path'
13
+ import { getActiveResourcesInfo } from 'node:process'
12
14
  import { workerData } from 'node:worker_threads'
13
15
  import { getGlobalDispatcher, setGlobalDispatcher } from 'undici'
14
16
  import { ApplicationAlreadyStartedError, RuntimeNotStartedError } from '../errors.js'
@@ -55,9 +57,11 @@ export class Controller extends EventEmitter {
55
57
  this.#lastELU = performance.eventLoopUtilization()
56
58
 
57
59
  this.#context = {
60
+ controller: this,
58
61
  applicationId: this.applicationId,
59
62
  workerId: this.workerId,
60
63
  directory: this.appConfig.path,
64
+ dependencies: this.appConfig.dependencies,
61
65
  isEntrypoint: this.appConfig.entrypoint,
62
66
  isProduction: this.appConfig.isProduction,
63
67
  telemetryConfig,
@@ -83,10 +87,7 @@ export class Controller extends EventEmitter {
83
87
  }
84
88
  }
85
89
 
86
- async getBootstrapDependencies () {
87
- return this.capability.getBootstrapDependencies?.() ?? []
88
- }
89
-
90
+ // Note: capability's init() is executed within start
90
91
  async init () {
91
92
  try {
92
93
  const appConfig = this.appConfig
@@ -142,10 +143,17 @@ export class Controller extends EventEmitter {
142
143
 
143
144
  try {
144
145
  await this.capability.init?.()
146
+ this.emit('init')
145
147
  } catch (err) {
146
148
  this.#logAndThrow(err)
147
149
  }
148
150
 
151
+ this.emit('starting')
152
+
153
+ if (this.capability.status === 'stopped') {
154
+ return
155
+ }
156
+
149
157
  if (this.#watch) {
150
158
  const watchConfig = await this.capability.getWatchConfig()
151
159
 
@@ -174,21 +182,23 @@ export class Controller extends EventEmitter {
174
182
 
175
183
  this.#started = true
176
184
  this.#starting = false
177
- this.emit('start')
185
+ this.emit('started')
178
186
  }
179
187
 
180
- async stop (force = false) {
188
+ async stop (force = false, dependents = []) {
181
189
  if (!force && (!this.#started || this.#starting)) {
182
190
  throw new RuntimeNotStartedError()
183
191
  }
184
192
 
193
+ this.emit('stopping')
185
194
  await this.#stopFileWatching()
195
+ await this.capability.waitForDependentsStop(dependents)
186
196
  await this.capability.stop()
187
197
 
188
198
  this.#started = false
189
199
  this.#starting = false
190
200
  this.#listening = false
191
- this.emit('stop')
201
+ this.emit('stopped')
192
202
  }
193
203
 
194
204
  async listen () {
@@ -213,6 +223,7 @@ export class Controller extends EventEmitter {
213
223
  globalThis.platformatic.onHttpStatsSize(url, size || 0)
214
224
  }
215
225
  }
226
+ globalThis.platformatic.onActiveResourcesEventLoop(getActiveResourcesInfo().length)
216
227
  return this.capability.getMetrics({ format })
217
228
  }
218
229
 
@@ -260,7 +271,11 @@ export class Controller extends EventEmitter {
260
271
  }
261
272
 
262
273
  #logAndThrow (err) {
263
- globalThis.platformatic.logger.error({ err: ensureLoggableError(err) }, 'The application threw an error.')
274
+ globalThis.platformatic.logger.error(
275
+ { err: ensureLoggableError(err) },
276
+ err[kHandledError] ? err.message : 'The application threw an error.'
277
+ )
278
+
264
279
  throw err
265
280
  }
266
281