@platformatic/runtime 3.67.0 → 4.0.0-new-config.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/runtime.js CHANGED
@@ -35,11 +35,12 @@ import { Agent, request, interceptors as undiciInterceptors } from 'undici'
35
35
  import { createThreadInterceptor } from 'undici-thread-interceptor'
36
36
  import { pprofCapturePreloadPath } from './config.js'
37
37
  import {
38
+ AddressInUseError,
39
+ WorkerAddressInUseError,
38
40
  ApplicationAlreadyStartedError,
39
41
  ApplicationNotFoundError,
40
42
  ApplicationNotStartedError,
41
43
  ApplicationStartTimeoutError,
42
- CannotRemoveEntrypointError,
43
44
  DuplicateExtensionHealthCheckError,
44
45
  DuplicateITCHandlerNameError,
45
46
  ExtensionHealthRoutesUnavailableError,
@@ -85,6 +86,8 @@ import {
85
86
  kStderrMarker,
86
87
  kWorkerHealthSignals,
87
88
  kWorkerId,
89
+ kWorkerPortOffset,
90
+ kWorkerUrl,
88
91
  kWorkersBroadcast,
89
92
  kWorkerStartTime,
90
93
  kWorkerStatus
@@ -193,9 +196,6 @@ export class Runtime extends EventEmitter {
193
196
  #sharedContext
194
197
  #isProduction
195
198
  #concurrency
196
- #entrypointId
197
- #url
198
- #entrypointPort
199
199
 
200
200
  #healthMetricsTimer
201
201
  #healthMetricsCollectionActive
@@ -225,9 +225,9 @@ export class Runtime extends EventEmitter {
225
225
  #lastOverloadProfiles
226
226
  #restartingApplications
227
227
  #restartingWorkers
228
+ #workerPortOffsets
228
229
  #dynamicWorkersScaler
229
230
  #nextWorkerIndex
230
- #workerPortOffsets
231
231
 
232
232
  #sharedHttpCache
233
233
  #scheduler
@@ -254,8 +254,6 @@ export class Runtime extends EventEmitter {
254
254
  this.#applications = new Map()
255
255
  this.#applicationRestartCounts = new Map()
256
256
  this.#workers = new RoundRobinMap()
257
- this.#url = undefined
258
- this.#entrypointPort = undefined
259
257
  this.#channelCreationHook = createChannelCreationHook(this.#config)
260
258
  this.#meshInterceptor = createThreadInterceptor({
261
259
  domain: '.plt.local',
@@ -268,8 +266,8 @@ export class Runtime extends EventEmitter {
268
266
  this.#status = undefined
269
267
  this.#restartingApplications = new Set()
270
268
  this.#restartingWorkers = new Map()
271
- this.#nextWorkerIndex = new Map()
272
269
  this.#workerPortOffsets = new Map() // fullWorkerId => portOffset
270
+ this.#nextWorkerIndex = new Map()
273
271
  this.#sharedHttpCache = null
274
272
  this.#applicationsConfigsPatches = new Map()
275
273
 
@@ -406,7 +404,7 @@ export class Runtime extends EventEmitter {
406
404
  if (this.getApplicationsIds().length === 0) {
407
405
  this.#updateStatus('started')
408
406
  await this.close(silent)
409
- return
407
+ return {}
410
408
  }
411
409
 
412
410
  if (this.#config.inspectorOptions) {
@@ -453,10 +451,8 @@ export class Runtime extends EventEmitter {
453
451
  this.#startHealthMetricsCollectionIfNeeded()
454
452
 
455
453
  await this.#dynamicWorkersScaler?.start()
456
- if (this.#url) {
457
- this.#showUrl()
458
- }
459
- return this.#url
454
+ this.#showUrls()
455
+ return this.getUrls()
460
456
  }
461
457
 
462
458
  async stop (silent = false) {
@@ -485,11 +481,6 @@ export class Runtime extends EventEmitter {
485
481
 
486
482
  await this.#dynamicWorkersScaler?.stop()
487
483
 
488
- // Stop the entrypoint first so that no new requests are accepted
489
- if (this.#entrypointId) {
490
- await this.stopApplication(this.#entrypointId, silent)
491
- }
492
-
493
484
  // Await extension stop hooks before stopping remaining applications so that
494
485
  // control-plane extensions can settle work and hand off state first.
495
486
  await this.#stopExtensions()
@@ -514,8 +505,6 @@ export class Runtime extends EventEmitter {
514
505
  await this.restartApplications(toRestart)
515
506
 
516
507
  this.emitAndNotify('restarted')
517
-
518
- return this.#url
519
508
  }
520
509
 
521
510
  async close (silent = false) {
@@ -667,25 +656,8 @@ export class Runtime extends EventEmitter {
667
656
 
668
657
  async addApplications (applications, start = false) {
669
658
  const setupInvocations = []
670
- // Per-worker ports do not need SO_REUSEPORT because each worker binds a different port.
671
- const usesPerWorkerPorts = this.#config.server?.portAssignment === 'perWorkerIncrement'
672
-
673
659
  const toStart = []
674
660
  for (const application of applications) {
675
- const workers = application.workers
676
-
677
- if (
678
- (workers.static > 1 || workers.minimum > 1) &&
679
- application.entrypoint &&
680
- !features.node.reusePort &&
681
- !usesPerWorkerPorts
682
- ) {
683
- this.logger.warn(
684
- `"${application.id}" is set as the entrypoint, but reusePort is not available in your OS; setting workers to 1 instead of ${workers.static}`
685
- )
686
- application.workers = { dynamic: false, static: 1 }
687
- }
688
-
689
661
  this.#applications.set(application.id, application)
690
662
  this.#applicationRestartCounts.set(application.id, this.#applicationRestartCounts.get(application.id) ?? 0)
691
663
  setupInvocations.push([application])
@@ -695,7 +667,7 @@ export class Runtime extends EventEmitter {
695
667
  await executeInParallel(this.#setupApplication.bind(this), setupInvocations, this.#concurrency)
696
668
 
697
669
  for (const application of applications) {
698
- this.logger.debug(`Added application "${application.id}"${application.entrypoint ? ' (entrypoint)' : ''}.`)
670
+ this.logger.debug(`Added application "${application.id}".`)
699
671
  this.emitAndNotify('application:added', application)
700
672
  }
701
673
 
@@ -713,10 +685,6 @@ export class Runtime extends EventEmitter {
713
685
  }
714
686
 
715
687
  async removeApplications (applications, silent = false) {
716
- if (applications.includes(this.#entrypointId)) {
717
- throw new CannotRemoveEntrypointError()
718
- }
719
-
720
688
  const removed = []
721
689
  for (const application of applications) {
722
690
  if (!this.#applications.has(application)) {
@@ -780,16 +748,21 @@ export class Runtime extends EventEmitter {
780
748
 
781
749
  if (!skipDependencies) {
782
750
  try {
783
- const { applications } = await this.getApplications(true)
784
- for (const application of applications) {
785
- for (const dependency of application.dependencies ?? []) {
786
- let applicationDependents = dependents[dependency]
787
- if (!applicationDependents) {
788
- applicationDependents = new Set()
789
- dependents[dependency] = applicationDependents
790
- }
751
+ const details = await executeWithTimeout(
752
+ this.getApplications(true),
753
+ this.#config.gracefulShutdown.application
754
+ )
755
+ if (details !== kTimeout) {
756
+ for (const application of details.applications) {
757
+ for (const dependency of application.dependencies ?? []) {
758
+ let applicationDependents = dependents[dependency]
759
+ if (!applicationDependents) {
760
+ applicationDependents = new Set()
761
+ dependents[dependency] = applicationDependents
762
+ }
791
763
 
792
- applicationDependents.add(application.id)
764
+ applicationDependents.add(application.id)
765
+ }
793
766
  }
794
767
  }
795
768
  } catch (e) {
@@ -798,11 +771,6 @@ export class Runtime extends EventEmitter {
798
771
  }
799
772
 
800
773
  for (const application of applicationsToStop) {
801
- // The entrypoint has been stopped above
802
- if (application === this.#entrypointId) {
803
- continue
804
- }
805
-
806
774
  stopInvocations.push([application, silent, Array.from(dependents[application] ?? [])])
807
775
  }
808
776
 
@@ -810,13 +778,39 @@ export class Runtime extends EventEmitter {
810
778
  }
811
779
 
812
780
  async restartApplications (applicationsToRestart) {
813
- const restartInvocations = []
781
+ const restartInvocations = applicationsToRestart.map(application => [application, true])
782
+ const restarts = await executeInParallel(
783
+ this.restartApplication.bind(this),
784
+ restartInvocations,
785
+ this.#concurrency,
786
+ false
787
+ )
788
+ const failed = restarts.filter(result => result instanceof Error)
789
+ const succeeded = restarts.filter(result => result && !(result instanceof Error))
790
+
791
+ if (failed.length > 0) {
792
+ await Promise.allSettled(succeeded.map(restart => restart.discard()))
793
+ throw failed[0]
794
+ }
795
+
796
+ const results = await executeInParallel(
797
+ retirement => retirement.retire(),
798
+ succeeded.flatMap(({ retirements }) => retirements.map(retirement => [retirement])),
799
+ this.#concurrency,
800
+ false
801
+ )
814
802
 
815
- for (const application of applicationsToRestart) {
816
- restartInvocations.push([application])
803
+ const error = results.find(result => result instanceof Error)
804
+ if (error) {
805
+ for (const restart of succeeded) {
806
+ restart.release()
807
+ }
808
+ throw error
817
809
  }
818
810
 
819
- return executeInParallel(this.restartApplication.bind(this), restartInvocations, this.#concurrency)
811
+ for (const restart of succeeded) {
812
+ restart.complete()
813
+ }
820
814
  }
821
815
 
822
816
  async startApplication (id, silent = false) {
@@ -873,7 +867,7 @@ export class Runtime extends EventEmitter {
873
867
  this.emitAndNotify('application:stopped', id)
874
868
  }
875
869
 
876
- async restartApplication (id) {
870
+ async restartApplication (id, deferOldWorkerRetirement = false) {
877
871
  const applicationConfig = this.#applications.get(id)
878
872
 
879
873
  if (!applicationConfig) {
@@ -892,6 +886,8 @@ export class Runtime extends EventEmitter {
892
886
  }
893
887
 
894
888
  this.#restartingApplications.add(id)
889
+ const retirements = []
890
+ let deferred = false
895
891
 
896
892
  try {
897
893
  const config = this.#config
@@ -909,14 +905,45 @@ export class Runtime extends EventEmitter {
909
905
  await sleep(config.workersRestartDelay)
910
906
  }
911
907
 
912
- await this.#replaceWorker(config, applicationConfig, workersCount, id, workerIndex, worker, true)
908
+ const retirement = await this.#replaceWorker(
909
+ config,
910
+ applicationConfig,
911
+ workersCount,
912
+ id,
913
+ workerIndex,
914
+ worker,
915
+ true,
916
+ deferOldWorkerRetirement
917
+ )
918
+ if (retirement) {
919
+ retirements.push(retirement)
920
+ }
921
+ }
922
+
923
+ if (deferOldWorkerRetirement) {
924
+ deferred = true
925
+ return {
926
+ retirements,
927
+ release: () => this.#restartingApplications.delete(id),
928
+ discard: async () => {
929
+ await Promise.allSettled(retirements.map(retirement => retirement.discard()))
930
+ this.#restartingApplications.delete(id)
931
+ },
932
+ complete: () => {
933
+ this.#incrementApplicationRestartCount(id)
934
+ this.emitAndNotify('application:restarted', id)
935
+ this.#restartingApplications.delete(id)
936
+ }
937
+ }
913
938
  }
914
939
 
915
940
  await this.#registerApplicationSchedulerJobs(id)
916
941
  this.#incrementApplicationRestartCount(id)
917
942
  this.emitAndNotify('application:restarted', id)
918
943
  } finally {
919
- this.#restartingApplications.delete(id)
944
+ if (!deferred) {
945
+ this.#restartingApplications.delete(id)
946
+ }
920
947
  }
921
948
  }
922
949
 
@@ -1524,8 +1551,20 @@ export class Runtime extends EventEmitter {
1524
1551
  return this.#root
1525
1552
  }
1526
1553
 
1527
- getUrl () {
1528
- return this.#url
1554
+ getUrls (applicationId) {
1555
+ const urls = {}
1556
+ const applicationIds = applicationId ? [applicationId] : this.#applications.keys()
1557
+
1558
+ for (const id of applicationIds) {
1559
+ for (const workerId of this.#workers.getKeys(id)) {
1560
+ const url = this.#workers.get(workerId)?.[kWorkerUrl]
1561
+ if (url) {
1562
+ urls[workerId] = url
1563
+ }
1564
+ }
1565
+ }
1566
+
1567
+ return urls
1529
1568
  }
1530
1569
 
1531
1570
  getRuntimeStatus () {
@@ -1534,8 +1573,6 @@ export class Runtime extends EventEmitter {
1534
1573
 
1535
1574
  async getRuntimeMetadata () {
1536
1575
  const packageJson = await this.#getRuntimePackageJson()
1537
- const entrypointDetails = await this.getEntrypointDetails()
1538
-
1539
1576
  return {
1540
1577
  pid: process.pid,
1541
1578
  cwd: process.cwd(),
@@ -1546,8 +1583,8 @@ export class Runtime extends EventEmitter {
1546
1583
  projectDir: this.#root,
1547
1584
  packageName: packageJson.name ?? null,
1548
1585
  packageVersion: packageJson.version ?? null,
1549
- url: entrypointDetails?.url ?? null,
1550
- platformaticVersion: version
1586
+ platformaticVersion: version,
1587
+ urls: this.getUrls()
1551
1588
  }
1552
1589
  }
1553
1590
 
@@ -1580,14 +1617,6 @@ export class Runtime extends EventEmitter {
1580
1617
  return this.#managementApi?.server.address() ?? null
1581
1618
  }
1582
1619
 
1583
- async getEntrypointDetails () {
1584
- if (!this.#entrypointId) {
1585
- return null
1586
- }
1587
-
1588
- return this.getApplicationDetails(this.#entrypointId)
1589
- }
1590
-
1591
1620
  async getCustomHealthChecks () {
1592
1621
  const invocations = []
1593
1622
 
@@ -2067,12 +2096,11 @@ export class Runtime extends EventEmitter {
2067
2096
  }
2068
2097
 
2069
2098
  getApplicationsIds () {
2070
- return Array.from(this.#applications.keys())
2099
+ return Array.from(this.#applications.keys()).sort()
2071
2100
  }
2072
2101
 
2073
2102
  async getApplications (allowUnloaded = false) {
2074
2103
  return {
2075
- entrypoint: this.#entrypointId,
2076
2104
  production: this.#isProduction,
2077
2105
  applications: await Promise.all(
2078
2106
  this.getApplicationsIds().map(id => this.getApplicationDetails(id, allowUnloaded))
@@ -2118,7 +2146,7 @@ export class Runtime extends EventEmitter {
2118
2146
  throw e
2119
2147
  }
2120
2148
 
2121
- const { entrypoint, localUrl, config, path } = application[kConfig]
2149
+ const { localUrl, config, path } = application[kConfig]
2122
2150
 
2123
2151
  const sourceMaps = application[kConfig].sourceMaps ?? this.#config.sourceMaps
2124
2152
  const status = await sendViaITC(application, 'getStatus')
@@ -2133,7 +2161,6 @@ export class Runtime extends EventEmitter {
2133
2161
  dependencies,
2134
2162
  version,
2135
2163
  localUrl,
2136
- entrypoint,
2137
2164
  sourceMaps
2138
2165
  }
2139
2166
 
@@ -2141,9 +2168,9 @@ export class Runtime extends EventEmitter {
2141
2168
  applicationDetails.workers = this.#workers.getKeys(id).length
2142
2169
  }
2143
2170
 
2144
- if (entrypoint) {
2145
- applicationDetails.url = status === 'started' ? this.#url : null
2146
- }
2171
+ const urls = status === 'started' ? Object.values(this.getUrls(id)) : []
2172
+ applicationDetails.urls = urls
2173
+ applicationDetails.url = urls[0] ?? null
2147
2174
 
2148
2175
  return applicationDetails
2149
2176
  }
@@ -2260,6 +2287,7 @@ export class Runtime extends EventEmitter {
2260
2287
  worker: index,
2261
2288
  status: worker[kWorkerStatus],
2262
2289
  thread: worker.threadId,
2290
+ url: worker[kWorkerUrl] ?? null,
2263
2291
  raw: includeRaw ? worker : undefined
2264
2292
  }
2265
2293
  }
@@ -2381,12 +2409,26 @@ export class Runtime extends EventEmitter {
2381
2409
  this.emitAndNotify(status, args)
2382
2410
  }
2383
2411
 
2384
- #showUrl () {
2385
- if (!this.#url) {
2386
- return
2387
- }
2412
+ #showUrls (workerIds) {
2413
+ const whitelist = workerIds ? new Set(workerIds) : null
2414
+
2415
+ for (const applicationId of this.#applications.keys()) {
2416
+ for (const workerId of this.#workers.getKeys(applicationId)) {
2417
+ if (whitelist && !whitelist.has(workerId)) {
2418
+ continue
2419
+ }
2420
+
2421
+ const worker = this.#workers.get(workerId)
2422
+ const url = worker?.[kWorkerUrl]
2423
+ if (!url) {
2424
+ continue
2425
+ }
2388
2426
 
2389
- this.logger.info(`Platformatic is now listening at ${this.#url}`)
2427
+ this.logger.info(
2428
+ `Platformatic is now listening at ${url} for ${this.#workerExtendedLabel(applicationId, worker[kWorkerId])}`
2429
+ )
2430
+ }
2431
+ }
2390
2432
  }
2391
2433
 
2392
2434
  async #setupApplication (applicationConfig) {
@@ -2422,11 +2464,37 @@ export class Runtime extends EventEmitter {
2422
2464
  }
2423
2465
  }
2424
2466
 
2425
- const workers = applicationConfig.workers.static
2467
+ let workers = applicationConfig.workers.static
2426
2468
  const setupInvocations = []
2427
2469
 
2428
- for (let i = 0; i < workers; i++) {
2429
- this.#workerPortOffsets.set(`${id}:${i}`, i)
2470
+ // All the workers of the application are (re)created, so their port offsets match their indexes
2471
+ for (const workerId of this.#workerPortOffsets.keys()) {
2472
+ if (workerId.slice(0, workerId.lastIndexOf(':')) === id) {
2473
+ this.#workerPortOffsets.delete(workerId)
2474
+ }
2475
+ }
2476
+
2477
+ let firstIndex = 0
2478
+
2479
+ // On platforms where reusePort is not available, multiple workers cannot listen on the same fixed port.
2480
+ // The listener configuration is owned by the capability, so it can only be inspected after setting up the first
2481
+ // worker: if the application would try to share a fixed port between workers, clamp it to a single worker.
2482
+ if (!features.node.reusePort && (workers > 1 || applicationConfig.workers.dynamic)) {
2483
+ const worker = await this.#setupWorker(config, applicationConfig, workers, id, 0)
2484
+ firstIndex = 1
2485
+
2486
+ if (await this.#usesSharedFixedPort(worker)) {
2487
+ this.logger.warn(
2488
+ `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.`
2489
+ )
2490
+
2491
+ applicationConfig.workers = { dynamic: false, static: 1 }
2492
+ workers = 1
2493
+ await sendViaITC(worker, 'updateWorkersCount', { applicationId: id, workers })
2494
+ }
2495
+ }
2496
+
2497
+ for (let i = firstIndex; i < workers; i++) {
2430
2498
  setupInvocations.push([config, applicationConfig, workers, id, i])
2431
2499
  }
2432
2500
 
@@ -2443,6 +2511,11 @@ export class Runtime extends EventEmitter {
2443
2511
  const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
2444
2512
  const workerId = `${applicationId}:${index}`
2445
2513
 
2514
+ // The port offset is the slot the worker occupies when the application uses server.portAssignment=perWorkerIncrement.
2515
+ // It matches the worker index unless the worker was created to replace another one, in which case it inherits its offset.
2516
+ const portOffset = this.#workerPortOffsets.get(workerId) ?? index
2517
+ this.#workerPortOffsets.set(workerId, portOffset)
2518
+
2446
2519
  // Handle inspector
2447
2520
  let inspectorOptions
2448
2521
 
@@ -2454,12 +2527,6 @@ export class Runtime extends EventEmitter {
2454
2527
  inspectorOptions.port = inspectorOptions.port + this.#workers.size + 1
2455
2528
  }
2456
2529
 
2457
- let serverConfigOverride
2458
- if (this.#config.server?.portAssignment === 'perWorkerIncrement') {
2459
- const portOffset = this.#workerPortOffsets.get(workerId)
2460
- serverConfigOverride = { port: Number(this.#config.server.port) + portOffset }
2461
- }
2462
-
2463
2530
  if (config.telemetry) {
2464
2531
  applicationConfig.telemetry = {
2465
2532
  ...config.telemetry,
@@ -2500,7 +2567,10 @@ export class Runtime extends EventEmitter {
2500
2567
  preload = preload.filter(p => p !== pprofCapturePath)
2501
2568
  }
2502
2569
 
2503
- const workerEnv = structuredClone(this.#env)
2570
+ // v4 resolves each application's worker environment main-side, with its own env-file chain, the
2571
+ // two env blocks and the injected topology URLs. v3 seeded every worker from one loadEnv at the
2572
+ // runtime root, which is what #env still holds.
2573
+ const workerEnv = structuredClone(applicationConfig.workerEnv ?? this.#env)
2504
2574
 
2505
2575
  if (applicationConfig.nodeOptions?.trim().length > 0) {
2506
2576
  const originalNodeOptions = workerEnv.NODE_OPTIONS ?? ''
@@ -2523,17 +2593,7 @@ export class Runtime extends EventEmitter {
2523
2593
  const maxYoungGenerationSizeMb = maxYoungGeneration ? Math.floor(maxYoungGeneration / (1024 * 1024)) : undefined
2524
2594
  const codeRangeSizeMb = codeRangeSize ? Math.floor(codeRangeSize / (1024 * 1024)) : undefined
2525
2595
 
2526
- const workerConfig = {
2527
- ...config,
2528
- preload
2529
- }
2530
-
2531
- if (config.server && serverConfigOverride) {
2532
- workerConfig.server = {
2533
- ...config.server,
2534
- ...serverConfigOverride
2535
- }
2536
- }
2596
+ const workerConfig = { ...config, preload }
2537
2597
 
2538
2598
  const worker = new Worker(kWorkerFile, {
2539
2599
  workerData: {
@@ -2546,7 +2606,8 @@ export class Runtime extends EventEmitter {
2546
2606
  worker: {
2547
2607
  id: workerId,
2548
2608
  index,
2549
- count: workersCount
2609
+ count: workersCount,
2610
+ portOffset
2550
2611
  },
2551
2612
  resourceLimits: {
2552
2613
  maxOldGenerationSizeMb,
@@ -2589,7 +2650,6 @@ export class Runtime extends EventEmitter {
2589
2650
  worker[kWorkerStatus] = 'exited'
2590
2651
  this.emitAndNotify('application:worker:exited', eventPayload)
2591
2652
 
2592
- const portOffset = this.#workerPortOffsets.get(worker[kFullId]) ?? index
2593
2653
  this.#cleanupWorker(worker)
2594
2654
 
2595
2655
  if (this.#status === 'stopping') {
@@ -2614,11 +2674,18 @@ export class Runtime extends EventEmitter {
2614
2674
  this.logger.warn(`The ${errorLabel} will be restarted in ${restartOnError}ms ...`)
2615
2675
  }
2616
2676
 
2617
- this.#restartCrashedWorker(config, applicationConfig, workersCount, applicationId, index, false, 0, portOffset).catch(
2618
- err => {
2619
- this.logger.error({ err: ensureLoggableError(err) }, `${errorLabel} could not be restarted.`)
2620
- }
2621
- )
2677
+ this.#restartCrashedWorker(
2678
+ config,
2679
+ applicationConfig,
2680
+ workersCount,
2681
+ applicationId,
2682
+ index,
2683
+ false,
2684
+ 0,
2685
+ worker[kWorkerPortOffset]
2686
+ ).catch(err => {
2687
+ this.logger.error({ err: ensureLoggableError(err) }, `${errorLabel} could not be restarted.`)
2688
+ })
2622
2689
  } else {
2623
2690
  this.emitAndNotify('application:worker:unvailable', eventPayload)
2624
2691
  this.logger.warn(`The ${errorLabel} is no longer available.`)
@@ -2631,6 +2698,7 @@ export class Runtime extends EventEmitter {
2631
2698
  worker[kFullId] = workerId
2632
2699
  worker[kApplicationId] = applicationId
2633
2700
  worker[kWorkerId] = index
2701
+ worker[kWorkerPortOffset] = portOffset
2634
2702
  worker[kWorkerStatus] = 'boot'
2635
2703
 
2636
2704
  if (inspectorOptions) {
@@ -2844,9 +2912,7 @@ export class Runtime extends EventEmitter {
2844
2912
  this.logger.info(`The application "${applicationId}" has been successfully reloaded.`)
2845
2913
  this.emitAndNotify('application:worker:reloaded', eventPayload)
2846
2914
 
2847
- if (applicationConfig.entrypoint) {
2848
- this.#showUrl()
2849
- }
2915
+ this.#showUrls(this.#workers.getKeys(applicationId))
2850
2916
  } catch (e) {
2851
2917
  this.logger.error(e)
2852
2918
  }
@@ -2881,13 +2947,11 @@ export class Runtime extends EventEmitter {
2881
2947
  }
2882
2948
 
2883
2949
  this.#workers.delete(workerId)
2950
+ // The exit handler of the failed worker might have removed the port offset, restore it for the next attempt
2951
+ this.#workerPortOffsets.set(workerId, portOffset)
2884
2952
  return this.#setupWorker(config, applicationConfig, workersCount, applicationId, index, enabled, attempt + 1)
2885
2953
  }
2886
2954
 
2887
- if (applicationConfig.entrypoint) {
2888
- this.#entrypointId = applicationId
2889
- }
2890
-
2891
2955
  worker[kConfig] = { ...applicationConfig, health, workers: workersCount }
2892
2956
  worker[kWorkerStatus] = 'init'
2893
2957
  this.emitAndNotify('application:worker:init', eventPayload)
@@ -3302,23 +3366,8 @@ export class Runtime extends EventEmitter {
3302
3366
 
3303
3367
  const { url: workerUrl, scheduledTasks } = workerStartResult
3304
3368
  worker[kWorkerScheduledTasks] = scheduledTasks
3305
- if (workerUrl) {
3306
- this.#url = workerUrl
3307
3369
 
3308
- // Pin the entrypoint port so that subsequent restarts (especially with
3309
- // stopBeforeStart when reuseTcpPorts is false) bind to the same port
3310
- // instead of getting a new random one.
3311
- if (applicationConfig.entrypoint) {
3312
- try {
3313
- const boundPort = Number(new URL(workerUrl).port)
3314
- if (boundPort) {
3315
- this.#entrypointPort = boundPort
3316
- }
3317
- } catch {
3318
- // URL parsing failed, leave unchanged
3319
- }
3320
- }
3321
- }
3370
+ this.#recordWorkerUrl(worker, id, workerUrl)
3322
3371
 
3323
3372
  // Wait for the interceptor to be ready
3324
3373
  const interceptorResult = await executeWithTimeout(worker[kInterceptorReadyPromise], config.startTimeout)
@@ -3348,12 +3397,21 @@ export class Runtime extends EventEmitter {
3348
3397
  ).unref()
3349
3398
  }
3350
3399
  } catch (err) {
3351
- const error = ensureError(err)
3400
+ let error = ensureError(err)
3352
3401
  worker[kITC].notify('application:worker:start:processed')
3353
- const portOffset = this.#workerPortOffsets.get(worker[kFullId]) ?? index
3354
3402
 
3355
- // TODO: handle port allocation error here
3356
- if (error.code === 'EADDRINUSE' || error.code === 'EACCES') throw error
3403
+ if (error.code === 'EADDRINUSE' && Number.isInteger(Number(error.port))) {
3404
+ const port = Number(error.port)
3405
+ const owner = this.#getPortOwner(port, id)
3406
+
3407
+ if (owner) {
3408
+ error = new AddressInUseError(port, owner, id)
3409
+ } else if (this.#getPortOwner(port, id, undefined, true)) {
3410
+ error = new WorkerAddressInUseError(port, id)
3411
+ }
3412
+ // Otherwise the port is used by an external process: keep the original error, which already describes it
3413
+ }
3414
+ if (error.code === 'EACCES') throw error
3357
3415
 
3358
3416
  this.#cleanupWorker(worker)
3359
3417
 
@@ -3377,7 +3435,15 @@ export class Runtime extends EventEmitter {
3377
3435
 
3378
3436
  const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
3379
3437
 
3380
- if (disableRestartAttempts || !restartOnError) {
3438
+ if (
3439
+ disableRestartAttempts ||
3440
+ !restartOnError ||
3441
+ error.code === 'EACCES' ||
3442
+ error.code === 'EADDRINUSE' ||
3443
+ error.code === 'EADDRNOTAVAIL' ||
3444
+ error.code === 'PLT_RUNTIME_EADDR_IN_USE' ||
3445
+ error.code === 'PLT_RUNTIME_WORKER_EADDR_IN_USE'
3446
+ ) {
3381
3447
  throw error
3382
3448
  }
3383
3449
 
@@ -3397,7 +3463,16 @@ export class Runtime extends EventEmitter {
3397
3463
  )
3398
3464
  }
3399
3465
 
3400
- await this.#restartCrashedWorker(config, applicationConfig, workersCount, id, index, silent, bootstrapAttempt, portOffset)
3466
+ await this.#restartCrashedWorker(
3467
+ config,
3468
+ applicationConfig,
3469
+ workersCount,
3470
+ id,
3471
+ index,
3472
+ silent,
3473
+ bootstrapAttempt,
3474
+ worker[kWorkerPortOffset]
3475
+ )
3401
3476
  }
3402
3477
  }
3403
3478
 
@@ -3466,18 +3541,19 @@ export class Runtime extends EventEmitter {
3466
3541
  await this.#avoidOutOfOrderThreadLogs()
3467
3542
 
3468
3543
  worker[kWorkerStatus] = 'stopped'
3544
+ worker[kWorkerUrl] = undefined
3469
3545
  this.emitAndNotify('application:worker:stopped', eventPayload)
3470
3546
  this.#broadcastWorkers()
3471
3547
  }
3472
3548
 
3473
3549
  #cleanupWorker (worker) {
3474
3550
  clearTimeout(worker[kHealthCheckTimer])
3475
- this.#workerPortOffsets.delete(worker[kFullId])
3476
3551
 
3477
3552
  const currentWorker = this.#workers.get(worker[kFullId])
3478
3553
 
3479
3554
  if (currentWorker === worker) {
3480
3555
  this.#workers.delete(worker[kFullId])
3556
+ this.#workerPortOffsets.delete(worker[kFullId])
3481
3557
  }
3482
3558
 
3483
3559
  worker[kITC].close()
@@ -3488,6 +3564,8 @@ export class Runtime extends EventEmitter {
3488
3564
  worker.removeAllListeners('exit')
3489
3565
  await worker.terminate()
3490
3566
 
3567
+ // The worker might have never been registered, make sure its port offset is released
3568
+ this.#workerPortOffsets.delete(worker[kFullId])
3491
3569
  return this.#cleanupWorker(worker)
3492
3570
  }
3493
3571
 
@@ -3501,6 +3579,42 @@ export class Runtime extends EventEmitter {
3501
3579
  return index
3502
3580
  }
3503
3581
 
3582
+ // Returns the lowest port offset which is not used by any worker of the application
3583
+ #getNextWorkerPortOffset (applicationId) {
3584
+ const used = new Set()
3585
+
3586
+ for (const [workerId, offset] of this.#workerPortOffsets) {
3587
+ if (workerId.slice(0, workerId.lastIndexOf(':')) === applicationId) {
3588
+ used.add(offset)
3589
+ }
3590
+ }
3591
+
3592
+ let offset = 0
3593
+ while (used.has(offset)) {
3594
+ offset++
3595
+ }
3596
+
3597
+ return offset
3598
+ }
3599
+
3600
+ // Returns true if the application of the worker is configured to listen on a fixed port shared by all its workers
3601
+ async #usesSharedFixedPort (worker) {
3602
+ let server
3603
+
3604
+ try {
3605
+ server = (await sendViaITC(worker, 'getApplicationConfig'))?.server
3606
+ } catch {
3607
+ return false
3608
+ }
3609
+
3610
+ if (!server || server.portAssignment === 'perWorkerIncrement') {
3611
+ return false
3612
+ }
3613
+
3614
+ const port = Number(server.port)
3615
+ return Number.isInteger(port) && port > 0
3616
+ }
3617
+
3504
3618
  // Returns the effective restartOnError value for an application: the application-level
3505
3619
  // value, when defined, takes precedence over the runtime-level one.
3506
3620
  // The value is normalized to a number: false becomes 0 (never restart),
@@ -3517,7 +3631,16 @@ export class Runtime extends EventEmitter {
3517
3631
  return restartOnError
3518
3632
  }
3519
3633
 
3520
- async #restartCrashedWorker (config, applicationConfig, workersCount, id, oldIndex, silent, bootstrapAttempt, portOffset) {
3634
+ async #restartCrashedWorker (
3635
+ config,
3636
+ applicationConfig,
3637
+ workersCount,
3638
+ id,
3639
+ oldIndex,
3640
+ silent,
3641
+ bootstrapAttempt,
3642
+ portOffset
3643
+ ) {
3521
3644
  const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
3522
3645
 
3523
3646
  // Use oldIndex for tracking to prevent duplicate restarts of the same crashed worker
@@ -3536,17 +3659,26 @@ export class Runtime extends EventEmitter {
3536
3659
  // If some processes were scheduled to restart
3537
3660
  // but the runtime is stopped, ignore it
3538
3661
  if (!this.#status.startsWith('start')) {
3662
+ resolve()
3539
3663
  return
3540
3664
  }
3541
3665
 
3542
- // Get a new unique index for the restarted worker
3666
+ // Get a new unique index for the restarted worker, which inherits the port offset of the crashed one
3543
3667
  const newIndex = this.#getNextWorkerIndex(id)
3544
3668
  const newWorkerId = `${id}:${newIndex}`
3545
- this.#workerPortOffsets.set(newWorkerId, portOffset)
3669
+ this.#workerPortOffsets.set(newWorkerId, portOffset ?? oldIndex)
3546
3670
 
3547
3671
  try {
3548
3672
  await this.#setupWorker(config, applicationConfig, workersCount, id, newIndex)
3549
- await this.#startWorker(config, applicationConfig, workersCount, id, newIndex, silent, bootstrapAttempt)
3673
+ await this.#startWorker(
3674
+ config,
3675
+ applicationConfig,
3676
+ workersCount,
3677
+ id,
3678
+ newIndex,
3679
+ silent,
3680
+ bootstrapAttempt
3681
+ )
3550
3682
  this.#incrementApplicationRestartCount(id)
3551
3683
 
3552
3684
  this.logger.info(
@@ -3559,6 +3691,7 @@ export class Runtime extends EventEmitter {
3559
3691
  // The runtime was stopped while the restart was happening, ignore any error.
3560
3692
  if (!this.#status.startsWith('start')) {
3561
3693
  resolve()
3694
+ return
3562
3695
  }
3563
3696
 
3564
3697
  reject(err)
@@ -3576,31 +3709,29 @@ export class Runtime extends EventEmitter {
3576
3709
  await restartPromise
3577
3710
  }
3578
3711
 
3579
- async #replaceWorker (config, applicationConfig, workersCount, applicationId, oldIndex, worker, silent) {
3712
+ async #replaceWorker (
3713
+ config,
3714
+ applicationConfig,
3715
+ workersCount,
3716
+ applicationId,
3717
+ oldIndex,
3718
+ worker,
3719
+ silent,
3720
+ deferOldWorkerRetirement = false
3721
+ ) {
3580
3722
  const oldLabel = this.#workerExtendedLabel(applicationId, oldIndex, workersCount)
3581
3723
  let newWorker
3582
3724
 
3583
- // Get a new unique index for the replacement worker
3725
+ // Get a new unique index for the replacement worker, which inherits the port offset of the replaced one
3584
3726
  const newIndex = this.#getNextWorkerIndex(applicationId)
3585
3727
  const newWorkerId = `${applicationId}:${newIndex}`
3586
3728
  const newLabel = this.#workerExtendedLabel(applicationId, newIndex, workersCount)
3587
- const portOffset = this.#workerPortOffsets.get(`${applicationId}:${oldIndex}`)
3588
- this.#workerPortOffsets.set(newWorkerId, portOffset)
3729
+ this.#workerPortOffsets.set(newWorkerId, worker[kWorkerPortOffset] ?? oldIndex)
3589
3730
 
3590
3731
  const stopBeforeStart =
3591
- applicationConfig.entrypoint &&
3732
+ Boolean(worker[kWorkerUrl]) &&
3592
3733
  (config.reuseTcpPorts === false || applicationConfig.reuseTcpPorts === false || !features.node.reusePort)
3593
3734
 
3594
- // When we must stop before start (no reusePort available), pin the entrypoint
3595
- // port in the config so the replacement worker binds to the same port.
3596
- // We only do this for stopBeforeStart because when reusePort is available
3597
- // the new worker starts alongside the old one and must use port 0 to avoid
3598
- // SO_REUSEPORT routing requests to the stale old worker.
3599
- let configForNewWorker = config
3600
- if (stopBeforeStart && this.#entrypointPort && config.server) {
3601
- configForNewWorker = { ...config, server: { ...config.server, port: this.#entrypointPort } }
3602
- }
3603
-
3604
3735
  try {
3605
3736
  if (!silent) {
3606
3737
  this.logger.debug(`Preparing to start ${newLabel} as replacement for ${oldLabel} ...`)
@@ -3610,8 +3741,14 @@ export class Runtime extends EventEmitter {
3610
3741
  await this.#removeWorker(workersCount, applicationId, oldIndex, worker, silent, oldLabel)
3611
3742
  }
3612
3743
 
3613
- // Create a new worker with a new index, preserving any pinned port when stopBeforeStart is required.
3614
- newWorker = await this.#setupWorker(configForNewWorker, applicationConfig, workersCount, applicationId, newIndex, false)
3744
+ newWorker = await this.#setupWorker(
3745
+ config,
3746
+ applicationConfig,
3747
+ workersCount,
3748
+ applicationId,
3749
+ newIndex,
3750
+ false
3751
+ )
3615
3752
 
3616
3753
  // Make sure the runtime hasn't been stopped in the meanwhile
3617
3754
  if (this.#status !== 'started') {
@@ -3625,7 +3762,7 @@ export class Runtime extends EventEmitter {
3625
3762
 
3626
3763
  // Add the worker to the mesh
3627
3764
  await this.#startWorker(
3628
- configForNewWorker,
3765
+ config,
3629
3766
  applicationConfig,
3630
3767
  workersCount,
3631
3768
  applicationId,
@@ -3641,17 +3778,23 @@ export class Runtime extends EventEmitter {
3641
3778
  return this.#discardWorker(newWorker)
3642
3779
  }
3643
3780
  } catch (e) {
3644
- this.#workerPortOffsets.delete(newWorkerId)
3645
-
3646
3781
  if (this.#workers.get(newWorkerId) === newWorker) {
3647
3782
  this.#workers.delete(newWorkerId)
3648
3783
  }
3649
3784
 
3785
+ this.#workerPortOffsets.delete(newWorkerId)
3650
3786
  newWorker?.terminate?.()
3651
3787
  throw e
3652
3788
  }
3653
3789
 
3654
3790
  if (!stopBeforeStart) {
3791
+ if (deferOldWorkerRetirement) {
3792
+ return {
3793
+ discard: () => this.#discardWorker(newWorker),
3794
+ retire: () => this.#removeWorker(workersCount, applicationId, oldIndex, worker, silent, oldLabel)
3795
+ }
3796
+ }
3797
+
3655
3798
  await this.#removeWorker(workersCount, applicationId, oldIndex, worker, silent, oldLabel)
3656
3799
  }
3657
3800
  }
@@ -4232,13 +4375,14 @@ export class Runtime extends EventEmitter {
4232
4375
 
4233
4376
  if (currentWorkers < workers) {
4234
4377
  report.started = []
4378
+
4235
4379
  let pendingWorkerId
4236
4380
 
4237
4381
  try {
4238
4382
  for (let i = currentWorkers; i < workers; i++) {
4239
4383
  const newIndex = this.#getNextWorkerIndex(applicationId)
4240
4384
  pendingWorkerId = `${applicationId}:${newIndex}`
4241
- this.#workerPortOffsets.set(pendingWorkerId, i)
4385
+ this.#workerPortOffsets.set(pendingWorkerId, this.#getNextWorkerPortOffset(applicationId))
4242
4386
 
4243
4387
  await this.#setupWorker(config, applicationConfig, workers, applicationId, newIndex)
4244
4388
  await this.#startWorker(config, applicationConfig, workers, applicationId, newIndex, false, 0)
@@ -4249,7 +4393,9 @@ export class Runtime extends EventEmitter {
4249
4393
  }
4250
4394
  report.success = true
4251
4395
  } catch (err) {
4252
- pendingWorkerId && this.#workerPortOffsets.delete(pendingWorkerId)
4396
+ if (pendingWorkerId) {
4397
+ this.#workerPortOffsets.delete(pendingWorkerId)
4398
+ }
4253
4399
 
4254
4400
  if (startedWorkersCount < 1) {
4255
4401
  this.logger.error({ err }, 'Cannot start application workers, no worker started')
@@ -4266,22 +4412,18 @@ export class Runtime extends EventEmitter {
4266
4412
  report.stopped = []
4267
4413
  try {
4268
4414
  const workersToStop = currentWorkers - workers
4269
- const allInOnePort = this.#config.server?.portAssignment !== 'perWorkerIncrement'
4415
+ // Stop the workers with the highest port offsets (which are the most recent workers, unless some of them
4416
+ // were replaced) first, so that applications using server.portAssignment=perWorkerIncrement keep listening
4417
+ // on a contiguous range of ports starting from the configured one.
4270
4418
  const workerIdsToStop = this.#workers
4271
4419
  .getKeys(applicationId)
4272
4420
  .map(key => parseInt(key.split(':')[1], 10))
4273
-
4274
- if (allInOnePort) {
4275
- // Stop most recent workers first.
4276
- workerIdsToStop.sort((a, b) => b - a)
4277
- } else {
4278
- // When port increment is enabled, disable the workers with the highest ports.
4279
- workerIdsToStop.sort((a, b) => {
4421
+ .sort((a, b) => {
4280
4422
  const offsetA = this.#workerPortOffsets.get(`${applicationId}:${a}`) ?? a
4281
4423
  const offsetB = this.#workerPortOffsets.get(`${applicationId}:${b}`) ?? b
4282
- return offsetB - offsetA
4424
+
4425
+ return offsetB - offsetA || b - a
4283
4426
  })
4284
- }
4285
4427
 
4286
4428
  for (const workerIndex of workerIdsToStop.splice(0, workersToStop)) {
4287
4429
  const worker = this.#workers.get(`${applicationId}:${workerIndex}`)
@@ -4821,4 +4963,56 @@ export class Runtime extends EventEmitter {
4821
4963
  this.emit('application:worker:init:failed', { application: worker[kApplicationId], worker: worker[kWorkerId] })
4822
4964
  worker.terminate()
4823
4965
  }
4966
+
4967
+ #getPortOwner (port, applicationId, hostname, includeSameApplication = false) {
4968
+ if (!Number.isInteger(port) || port <= 0) {
4969
+ return null
4970
+ }
4971
+
4972
+ for (const worker of this.#workers.values()) {
4973
+ if (!worker[kWorkerUrl] || (!includeSameApplication && worker[kApplicationId] === applicationId)) {
4974
+ continue
4975
+ }
4976
+
4977
+ try {
4978
+ const url = new URL(worker[kWorkerUrl])
4979
+ const workerPort = Number(url.port || (url.protocol === 'https:' ? 443 : 80))
4980
+ const workerHost = url.hostname
4981
+ if (workerPort === port && this.#listenersOverlap(hostname ?? workerHost, workerHost)) {
4982
+ return worker[kApplicationId]
4983
+ }
4984
+ } catch {}
4985
+ }
4986
+
4987
+ return null
4988
+ }
4989
+
4990
+ #listenersOverlap (host, otherHost) {
4991
+ host = host.toLowerCase()
4992
+ const wildcards = new Set(['0.0.0.0', '::', '[::]'])
4993
+ otherHost = otherHost.toLowerCase()
4994
+ return host === otherHost || wildcards.has(host) || wildcards.has(otherHost)
4995
+ }
4996
+
4997
+ #recordWorkerUrl (worker, applicationId, workerUrl) {
4998
+ if (!workerUrl) {
4999
+ worker[kWorkerUrl] = undefined
5000
+ return
5001
+ }
5002
+
5003
+ let hostname
5004
+ let port
5005
+ try {
5006
+ const url = new URL(workerUrl)
5007
+ hostname = url.hostname
5008
+ port = Number(url.port || (url.protocol === 'https:' ? 443 : 80))
5009
+ } catch {}
5010
+
5011
+ const owner = this.#getPortOwner(port, applicationId, hostname)
5012
+ if (owner) {
5013
+ throw new AddressInUseError(port, owner, applicationId)
5014
+ }
5015
+
5016
+ worker[kWorkerUrl] = workerUrl
5017
+ }
4824
5018
  }