@platformatic/runtime 3.72.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.
package/lib/runtime.js CHANGED
@@ -5,7 +5,6 @@ import {
5
5
  executeInParallel,
6
6
  executeWithTimeout,
7
7
  features,
8
- kEnvFileFallbackKeys,
9
8
  kMetadata,
10
9
  kTimeout,
11
10
  parseMemorySize,
@@ -26,6 +25,7 @@ import { STATUS_CODES } from 'node:http'
26
25
  import { createRequire } from 'node:module'
27
26
  import { availableParallelism } from 'node:os'
28
27
  import { basename, dirname, isAbsolute, join } from 'node:path'
28
+ import { randomUUID } from 'node:crypto'
29
29
  import { Readable } from 'node:stream'
30
30
  import { finished } from 'node:stream/promises'
31
31
  import { setImmediate as immediate, setTimeout as sleep } from 'node:timers/promises'
@@ -33,14 +33,15 @@ import { pathToFileURL } from 'node:url'
33
33
  import { Worker } from 'node:worker_threads'
34
34
  import SonicBoom from 'sonic-boom'
35
35
  import { Agent, request, interceptors as undiciInterceptors } from 'undici'
36
- import { createThreadInterceptor } from 'undici-thread-interceptor'
36
+ import { createCoordinator, createInterceptor } from 'undici-thread-interceptor'
37
37
  import { pprofCapturePreloadPath } from './config.js'
38
38
  import {
39
+ AddressInUseError,
40
+ WorkerAddressInUseError,
39
41
  ApplicationAlreadyStartedError,
40
42
  ApplicationNotFoundError,
41
43
  ApplicationNotStartedError,
42
44
  ApplicationStartTimeoutError,
43
- CannotRemoveEntrypointError,
44
45
  DuplicateExtensionHealthCheckError,
45
46
  DuplicateITCHandlerNameError,
46
47
  ExtensionHealthRoutesUnavailableError,
@@ -53,23 +54,23 @@ import {
53
54
  MessagingError,
54
55
  MetricFamilyCollisionError,
55
56
  MissingPprofCapture,
57
+ MixedServingStateError,
56
58
  ReservedITCHandlerNameError,
57
59
  RuntimeAbortedError,
58
60
  RuntimeExtensionBuildAlreadyCalledError,
59
- WorkerInterceptorJoinTimeoutError,
60
61
  WorkerNotFoundError
61
62
  } from './errors.js'
62
63
  import { abstractLogger, createLogger } from './logger.js'
63
64
  import { startManagementApi } from './management-api.js'
64
65
  import { createManagementHandlers } from './management-handlers.js'
65
66
  import { OpenTelemetryMetricsForwarder } from './opentelemetry-metrics.js'
66
- import { createChannelCreationHook } from './policies.js'
67
+ import { createChannelCreationHook, createTargetPermissionHook } from './policies.js'
67
68
  import { startHealthProbesServer, startPrometheusServer } from './prom-server.js'
68
69
  import { startScheduler } from './scheduler.js'
69
70
  import { createSharedStore } from './shared-http-cache.js'
70
71
  import { topologicalLevels, topologicalSort } from './utils.js'
71
72
  import { version } from './version.js'
72
- import { DynamicWorkersScaler } from './worker-scaler.js'
73
+ import { PredictiveWorkersScaler } from './predictive-worker-scaler.js'
73
74
  import { HealthSignalsQueue } from './worker/health-signals.js'
74
75
  import { sendMultipleViaITC, sendViaITC, waitEventFromITC } from './worker/itc.js'
75
76
  import { RoundRobinMap } from './worker/round-robin-map.js'
@@ -79,14 +80,19 @@ import {
79
80
  kFullId,
80
81
  kHealthCheckTimer,
81
82
  kId,
82
- kInterceptorReadyPromise,
83
83
  kIsSubprocessHost,
84
+ kSubprocessPid,
84
85
  kITC,
85
86
  kLastHealthCheckELU,
86
87
  kStderrMarker,
88
+ kWorkerExitCode,
87
89
  kWorkerHealthSignals,
88
90
  kWorkerId,
91
+ kWorkerPortOffset,
92
+ kWorkerServerOptions,
93
+ kWorkerUrl,
89
94
  kWorkersBroadcast,
95
+ kWorkerStartAborted,
90
96
  kWorkerStartTime,
91
97
  kWorkerStatus
92
98
  } from './worker/symbols.js'
@@ -118,6 +124,10 @@ const kLastProfileFallbackCodes = new Set([
118
124
  const kApplicationRestartsMetricName = 'platformatic_application_restarts_total'
119
125
  const kApplicationRestartsMetricHelp = 'Total number of restarts triggered by the runtime for an application.'
120
126
 
127
+ function normalizeCompileCache (value) {
128
+ return typeof value === 'boolean' ? { enabled: value } : value ?? {}
129
+ }
130
+
121
131
  const MAX_LISTENERS_COUNT = 100
122
132
 
123
133
  function hasWorkerIndex (applicationId) {
@@ -174,6 +184,56 @@ const IMMEDIATE_RESTART_MAX_THRESHOLD = 10
174
184
  const MAX_WORKERS = 100
175
185
  const DEFAULT_RESTART_ON_ERROR_DELAY = 5000
176
186
 
187
+ /*
188
+ Both public payloads are built from a snapshot and frozen through. What a consumer could observe
189
+ could once observe was scalars and a file path, so handing out interior state was harmless in
190
+ practice; the payload now nests resolvedConfig -- an entire capability payload -- inside every
191
+ entry, and the getters read
192
+ straight off live state. A consumer mutating what it received would be editing the configuration
193
+ that later restarts and scale-up workers read, silently, and would make worker generations
194
+ disagree about what they are running. setApplicationConfigPatch exists precisely so that changing
195
+ a running application's configuration is explicit, patch-shaped and visible.
196
+
197
+ Only plain objects and arrays are copied. Anything carrying its own prototype -- a class
198
+ instance, a stream, a buffer -- is handed back as it is, because a copy of it would not be the
199
+ thing; the hazard this closes is a consumer editing configuration, not one editing a socket.
200
+ */
201
+ function frozenSnapshot (value, seen = new Map()) {
202
+ if (value === null || typeof value !== 'object') {
203
+ return value
204
+ }
205
+
206
+ if (seen.has(value)) {
207
+ return seen.get(value)
208
+ }
209
+
210
+ if (Array.isArray(value)) {
211
+ const copy = []
212
+ seen.set(value, copy)
213
+
214
+ for (const entry of value) {
215
+ copy.push(frozenSnapshot(entry, seen))
216
+ }
217
+
218
+ return Object.freeze(copy)
219
+ }
220
+
221
+ const prototype = Object.getPrototypeOf(value)
222
+
223
+ if (prototype !== Object.prototype && prototype !== null) {
224
+ return value
225
+ }
226
+
227
+ const copy = {}
228
+ seen.set(value, copy)
229
+
230
+ for (const [key, entry] of Object.entries(value)) {
231
+ copy[key] = frozenSnapshot(entry, seen)
232
+ }
233
+
234
+ return Object.freeze(copy)
235
+ }
236
+
177
237
  export class Runtime extends EventEmitter {
178
238
  logger
179
239
  error
@@ -194,19 +254,20 @@ export class Runtime extends EventEmitter {
194
254
  #sharedContext
195
255
  #isProduction
196
256
  #concurrency
197
- #entrypointId
198
- #url
199
- #entrypointPort
200
257
 
201
258
  #healthMetricsTimer
202
259
  #healthMetricsCollectionActive
203
260
 
204
261
  #meshInterceptor
262
+ #meshCoordinator
263
+ #meshId
205
264
  #dispatcher
206
265
 
207
266
  #managementApi
208
267
  #prometheusServer
209
268
  #healthProbesServer
269
+ #metricsServersInitialized
270
+ #metricsServersStartPromise
210
271
  #opentelemetryMetricsForwarder
211
272
  #inspectorServer
212
273
  #metricsLabelName
@@ -224,11 +285,11 @@ export class Runtime extends EventEmitter {
224
285
  #extensionLivenessChecks
225
286
  #extensionHealthRoutes
226
287
  #lastOverloadProfiles
288
+ #servingStates
227
289
  #restartingApplications
228
- #restartingWorkers
290
+ #replicas
229
291
  #dynamicWorkersScaler
230
292
  #nextWorkerIndex
231
- #workerPortOffsets
232
293
 
233
294
  #sharedHttpCache
234
295
  #scheduler
@@ -255,22 +316,13 @@ export class Runtime extends EventEmitter {
255
316
  this.#applications = new Map()
256
317
  this.#applicationRestartCounts = new Map()
257
318
  this.#workers = new RoundRobinMap()
258
- this.#url = undefined
259
- this.#entrypointPort = undefined
260
319
  this.#channelCreationHook = createChannelCreationHook(this.#config)
261
- this.#meshInterceptor = createThreadInterceptor({
262
- domain: '.plt.local',
263
- timeout: this.#config.applicationTimeout,
264
- meshTimeout: this.#context.meshTimeout ?? true,
265
- onChannelCreation: this.#channelCreationHook,
266
- onError: this.#onMeshInterceptorError.bind(this)
267
- })
320
+ this.#meshId = `runtime-${randomUUID()}`
268
321
  this.logger = abstractLogger // This is replaced by the real logger in init() and eventually removed in close()
269
322
  this.#status = undefined
270
323
  this.#restartingApplications = new Set()
271
- this.#restartingWorkers = new Map()
324
+ this.#replicas = new Map()
272
325
  this.#nextWorkerIndex = new Map()
273
- this.#workerPortOffsets = new Map() // fullWorkerId => portOffset
274
326
  this.#sharedHttpCache = null
275
327
  this.#applicationsConfigsPatches = new Map()
276
328
 
@@ -299,20 +351,26 @@ export class Runtime extends EventEmitter {
299
351
  // Registered per-worker in #setupWorker, reserved so extensions cannot clobber it
300
352
  this.#reservedITCHandlerNames.add('profiling:started')
301
353
  this.#extensions = []
354
+ this.#metricsServersInitialized = false
355
+ this.#metricsServersStartPromise = null
302
356
  this.#extensionsWantHealthMetrics = false
303
357
  this.#extensionReadinessChecks = new Map()
304
358
  this.#extensionLivenessChecks = new Map()
305
359
  this.#extensionHealthRoutes = []
306
360
  this.#lastOverloadProfiles = new Map()
361
+ this.#servingStates = new Map()
307
362
  this.#sharedContext = {}
308
363
 
309
- if (this.#isProduction) {
310
- this.#env.PLT_DEV = 'false'
311
- this.#env.PLT_ENVIRONMENT = 'production'
312
- } else {
313
- this.#env.PLT_DEV = 'true'
314
- this.#env.PLT_ENVIRONMENT = 'development'
315
- }
364
+ /*
365
+ `PLT_DEV` and `PLT_ENVIRONMENT` are no longer injected here: an application branches on
366
+ its own variables, or the decision moves into the configuration, where the context carries
367
+ `production` and `mode` with types. `NODE_ENV` is the one the runtime still defaults, at the
368
+ bottom of the env ladder, so anything the project sets outranks it.
369
+
370
+ They were worse than redundant by the end -- the worker environment is the one the loader
371
+ resolved per application, and these were written onto the runtime's own copy, so under
372
+ `wattpm start` an application still read `PLT_ENVIRONMENT=development`.
373
+ */
316
374
  }
317
375
 
318
376
  async init () {
@@ -330,10 +388,9 @@ export class Runtime extends EventEmitter {
330
388
  this.#metricsLabelName = 'applicationId'
331
389
  }
332
390
 
333
- // Initialize process-level metrics registry in the main thread if metrics or management API is enabled
391
+ // Initialize process-level metrics registry only when metrics are enabled.
334
392
  // These metrics are the same across all workers and only need to be collected once
335
- // We need this for management API as it can request metrics even without explicit metrics config
336
- if (config.metrics || config.managementApi) {
393
+ if (config.metrics && config.metrics.enabled !== false) {
337
394
  this.#processMetricsRegistry = new metricsClient.Registry()
338
395
  collectProcessMetrics(this.#processMetricsRegistry)
339
396
  }
@@ -354,13 +411,7 @@ export class Runtime extends EventEmitter {
354
411
  this.#createWorkersBroadcastChannel()
355
412
 
356
413
  if (this.#config.workers.dynamic) {
357
- if (this.#config.workers.dynamic === false) {
358
- this.logger.warn(
359
- `Worker scaler disabled because the "workers" configuration is set to ${this.#config.workers.static}.`
360
- )
361
- } else {
362
- this.#dynamicWorkersScaler = new DynamicWorkersScaler(this, this.#config.workers)
363
- }
414
+ this.#dynamicWorkersScaler = new PredictiveWorkersScaler(this, this.#config.workers)
364
415
  }
365
416
 
366
417
  // Load extensions before creating any worker so that custom ITC handlers
@@ -369,18 +420,28 @@ export class Runtime extends EventEmitter {
369
420
  // readiness/liveness checks and probe routes before Fastify starts listening.
370
421
  await this.#loadExtensions()
371
422
 
372
- if (config.metrics || (typeof config.healthProbes === 'object' && config.healthProbes !== null)) {
373
- this.#prometheusServer = await startPrometheusServer(this, config.metrics ?? false, config.healthProbes)
423
+ // A runtime without applications exits during start and must not briefly
424
+ // claim the default health-probe port. Applications added later, including
425
+ // by extension start hooks, initialize these servers in addApplications().
426
+ if (config.applications.length > 0) {
427
+ await this.#ensureMetricsServersStarted()
374
428
  }
375
429
 
376
- this.#healthProbesServer = await startHealthProbesServer(this, config.metrics, config.healthProbes)
377
- this.#assertExtensionHealthRoutesApplied()
430
+ this.#meshCoordinator = createCoordinator({ meshId: this.#meshId })
431
+ this.#meshInterceptor = createInterceptor({
432
+ meshId: this.#meshId,
433
+ domain: '.plt.local',
434
+ connectTimeout: this.#config.applicationTimeout,
435
+ bootstrapTimeout: this.#config.applicationTimeout,
436
+ allowTarget: createTargetPermissionHook(this.#config)
437
+ })
438
+ await this.#meshInterceptor.ready
378
439
 
379
440
  await this.addApplications(this.#config.applications)
380
441
  await this.#setDispatcher(config.undici)
381
442
 
382
443
  if (!this.#context.build) {
383
- this.#scheduler = startScheduler(config.scheduler ?? [], this.#dispatcher, logger)
444
+ this.#scheduler = startScheduler(logger)
384
445
  }
385
446
 
386
447
  this.#updateStatus('init')
@@ -407,7 +468,7 @@ export class Runtime extends EventEmitter {
407
468
  if (this.getApplicationsIds().length === 0) {
408
469
  this.#updateStatus('started')
409
470
  await this.close(silent)
410
- return
471
+ return {}
411
472
  }
412
473
 
413
474
  if (this.#config.inspectorOptions) {
@@ -450,19 +511,13 @@ export class Runtime extends EventEmitter {
450
511
 
451
512
  this.#updateStatus('started')
452
513
 
453
- // The CLI enables the module compile cache for this process. Node.js would only write it when
454
- // the process terminates, so flush it now that the boot is complete to not lose it when the
455
- // process is killed abruptly. This is a no-op when the compile cache is not enabled.
456
- scheduleCompileCacheFlush()
457
-
458
514
  // Start the global health metrics timer for all workers if needed
459
515
  this.#startHealthMetricsCollectionIfNeeded()
460
516
 
461
517
  await this.#dynamicWorkersScaler?.start()
462
- if (this.#url) {
463
- this.#showUrl()
464
- }
465
- return this.#url
518
+ this.#showUrls()
519
+ scheduleCompileCacheFlush(this.logger)
520
+ return this.getUrls()
466
521
  }
467
522
 
468
523
  async stop (silent = false) {
@@ -491,18 +546,14 @@ export class Runtime extends EventEmitter {
491
546
 
492
547
  await this.#dynamicWorkersScaler?.stop()
493
548
 
494
- // Stop the entrypoint first so that no new requests are accepted
495
- if (this.#entrypointId) {
496
- await this.stopApplication(this.#entrypointId, silent)
497
- }
498
-
499
549
  // Await extension stop hooks before stopping remaining applications so that
500
550
  // control-plane extensions can settle work and hand off state first.
501
551
  await this.#stopExtensions()
502
552
 
503
553
  await this.stopApplications(this.getApplicationsIds(), silent)
504
554
 
505
- await this.#meshInterceptor.close()
555
+ await this.#meshInterceptor?.close()
556
+ this.#meshCoordinator?.destroy()
506
557
  this.#workersBroadcastChannel?.close()
507
558
 
508
559
  this.#updateStatus('stopped')
@@ -520,8 +571,6 @@ export class Runtime extends EventEmitter {
520
571
  await this.restartApplications(toRestart)
521
572
 
522
573
  this.emitAndNotify('restarted')
523
-
524
- return this.#url
525
574
  }
526
575
 
527
576
  async close (silent = false) {
@@ -537,6 +586,7 @@ export class Runtime extends EventEmitter {
537
586
  clearTimeout(this.#healthMetricsTimer)
538
587
  this.#healthMetricsCollectionActive = false
539
588
  this.#lastOverloadProfiles.clear()
589
+ this.#servingStates.clear()
540
590
 
541
591
  await this.stop(silent)
542
592
  this.#updateStatus('closing')
@@ -673,25 +723,8 @@ export class Runtime extends EventEmitter {
673
723
 
674
724
  async addApplications (applications, start = false) {
675
725
  const setupInvocations = []
676
- // Per-worker ports do not need SO_REUSEPORT because each worker binds a different port.
677
- const usesPerWorkerPorts = this.#config.server?.portAssignment === 'perWorkerIncrement'
678
-
679
726
  const toStart = []
680
727
  for (const application of applications) {
681
- const workers = application.workers
682
-
683
- if (
684
- (workers.static > 1 || workers.minimum > 1) &&
685
- application.entrypoint &&
686
- !features.node.reusePort &&
687
- !usesPerWorkerPorts
688
- ) {
689
- this.logger.warn(
690
- `"${application.id}" is set as the entrypoint, but reusePort is not available in your OS; setting workers to 1 instead of ${workers.static}`
691
- )
692
- application.workers = { dynamic: false, static: 1 }
693
- }
694
-
695
728
  this.#applications.set(application.id, application)
696
729
  this.#applicationRestartCounts.set(application.id, this.#applicationRestartCounts.get(application.id) ?? 0)
697
730
  setupInvocations.push([application])
@@ -700,8 +733,12 @@ export class Runtime extends EventEmitter {
700
733
 
701
734
  await executeInParallel(this.#setupApplication.bind(this), setupInvocations, this.#concurrency)
702
735
 
736
+ if (applications.length > 0) {
737
+ await this.#ensureMetricsServersStarted()
738
+ }
739
+
703
740
  for (const application of applications) {
704
- this.logger.debug(`Added application "${application.id}"${application.entrypoint ? ' (entrypoint)' : ''}.`)
741
+ this.logger.debug(`Added application "${application.id}".`)
705
742
  this.emitAndNotify('application:added', application)
706
743
  }
707
744
 
@@ -711,7 +748,7 @@ export class Runtime extends EventEmitter {
711
748
 
712
749
  const created = []
713
750
  for (const { id } of applications) {
714
- created.push(await this.getApplicationDetails(id))
751
+ created.push(await this.#buildApplicationDetails(id))
715
752
  }
716
753
 
717
754
  this.#updateLoggingPrefixes()
@@ -719,10 +756,6 @@ export class Runtime extends EventEmitter {
719
756
  }
720
757
 
721
758
  async removeApplications (applications, silent = false) {
722
- if (applications.includes(this.#entrypointId)) {
723
- throw new CannotRemoveEntrypointError()
724
- }
725
-
726
759
  const removed = []
727
760
  for (const application of applications) {
728
761
  if (!this.#applications.has(application)) {
@@ -731,18 +764,23 @@ export class Runtime extends EventEmitter {
731
764
 
732
765
  // Use allowUnloaded so that applications without a live worker
733
766
  // (stopped or crashed with restartOnError: 0) can still be removed.
734
- const details = await this.getApplicationDetails(application, true)
767
+ const details = await this.#buildApplicationDetails(application, true)
735
768
  details.status = 'removed'
769
+
770
+ // The snapshot is taken while the application is still up, but what it reports is an
771
+ // application about to be gone: it is not making a claim about how it would serve.
772
+ delete details.servingState
773
+
736
774
  removed.push(details)
737
775
  }
738
776
 
739
777
  await this.stopApplications(applications, silent, true)
740
778
 
741
779
  for (const application of applications) {
742
- this.#dynamicWorkersScaler?.remove(application)
743
780
  await this.#scheduler?.removeApplicationJobs(application)
744
781
  this.#applications.delete(application)
745
782
  this.#applicationRestartCounts.delete(application)
783
+ this.#replicas.delete(application)
746
784
  }
747
785
 
748
786
  for (const application of applications) {
@@ -773,8 +811,24 @@ export class Runtime extends EventEmitter {
773
811
  const levels = topologicalLevels(applications, dependencies)
774
812
 
775
813
  for (const level of levels) {
776
- const startInvocations = level.map(app => [app, silent])
777
- await executeInParallel(this.startApplication.bind(this), startInvocations, this.#concurrency)
814
+ const applicationsWithPort = await Promise.all(
815
+ level.map(async applicationId => {
816
+ const worker = await this.#getWorkerByIdOrNext(applicationId)
817
+ const applicationConfig = await sendViaITC(worker, 'getApplicationConfig')
818
+ const port = Number(applicationConfig?.server?.port)
819
+ return { applicationId, hasPort: Number.isInteger(port) && port > 0 }
820
+ })
821
+ )
822
+
823
+ for (const hasPort of [true, false]) {
824
+ const startInvocations = applicationsWithPort
825
+ .filter(application => application.hasPort === hasPort)
826
+ .map(({ applicationId }) => [applicationId, silent])
827
+
828
+ if (startInvocations.length > 0) {
829
+ await executeInParallel(this.startApplication.bind(this), startInvocations, this.#concurrency)
830
+ }
831
+ }
778
832
  }
779
833
  }
780
834
 
@@ -786,16 +840,21 @@ export class Runtime extends EventEmitter {
786
840
 
787
841
  if (!skipDependencies) {
788
842
  try {
789
- const { applications } = await this.getApplications(true)
790
- for (const application of applications) {
791
- for (const dependency of application.dependencies ?? []) {
792
- let applicationDependents = dependents[dependency]
793
- if (!applicationDependents) {
794
- applicationDependents = new Set()
795
- dependents[dependency] = applicationDependents
796
- }
843
+ const details = await executeWithTimeout(
844
+ this.getApplications(true),
845
+ this.#config.gracefulShutdown.application
846
+ )
847
+ if (details !== kTimeout) {
848
+ for (const application of details.applications) {
849
+ for (const dependency of application.dependencies ?? []) {
850
+ let applicationDependents = dependents[dependency]
851
+ if (!applicationDependents) {
852
+ applicationDependents = new Set()
853
+ dependents[dependency] = applicationDependents
854
+ }
797
855
 
798
- applicationDependents.add(application.id)
856
+ applicationDependents.add(application.id)
857
+ }
799
858
  }
800
859
  }
801
860
  } catch (e) {
@@ -804,11 +863,6 @@ export class Runtime extends EventEmitter {
804
863
  }
805
864
 
806
865
  for (const application of applicationsToStop) {
807
- // The entrypoint has been stopped above
808
- if (application === this.#entrypointId) {
809
- continue
810
- }
811
-
812
866
  stopInvocations.push([application, silent, Array.from(dependents[application] ?? [])])
813
867
  }
814
868
 
@@ -816,13 +870,39 @@ export class Runtime extends EventEmitter {
816
870
  }
817
871
 
818
872
  async restartApplications (applicationsToRestart) {
819
- const restartInvocations = []
873
+ const restartInvocations = applicationsToRestart.map(application => [application, true])
874
+ const restarts = await executeInParallel(
875
+ this.restartApplication.bind(this),
876
+ restartInvocations,
877
+ this.#concurrency,
878
+ false
879
+ )
880
+ const failed = restarts.filter(result => result instanceof Error)
881
+ const succeeded = restarts.filter(result => result && !(result instanceof Error))
820
882
 
821
- for (const application of applicationsToRestart) {
822
- restartInvocations.push([application])
883
+ if (failed.length > 0) {
884
+ await Promise.allSettled(succeeded.map(restart => restart.discard()))
885
+ throw failed[0]
823
886
  }
824
887
 
825
- return executeInParallel(this.restartApplication.bind(this), restartInvocations, this.#concurrency)
888
+ const results = await executeInParallel(
889
+ retirement => retirement.retire(),
890
+ succeeded.flatMap(({ retirements }) => retirements.map(retirement => [retirement])),
891
+ this.#concurrency,
892
+ false
893
+ )
894
+
895
+ const error = results.find(result => result instanceof Error)
896
+ if (error) {
897
+ for (const restart of succeeded) {
898
+ restart.release()
899
+ }
900
+ throw error
901
+ }
902
+
903
+ for (const restart of succeeded) {
904
+ await restart.complete()
905
+ }
826
906
  }
827
907
 
828
908
  async startApplication (id, silent = false) {
@@ -833,26 +913,33 @@ export class Runtime extends EventEmitter {
833
913
  throw new ApplicationNotFoundError(id, this.getApplicationsIds().join(', '))
834
914
  }
835
915
 
916
+ if (this.#isApplicationStarted(id)) {
917
+ throw new ApplicationAlreadyStartedError()
918
+ }
919
+
836
920
  const workers = applicationConfig.workers.static
837
- for (let i = 0; i < workers; i++) {
838
- const worker = this.#workers.get(`${id}:${i}`)
839
- const status = worker?.[kWorkerStatus]
921
+ this.emitAndNotify('application:starting', id)
840
922
 
841
- if (status && status !== 'boot' && status !== 'init') {
842
- throw new ApplicationAlreadyStartedError()
843
- }
923
+ const compileCache = {
924
+ ...normalizeCompileCache(config.compileCache),
925
+ ...normalizeCompileCache(applicationConfig.compileCache)
844
926
  }
927
+ const awaitFirstWorker =
928
+ typeof compileCache === 'object' && compileCache.enabled !== false && compileCache.awaitFirstWorker === true
845
929
 
846
- this.emitAndNotify('application:starting', id)
930
+ if (awaitFirstWorker && workers > 0) {
931
+ await this.#startFirstWorkerWithCompileCache(config, applicationConfig, workers, id, silent)
932
+ }
847
933
 
848
- for (let i = 0; i < workers; i++) {
934
+ // Keep the first worker separate so compile-cache startup can be made a barrier without changing this loop later.
935
+ for (let i = awaitFirstWorker ? 1 : 0; i < workers; i++) {
849
936
  await this.#startWorker(config, applicationConfig, workers, id, i, silent)
850
937
  }
851
938
 
852
939
  await this.#registerApplicationSchedulerJobs(id)
940
+ await this.#collectServingState(id)
853
941
 
854
942
  this.emitAndNotify('application:started', id)
855
- await this.#dynamicWorkersScaler?.applyPendingUpdate(id)
856
943
  }
857
944
 
858
945
  async stopApplication (id, silent = false, dependents = []) {
@@ -860,7 +947,17 @@ export class Runtime extends EventEmitter {
860
947
  throw new ApplicationNotFoundError(id, this.getApplicationsIds().join(', '))
861
948
  }
862
949
 
950
+ this.#dynamicWorkersScaler?.remove(id)
951
+
952
+ // Removing the replicas cancels the work in progress for them, i.e. refills of crashed workers and replacements
953
+ const replicas = this.#replicas.get(id) ?? []
954
+ this.#replicas.set(id, [])
955
+ const detached = Promise.all(replicas.map(replica => this.#detachReplica(id, replica)))
956
+
957
+ // Workers being discarded by the detach above are already marked as exited
863
958
  const workersIds = this.#workers.getKeys(id)
959
+ .filter(workerId => this.#workers.get(workerId)[kWorkerStatus] !== 'exited')
960
+
864
961
  const workersCount = workersIds.length
865
962
 
866
963
  this.emitAndNotify('application:stopping', id)
@@ -876,10 +973,17 @@ export class Runtime extends EventEmitter {
876
973
  await executeInParallel(this.#stopWorker.bind(this), stopInvocations, this.#concurrency)
877
974
  }
878
975
 
976
+ await detached
977
+
978
+ // Absent rather than 'inactive': a stopped application is not making a claim about how it
979
+ // would serve, and conflating "not started" with "started and serving nothing" is the exact
980
+ // distinction this field exists to draw.
981
+ this.#servingStates.delete(id)
982
+
879
983
  this.emitAndNotify('application:stopped', id)
880
984
  }
881
985
 
882
- async restartApplication (id) {
986
+ async restartApplication (id, deferOldWorkerRetirement = false) {
883
987
  const applicationConfig = this.#applications.get(id)
884
988
 
885
989
  if (!applicationConfig) {
@@ -898,31 +1002,75 @@ export class Runtime extends EventEmitter {
898
1002
  }
899
1003
 
900
1004
  this.#restartingApplications.add(id)
1005
+ const retirements = []
1006
+ let deferred = false
901
1007
 
902
1008
  try {
903
1009
  const config = this.#config
904
- const workersIds = await this.#workers.getKeys(id)
905
- const workersCount = workersIds.length
1010
+ // A copy: while the loop awaits, scale-downs remove replicas from the list, which would shift the remaining ones
1011
+ // and skip some. Replicas added in the meanwhile (scale-up) already run fresh workers.
1012
+ const replicas = [...(this.#replicas.get(id) ?? [])]
1013
+ const replicasCount = replicas.length
906
1014
 
907
1015
  this.emitAndNotify('application:restarting', id)
908
1016
 
909
- for (let i = 0; i < workersCount; i++) {
910
- const workerId = workersIds[i]
911
- const worker = this.#workers.get(workerId)
912
- const workerIndex = parseInt(workerId.split(':')[1], 10)
1017
+ for (let i = 0; i < replicasCount; i++) {
1018
+ const replica = replicas[i]
913
1019
 
914
1020
  if (i > 0 && config.workersRestartDelay > 0) {
915
1021
  await sleep(config.workersRestartDelay)
916
1022
  }
917
1023
 
918
- await this.#replaceWorker(config, applicationConfig, workersCount, id, workerIndex, worker, true)
1024
+ // The replica might have been removed while the previous ones were being replaced,
1025
+ // or its worker crashed and the replica is being refilled with a fresh worker.
1026
+ const worker = replica.worker
1027
+ if (replica.abortController.signal.aborted || !worker) {
1028
+ continue
1029
+ }
1030
+
1031
+ const retirement = await this.#replaceWorker(
1032
+ config,
1033
+ applicationConfig,
1034
+ replicasCount,
1035
+ id,
1036
+ worker[kWorkerId],
1037
+ worker,
1038
+ true,
1039
+ deferOldWorkerRetirement
1040
+ )
1041
+ if (retirement) {
1042
+ retirements.push(retirement)
1043
+ }
1044
+ }
1045
+
1046
+ if (deferOldWorkerRetirement) {
1047
+ deferred = true
1048
+ return {
1049
+ retirements,
1050
+ release: () => this.#restartingApplications.delete(id),
1051
+ discard: async () => {
1052
+ await Promise.allSettled(retirements.map(retirement => retirement.discard()))
1053
+ this.#restartingApplications.delete(id)
1054
+ },
1055
+ complete: async () => {
1056
+ try {
1057
+ await this.#registerApplicationSchedulerJobs(id)
1058
+ this.#incrementApplicationRestartCount(id)
1059
+ this.emitAndNotify('application:restarted', id)
1060
+ } finally {
1061
+ this.#restartingApplications.delete(id)
1062
+ }
1063
+ }
1064
+ }
919
1065
  }
920
1066
 
921
1067
  await this.#registerApplicationSchedulerJobs(id)
922
1068
  this.#incrementApplicationRestartCount(id)
923
1069
  this.emitAndNotify('application:restarted', id)
924
1070
  } finally {
925
- this.#restartingApplications.delete(id)
1071
+ if (!deferred) {
1072
+ this.#restartingApplications.delete(id)
1073
+ }
926
1074
  }
927
1075
  }
928
1076
 
@@ -994,13 +1142,7 @@ export class Runtime extends EventEmitter {
994
1142
 
995
1143
  if (!allWorkers || hasWorkerIndex(id)) {
996
1144
  const service = await this.#getApplicationWorkerForProfiling(id, ensureStarted)
997
- const nodeModulesSourceMaps = profilingOptions.nodeModulesSourceMaps ??
998
- service[kConfig].nodeModulesSourceMaps ?? this.#config.nodeModulesSourceMaps
999
-
1000
- return sendViaITC(service, 'startProfiling', {
1001
- ...profilingOptions,
1002
- nodeModulesSourceMaps
1003
- })
1145
+ return sendViaITC(service, 'startProfiling', profilingOptions)
1004
1146
  }
1005
1147
 
1006
1148
  const started = []
@@ -1009,14 +1151,7 @@ export class Runtime extends EventEmitter {
1009
1151
 
1010
1152
  for (const { workerIndex, worker } of await this.#getApplicationWorkersForProfiling(id, ensureStarted)) {
1011
1153
  try {
1012
- const nodeModulesSourceMaps = profilingOptions.nodeModulesSourceMaps ??
1013
- worker[kConfig].nodeModulesSourceMaps ??
1014
- this.#config.nodeModulesSourceMaps
1015
-
1016
- await sendViaITC(worker, 'startProfiling', {
1017
- ...profilingOptions,
1018
- nodeModulesSourceMaps
1019
- })
1154
+ await sendViaITC(worker, 'startProfiling', profilingOptions)
1020
1155
  started.push(workerIndex)
1021
1156
  } catch (error) {
1022
1157
  // A worker which is already being profiled is considered covered, but
@@ -1300,6 +1435,8 @@ export class Runtime extends EventEmitter {
1300
1435
  this.#healthProbesServer = null
1301
1436
  }
1302
1437
 
1438
+ this.#metricsServersInitialized = false
1439
+
1303
1440
  if (this.#opentelemetryMetricsForwarder) {
1304
1441
  await this.#opentelemetryMetricsForwarder.close()
1305
1442
  this.#opentelemetryMetricsForwarder = null
@@ -1313,10 +1450,7 @@ export class Runtime extends EventEmitter {
1313
1450
  entry.applied = false
1314
1451
  }
1315
1452
 
1316
- this.#prometheusServer = await startPrometheusServer(this, metricsConfig, this.#config.healthProbes)
1317
-
1318
- this.#healthProbesServer = await startHealthProbesServer(this, metricsConfig, this.#config.healthProbes)
1319
- this.#assertExtensionHealthRoutesApplied()
1453
+ await this.#ensureMetricsServersStarted()
1320
1454
 
1321
1455
  await this.#startOpenTelemetryMetricsForwarder(metricsConfig?.opentelemetry)
1322
1456
 
@@ -1338,6 +1472,41 @@ export class Runtime extends EventEmitter {
1338
1472
  return { success: true, config: metricsConfig }
1339
1473
  }
1340
1474
 
1475
+ async #ensureMetricsServersStarted () {
1476
+ if (this.#metricsServersInitialized) {
1477
+ return
1478
+ }
1479
+
1480
+ if (this.#metricsServersStartPromise) {
1481
+ return this.#metricsServersStartPromise
1482
+ }
1483
+
1484
+ this.#metricsServersStartPromise = (async () => {
1485
+ const config = this.#config
1486
+
1487
+ try {
1488
+ if (config.metrics || (typeof config.healthProbes === 'object' && config.healthProbes !== null)) {
1489
+ this.#prometheusServer = await startPrometheusServer(this, config.metrics ?? false, config.healthProbes)
1490
+ }
1491
+
1492
+ this.#healthProbesServer = await startHealthProbesServer(this, config.metrics, config.healthProbes)
1493
+ this.#assertExtensionHealthRoutesApplied()
1494
+ this.#metricsServersInitialized = true
1495
+ } catch (err) {
1496
+ await Promise.allSettled([this.#prometheusServer?.close(), this.#healthProbesServer?.close()])
1497
+ this.#prometheusServer = null
1498
+ this.#healthProbesServer = null
1499
+ throw err
1500
+ }
1501
+ })()
1502
+
1503
+ try {
1504
+ await this.#metricsServersStartPromise
1505
+ } finally {
1506
+ this.#metricsServersStartPromise = null
1507
+ }
1508
+ }
1509
+
1341
1510
  async #startOpenTelemetryMetricsForwarder (config) {
1342
1511
  if (!config?.endpoint || config.enabled === false || config.enabled === 'false') {
1343
1512
  return
@@ -1490,7 +1659,7 @@ export class Runtime extends EventEmitter {
1490
1659
  const { applicationId, config: applicationConfig, workers, health, currentWorkers, currentHealth } = update
1491
1660
 
1492
1661
  if (workers && health) {
1493
- const r = await this.#updateApplicationWorkersAndHealth(
1662
+ const resourceReport = await this.#updateApplicationWorkersAndHealth(
1494
1663
  applicationId,
1495
1664
  config,
1496
1665
  applicationConfig,
@@ -1501,11 +1670,11 @@ export class Runtime extends EventEmitter {
1501
1670
  )
1502
1671
  report.push({
1503
1672
  application: applicationId,
1504
- workers: r.workers,
1505
- health: r.health
1673
+ workers: resourceReport.workers,
1674
+ health: resourceReport.health
1506
1675
  })
1507
1676
  } else if (health) {
1508
- const r = await this.#updateApplicationHealth(
1677
+ const healthReport = await this.#updateApplicationHealth(
1509
1678
  applicationId,
1510
1679
  config,
1511
1680
  applicationConfig,
@@ -1515,10 +1684,10 @@ export class Runtime extends EventEmitter {
1515
1684
  )
1516
1685
  report.push({
1517
1686
  application: applicationId,
1518
- health: r.health
1687
+ health: healthReport
1519
1688
  })
1520
1689
  } else if (workers) {
1521
- const r = await this.#updateApplicationWorkers(
1690
+ const workersReport = await this.#updateApplicationWorkers(
1522
1691
  applicationId,
1523
1692
  config,
1524
1693
  applicationConfig,
@@ -1527,7 +1696,7 @@ export class Runtime extends EventEmitter {
1527
1696
  )
1528
1697
  report.push({
1529
1698
  application: applicationId,
1530
- workers: r.workers
1699
+ workers: workersReport
1531
1700
  })
1532
1701
  }
1533
1702
  }
@@ -1543,8 +1712,20 @@ export class Runtime extends EventEmitter {
1543
1712
  return this.#root
1544
1713
  }
1545
1714
 
1546
- getUrl () {
1547
- return this.#url
1715
+ getUrls (applicationId) {
1716
+ const urls = {}
1717
+ const applicationIds = applicationId ? [applicationId] : this.#applications.keys()
1718
+
1719
+ for (const id of applicationIds) {
1720
+ for (const workerId of this.#workers.getKeys(id)) {
1721
+ const url = this.#workers.get(workerId)?.[kWorkerUrl]
1722
+ if (url) {
1723
+ urls[workerId] = url
1724
+ }
1725
+ }
1726
+ }
1727
+
1728
+ return urls
1548
1729
  }
1549
1730
 
1550
1731
  getRuntimeStatus () {
@@ -1553,8 +1734,6 @@ export class Runtime extends EventEmitter {
1553
1734
 
1554
1735
  async getRuntimeMetadata () {
1555
1736
  const packageJson = await this.#getRuntimePackageJson()
1556
- const entrypointDetails = await this.getEntrypointDetails()
1557
-
1558
1737
  return {
1559
1738
  pid: process.pid,
1560
1739
  cwd: process.cwd(),
@@ -1563,10 +1742,18 @@ export class Runtime extends EventEmitter {
1563
1742
  execPath: process.execPath,
1564
1743
  nodeVersion: process.version,
1565
1744
  projectDir: this.#root,
1745
+ /*
1746
+ What `applications:add`/`remove --save` actually consume, so they can stop reading the whole
1747
+ runtime configuration over HTTP to get at three values. `autoload` is the declaration as
1748
+ authored, not the expansion: it is expanded in the eval worker, and --save has to edit what
1749
+ the file says rather than what it produced.
1750
+ */
1751
+ configPath: this.#config[kMetadata]?.path ?? null,
1752
+ autoload: this.#config.autoload ?? null,
1566
1753
  packageName: packageJson.name ?? null,
1567
1754
  packageVersion: packageJson.version ?? null,
1568
- url: entrypointDetails?.url ?? null,
1569
- platformaticVersion: version
1755
+ platformaticVersion: version,
1756
+ urls: this.getUrls()
1570
1757
  }
1571
1758
  }
1572
1759
 
@@ -1574,13 +1761,31 @@ export class Runtime extends EventEmitter {
1574
1761
  return this.#env
1575
1762
  }
1576
1763
 
1764
+ /*
1765
+ What a watcher has to follow to know this configuration changed. The whole set the evaluation
1766
+ read is reported; resolving per worker had no such set, so it was the deciding file alone --
1767
+ which is what dev watched before either way.
1768
+ */
1769
+ getConfigurationWatchTargets () {
1770
+ const metadata = this.#config[kMetadata]
1771
+ const targets = metadata?.loader?.watchTargets
1772
+
1773
+ if (targets) {
1774
+ return { files: [...targets.files], directories: [...targets.directories] }
1775
+ }
1776
+
1777
+ return { files: metadata?.path ? [metadata.path] : [], directories: [] }
1778
+ }
1779
+
1577
1780
  getRuntimeConfig (includeMeta = false) {
1781
+ // includeMeta is an internal contract and hands back live state, symbol key and all. It leaves
1782
+ // the public surface with the DTO change; until then, its in-tree callers depend on identity.
1578
1783
  if (includeMeta) {
1579
1784
  return this.#config
1580
1785
  }
1581
1786
 
1582
1787
  const { [kMetadata]: _, ...config } = this.#config
1583
- return config
1788
+ return frozenSnapshot(config)
1584
1789
  }
1585
1790
 
1586
1791
  getInterceptor () {
@@ -1599,12 +1804,39 @@ export class Runtime extends EventEmitter {
1599
1804
  return this.#managementApi?.server.address() ?? null
1600
1805
  }
1601
1806
 
1602
- async getEntrypointDetails () {
1603
- if (!this.#entrypointId) {
1604
- return null
1807
+ /*
1808
+ servingState is computed per worker: for a worker-classified capability it depends on what the
1809
+ application's factory returned in *that* worker, and nothing stops arbitrary code from returning
1810
+ a server from worker 0 and a background result from worker 1. Sampling one worker -- which is
1811
+ what getApplicationDetails does for every other field -- would make the reported value depend on
1812
+ which worker the selector picked, and, worse, would leave mesh dispatch routing a share of
1813
+ requests to a worker that destroys them. So every worker answers and a mixed answer is refused,
1814
+ naming each worker and the state it reported.
1815
+ */
1816
+ async #collectServingState (id) {
1817
+ const invocations = this.#workers.getKeys(id).map(workerId => [workerId, this.#workers.get(workerId)])
1818
+
1819
+ if (invocations.length === 0) {
1820
+ this.#servingStates.delete(id)
1821
+ return
1605
1822
  }
1606
1823
 
1607
- return this.getApplicationDetails(this.#entrypointId)
1824
+ const states = await sendMultipleViaITC(invocations, 'getServingState', undefined, [], this.#concurrency)
1825
+ const reported = Object.entries(states).filter(([, state]) => typeof state === 'string')
1826
+
1827
+ if (reported.length === 0) {
1828
+ this.#servingStates.delete(id)
1829
+ return
1830
+ }
1831
+
1832
+ const distinct = new Set(reported.map(([, state]) => state))
1833
+
1834
+ if (distinct.size > 1) {
1835
+ this.#servingStates.delete(id)
1836
+ throw new MixedServingStateError(id, reported.map(([worker, state]) => `${worker} reported ${state}`).join(', '))
1837
+ }
1838
+
1839
+ this.#servingStates.set(id, reported[0][1])
1608
1840
  }
1609
1841
 
1610
1842
  async getCustomHealthChecks () {
@@ -1758,7 +1990,7 @@ export class Runtime extends EventEmitter {
1758
1990
  // Drop any configured custom label that shares the name of the application
1759
1991
  // label (a config can set both `applicationLabel: 'serviceId'` and a static
1760
1992
  // `serviceId` label): keeping it would make these runtime-wide metrics look
1761
- // like they belong to an application, both here and in getFormattedMetrics().
1993
+ // like they belong to an application during metrics aggregation.
1762
1994
  const processLabels = { ...this.#config.metrics?.labels }
1763
1995
  delete processLabels[this.#metricsLabelName]
1764
1996
  this.#applyLabelsToMetrics(processMetricsJson, processLabels, processMetrics)
@@ -1923,180 +2155,41 @@ export class Runtime extends EventEmitter {
1923
2155
  return output
1924
2156
  }
1925
2157
 
1926
- async getFormattedMetrics () {
1927
- try {
1928
- const { metrics } = await this.getMetrics()
1929
-
1930
- if (metrics === null || metrics.length === 0) {
1931
- return null
1932
- }
1933
-
1934
- const metricsNames = [
1935
- 'process_cpu_percent_usage',
1936
- 'process_resident_memory_bytes',
1937
- 'nodejs_heap_size_total_bytes',
1938
- 'nodejs_heap_size_used_bytes',
1939
- 'nodejs_heap_space_size_total_bytes',
1940
- 'nodejs_eventloop_utilization',
1941
- 'http_request_all_summary_seconds'
1942
- ]
1943
-
1944
- const applicationsMetrics = {}
1945
-
1946
- // Process-level metrics are reported only once for the whole runtime, without
1947
- // an application label, since they are shared by all the applications running
1948
- // in worker threads (see issue #3332). Applications running as separate OS
1949
- // processes report their own labeled values, which take precedence below.
1950
- const runtimeProcessMetrics = {}
1951
- const applicationProcessMetrics = new Set()
1952
-
1953
- for (const metric of metrics) {
1954
- const { name, values } = metric
1955
-
1956
- if (!metricsNames.includes(name)) continue
1957
- if (!values || values.length === 0) continue
1958
-
1959
- const labels = values[0].labels
1960
- // Use the configured label name (serviceId for v2 compatibility, applicationId for v3+)
1961
- const applicationId = labels?.[this.#metricsLabelName]
1962
-
1963
- if (!applicationId) {
1964
- if (name === 'process_cpu_percent_usage') {
1965
- runtimeProcessMetrics.cpu = values[0].value
1966
- continue
1967
- }
1968
- if (name === 'process_resident_memory_bytes') {
1969
- runtimeProcessMetrics.rss = values[0].value
1970
- continue
1971
- }
1972
-
1973
- throw new Error(`Missing ${this.#metricsLabelName} label in metrics`)
1974
- }
1975
-
1976
- if (name === 'process_cpu_percent_usage' || name === 'process_resident_memory_bytes') {
1977
- applicationProcessMetrics.add(`${applicationId}:${name}`)
1978
- }
1979
-
1980
- let applicationMetrics = applicationsMetrics[applicationId]
1981
- if (!applicationMetrics) {
1982
- applicationMetrics = {
1983
- cpu: 0,
1984
- rss: 0,
1985
- totalHeapSize: 0,
1986
- usedHeapSize: 0,
1987
- newSpaceSize: 0,
1988
- oldSpaceSize: 0,
1989
- elu: 0,
1990
- latency: {
1991
- p50: 0,
1992
- p90: 0,
1993
- p95: 0,
1994
- p99: 0
1995
- }
1996
- }
1997
- applicationsMetrics[applicationId] = applicationMetrics
1998
- }
1999
-
2000
- parsePromMetric(applicationMetrics, metric)
2001
- }
2002
-
2003
- // Apply the runtime-wide process-level values to every application that did
2004
- // not report its own (i.e. every application running in a worker thread).
2005
- for (const [applicationId, applicationMetrics] of Object.entries(applicationsMetrics)) {
2006
- if (
2007
- runtimeProcessMetrics.cpu !== undefined &&
2008
- !applicationProcessMetrics.has(`${applicationId}:process_cpu_percent_usage`)
2009
- ) {
2010
- applicationMetrics.cpu = runtimeProcessMetrics.cpu
2011
- }
2012
-
2013
- if (
2014
- runtimeProcessMetrics.rss !== undefined &&
2015
- !applicationProcessMetrics.has(`${applicationId}:process_resident_memory_bytes`)
2016
- ) {
2017
- applicationMetrics.rss = runtimeProcessMetrics.rss
2018
- }
2019
- }
2020
-
2021
- function parsePromMetric (applicationMetrics, promMetric) {
2022
- const { name } = promMetric
2023
-
2024
- if (name === 'process_cpu_percent_usage') {
2025
- applicationMetrics.cpu = promMetric.values[0].value
2026
- return
2027
- }
2028
- if (name === 'process_resident_memory_bytes') {
2029
- applicationMetrics.rss = promMetric.values[0].value
2030
- return
2031
- }
2032
- if (name === 'nodejs_heap_size_total_bytes') {
2033
- applicationMetrics.totalHeapSize = promMetric.values[0].value
2034
- return
2035
- }
2036
- if (name === 'nodejs_heap_size_used_bytes') {
2037
- applicationMetrics.usedHeapSize = promMetric.values[0].value
2038
- return
2039
- }
2040
- if (name === 'nodejs_heap_space_size_total_bytes') {
2041
- const newSpaceSize = promMetric.values.find(value => value.labels.space === 'new')
2042
- const oldSpaceSize = promMetric.values.find(value => value.labels.space === 'old')
2043
-
2044
- applicationMetrics.newSpaceSize = newSpaceSize.value
2045
- applicationMetrics.oldSpaceSize = oldSpaceSize.value
2046
- return
2047
- }
2048
- if (name === 'nodejs_eventloop_utilization') {
2049
- applicationMetrics.elu = promMetric.values[0].value
2050
- return
2051
- }
2052
- if (name === 'http_request_all_summary_seconds') {
2053
- applicationMetrics.latency = {
2054
- p50: promMetric.values.find(value => value.labels.quantile === 0.5)?.value || 0,
2055
- p90: promMetric.values.find(value => value.labels.quantile === 0.9)?.value || 0,
2056
- p95: promMetric.values.find(value => value.labels.quantile === 0.95)?.value || 0,
2057
- p99: promMetric.values.find(value => value.labels.quantile === 0.99)?.value || 0
2058
- }
2059
- }
2060
- }
2061
-
2062
- return {
2063
- version: 1,
2064
- date: new Date().toISOString(),
2065
- applications: applicationsMetrics
2066
- }
2067
- } catch (err) {
2068
- // If any metric is missing, return nothing
2069
- this.logger.warn({ err }, 'Cannot fetch metrics')
2070
-
2071
- return null
2072
- }
2073
- }
2074
-
2075
2158
  getSharedContext () {
2076
2159
  return this.#sharedContext
2077
2160
  }
2078
2161
 
2079
2162
  async getApplicationResourcesInfo (id) {
2080
- const workersCount = this.#workers.getKeys(id).length
2163
+ const workers = this.#replicas.get(id)?.length ?? 0
2081
2164
  // Use round-robin to get any available worker instead of assuming index 0 exists
2082
2165
  const worker = await this.#getWorkerByIdOrNext(id, null, false, false)
2083
- const health = worker[kConfig].health
2084
2166
 
2085
- return { workers: workersCount, health }
2167
+ if (worker) {
2168
+ return { workers, health: worker[kConfig].health }
2169
+ }
2170
+
2171
+ // No worker can answer (the application is stopped or its only worker is being restarted), use the configuration
2172
+ const applicationConfig = this.#applications.get(id)
2173
+ const health = deepmerge(this.#config.health ?? {}, applicationConfig.health ?? {})
2174
+
2175
+ return { workers, health }
2086
2176
  }
2087
2177
 
2088
2178
  getApplicationsIds () {
2089
- return Array.from(this.#applications.keys())
2179
+ return Array.from(this.#applications.keys()).sort()
2090
2180
  }
2091
2181
 
2092
2182
  async getApplications (allowUnloaded = false) {
2093
- return {
2094
- entrypoint: this.#entrypointId,
2183
+ return frozenSnapshot({
2095
2184
  production: this.#isProduction,
2096
2185
  applications: await Promise.all(
2097
- this.getApplicationsIds().map(id => this.getApplicationDetails(id, allowUnloaded))
2186
+ this.getApplicationsIds().map(id => this.#buildApplicationDetails(id, allowUnloaded))
2098
2187
  )
2099
- }
2188
+ })
2189
+ }
2190
+
2191
+ async getApplicationDetails (id, allowUnloaded = false) {
2192
+ return frozenSnapshot(await this.#buildApplicationDetails(id, allowUnloaded))
2100
2193
  }
2101
2194
 
2102
2195
  async getApplicationMeta (id) {
@@ -2124,7 +2217,7 @@ export class Runtime extends EventEmitter {
2124
2217
  }
2125
2218
  }
2126
2219
 
2127
- async getApplicationDetails (id, allowUnloaded = false) {
2220
+ async #buildApplicationDetails (id, allowUnloaded = false) {
2128
2221
  let application
2129
2222
 
2130
2223
  try {
@@ -2137,7 +2230,7 @@ export class Runtime extends EventEmitter {
2137
2230
  throw e
2138
2231
  }
2139
2232
 
2140
- const { entrypoint, localUrl, config, path } = application[kConfig]
2233
+ const { localUrl, config, configPath, path } = application[kConfig]
2141
2234
 
2142
2235
  const sourceMaps = application[kConfig].sourceMaps ?? this.#config.sourceMaps
2143
2236
  const status = await sendViaITC(application, 'getStatus')
@@ -2146,24 +2239,43 @@ export class Runtime extends EventEmitter {
2146
2239
  const applicationDetails = {
2147
2240
  id,
2148
2241
  type,
2149
- config,
2150
2242
  path,
2151
2243
  status,
2152
2244
  dependencies,
2153
2245
  version,
2154
2246
  localUrl,
2155
- entrypoint,
2156
2247
  sourceMaps
2157
2248
  }
2158
2249
 
2159
- if (this.#isProduction) {
2160
- applicationDetails.workers = this.#workers.getKeys(id).length
2250
+ /*
2251
+ The `config` key once held the application's configuration file path. That file is now
2252
+ evaluated main-side and the worker receives the payload, so the path becomes `configPath` and the entry's own
2253
+ `config` is no longer a path to report. Emitting the key that matches the dialect keeps a
2254
+ consumer from reading one and silently getting the other.
2255
+ */
2256
+ if (typeof config === 'string') {
2257
+ applicationDetails.config = config
2258
+ } else if (configPath) {
2259
+ applicationDetails.configPath = configPath
2260
+ }
2261
+
2262
+ // status is worker lifecycle; servingState is how the thing serves. They are different
2263
+ // questions, and the runtime gates URL emission on status === 'started', so they cannot share
2264
+ // a field.
2265
+ const servingState = this.#servingStates.get(id)
2266
+
2267
+ if (servingState) {
2268
+ applicationDetails.servingState = servingState
2161
2269
  }
2162
2270
 
2163
- if (entrypoint) {
2164
- applicationDetails.url = status === 'started' ? this.#url : null
2271
+ if (this.#isProduction) {
2272
+ applicationDetails.workers = this.#replicas.get(id)?.length ?? 0
2165
2273
  }
2166
2274
 
2275
+ const urls = status === 'started' ? Object.values(this.getUrls(id)) : []
2276
+ applicationDetails.urls = urls
2277
+ applicationDetails.url = urls[0] ?? null
2278
+
2167
2279
  return applicationDetails
2168
2280
  }
2169
2281
 
@@ -2211,10 +2323,6 @@ export class Runtime extends EventEmitter {
2211
2323
  return this.#scheduler?.getJobs() ?? []
2212
2324
  }
2213
2325
 
2214
- getScheduler () {
2215
- return this.getSchedulerJobs()
2216
- }
2217
-
2218
2326
  pauseSchedulerJob (name) {
2219
2327
  return this.#schedulerOrThrow().pauseJob(name)
2220
2328
  }
@@ -2283,6 +2391,7 @@ export class Runtime extends EventEmitter {
2283
2391
  worker: index,
2284
2392
  status: worker[kWorkerStatus],
2285
2393
  thread: worker.threadId,
2394
+ url: worker[kWorkerUrl] ?? null,
2286
2395
  raw: includeRaw ? worker : undefined
2287
2396
  }
2288
2397
  }
@@ -2309,10 +2418,6 @@ export class Runtime extends EventEmitter {
2309
2418
  elu = worker.performance.eventLoopUtilization(elu, previousELU)
2310
2419
  }
2311
2420
 
2312
- if (!features.node.worker.getHeapStatistics) {
2313
- return { elu: elu.utilization, currentELU }
2314
- }
2315
-
2316
2421
  // Only refresh heap statistics every 60 health checks (once per minute).
2317
2422
  // This keeps the common path fully synchronous — no promise allocation.
2318
2423
  const counter = (worker[kHeapCheckCounter] ?? 0) + 1
@@ -2404,12 +2509,37 @@ export class Runtime extends EventEmitter {
2404
2509
  this.emitAndNotify(status, args)
2405
2510
  }
2406
2511
 
2407
- #showUrl () {
2408
- if (!this.#url) {
2409
- return
2512
+ #showUrls (workerIds) {
2513
+ const whitelist = workerIds ? new Set(workerIds) : null
2514
+
2515
+ for (const applicationId of this.#applications.keys()) {
2516
+ for (const workerId of this.#workers.getKeys(applicationId)) {
2517
+ if (whitelist && !whitelist.has(workerId)) {
2518
+ continue
2519
+ }
2520
+
2521
+ const worker = this.#workers.get(workerId)
2522
+ const url = worker?.[kWorkerUrl]
2523
+ if (!url) {
2524
+ continue
2525
+ }
2526
+
2527
+ this.logger.info(
2528
+ `Platformatic is now listening at ${url} for ${this.#workerExtendedLabel(applicationId, worker[kWorkerId])}`
2529
+ )
2530
+ }
2410
2531
  }
2532
+ }
2533
+
2534
+ async #refuseUnresolvedApplication (id) {
2535
+ const executable = getExecutable() ?? 'wattpm'
2536
+
2537
+ this.logger.error(
2538
+ `The path for application "%s" does not exist. Please run "${executable} resolve" and try again.`,
2539
+ id
2540
+ )
2411
2541
 
2412
- this.logger.info(`Platformatic is now listening at ${this.#url}`)
2542
+ await this.closeAndThrow(new RuntimeAbortedError())
2413
2543
  }
2414
2544
 
2415
2545
  async #setupApplication (applicationConfig) {
@@ -2427,13 +2557,7 @@ export class Runtime extends EventEmitter {
2427
2557
  applicationConfig.path = join(this.#root, config.resolvedApplicationsBasePath, id)
2428
2558
 
2429
2559
  if (!existsSync(applicationConfig.path)) {
2430
- const executable = getExecutable() ?? 'platformatic'
2431
- this.logger.error(
2432
- `The path for application "%s" does not exist. Please run "${executable} resolve" and try again.`,
2433
- id
2434
- )
2435
-
2436
- await this.closeAndThrow(new RuntimeAbortedError())
2560
+ await this.#refuseUnresolvedApplication(id)
2437
2561
  }
2438
2562
  } else {
2439
2563
  this.logger.error(
@@ -2443,17 +2567,47 @@ export class Runtime extends EventEmitter {
2443
2567
 
2444
2568
  await this.closeAndThrow(new RuntimeAbortedError())
2445
2569
  }
2570
+ } else if (applicationConfig.unresolved) {
2571
+ /*
2572
+ A remote application that declares where its clone belongs, and whose clone is not there --
2573
+ either the directory is missing or it holds no application at all. The pathless case above
2574
+ is the same state said differently, and both are what `resolve` exists to fix, so both get
2575
+ the message that names it rather than the capability detector's report that the directory
2576
+ holds nothing.
2577
+ */
2578
+ await this.#refuseUnresolvedApplication(id)
2446
2579
  }
2447
2580
 
2448
- const workers = applicationConfig.workers.static
2449
- const setupInvocations = []
2581
+ let workers = applicationConfig.workers.static
2582
+
2583
+ this.#replicas.set(id, [])
2584
+
2585
+ let firstIndex = 0
2450
2586
 
2451
- for (let i = 0; i < workers; i++) {
2452
- this.#workerPortOffsets.set(`${id}:${i}`, i)
2453
- setupInvocations.push([config, applicationConfig, workers, id, i])
2587
+ // On platforms where reusePort is not available, multiple workers cannot listen on the same fixed port.
2588
+ // The listener configuration is owned by the capability, so it can only be inspected after setting up the first
2589
+ // worker: if the application would try to share a fixed port between workers, clamp it to a single worker.
2590
+ if (!features.node.reusePort && (workers > 1 || applicationConfig.workers.dynamic)) {
2591
+ const { worker } = await this.#setupReplica(config, applicationConfig, workers, id, 0, 0)
2592
+ firstIndex = 1
2593
+
2594
+ if (await this.#usesSharedFixedPort(worker)) {
2595
+ this.logger.warn(
2596
+ `The application "${id}" is configured to listen on a fixed port with multiple workers, but reusePort is not available in your OS. ${workers > 1 ? `Setting workers to 1 instead of ${workers}` : 'Disabling dynamic workers scaling'}. To run multiple workers, set "server.portAssignment" to "perWorkerIncrement" in the application configuration.`
2597
+ )
2598
+
2599
+ applicationConfig.workers = { dynamic: false, static: 1 }
2600
+ workers = 1
2601
+ await sendViaITC(worker, 'updateWorkersCount', { applicationId: id, workers })
2602
+ }
2603
+ }
2604
+
2605
+ const setupInvocations = []
2606
+ for (let i = firstIndex; i < workers; i++) {
2607
+ setupInvocations.push([config, applicationConfig, workers, id, i, i])
2454
2608
  }
2455
2609
 
2456
- await executeInParallel(this.#setupWorker.bind(this), setupInvocations, this.#concurrency)
2610
+ await executeInParallel(this.#setupReplica.bind(this), setupInvocations, this.#concurrency)
2457
2611
 
2458
2612
  // Initialize the next worker index counter (next index starts after initial workers)
2459
2613
  this.#nextWorkerIndex.set(id, workers)
@@ -2462,7 +2616,51 @@ export class Runtime extends EventEmitter {
2462
2616
  this.emitAndNotify('application:init', id)
2463
2617
  }
2464
2618
 
2465
- async #setupWorker (config, applicationConfig, workersCount, applicationId, index, enabled = true, attempt = 0) {
2619
+ // Adds a replica to the application (see the #replicas field) and sets up its worker.
2620
+ // Returns null if the replica was removed in the meanwhile.
2621
+ async #setupReplica (config, applicationConfig, workersCount, applicationId, index, portOffset) {
2622
+ const replica = {
2623
+ portOffset,
2624
+ // The worker serving the replica, null while the replica is being refilled
2625
+ worker: null,
2626
+ // The worker being started to refill the replica or to replace its worker
2627
+ replacement: null,
2628
+ // The operation in progress: 'refill' or 'replace'
2629
+ pending: null,
2630
+ abortController: new AbortController()
2631
+ }
2632
+
2633
+ // Added right away, so that concurrent operations see the requested workers
2634
+ this.#replicas.get(applicationId).push(replica)
2635
+
2636
+ let worker
2637
+ try {
2638
+ worker = await this.#setupWorker(config, applicationConfig, workersCount, applicationId, index, portOffset)
2639
+ } catch (err) {
2640
+ await this.#detachReplica(applicationId, replica)
2641
+ throw err
2642
+ }
2643
+
2644
+ // The replica was removed in the meanwhile (i.e. scale-down or application stopped)
2645
+ if (replica.abortController.signal.aborted) {
2646
+ await this.#discardWorker(worker)
2647
+ return null
2648
+ }
2649
+
2650
+ replica.worker = worker
2651
+ return replica
2652
+ }
2653
+
2654
+ async #setupWorker (
2655
+ config,
2656
+ applicationConfig,
2657
+ workersCount,
2658
+ applicationId,
2659
+ index,
2660
+ portOffset,
2661
+ enabled = true,
2662
+ attempt = 0
2663
+ ) {
2466
2664
  const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
2467
2665
  const workerId = `${applicationId}:${index}`
2468
2666
 
@@ -2477,17 +2675,11 @@ export class Runtime extends EventEmitter {
2477
2675
  inspectorOptions.port = inspectorOptions.port + this.#workers.size + 1
2478
2676
  }
2479
2677
 
2480
- let serverConfigOverride
2481
- if (this.#config.server?.portAssignment === 'perWorkerIncrement') {
2482
- const portOffset = this.#workerPortOffsets.get(workerId)
2483
- serverConfigOverride = { port: Number(this.#config.server.port) + portOffset }
2484
- }
2485
-
2486
- if (config.telemetry) {
2487
- applicationConfig.telemetry = {
2488
- ...config.telemetry,
2489
- ...applicationConfig.telemetry,
2490
- applicationName: `${config.telemetry.applicationName}-${applicationConfig.id}`
2678
+ if (config.tracing) {
2679
+ applicationConfig.tracing = {
2680
+ ...config.tracing,
2681
+ ...applicationConfig.tracing,
2682
+ applicationName: `${config.tracing.applicationName}-${applicationConfig.id}`
2491
2683
  }
2492
2684
  }
2493
2685
 
@@ -2496,9 +2688,9 @@ export class Runtime extends EventEmitter {
2496
2688
 
2497
2689
  const execArgv = applicationConfig.execArgv ?? []
2498
2690
 
2499
- if (!applicationConfig.skipTelemetryHooks && config.telemetry && config.telemetry.enabled !== false) {
2691
+ if (!applicationConfig.skipTracingHooks && config.tracing && config.tracing.enabled !== false) {
2500
2692
  const require = createRequire(import.meta.url)
2501
- const telemetryPath = require.resolve('@platformatic/telemetry')
2693
+ const telemetryPath = require.resolve('@platformatic/tracing')
2502
2694
  const openTelemetrySetupPath = join(telemetryPath, '..', 'lib', 'node-telemetry.js')
2503
2695
  const hookUrl = pathToFileURL(require.resolve('@opentelemetry/instrumentation/hook.mjs'))
2504
2696
 
@@ -2523,7 +2715,10 @@ export class Runtime extends EventEmitter {
2523
2715
  preload = preload.filter(p => p !== pprofCapturePath)
2524
2716
  }
2525
2717
 
2526
- const workerEnv = structuredClone(this.#env)
2718
+ // Each application's worker environment is resolved main-side, with its own env-file chain, the
2719
+ // two env blocks and the injected topology URLs. Every worker used to be seeded from one loadEnv
2720
+ // at the runtime root, which is what #env still holds.
2721
+ const workerEnv = structuredClone(applicationConfig.workerEnv ?? this.#env)
2527
2722
 
2528
2723
  if (applicationConfig.nodeOptions?.trim().length > 0) {
2529
2724
  const originalNodeOptions = workerEnv.NODE_OPTIONS ?? ''
@@ -2546,21 +2741,12 @@ export class Runtime extends EventEmitter {
2546
2741
  const maxYoungGenerationSizeMb = maxYoungGeneration ? Math.floor(maxYoungGeneration / (1024 * 1024)) : undefined
2547
2742
  const codeRangeSizeMb = codeRangeSize ? Math.floor(codeRangeSize / (1024 * 1024)) : undefined
2548
2743
 
2549
- const workerConfig = {
2550
- ...config,
2551
- preload
2552
- }
2553
-
2554
- if (config.server && serverConfigOverride) {
2555
- workerConfig.server = {
2556
- ...config.server,
2557
- ...serverConfigOverride
2558
- }
2559
- }
2744
+ const workerConfig = { ...config, preload }
2560
2745
 
2561
2746
  const worker = new Worker(kWorkerFile, {
2562
2747
  workerData: {
2563
2748
  config: workerConfig,
2749
+ meshId: this.#meshId,
2564
2750
  applicationConfig: {
2565
2751
  ...applicationConfig,
2566
2752
  isProduction: this.#isProduction,
@@ -2569,7 +2755,8 @@ export class Runtime extends EventEmitter {
2569
2755
  worker: {
2570
2756
  id: workerId,
2571
2757
  index,
2572
- count: workersCount
2758
+ count: workersCount,
2759
+ portOffset
2573
2760
  },
2574
2761
  resourceLimits: {
2575
2762
  maxOldGenerationSizeMb,
@@ -2577,10 +2764,7 @@ export class Runtime extends EventEmitter {
2577
2764
  codeRangeSizeMb
2578
2765
  },
2579
2766
  inspectorOptions,
2580
- dirname: this.#root,
2581
- // Keys of the worker environment which only come from an env file of the runtime:
2582
- // the env file of the application is allowed to override those.
2583
- envFileFallbackKeys: this.#env[kEnvFileFallbackKeys] ?? []
2767
+ dirname: this.#root
2584
2768
  },
2585
2769
  argv: applicationConfig.arguments,
2586
2770
  execArgv,
@@ -2604,15 +2788,24 @@ export class Runtime extends EventEmitter {
2604
2788
  const eventPayload = { application: applicationId, worker: index, workersCount }
2605
2789
 
2606
2790
  worker.once('exit', code => {
2791
+ worker[kWorkerExitCode] = code
2792
+
2607
2793
  if (worker[kWorkerStatus] === 'exited') {
2608
2794
  return
2609
2795
  }
2610
2796
 
2611
2797
  const started = worker[kWorkerStatus] === 'started'
2798
+
2799
+ // A crashed worker leaves its replica empty until the replica is refilled below.
2800
+ // Workers which crash while starting are handled by #startWorker.
2801
+ const replica = started ? this.#getWorkerReplica(applicationId, worker) : undefined
2802
+ if (replica) {
2803
+ replica.worker = null
2804
+ }
2805
+
2612
2806
  worker[kWorkerStatus] = 'exited'
2613
2807
  this.emitAndNotify('application:worker:exited', eventPayload)
2614
2808
 
2615
- const portOffset = this.#workerPortOffsets.get(worker[kFullId]) ?? index
2616
2809
  this.#cleanupWorker(worker)
2617
2810
 
2618
2811
  if (this.#status === 'stopping') {
@@ -2628,8 +2821,9 @@ export class Runtime extends EventEmitter {
2628
2821
  this.logger.warn(`The ${errorLabel} unexpectedly exited with code ${code}.`)
2629
2822
  }
2630
2823
 
2631
- // Restart the application if it was started
2632
- if (started && this.#status === 'started') {
2824
+ // Refill the replica of the worker if it was started. Workers which were not serving a replica anymore
2825
+ // (i.e. being retired after a restart) are not replaced, and a replica being replaced gets the replacement.
2826
+ if (started && this.#status === 'started' && replica && replica.pending !== 'replace') {
2633
2827
  if (restartOnError > 0) {
2634
2828
  if (restartOnError < IMMEDIATE_RESTART_MAX_THRESHOLD) {
2635
2829
  this.logger.warn(`The ${errorLabel} is being restarted ...`)
@@ -2637,14 +2831,16 @@ export class Runtime extends EventEmitter {
2637
2831
  this.logger.warn(`The ${errorLabel} will be restarted in ${restartOnError}ms ...`)
2638
2832
  }
2639
2833
 
2640
- this.#restartCrashedWorker(config, applicationConfig, workersCount, applicationId, index, false, 0, portOffset).catch(
2834
+ this.#refillReplica(config, applicationConfig, workersCount, applicationId, replica, false, 0).catch(
2641
2835
  err => {
2642
2836
  this.logger.error({ err: ensureLoggableError(err) }, `${errorLabel} could not be restarted.`)
2837
+ this.#detachReplica(applicationId, replica)
2643
2838
  }
2644
2839
  )
2645
2840
  } else {
2646
2841
  this.emitAndNotify('application:worker:unvailable', eventPayload)
2647
2842
  this.logger.warn(`The ${errorLabel} is no longer available.`)
2843
+ this.#detachReplica(applicationId, replica)
2648
2844
  }
2649
2845
  }
2650
2846
  })
@@ -2654,6 +2850,7 @@ export class Runtime extends EventEmitter {
2654
2850
  worker[kFullId] = workerId
2655
2851
  worker[kApplicationId] = applicationId
2656
2852
  worker[kWorkerId] = index
2853
+ worker[kWorkerPortOffset] = portOffset
2657
2854
  worker[kWorkerStatus] = 'boot'
2658
2855
 
2659
2856
  if (inspectorOptions) {
@@ -2694,18 +2891,47 @@ export class Runtime extends EventEmitter {
2694
2891
  // Forward events from the worker
2695
2892
  // Do not use emitAndNotify here since we don't want to forward unknown events
2696
2893
  worker[kITC].on('event', ({ event, payload }) => {
2894
+ if (event === 'serverOptions') {
2895
+ worker[kWorkerServerOptions] = payload[0]
2896
+ }
2897
+
2697
2898
  event = `application:worker:event:${event}`
2698
2899
 
2699
2900
  this.emit(event, ...payload, workerId, applicationId, index)
2700
2901
  this.logger.trace({ event, payload, id: workerId, application: applicationId, worker: index }, 'Runtime event')
2701
2902
  })
2702
2903
 
2904
+ worker[kITC].on('compile-cache:flushed', ({ flushed, source }) => {
2905
+ this.emit('application:worker:compile-cache:flushed', {
2906
+ application: applicationId,
2907
+ worker: index,
2908
+ workersCount,
2909
+ flushed,
2910
+ source
2911
+ })
2912
+ })
2913
+
2914
+ worker[kITC].on('compile-cache:unavailable', ({ source }) => {
2915
+ this.emit('application:worker:compile-cache:unavailable', {
2916
+ application: applicationId,
2917
+ worker: index,
2918
+ workersCount,
2919
+ source
2920
+ })
2921
+ })
2922
+
2703
2923
  // The worker notifies us when its capability has spawned a child process
2704
2924
  // (e.g. Next.js in dev mode). From that point on health metrics must come
2705
2925
  // from the child via ITC; for thread-only workers we keep reading the
2706
2926
  // handle directly in getWorkerHealth().
2707
- worker[kITC].on('subprocess:started', () => {
2927
+ worker[kITC].on('subprocess:started', ({ pid } = {}) => {
2708
2928
  worker[kIsSubprocessHost] = true
2929
+ worker[kSubprocessPid] = pid
2930
+ })
2931
+ worker[kITC].on('subprocess:exited', ({ pid }) => {
2932
+ if (worker[kSubprocessPid] === pid) {
2933
+ worker[kSubprocessPid] = undefined
2934
+ }
2709
2935
  })
2710
2936
 
2711
2937
  worker[kITC].on(openTelemetryITCMessage, resourceMetrics => {
@@ -2867,9 +3093,7 @@ export class Runtime extends EventEmitter {
2867
3093
  this.logger.info(`The application "${applicationId}" has been successfully reloaded.`)
2868
3094
  this.emitAndNotify('application:worker:reloaded', eventPayload)
2869
3095
 
2870
- if (applicationConfig.entrypoint) {
2871
- this.#showUrl()
2872
- }
3096
+ this.#showUrls(this.#workers.getKeys(applicationId))
2873
3097
  } catch (e) {
2874
3098
  this.logger.error(e)
2875
3099
  }
@@ -2881,25 +3105,6 @@ export class Runtime extends EventEmitter {
2881
3105
  this.#workers.set(workerId, worker)
2882
3106
  }
2883
3107
 
2884
- // Setup the interceptor
2885
- // kInterceptorReadyPromise resolves when the worker
2886
- // is ready to receive requests: after calling the replaceServer method
2887
- //
2888
- // It is stored, not awaited: #startWorker awaits it later, and only if the
2889
- // worker is actually started. A worker that is discarded or torn down
2890
- // before that — a replacement abandoned because the runtime stopped
2891
- // mid-restart, for instance — leaves the promise with nobody to observe
2892
- // it. Closing the mesh interceptor rejects any routing still in flight
2893
- // ('The dispatcher has been closed.'), so that is a routine shutdown
2894
- // outcome, not an exotic one, and it surfaced as an unhandledRejection
2895
- // that could take the process down before graceful shutdown finished.
2896
- //
2897
- // Attaching a no-op handler marks the rejection observed WITHOUT
2898
- // swallowing it: the await in #startWorker still sees and reports it.
2899
- const interceptorReady = this.#meshInterceptor.route(applicationId, worker)
2900
- interceptorReady.catch(() => {})
2901
- worker[kInterceptorReadyPromise] = interceptorReady
2902
-
2903
3108
  // Wait for initialization
2904
3109
  try {
2905
3110
  await waitEventFromITC(worker, 'init')
@@ -2918,11 +3123,16 @@ export class Runtime extends EventEmitter {
2918
3123
  }
2919
3124
 
2920
3125
  this.#workers.delete(workerId)
2921
- return this.#setupWorker(config, applicationConfig, workersCount, applicationId, index, enabled, attempt + 1)
2922
- }
2923
-
2924
- if (applicationConfig.entrypoint) {
2925
- this.#entrypointId = applicationId
3126
+ return this.#setupWorker(
3127
+ config,
3128
+ applicationConfig,
3129
+ workersCount,
3130
+ applicationId,
3131
+ index,
3132
+ portOffset,
3133
+ enabled,
3134
+ attempt + 1
3135
+ )
2926
3136
  }
2927
3137
 
2928
3138
  worker[kConfig] = { ...applicationConfig, health, workers: workersCount }
@@ -3339,29 +3549,8 @@ export class Runtime extends EventEmitter {
3339
3549
 
3340
3550
  const { url: workerUrl, scheduledTasks } = workerStartResult
3341
3551
  worker[kWorkerScheduledTasks] = scheduledTasks
3342
- if (workerUrl) {
3343
- this.#url = workerUrl
3344
-
3345
- // Pin the entrypoint port so that subsequent restarts (especially with
3346
- // stopBeforeStart when reuseTcpPorts is false) bind to the same port
3347
- // instead of getting a new random one.
3348
- if (applicationConfig.entrypoint) {
3349
- try {
3350
- const boundPort = Number(new URL(workerUrl).port)
3351
- if (boundPort) {
3352
- this.#entrypointPort = boundPort
3353
- }
3354
- } catch {
3355
- // URL parsing failed, leave unchanged
3356
- }
3357
- }
3358
- }
3359
3552
 
3360
- // Wait for the interceptor to be ready
3361
- const interceptorResult = await executeWithTimeout(worker[kInterceptorReadyPromise], config.startTimeout)
3362
- if (interceptorResult === kTimeout) {
3363
- throw new WorkerInterceptorJoinTimeoutError(label, config.startTimeout)
3364
- }
3553
+ this.#recordWorkerUrl(worker, id, workerUrl)
3365
3554
 
3366
3555
  worker[kWorkerStatus] = 'started'
3367
3556
  worker[kWorkerStartTime] = Date.now()
@@ -3385,12 +3574,28 @@ export class Runtime extends EventEmitter {
3385
3574
  ).unref()
3386
3575
  }
3387
3576
  } catch (err) {
3388
- const error = ensureError(err)
3577
+ let error = ensureError(err)
3578
+ // Forced shutdown may terminate the thread before its start handler can report the abort.
3579
+ if (
3580
+ worker[kWorkerStartAborted] &&
3581
+ error.code === 'PLT_RUNTIME_APPLICATION_WORKER_EXIT'
3582
+ ) {
3583
+ error = new RuntimeAbortedError({ cause: error })
3584
+ }
3389
3585
  worker[kITC].notify('application:worker:start:processed')
3390
- const portOffset = this.#workerPortOffsets.get(worker[kFullId]) ?? index
3391
3586
 
3392
- // TODO: handle port allocation error here
3393
- if (error.code === 'EADDRINUSE' || error.code === 'EACCES') throw error
3587
+ if (error.code === 'EADDRINUSE' && Number.isInteger(Number(error.port))) {
3588
+ const port = Number(error.port)
3589
+ const owner = this.#getPortOwner(port, id)
3590
+
3591
+ if (owner) {
3592
+ error = new AddressInUseError(port, owner, id)
3593
+ } else if (this.#getPortOwner(port, id, undefined, true)) {
3594
+ error = new WorkerAddressInUseError(port, id)
3595
+ }
3596
+ // Otherwise the port is used by an external process: keep the original error, which already describes it
3597
+ }
3598
+ if (error.code === 'EACCES') throw error
3394
3599
 
3395
3600
  this.#cleanupWorker(worker)
3396
3601
 
@@ -3414,7 +3619,16 @@ export class Runtime extends EventEmitter {
3414
3619
 
3415
3620
  const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
3416
3621
 
3417
- if (disableRestartAttempts || !restartOnError) {
3622
+ if (
3623
+ disableRestartAttempts ||
3624
+ !restartOnError ||
3625
+ error.code === 'EACCES' ||
3626
+ error.code === 'EADDRINUSE' ||
3627
+ error.code === 'EADDRNOTAVAIL' ||
3628
+ error.code === 'PLT_RUNTIME_EADDR_IN_USE' ||
3629
+ error.code === 'PLT_RUNTIME_RUNTIME_ABORT' ||
3630
+ error.code === 'PLT_RUNTIME_WORKER_EADDR_IN_USE'
3631
+ ) {
3418
3632
  throw error
3419
3633
  }
3420
3634
 
@@ -3434,7 +3648,64 @@ export class Runtime extends EventEmitter {
3434
3648
  )
3435
3649
  }
3436
3650
 
3437
- await this.#restartCrashedWorker(config, applicationConfig, workersCount, id, index, silent, bootstrapAttempt, portOffset)
3651
+ // The replica was removed while the worker was starting, there is nothing to refill
3652
+ const replica = this.#getWorkerReplica(id, worker)
3653
+ if (!replica) {
3654
+ throw error
3655
+ }
3656
+ replica.worker = null
3657
+ await this.#refillReplica(config, applicationConfig, workersCount, id, replica, silent, bootstrapAttempt)
3658
+ }
3659
+ }
3660
+
3661
+ async #startFirstWorkerWithCompileCache (config, applicationConfig, workers, id, silent) {
3662
+ const cacheFlushEvent = 'application:worker:compile-cache:flushed'
3663
+ const cacheUnavailableEvent = 'application:worker:compile-cache:unavailable'
3664
+ const { promise, resolve } = Promise.withResolvers()
3665
+ const cacheEvents = []
3666
+ const runtime = this
3667
+ let expectedSource
3668
+
3669
+ function removeCacheFlushListener () {
3670
+ runtime.off(cacheFlushEvent, cacheFlushListener)
3671
+ runtime.off(cacheUnavailableEvent, cacheUnavailableListener)
3672
+ }
3673
+
3674
+ function resolveIfReady () {
3675
+ if (expectedSource && cacheEvents.some(function (event) {
3676
+ return event.source === expectedSource
3677
+ })) {
3678
+ removeCacheFlushListener()
3679
+ resolve()
3680
+ }
3681
+ }
3682
+
3683
+ function cacheFlushListener (event) {
3684
+ if (event.application === id && event.worker === 0) {
3685
+ cacheEvents.push(event)
3686
+ resolveIfReady()
3687
+ }
3688
+ }
3689
+
3690
+ function cacheUnavailableListener (event) {
3691
+ if (event.application === id && event.worker === 0) {
3692
+ cacheEvents.push(event)
3693
+ resolveIfReady()
3694
+ }
3695
+ }
3696
+
3697
+ runtime.on(cacheFlushEvent, cacheFlushListener)
3698
+ runtime.on(cacheUnavailableEvent, cacheUnavailableListener)
3699
+
3700
+ try {
3701
+ await this.#startWorker(config, applicationConfig, workers, id, 0, silent)
3702
+ const firstWorker = this.#workers.get(`${id}:0`)
3703
+ expectedSource = firstWorker?.[kIsSubprocessHost] === true ? 'child-process' : 'worker'
3704
+ resolveIfReady()
3705
+ await promise
3706
+ } catch (err) {
3707
+ removeCacheFlushListener()
3708
+ throw err
3438
3709
  }
3439
3710
  }
3440
3711
 
@@ -3454,6 +3725,9 @@ export class Runtime extends EventEmitter {
3454
3725
 
3455
3726
  const eventPayload = { application: id, worker: index, workersCount }
3456
3727
 
3728
+ if (worker[kWorkerStatus] === 'starting' && this.error) {
3729
+ worker[kWorkerStartAborted] = true
3730
+ }
3457
3731
  worker[kWorkerStatus] = 'stopping'
3458
3732
  worker[kITC].removeAllListeners('changed')
3459
3733
  this.emitAndNotify('application:worker:stopping', eventPayload)
@@ -3469,13 +3743,18 @@ export class Runtime extends EventEmitter {
3469
3743
 
3470
3744
  // Always send the stop message, it will shut down workers that only had ITC and interceptors setup
3471
3745
  try {
3472
- const res = await executeWithTimeout(sendViaITC(worker, 'stop', { force: !!this.error, dependents }), exitTimeout)
3746
+ const res = await executeWithTimeout(
3747
+ sendViaITC(worker, 'stop', { force: !!this.error, dependents, shutdownTimeout: exitTimeout }),
3748
+ exitTimeout
3749
+ )
3473
3750
 
3474
3751
  if (res === kTimeout) {
3752
+ this.#terminateSubprocess(worker)
3475
3753
  this.emitAndNotify('application:worker:stop:timeout', eventPayload)
3476
3754
  this.logger.error(`Timeout while stopping ${label}. Killing a worker thread.`)
3477
3755
  }
3478
3756
  } catch (error) {
3757
+ this.#terminateSubprocess(worker)
3479
3758
  this.emitAndNotify('application:worker:stop:error', eventPayload)
3480
3759
  this.logger.error({ err: ensureLoggableError(error) }, `Failed to stop ${label}. Killing a worker thread.`)
3481
3760
  } finally {
@@ -3503,13 +3782,32 @@ export class Runtime extends EventEmitter {
3503
3782
  await this.#avoidOutOfOrderThreadLogs()
3504
3783
 
3505
3784
  worker[kWorkerStatus] = 'stopped'
3785
+ worker[kWorkerUrl] = undefined
3506
3786
  this.emitAndNotify('application:worker:stopped', eventPayload)
3507
3787
  this.#broadcastWorkers()
3508
3788
  }
3509
3789
 
3790
+ #terminateSubprocess (worker) {
3791
+ const pid = worker[kSubprocessPid]
3792
+ if (!Number.isInteger(pid) || pid <= 0) {
3793
+ return
3794
+ }
3795
+ try {
3796
+ // The main thread owns the fallback when a worker cannot supervise its child.
3797
+ this.logger.debug({ pid }, 'Terminating application child process from the runtime.')
3798
+ process.kill(pid, 'SIGKILL')
3799
+ } catch (error) {
3800
+ if (error.code === 'ESRCH') {
3801
+ // ESRCH means no such process: the child exited before the fallback signal arrived.
3802
+ this.logger.debug({ pid }, 'Application child process already exited before termination.')
3803
+ } else {
3804
+ this.logger.error({ err: ensureLoggableError(error) }, 'Failed to terminate application child process.')
3805
+ }
3806
+ }
3807
+ }
3808
+
3510
3809
  #cleanupWorker (worker) {
3511
3810
  clearTimeout(worker[kHealthCheckTimer])
3512
- this.#workerPortOffsets.delete(worker[kFullId])
3513
3811
 
3514
3812
  const currentWorker = this.#workers.get(worker[kFullId])
3515
3813
 
@@ -3521,8 +3819,8 @@ export class Runtime extends EventEmitter {
3521
3819
  }
3522
3820
 
3523
3821
  async #discardWorker (worker) {
3524
- await this.#meshInterceptor.unroute(worker[kApplicationId], worker, true)
3525
- worker.removeAllListeners('exit')
3822
+ // Marked synchronously, so that the exit handler ignores the worker and nobody else tries to stop it
3823
+ worker[kWorkerStatus] = 'exited'
3526
3824
  await worker.terminate()
3527
3825
 
3528
3826
  return this.#cleanupWorker(worker)
@@ -3538,6 +3836,57 @@ export class Runtime extends EventEmitter {
3538
3836
  return index
3539
3837
  }
3540
3838
 
3839
+ #getWorkerReplica (applicationId, worker) {
3840
+ return this.#replicas.get(applicationId)?.find(replica => replica.worker === worker)
3841
+ }
3842
+
3843
+ // Removes a replica and cancels the work in progress for it. Stopping its worker is up to the caller.
3844
+ async #detachReplica (applicationId, replica) {
3845
+ const replicas = this.#replicas.get(applicationId)
3846
+ const position = replicas?.indexOf(replica) ?? -1
3847
+
3848
+ if (position !== -1) {
3849
+ replicas.splice(position, 1)
3850
+ }
3851
+
3852
+ replica.abortController.abort()
3853
+
3854
+ if (replica.replacement) {
3855
+ await this.#discardWorker(replica.replacement)
3856
+ }
3857
+ }
3858
+
3859
+ // Returns the lowest port offset which is not used by any replica of the application.
3860
+ // Empty replicas keep their port offset, as they will be refilled.
3861
+ #getNextReplicaPortOffset (applicationId) {
3862
+ const used = new Set(this.#replicas.get(applicationId)?.map(replica => replica.portOffset))
3863
+
3864
+ let offset = 0
3865
+ while (used.has(offset)) {
3866
+ offset++
3867
+ }
3868
+
3869
+ return offset
3870
+ }
3871
+
3872
+ // Returns true if the application of the worker is configured to listen on a fixed port shared by all its workers
3873
+ async #usesSharedFixedPort (worker) {
3874
+ let server
3875
+
3876
+ try {
3877
+ server = (await sendViaITC(worker, 'getApplicationConfig'))?.server
3878
+ } catch {
3879
+ return false
3880
+ }
3881
+
3882
+ if (!server || server.portAssignment === 'perWorkerIncrement') {
3883
+ return false
3884
+ }
3885
+
3886
+ const port = Number(server.port)
3887
+ return Number.isInteger(port) && port > 0
3888
+ }
3889
+
3541
3890
  // Returns the effective restartOnError value for an application: the application-level
3542
3891
  // value, when defined, takes precedence over the runtime-level one.
3543
3892
  // The value is normalized to a number: false becomes 0 (never restart),
@@ -3554,89 +3903,101 @@ export class Runtime extends EventEmitter {
3554
3903
  return restartOnError
3555
3904
  }
3556
3905
 
3557
- async #restartCrashedWorker (config, applicationConfig, workersCount, id, oldIndex, silent, bootstrapAttempt, portOffset) {
3906
+ // Starts a new worker for a replica whose worker crashed (or failed to start), after the restartOnError delay
3907
+ async #refillReplica (config, applicationConfig, workersCount, id, replica, silent, bootstrapAttempt) {
3558
3908
  const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
3909
+ const { signal } = replica.abortController
3910
+ replica.pending = 'refill'
3559
3911
 
3560
- // Use oldIndex for tracking to prevent duplicate restarts of the same crashed worker
3561
- const restartKey = `${id}:${oldIndex}`
3562
-
3563
- let restartPromise = this.#restartingWorkers.get(restartKey)
3564
- if (restartPromise) {
3565
- await restartPromise
3566
- return
3567
- }
3568
-
3569
- restartPromise = new Promise((resolve, reject) => {
3570
- async function restart () {
3571
- this.#restartingWorkers.delete(restartKey)
3572
-
3573
- // If some processes were scheduled to restart
3574
- // but the runtime is stopped, ignore it
3575
- if (!this.#status.startsWith('start')) {
3576
- return
3912
+ try {
3913
+ try {
3914
+ if (restartOnError < IMMEDIATE_RESTART_MAX_THRESHOLD) {
3915
+ await new Promise(resolve => process.nextTick(resolve))
3916
+ } else {
3917
+ await sleep(restartOnError, undefined, { signal })
3577
3918
  }
3919
+ } catch {
3920
+ // The replica was removed in the meanwhile
3921
+ return
3922
+ }
3578
3923
 
3579
- // Get a new unique index for the restarted worker
3580
- const newIndex = this.#getNextWorkerIndex(id)
3581
- const newWorkerId = `${id}:${newIndex}`
3582
- this.#workerPortOffsets.set(newWorkerId, portOffset)
3924
+ // If the replica was removed or the runtime was stopped in the meanwhile, ignore it
3925
+ if (signal.aborted || !this.#status.startsWith('start')) {
3926
+ return
3927
+ }
3583
3928
 
3584
- try {
3585
- await this.#setupWorker(config, applicationConfig, workersCount, id, newIndex)
3586
- await this.#startWorker(config, applicationConfig, workersCount, id, newIndex, silent, bootstrapAttempt)
3587
- this.#incrementApplicationRestartCount(id)
3929
+ // Get a new unique index for the worker, which inherits the port offset of the replica
3930
+ const newIndex = this.#getNextWorkerIndex(id)
3588
3931
 
3589
- this.logger.info(
3590
- `The ${this.#workerExtendedLabel(id, newIndex, workersCount)} has been successfully restarted ...`
3591
- )
3592
- resolve()
3593
- } catch (err) {
3594
- this.#workerPortOffsets.delete(newWorkerId)
3932
+ try {
3933
+ const worker = await this.#setupWorker(
3934
+ config,
3935
+ applicationConfig,
3936
+ workersCount,
3937
+ id,
3938
+ newIndex,
3939
+ replica.portOffset
3940
+ )
3595
3941
 
3596
- // The runtime was stopped while the restart was happening, ignore any error.
3597
- if (!this.#status.startsWith('start')) {
3598
- resolve()
3599
- }
3942
+ if (signal.aborted) {
3943
+ await this.#discardWorker(worker)
3944
+ return
3945
+ }
3600
3946
 
3601
- reject(err)
3947
+ replica.worker = worker
3948
+ await this.#startWorker(config, applicationConfig, workersCount, id, newIndex, silent, bootstrapAttempt, worker)
3949
+ } catch (err) {
3950
+ // The replica was removed or the runtime was stopped while the worker was starting, ignore any error.
3951
+ if (signal.aborted || !this.#status.startsWith('start')) {
3952
+ return
3602
3953
  }
3603
- }
3604
3954
 
3605
- if (restartOnError < IMMEDIATE_RESTART_MAX_THRESHOLD) {
3606
- process.nextTick(restart.bind(this))
3607
- } else {
3608
- setTimeout(restart.bind(this), restartOnError)
3955
+ throw err
3609
3956
  }
3610
- })
3611
3957
 
3612
- this.#restartingWorkers.set(restartKey, restartPromise)
3613
- await restartPromise
3958
+ this.#incrementApplicationRestartCount(id)
3959
+ this.logger.info(`The ${this.#workerExtendedLabel(id, newIndex, workersCount)} has been successfully restarted ...`)
3960
+ } finally {
3961
+ if (replica.pending === 'refill') {
3962
+ replica.pending = null
3963
+ }
3964
+ }
3614
3965
  }
3615
3966
 
3616
- async #replaceWorker (config, applicationConfig, workersCount, applicationId, oldIndex, worker, silent) {
3967
+ // Replaces the worker of a replica with a new one. Only one operation at a time is performed on a replica.
3968
+ async #replaceWorker (
3969
+ config,
3970
+ applicationConfig,
3971
+ workersCount,
3972
+ applicationId,
3973
+ oldIndex,
3974
+ worker,
3975
+ silent,
3976
+ deferOldWorkerRetirement = false
3977
+ ) {
3978
+ const replica = this.#getWorkerReplica(applicationId, worker)
3979
+
3980
+ // The worker is not serving a replica anymore, or the replica is already being refilled or replaced
3981
+ if (!replica || replica.pending) {
3982
+ return
3983
+ }
3984
+
3985
+ const { signal } = replica.abortController
3617
3986
  const oldLabel = this.#workerExtendedLabel(applicationId, oldIndex, workersCount)
3618
3987
  let newWorker
3988
+ let error
3619
3989
 
3620
- // Get a new unique index for the replacement worker
3990
+ // Get a new unique index for the replacement worker, which inherits the port offset of the replica
3621
3991
  const newIndex = this.#getNextWorkerIndex(applicationId)
3622
3992
  const newWorkerId = `${applicationId}:${newIndex}`
3623
3993
  const newLabel = this.#workerExtendedLabel(applicationId, newIndex, workersCount)
3624
- const portOffset = this.#workerPortOffsets.get(`${applicationId}:${oldIndex}`)
3625
- this.#workerPortOffsets.set(newWorkerId, portOffset)
3626
3994
 
3627
3995
  const stopBeforeStart =
3628
- applicationConfig.entrypoint &&
3996
+ Boolean(worker[kWorkerUrl]) &&
3997
+ worker[kWorkerServerOptions]?.port !== 0 &&
3629
3998
  (config.reuseTcpPorts === false || applicationConfig.reuseTcpPorts === false || !features.node.reusePort)
3630
3999
 
3631
- // When we must stop before start (no reusePort available), pin the entrypoint
3632
- // port in the config so the replacement worker binds to the same port.
3633
- // We only do this for stopBeforeStart because when reusePort is available
3634
- // the new worker starts alongside the old one and must use port 0 to avoid
3635
- // SO_REUSEPORT routing requests to the stale old worker.
3636
- let configForNewWorker = config
3637
- if (stopBeforeStart && this.#entrypointPort && config.server) {
3638
- configForNewWorker = { ...config, server: { ...config.server, port: this.#entrypointPort } }
3639
- }
4000
+ replica.pending = 'replace'
3640
4001
 
3641
4002
  try {
3642
4003
  if (!silent) {
@@ -3645,13 +4006,22 @@ export class Runtime extends EventEmitter {
3645
4006
 
3646
4007
  if (stopBeforeStart) {
3647
4008
  await this.#removeWorker(workersCount, applicationId, oldIndex, worker, silent, oldLabel)
4009
+ replica.worker = null
3648
4010
  }
3649
4011
 
3650
- // Create a new worker with a new index, preserving any pinned port when stopBeforeStart is required.
3651
- newWorker = await this.#setupWorker(configForNewWorker, applicationConfig, workersCount, applicationId, newIndex, false)
4012
+ newWorker = await this.#setupWorker(
4013
+ config,
4014
+ applicationConfig,
4015
+ workersCount,
4016
+ applicationId,
4017
+ newIndex,
4018
+ replica.portOffset,
4019
+ false
4020
+ )
4021
+ replica.replacement = newWorker
3652
4022
 
3653
- // Make sure the runtime hasn't been stopped in the meanwhile
3654
- if (this.#status !== 'started') {
4023
+ // Make sure the runtime hasn't been stopped and the replica hasn't been removed in the meanwhile
4024
+ if (this.#status !== 'started' || signal.aborted) {
3655
4025
  return this.#discardWorker(newWorker)
3656
4026
  }
3657
4027
 
@@ -3662,7 +4032,7 @@ export class Runtime extends EventEmitter {
3662
4032
 
3663
4033
  // Add the worker to the mesh
3664
4034
  await this.#startWorker(
3665
- configForNewWorker,
4035
+ config,
3666
4036
  applicationConfig,
3667
4037
  workersCount,
3668
4038
  applicationId,
@@ -3673,27 +4043,68 @@ export class Runtime extends EventEmitter {
3673
4043
  true
3674
4044
  )
3675
4045
 
3676
- // Make sure the runtime hasn't been stopped in the meanwhile
3677
- if (this.#status !== 'started') {
4046
+ // Make sure the runtime hasn't been stopped and the replica hasn't been removed in the meanwhile
4047
+ if (this.#status !== 'started' || signal.aborted) {
3678
4048
  return this.#discardWorker(newWorker)
3679
4049
  }
3680
4050
  } catch (e) {
3681
- this.#workerPortOffsets.delete(newWorkerId)
4051
+ error = e
4052
+ } finally {
4053
+ replica.pending = null
4054
+ replica.replacement = null
4055
+ }
3682
4056
 
4057
+ if (error) {
3683
4058
  if (this.#workers.get(newWorkerId) === newWorker) {
3684
4059
  this.#workers.delete(newWorkerId)
3685
4060
  }
3686
4061
 
3687
4062
  newWorker?.terminate?.()
3688
- throw e
4063
+
4064
+ // The replica was removed in the meanwhile, the new worker was meant to be discarded anyway
4065
+ if (signal.aborted) {
4066
+ return
4067
+ }
4068
+
4069
+ // The replaced worker crashed (or was stopped before starting the new one): the replica must be refilled
4070
+ if (!replica.worker && this.#getApplicationRestartOnError(config, applicationConfig) > 0) {
4071
+ this.#refillReplica(config, applicationConfig, workersCount, applicationId, replica, true, 0).catch(err => {
4072
+ this.logger.error({ err: ensureLoggableError(err) }, `${oldLabel} could not be restarted.`)
4073
+ this.#detachReplica(applicationId, replica)
4074
+ })
4075
+ }
4076
+
4077
+ throw error
3689
4078
  }
3690
4079
 
4080
+ // The replaced worker might have crashed in the meanwhile, in which case it is not in the replica anymore
4081
+ replica.worker = newWorker
4082
+
3691
4083
  if (!stopBeforeStart) {
4084
+ if (deferOldWorkerRetirement) {
4085
+ return {
4086
+ discard: async () => {
4087
+ // Roll back: the replaced worker serves the replica again
4088
+ if (replica.worker === newWorker) {
4089
+ replica.worker = typeof worker[kWorkerExitCode] === 'number' ? null : worker
4090
+ }
4091
+
4092
+ await this.#discardWorker(newWorker)
4093
+ },
4094
+ retire: () => this.#removeWorker(workersCount, applicationId, oldIndex, worker, silent, oldLabel)
4095
+ }
4096
+ }
4097
+
3692
4098
  await this.#removeWorker(workersCount, applicationId, oldIndex, worker, silent, oldLabel)
3693
4099
  }
3694
4100
  }
3695
4101
 
3696
4102
  async #removeWorker (workersCount, applicationId, index, worker, silent, label) {
4103
+ // The worker crashed in the meanwhile, there is nothing left to remove
4104
+ if (typeof worker[kWorkerExitCode] === 'number') {
4105
+ return
4106
+ }
4107
+
3697
4108
  if (!silent) {
3698
4109
  this.logger.debug(`Preparing to stop the old version of ${label} ...`)
3699
4110
  }
@@ -3759,7 +4170,13 @@ export class Runtime extends EventEmitter {
3759
4170
 
3760
4171
  // Note that in this class "== null" is purposely used instead of "===" to check for both null and undefined
3761
4172
  if (workerId == null) {
3762
- worker = this.#workers.next(applicationId)
4173
+ // During restarts and scaling the application also has workers which are still booting or being stopped:
4174
+ // only pick a worker which can serve the call. Booting workers cannot handle ITC calls yet,
4175
+ // stopping ones might exit before replying.
4176
+ worker = this.#workers.next(applicationId, worker => {
4177
+ const status = worker[kWorkerStatus]
4178
+ return status === 'started' || (!ensureStarted && (status === 'init' || status === 'starting'))
4179
+ })
3763
4180
  } else {
3764
4181
  worker = this.#workers.get(`${applicationId}:${workerId}`)
3765
4182
  }
@@ -3771,6 +4188,11 @@ export class Runtime extends EventEmitter {
3771
4188
  return null
3772
4189
  }
3773
4190
 
4191
+ // The application has workers, but they are all booting or being stopped
4192
+ if (workerId == null && this.#workers.getKeys(applicationId).length > 0) {
4193
+ throw new ApplicationNotStartedError(applicationId)
4194
+ }
4195
+
3774
4196
  if (applicationsIds.includes(applicationId)) {
3775
4197
  const availableWorkers = this.#workers
3776
4198
  .getKeys(applicationId)
@@ -3864,11 +4286,20 @@ export class Runtime extends EventEmitter {
3864
4286
  }
3865
4287
 
3866
4288
  async #getRuntimePackageJson () {
3867
- const runtimeDir = this.#root
3868
- const packageJsonPath = join(runtimeDir, 'package.json')
3869
- const packageJsonFile = await readFile(packageJsonPath, 'utf8')
3870
- const packageJson = JSON.parse(packageJsonFile)
3871
- return packageJson
4289
+ const packageJsonPath = join(this.#root, 'package.json')
4290
+
4291
+ /*
4292
+ A project need not have one, and a runtime that cannot report its metadata without one is a
4293
+ runtime whose `applications:add --save` stops working -- that command reads `projectDir`,
4294
+ `configPath` and `autoload` from this, and has done since `GET /config` was removed. The
4295
+ metadata already declares `packageName` and `packageVersion` as nullable, so absence is a
4296
+ state its shape allows.
4297
+ */
4298
+ try {
4299
+ return JSON.parse(await readFile(packageJsonPath, 'utf8'))
4300
+ } catch {
4301
+ return {}
4302
+ }
3872
4303
  }
3873
4304
 
3874
4305
  #handleWorkerStandardStreams (worker, applicationId, workerId) {
@@ -4217,16 +4648,21 @@ export class Runtime extends EventEmitter {
4217
4648
  await this.#updateApplicationConfigHealth(applicationId, health)
4218
4649
  }
4219
4650
 
4220
- // Get actual worker keys to iterate over existing workers (snapshot to avoid mutation during iteration)
4221
- const workerKeys = [...this.#workers.getKeys(applicationId)]
4222
- for (const workerKey of workerKeys) {
4223
- const workerIndex = parseInt(workerKey.split(':')[1], 10)
4651
+ // A copy: while the loop awaits, scale-downs remove replicas from the list, which would shift the remaining ones
4652
+ // and skip some. Replicas added in the meanwhile (scale-up) already use the new health config.
4653
+ for (const replica of [...(this.#replicas.get(applicationId) ?? [])]) {
4654
+ // The worker crashed, the replica is refilled with a fresh worker which uses the new health config
4655
+ const worker = replica.worker
4656
+ if (!worker) {
4657
+ continue
4658
+ }
4659
+
4660
+ const workerIndex = worker[kWorkerId]
4224
4661
  this.logger.info(
4225
4662
  { health: { current: currentHealth, new: health } },
4226
4663
  `Restarting application "${applicationId}" worker ${workerIndex} to update config health heap...`
4227
4664
  )
4228
4665
 
4229
- const worker = this.#workers.get(workerKey)
4230
4666
  if (health.maxHeapTotal) {
4231
4667
  worker[kConfig].health.maxHeapTotal = health.maxHeapTotal
4232
4668
  }
@@ -4269,24 +4705,51 @@ export class Runtime extends EventEmitter {
4269
4705
 
4270
4706
  if (currentWorkers < workers) {
4271
4707
  report.started = []
4272
- let pendingWorkerId
4708
+
4709
+ let pendingReplica
4273
4710
 
4274
4711
  try {
4275
4712
  for (let i = currentWorkers; i < workers; i++) {
4276
4713
  const newIndex = this.#getNextWorkerIndex(applicationId)
4277
- pendingWorkerId = `${applicationId}:${newIndex}`
4278
- this.#workerPortOffsets.set(pendingWorkerId, i)
4714
+ pendingReplica = await this.#setupReplica(
4715
+ config,
4716
+ applicationConfig,
4717
+ workers,
4718
+ applicationId,
4719
+ newIndex,
4720
+ this.#getNextReplicaPortOffset(applicationId)
4721
+ )
4722
+
4723
+ // The replica was removed in the meanwhile (i.e. scale-down or application stopped)
4724
+ if (!pendingReplica) {
4725
+ continue
4726
+ }
4279
4727
 
4280
- await this.#setupWorker(config, applicationConfig, workers, applicationId, newIndex)
4281
- await this.#startWorker(config, applicationConfig, workers, applicationId, newIndex, false, 0)
4728
+ await this.#startWorker(
4729
+ config,
4730
+ applicationConfig,
4731
+ workers,
4732
+ applicationId,
4733
+ newIndex,
4734
+ false,
4735
+ 0,
4736
+ pendingReplica.worker
4737
+ )
4282
4738
 
4283
- pendingWorkerId = undefined
4739
+ pendingReplica = undefined
4284
4740
  report.started.push(newIndex)
4285
4741
  startedWorkersCount++
4286
4742
  }
4743
+
4744
+ // A worker added after boot re-runs the application's code, so it can answer differently
4745
+ // from the workers already running. Scale-up is the second place the answers must agree.
4746
+ await this.#collectServingState(applicationId)
4287
4747
  report.success = true
4288
4748
  } catch (err) {
4289
- pendingWorkerId && this.#workerPortOffsets.delete(pendingWorkerId)
4749
+ // The worker of the replica could not be started
4750
+ if (pendingReplica && !pendingReplica.worker) {
4751
+ await this.#detachReplica(applicationId, pendingReplica)
4752
+ }
4290
4753
 
4291
4754
  if (startedWorkersCount < 1) {
4292
4755
  this.logger.error({ err }, 'Cannot start application workers, no worker started')
@@ -4303,31 +4766,30 @@ export class Runtime extends EventEmitter {
4303
4766
  report.stopped = []
4304
4767
  try {
4305
4768
  const workersToStop = currentWorkers - workers
4306
- const allInOnePort = this.#config.server?.portAssignment !== 'perWorkerIncrement'
4307
- const workerIdsToStop = this.#workers
4308
- .getKeys(applicationId)
4309
- .map(key => parseInt(key.split(':')[1], 10))
4310
-
4311
- if (allInOnePort) {
4312
- // Stop most recent workers first.
4313
- workerIdsToStop.sort((a, b) => b - a)
4314
- } else {
4315
- // When port increment is enabled, disable the workers with the highest ports.
4316
- workerIdsToStop.sort((a, b) => {
4317
- const offsetA = this.#workerPortOffsets.get(`${applicationId}:${a}`) ?? a
4318
- const offsetB = this.#workerPortOffsets.get(`${applicationId}:${b}`) ?? b
4319
- return offsetB - offsetA
4320
- })
4321
- }
4769
+ // Remove the empty replicas first (their worker crashed and they are waiting to be refilled), then the ones
4770
+ // with the highest port offsets, so that applications using server.portAssignment=perWorkerIncrement keep
4771
+ // listening on a contiguous range of ports starting from the configured one.
4772
+ const removed = (this.#replicas.get(applicationId) ?? [])
4773
+ .toSorted((a, b) => Number(Boolean(a.worker)) - Number(Boolean(b.worker)) || b.portOffset - a.portOffset)
4774
+ .slice(0, workersToStop)
4775
+
4776
+ // Detached right away, so that concurrent operations see the requested workers.
4777
+ // This also cancels the work in progress for them, i.e. refills and replacements.
4778
+ const detached = Promise.all(removed.map(replica => this.#detachReplica(applicationId, replica)))
4779
+
4780
+ for (const replica of removed) {
4781
+ const worker = replica.worker
4782
+ if (worker && typeof worker[kWorkerExitCode] !== 'number') {
4783
+ await sendViaITC(worker, 'removeFromMesh')
4784
+ await this.#stopWorker(currentWorkers, applicationId, worker[kWorkerId], false, worker, [])
4785
+ report.stopped.push(worker[kWorkerId])
4786
+ }
4322
4787
 
4323
- for (const workerIndex of workerIdsToStop.splice(0, workersToStop)) {
4324
- const worker = this.#workers.get(`${applicationId}:${workerIndex}`)
4325
- await sendViaITC(worker, 'removeFromMesh')
4326
- await this.#stopWorker(currentWorkers, applicationId, workerIndex, false, worker, [])
4327
- report.stopped.push(workerIndex)
4328
4788
  stoppedWorkersCount++
4329
4789
  }
4330
4790
 
4791
+ await detached
4792
+
4331
4793
  report.success = true
4332
4794
  } catch (err) {
4333
4795
  if (stoppedWorkersCount < 1) {
@@ -4373,6 +4835,10 @@ export class Runtime extends EventEmitter {
4373
4835
  }
4374
4836
  }
4375
4837
 
4838
+ if (applicationConfig.sourcePath) {
4839
+ allows.add(`--allow-fs-read=${join(applicationConfig.sourcePath, '*')}`)
4840
+ }
4841
+
4376
4842
  if (write?.length) {
4377
4843
  for (const p of write) {
4378
4844
  allows.add(`--allow-fs-write=${isAbsolute(p) ? p : join(applicationConfig.path, p)}`)
@@ -4418,6 +4884,11 @@ export class Runtime extends EventEmitter {
4418
4884
  #processHealthSignals ({ workerId, signals }) {
4419
4885
  const worker = this.#workers.get(workerId)
4420
4886
 
4887
+ // Signals can arrive from a worker which is not registered (anymore)
4888
+ if (!worker) {
4889
+ return
4890
+ }
4891
+
4421
4892
  worker[kWorkerHealthSignals] ??= new HealthSignalsQueue()
4422
4893
  worker[kWorkerHealthSignals].add(signals)
4423
4894
  }
@@ -4493,18 +4964,16 @@ export class Runtime extends EventEmitter {
4493
4964
  }
4494
4965
 
4495
4966
  #isApplicationStarted (id) {
4496
- const applicationConfig = this.#applications.get(id)
4497
- if (!applicationConfig) {
4967
+ if (!this.#applications.has(id)) {
4498
4968
  return false
4499
4969
  }
4500
4970
 
4501
- const workers = applicationConfig.workers.static
4502
- for (let i = 0; i < workers; i++) {
4503
- const worker = this.#workers.get(`${id}:${i}`)
4504
- const status = worker?.[kWorkerStatus]
4971
+ // Workers replaced by restarts get new indexes, so check all the workers of the application.
4972
+ // Anything past boot/init means already started.
4973
+ for (const workerId of this.#workers.getKeys(id)) {
4974
+ const status = this.#workers.get(workerId)[kWorkerStatus]
4505
4975
 
4506
- // Match startApplication(): anything past boot/init means already started.
4507
- if (status && status !== 'boot' && status !== 'init') {
4976
+ if (status !== 'boot' && status !== 'init') {
4508
4977
  return true
4509
4978
  }
4510
4979
  }
@@ -4847,15 +5316,55 @@ export class Runtime extends EventEmitter {
4847
5316
  this.#loggerContext.updatePrefixes(ids)
4848
5317
  }
4849
5318
 
4850
- #onMeshInterceptorError (error) {
4851
- const worker = error.port
5319
+ #getPortOwner (port, applicationId, hostname, includeSameApplication = false) {
5320
+ if (!Number.isInteger(port) || port <= 0) {
5321
+ return null
5322
+ }
4852
5323
 
4853
- this.logger.error(
4854
- { err: ensureLoggableError(error.cause) },
4855
- `The ${this.#workerExtendedLabel(worker[kApplicationId], worker[kWorkerId])} threw an error during mesh network setup. Replacing it ...`
4856
- )
5324
+ for (const worker of this.#workers.values()) {
5325
+ if (!worker[kWorkerUrl] || (!includeSameApplication && worker[kApplicationId] === applicationId)) {
5326
+ continue
5327
+ }
5328
+
5329
+ try {
5330
+ const url = new URL(worker[kWorkerUrl])
5331
+ const workerPort = Number(url.port || (url.protocol === 'https:' ? 443 : 80))
5332
+ const workerHost = url.hostname
5333
+ if (workerPort === port && this.#listenersOverlap(hostname ?? workerHost, workerHost)) {
5334
+ return worker[kApplicationId]
5335
+ }
5336
+ } catch {}
5337
+ }
5338
+
5339
+ return null
5340
+ }
5341
+
5342
+ #listenersOverlap (host, otherHost) {
5343
+ host = host.toLowerCase()
5344
+ const wildcards = new Set(['0.0.0.0', '::', '[::]'])
5345
+ otherHost = otherHost.toLowerCase()
5346
+ return host === otherHost || wildcards.has(host) || wildcards.has(otherHost)
5347
+ }
5348
+
5349
+ #recordWorkerUrl (worker, applicationId, workerUrl) {
5350
+ if (!workerUrl) {
5351
+ worker[kWorkerUrl] = undefined
5352
+ return
5353
+ }
5354
+
5355
+ let hostname
5356
+ let port
5357
+ try {
5358
+ const url = new URL(workerUrl)
5359
+ hostname = url.hostname
5360
+ port = Number(url.port || (url.protocol === 'https:' ? 443 : 80))
5361
+ } catch {}
5362
+
5363
+ const owner = this.#getPortOwner(port, applicationId, hostname)
5364
+ if (owner) {
5365
+ throw new AddressInUseError(port, owner, applicationId)
5366
+ }
4857
5367
 
4858
- this.emit('application:worker:init:failed', { application: worker[kApplicationId], worker: worker[kWorkerId] })
4859
- worker.terminate()
5368
+ worker[kWorkerUrl] = workerUrl
4860
5369
  }
4861
5370
  }