@platformatic/runtime 3.68.0 → 4.0.0-new-config.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
@@ -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
+ }
814
795
 
815
- for (const application of applicationsToRestart) {
816
- restartInvocations.push([application])
796
+ const results = await executeInParallel(
797
+ retirement => retirement.retire(),
798
+ succeeded.flatMap(({ retirements }) => retirements.map(retirement => [retirement])),
799
+ this.#concurrency,
800
+ false
801
+ )
802
+
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
  }
@@ -2192,10 +2219,6 @@ export class Runtime extends EventEmitter {
2192
2219
  return this.#scheduler?.getJobs() ?? []
2193
2220
  }
2194
2221
 
2195
- getScheduler () {
2196
- return this.getSchedulerJobs()
2197
- }
2198
-
2199
2222
  pauseSchedulerJob (name) {
2200
2223
  return this.#schedulerOrThrow().pauseJob(name)
2201
2224
  }
@@ -2264,6 +2287,7 @@ export class Runtime extends EventEmitter {
2264
2287
  worker: index,
2265
2288
  status: worker[kWorkerStatus],
2266
2289
  thread: worker.threadId,
2290
+ url: worker[kWorkerUrl] ?? null,
2267
2291
  raw: includeRaw ? worker : undefined
2268
2292
  }
2269
2293
  }
@@ -2385,12 +2409,26 @@ export class Runtime extends EventEmitter {
2385
2409
  this.emitAndNotify(status, args)
2386
2410
  }
2387
2411
 
2388
- #showUrl () {
2389
- if (!this.#url) {
2390
- return
2391
- }
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
+ }
2392
2420
 
2393
- this.logger.info(`Platformatic is now listening at ${this.#url}`)
2421
+ const worker = this.#workers.get(workerId)
2422
+ const url = worker?.[kWorkerUrl]
2423
+ if (!url) {
2424
+ continue
2425
+ }
2426
+
2427
+ this.logger.info(
2428
+ `Platformatic is now listening at ${url} for ${this.#workerExtendedLabel(applicationId, worker[kWorkerId])}`
2429
+ )
2430
+ }
2431
+ }
2394
2432
  }
2395
2433
 
2396
2434
  async #setupApplication (applicationConfig) {
@@ -2426,11 +2464,37 @@ export class Runtime extends EventEmitter {
2426
2464
  }
2427
2465
  }
2428
2466
 
2429
- const workers = applicationConfig.workers.static
2467
+ let workers = applicationConfig.workers.static
2430
2468
  const setupInvocations = []
2431
2469
 
2432
- for (let i = 0; i < workers; i++) {
2433
- 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++) {
2434
2498
  setupInvocations.push([config, applicationConfig, workers, id, i])
2435
2499
  }
2436
2500
 
@@ -2447,6 +2511,11 @@ export class Runtime extends EventEmitter {
2447
2511
  const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
2448
2512
  const workerId = `${applicationId}:${index}`
2449
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
+
2450
2519
  // Handle inspector
2451
2520
  let inspectorOptions
2452
2521
 
@@ -2458,12 +2527,6 @@ export class Runtime extends EventEmitter {
2458
2527
  inspectorOptions.port = inspectorOptions.port + this.#workers.size + 1
2459
2528
  }
2460
2529
 
2461
- let serverConfigOverride
2462
- if (this.#config.server?.portAssignment === 'perWorkerIncrement') {
2463
- const portOffset = this.#workerPortOffsets.get(workerId)
2464
- serverConfigOverride = { port: Number(this.#config.server.port) + portOffset }
2465
- }
2466
-
2467
2530
  if (config.telemetry) {
2468
2531
  applicationConfig.telemetry = {
2469
2532
  ...config.telemetry,
@@ -2504,7 +2567,10 @@ export class Runtime extends EventEmitter {
2504
2567
  preload = preload.filter(p => p !== pprofCapturePath)
2505
2568
  }
2506
2569
 
2507
- 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)
2508
2574
 
2509
2575
  if (applicationConfig.nodeOptions?.trim().length > 0) {
2510
2576
  const originalNodeOptions = workerEnv.NODE_OPTIONS ?? ''
@@ -2527,17 +2593,7 @@ export class Runtime extends EventEmitter {
2527
2593
  const maxYoungGenerationSizeMb = maxYoungGeneration ? Math.floor(maxYoungGeneration / (1024 * 1024)) : undefined
2528
2594
  const codeRangeSizeMb = codeRangeSize ? Math.floor(codeRangeSize / (1024 * 1024)) : undefined
2529
2595
 
2530
- const workerConfig = {
2531
- ...config,
2532
- preload
2533
- }
2534
-
2535
- if (config.server && serverConfigOverride) {
2536
- workerConfig.server = {
2537
- ...config.server,
2538
- ...serverConfigOverride
2539
- }
2540
- }
2596
+ const workerConfig = { ...config, preload }
2541
2597
 
2542
2598
  const worker = new Worker(kWorkerFile, {
2543
2599
  workerData: {
@@ -2550,7 +2606,8 @@ export class Runtime extends EventEmitter {
2550
2606
  worker: {
2551
2607
  id: workerId,
2552
2608
  index,
2553
- count: workersCount
2609
+ count: workersCount,
2610
+ portOffset
2554
2611
  },
2555
2612
  resourceLimits: {
2556
2613
  maxOldGenerationSizeMb,
@@ -2593,7 +2650,6 @@ export class Runtime extends EventEmitter {
2593
2650
  worker[kWorkerStatus] = 'exited'
2594
2651
  this.emitAndNotify('application:worker:exited', eventPayload)
2595
2652
 
2596
- const portOffset = this.#workerPortOffsets.get(worker[kFullId]) ?? index
2597
2653
  this.#cleanupWorker(worker)
2598
2654
 
2599
2655
  if (this.#status === 'stopping') {
@@ -2618,11 +2674,18 @@ export class Runtime extends EventEmitter {
2618
2674
  this.logger.warn(`The ${errorLabel} will be restarted in ${restartOnError}ms ...`)
2619
2675
  }
2620
2676
 
2621
- this.#restartCrashedWorker(config, applicationConfig, workersCount, applicationId, index, false, 0, portOffset).catch(
2622
- err => {
2623
- this.logger.error({ err: ensureLoggableError(err) }, `${errorLabel} could not be restarted.`)
2624
- }
2625
- )
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
+ })
2626
2689
  } else {
2627
2690
  this.emitAndNotify('application:worker:unvailable', eventPayload)
2628
2691
  this.logger.warn(`The ${errorLabel} is no longer available.`)
@@ -2635,6 +2698,7 @@ export class Runtime extends EventEmitter {
2635
2698
  worker[kFullId] = workerId
2636
2699
  worker[kApplicationId] = applicationId
2637
2700
  worker[kWorkerId] = index
2701
+ worker[kWorkerPortOffset] = portOffset
2638
2702
  worker[kWorkerStatus] = 'boot'
2639
2703
 
2640
2704
  if (inspectorOptions) {
@@ -2848,9 +2912,7 @@ export class Runtime extends EventEmitter {
2848
2912
  this.logger.info(`The application "${applicationId}" has been successfully reloaded.`)
2849
2913
  this.emitAndNotify('application:worker:reloaded', eventPayload)
2850
2914
 
2851
- if (applicationConfig.entrypoint) {
2852
- this.#showUrl()
2853
- }
2915
+ this.#showUrls(this.#workers.getKeys(applicationId))
2854
2916
  } catch (e) {
2855
2917
  this.logger.error(e)
2856
2918
  }
@@ -2885,13 +2947,11 @@ export class Runtime extends EventEmitter {
2885
2947
  }
2886
2948
 
2887
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)
2888
2952
  return this.#setupWorker(config, applicationConfig, workersCount, applicationId, index, enabled, attempt + 1)
2889
2953
  }
2890
2954
 
2891
- if (applicationConfig.entrypoint) {
2892
- this.#entrypointId = applicationId
2893
- }
2894
-
2895
2955
  worker[kConfig] = { ...applicationConfig, health, workers: workersCount }
2896
2956
  worker[kWorkerStatus] = 'init'
2897
2957
  this.emitAndNotify('application:worker:init', eventPayload)
@@ -3306,23 +3366,8 @@ export class Runtime extends EventEmitter {
3306
3366
 
3307
3367
  const { url: workerUrl, scheduledTasks } = workerStartResult
3308
3368
  worker[kWorkerScheduledTasks] = scheduledTasks
3309
- if (workerUrl) {
3310
- this.#url = workerUrl
3311
3369
 
3312
- // Pin the entrypoint port so that subsequent restarts (especially with
3313
- // stopBeforeStart when reuseTcpPorts is false) bind to the same port
3314
- // instead of getting a new random one.
3315
- if (applicationConfig.entrypoint) {
3316
- try {
3317
- const boundPort = Number(new URL(workerUrl).port)
3318
- if (boundPort) {
3319
- this.#entrypointPort = boundPort
3320
- }
3321
- } catch {
3322
- // URL parsing failed, leave unchanged
3323
- }
3324
- }
3325
- }
3370
+ this.#recordWorkerUrl(worker, id, workerUrl)
3326
3371
 
3327
3372
  // Wait for the interceptor to be ready
3328
3373
  const interceptorResult = await executeWithTimeout(worker[kInterceptorReadyPromise], config.startTimeout)
@@ -3352,12 +3397,21 @@ export class Runtime extends EventEmitter {
3352
3397
  ).unref()
3353
3398
  }
3354
3399
  } catch (err) {
3355
- const error = ensureError(err)
3400
+ let error = ensureError(err)
3356
3401
  worker[kITC].notify('application:worker:start:processed')
3357
- const portOffset = this.#workerPortOffsets.get(worker[kFullId]) ?? index
3358
3402
 
3359
- // TODO: handle port allocation error here
3360
- 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
3361
3415
 
3362
3416
  this.#cleanupWorker(worker)
3363
3417
 
@@ -3381,7 +3435,15 @@ export class Runtime extends EventEmitter {
3381
3435
 
3382
3436
  const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
3383
3437
 
3384
- 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
+ ) {
3385
3447
  throw error
3386
3448
  }
3387
3449
 
@@ -3401,7 +3463,16 @@ export class Runtime extends EventEmitter {
3401
3463
  )
3402
3464
  }
3403
3465
 
3404
- 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
+ )
3405
3476
  }
3406
3477
  }
3407
3478
 
@@ -3470,18 +3541,19 @@ export class Runtime extends EventEmitter {
3470
3541
  await this.#avoidOutOfOrderThreadLogs()
3471
3542
 
3472
3543
  worker[kWorkerStatus] = 'stopped'
3544
+ worker[kWorkerUrl] = undefined
3473
3545
  this.emitAndNotify('application:worker:stopped', eventPayload)
3474
3546
  this.#broadcastWorkers()
3475
3547
  }
3476
3548
 
3477
3549
  #cleanupWorker (worker) {
3478
3550
  clearTimeout(worker[kHealthCheckTimer])
3479
- this.#workerPortOffsets.delete(worker[kFullId])
3480
3551
 
3481
3552
  const currentWorker = this.#workers.get(worker[kFullId])
3482
3553
 
3483
3554
  if (currentWorker === worker) {
3484
3555
  this.#workers.delete(worker[kFullId])
3556
+ this.#workerPortOffsets.delete(worker[kFullId])
3485
3557
  }
3486
3558
 
3487
3559
  worker[kITC].close()
@@ -3492,6 +3564,8 @@ export class Runtime extends EventEmitter {
3492
3564
  worker.removeAllListeners('exit')
3493
3565
  await worker.terminate()
3494
3566
 
3567
+ // The worker might have never been registered, make sure its port offset is released
3568
+ this.#workerPortOffsets.delete(worker[kFullId])
3495
3569
  return this.#cleanupWorker(worker)
3496
3570
  }
3497
3571
 
@@ -3505,6 +3579,42 @@ export class Runtime extends EventEmitter {
3505
3579
  return index
3506
3580
  }
3507
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
+
3508
3618
  // Returns the effective restartOnError value for an application: the application-level
3509
3619
  // value, when defined, takes precedence over the runtime-level one.
3510
3620
  // The value is normalized to a number: false becomes 0 (never restart),
@@ -3521,7 +3631,16 @@ export class Runtime extends EventEmitter {
3521
3631
  return restartOnError
3522
3632
  }
3523
3633
 
3524
- 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
+ ) {
3525
3644
  const restartOnError = this.#getApplicationRestartOnError(config, applicationConfig)
3526
3645
 
3527
3646
  // Use oldIndex for tracking to prevent duplicate restarts of the same crashed worker
@@ -3540,17 +3659,26 @@ export class Runtime extends EventEmitter {
3540
3659
  // If some processes were scheduled to restart
3541
3660
  // but the runtime is stopped, ignore it
3542
3661
  if (!this.#status.startsWith('start')) {
3662
+ resolve()
3543
3663
  return
3544
3664
  }
3545
3665
 
3546
- // 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
3547
3667
  const newIndex = this.#getNextWorkerIndex(id)
3548
3668
  const newWorkerId = `${id}:${newIndex}`
3549
- this.#workerPortOffsets.set(newWorkerId, portOffset)
3669
+ this.#workerPortOffsets.set(newWorkerId, portOffset ?? oldIndex)
3550
3670
 
3551
3671
  try {
3552
3672
  await this.#setupWorker(config, applicationConfig, workersCount, id, newIndex)
3553
- 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
+ )
3554
3682
  this.#incrementApplicationRestartCount(id)
3555
3683
 
3556
3684
  this.logger.info(
@@ -3563,6 +3691,7 @@ export class Runtime extends EventEmitter {
3563
3691
  // The runtime was stopped while the restart was happening, ignore any error.
3564
3692
  if (!this.#status.startsWith('start')) {
3565
3693
  resolve()
3694
+ return
3566
3695
  }
3567
3696
 
3568
3697
  reject(err)
@@ -3580,31 +3709,29 @@ export class Runtime extends EventEmitter {
3580
3709
  await restartPromise
3581
3710
  }
3582
3711
 
3583
- 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
+ ) {
3584
3722
  const oldLabel = this.#workerExtendedLabel(applicationId, oldIndex, workersCount)
3585
3723
  let newWorker
3586
3724
 
3587
- // 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
3588
3726
  const newIndex = this.#getNextWorkerIndex(applicationId)
3589
3727
  const newWorkerId = `${applicationId}:${newIndex}`
3590
3728
  const newLabel = this.#workerExtendedLabel(applicationId, newIndex, workersCount)
3591
- const portOffset = this.#workerPortOffsets.get(`${applicationId}:${oldIndex}`)
3592
- this.#workerPortOffsets.set(newWorkerId, portOffset)
3729
+ this.#workerPortOffsets.set(newWorkerId, worker[kWorkerPortOffset] ?? oldIndex)
3593
3730
 
3594
3731
  const stopBeforeStart =
3595
- applicationConfig.entrypoint &&
3732
+ Boolean(worker[kWorkerUrl]) &&
3596
3733
  (config.reuseTcpPorts === false || applicationConfig.reuseTcpPorts === false || !features.node.reusePort)
3597
3734
 
3598
- // When we must stop before start (no reusePort available), pin the entrypoint
3599
- // port in the config so the replacement worker binds to the same port.
3600
- // We only do this for stopBeforeStart because when reusePort is available
3601
- // the new worker starts alongside the old one and must use port 0 to avoid
3602
- // SO_REUSEPORT routing requests to the stale old worker.
3603
- let configForNewWorker = config
3604
- if (stopBeforeStart && this.#entrypointPort && config.server) {
3605
- configForNewWorker = { ...config, server: { ...config.server, port: this.#entrypointPort } }
3606
- }
3607
-
3608
3735
  try {
3609
3736
  if (!silent) {
3610
3737
  this.logger.debug(`Preparing to start ${newLabel} as replacement for ${oldLabel} ...`)
@@ -3614,8 +3741,14 @@ export class Runtime extends EventEmitter {
3614
3741
  await this.#removeWorker(workersCount, applicationId, oldIndex, worker, silent, oldLabel)
3615
3742
  }
3616
3743
 
3617
- // Create a new worker with a new index, preserving any pinned port when stopBeforeStart is required.
3618
- 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
+ )
3619
3752
 
3620
3753
  // Make sure the runtime hasn't been stopped in the meanwhile
3621
3754
  if (this.#status !== 'started') {
@@ -3629,7 +3762,7 @@ export class Runtime extends EventEmitter {
3629
3762
 
3630
3763
  // Add the worker to the mesh
3631
3764
  await this.#startWorker(
3632
- configForNewWorker,
3765
+ config,
3633
3766
  applicationConfig,
3634
3767
  workersCount,
3635
3768
  applicationId,
@@ -3645,17 +3778,23 @@ export class Runtime extends EventEmitter {
3645
3778
  return this.#discardWorker(newWorker)
3646
3779
  }
3647
3780
  } catch (e) {
3648
- this.#workerPortOffsets.delete(newWorkerId)
3649
-
3650
3781
  if (this.#workers.get(newWorkerId) === newWorker) {
3651
3782
  this.#workers.delete(newWorkerId)
3652
3783
  }
3653
3784
 
3785
+ this.#workerPortOffsets.delete(newWorkerId)
3654
3786
  newWorker?.terminate?.()
3655
3787
  throw e
3656
3788
  }
3657
3789
 
3658
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
+
3659
3798
  await this.#removeWorker(workersCount, applicationId, oldIndex, worker, silent, oldLabel)
3660
3799
  }
3661
3800
  }
@@ -4236,13 +4375,14 @@ export class Runtime extends EventEmitter {
4236
4375
 
4237
4376
  if (currentWorkers < workers) {
4238
4377
  report.started = []
4378
+
4239
4379
  let pendingWorkerId
4240
4380
 
4241
4381
  try {
4242
4382
  for (let i = currentWorkers; i < workers; i++) {
4243
4383
  const newIndex = this.#getNextWorkerIndex(applicationId)
4244
4384
  pendingWorkerId = `${applicationId}:${newIndex}`
4245
- this.#workerPortOffsets.set(pendingWorkerId, i)
4385
+ this.#workerPortOffsets.set(pendingWorkerId, this.#getNextWorkerPortOffset(applicationId))
4246
4386
 
4247
4387
  await this.#setupWorker(config, applicationConfig, workers, applicationId, newIndex)
4248
4388
  await this.#startWorker(config, applicationConfig, workers, applicationId, newIndex, false, 0)
@@ -4253,7 +4393,9 @@ export class Runtime extends EventEmitter {
4253
4393
  }
4254
4394
  report.success = true
4255
4395
  } catch (err) {
4256
- pendingWorkerId && this.#workerPortOffsets.delete(pendingWorkerId)
4396
+ if (pendingWorkerId) {
4397
+ this.#workerPortOffsets.delete(pendingWorkerId)
4398
+ }
4257
4399
 
4258
4400
  if (startedWorkersCount < 1) {
4259
4401
  this.logger.error({ err }, 'Cannot start application workers, no worker started')
@@ -4270,22 +4412,18 @@ export class Runtime extends EventEmitter {
4270
4412
  report.stopped = []
4271
4413
  try {
4272
4414
  const workersToStop = currentWorkers - workers
4273
- 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.
4274
4418
  const workerIdsToStop = this.#workers
4275
4419
  .getKeys(applicationId)
4276
4420
  .map(key => parseInt(key.split(':')[1], 10))
4277
-
4278
- if (allInOnePort) {
4279
- // Stop most recent workers first.
4280
- workerIdsToStop.sort((a, b) => b - a)
4281
- } else {
4282
- // When port increment is enabled, disable the workers with the highest ports.
4283
- workerIdsToStop.sort((a, b) => {
4421
+ .sort((a, b) => {
4284
4422
  const offsetA = this.#workerPortOffsets.get(`${applicationId}:${a}`) ?? a
4285
4423
  const offsetB = this.#workerPortOffsets.get(`${applicationId}:${b}`) ?? b
4286
- return offsetB - offsetA
4424
+
4425
+ return offsetB - offsetA || b - a
4287
4426
  })
4288
- }
4289
4427
 
4290
4428
  for (const workerIndex of workerIdsToStop.splice(0, workersToStop)) {
4291
4429
  const worker = this.#workers.get(`${applicationId}:${workerIndex}`)
@@ -4825,4 +4963,56 @@ export class Runtime extends EventEmitter {
4825
4963
  this.emit('application:worker:init:failed', { application: worker[kApplicationId], worker: worker[kWorkerId] })
4826
4964
  worker.terminate()
4827
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
+ }
4828
5018
  }