@platformatic/runtime 3.0.0-alpha.6 → 3.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/config.d.ts CHANGED
@@ -32,6 +32,7 @@ export type PlatformaticRuntimeConfig = {
32
32
  maxYoungGeneration?: number | string;
33
33
  };
34
34
  preload?: string | string[];
35
+ dependencies?: string[];
35
36
  arguments?: string[];
36
37
  nodeOptions?: string;
37
38
  };
@@ -132,6 +133,7 @@ export type PlatformaticRuntimeConfig = {
132
133
  };
133
134
  startTimeout?: number;
134
135
  restartOnError?: boolean | number;
136
+ exitOnUnhandledErrors?: boolean;
135
137
  gracefulShutdown?: {
136
138
  runtime: number | string;
137
139
  application: number | string;
package/index.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  loadConfigurationModule,
9
9
  loadConfiguration as utilsLoadConfiguration
10
10
  } from '@platformatic/foundation'
11
+ import closeWithGrace from 'close-with-grace'
11
12
  import inspector from 'node:inspector'
12
13
  import { transform, wrapInRuntimeConfig } from './lib/config.js'
13
14
  import { NodeInspectorFlagsNotSupportedError } from './lib/errors.js'
@@ -25,12 +26,32 @@ async function restartRuntime (runtime) {
25
26
  }
26
27
  }
27
28
 
28
- function handleSignal (runtime) {
29
+ function handleSignal (runtime, config) {
30
+ // The very first time we add a listener for SIGUSR2,
31
+ // ignore it since it comes from close-with-grace and we want to use to restart the runtime
32
+ function filterCloseWithGraceSIGUSR2 (event, listener) {
33
+ if (event === 'SIGUSR2') {
34
+ process.removeListener('SIGUSR2', listener)
35
+ process.removeListener('newListener', filterCloseWithGraceSIGUSR2)
36
+ }
37
+ }
38
+
39
+ process.on('newListener', filterCloseWithGraceSIGUSR2)
40
+
41
+ const cwg = closeWithGrace({ delay: config.gracefulShutdown?.runtime ?? 10000 }, async event => {
42
+ if (event.err instanceof Error) {
43
+ console.error(event.err)
44
+ }
45
+ await runtime.close()
46
+ })
47
+
29
48
  /* c8 ignore next 3 */
30
49
  const restartListener = restartRuntime.bind(null, runtime)
31
50
  process.on('SIGUSR2', restartListener)
51
+
32
52
  runtime.on('closed', () => {
33
53
  process.removeListener('SIGUSR2', restartListener)
54
+ cwg.uninstall()
34
55
  })
35
56
  }
36
57
 
@@ -109,7 +130,7 @@ export async function create (configOrRoot, sourceOrConfig, context) {
109
130
  }
110
131
 
111
132
  let runtime = new Runtime(config, context)
112
- handleSignal(runtime)
133
+ handleSignal(runtime, config)
113
134
 
114
135
  // Handle port handling
115
136
  if (context?.start) {
@@ -135,7 +156,7 @@ export async function create (configOrRoot, sourceOrConfig, context) {
135
156
 
136
157
  config.server.port = ++port
137
158
  runtime = new Runtime(config, context)
138
- handleSignal(runtime)
159
+ handleSignal(runtime, config)
139
160
  }
140
161
  }
141
162
  }
package/lib/config.js CHANGED
@@ -16,12 +16,59 @@ import {
16
16
  InspectAndInspectBrkError,
17
17
  InspectorHostError,
18
18
  InspectorPortError,
19
+ InvalidArgumentError,
19
20
  InvalidEntrypointError,
20
21
  MissingEntrypointError
21
22
  } from './errors.js'
22
23
  import { schema } from './schema.js'
23
24
  import { upgrade } from './upgrade.js'
24
25
 
26
+ // Validate and coerce workers values early to avoid runtime hangs when invalid
27
+ function coercePositiveInteger (value) {
28
+ if (typeof value === 'number') {
29
+ if (!Number.isInteger(value) || value < 1) return null
30
+ return value
31
+ }
32
+ if (typeof value === 'string') {
33
+ // Trim to handle accidental spaces
34
+ const trimmed = value.trim()
35
+ if (trimmed.length === 0) return null
36
+ const num = Number(trimmed)
37
+ if (!Number.isFinite(num) || !Number.isInteger(num) || num < 1) return null
38
+ return num
39
+ }
40
+ return null
41
+ }
42
+
43
+ function raiseInvalidWorkersError (location, received, hint) {
44
+ const extra = hint ? ` (${hint})` : ''
45
+ throw new InvalidArgumentError(`${location} workers must be a positive integer; received "${received}"${extra}`)
46
+ }
47
+
48
+ export function autoDetectPprofCapture (config) {
49
+ const require = createRequire(import.meta.url)
50
+
51
+ let pprofCapturePath
52
+ try {
53
+ pprofCapturePath = require.resolve('@platformatic/wattpm-pprof-capture')
54
+ } catch (err) {
55
+ // No-op
56
+ }
57
+
58
+ // Add to preload if not already present
59
+ if (!config.preload) {
60
+ config.preload = []
61
+ } else if (typeof config.preload === 'string') {
62
+ config.preload = [config.preload]
63
+ }
64
+
65
+ if (pprofCapturePath && !config.preload.includes(pprofCapturePath)) {
66
+ config.preload.push(pprofCapturePath)
67
+ }
68
+
69
+ return config
70
+ }
71
+
25
72
  export async function wrapInRuntimeConfig (config, context) {
26
73
  let applicationId = 'main'
27
74
  try {
@@ -145,7 +192,14 @@ export async function transform (config, _, context) {
145
192
  config = join(entryPath, configFilename)
146
193
  }
147
194
 
148
- const application = { id, config, path: entryPath, useHttp: !!mapping.useHttp, health: mapping.health }
195
+ const application = {
196
+ id,
197
+ config,
198
+ path: entryPath,
199
+ useHttp: !!mapping.useHttp,
200
+ health: mapping.health,
201
+ dependencies: mapping.dependencies
202
+ }
149
203
  const existingApplicationId = applications.findIndex(application => application.id === id)
150
204
 
151
205
  if (existingApplicationId !== -1) {
@@ -161,6 +215,17 @@ export async function transform (config, _, context) {
161
215
 
162
216
  let hasValidEntrypoint = false
163
217
 
218
+ // Root-level workers
219
+ if (typeof config.workers !== 'undefined') {
220
+ const coerced = coercePositiveInteger(config.workers)
221
+ if (coerced === null) {
222
+ const raw = config.workers
223
+ const hint = typeof raw === 'string' && /\{.*\}/.test(raw) ? 'check your environment variable' : ''
224
+ raiseInvalidWorkersError('Runtime', config.workers, hint)
225
+ }
226
+ config.workers = coerced
227
+ }
228
+
164
229
  for (let i = 0; i < applications.length; ++i) {
165
230
  const application = applications[i]
166
231
 
@@ -207,8 +272,19 @@ export async function transform (config, _, context) {
207
272
  application.type = 'unknown'
208
273
  }
209
274
 
275
+ // Validate and coerce per-service workers
276
+ if (typeof application.workers !== 'undefined') {
277
+ const coerced = coercePositiveInteger(application.workers)
278
+ if (coerced === null) {
279
+ const raw = config.application?.[i]?.workers
280
+ const hint = typeof raw === 'string' && /\{.*\}/.test(raw) ? 'check your environment variable' : ''
281
+ raiseInvalidWorkersError(`Service "${application.id}"`, application.workers, hint)
282
+ }
283
+ application.workers = coerced
284
+ }
285
+
210
286
  application.entrypoint = application.id === config.entrypoint
211
- application.dependencies = []
287
+ application.dependencies ??= []
212
288
  application.localUrl = `http://${application.id}.plt.local`
213
289
 
214
290
  if (typeof application.watch === 'undefined') {
@@ -277,5 +353,8 @@ export async function transform (config, _, context) {
277
353
  }
278
354
  }
279
355
 
356
+ // Auto-detect and add pprof capture if available
357
+ autoDetectPprofCapture(config)
358
+
280
359
  return config
281
360
  }
package/lib/errors.js CHANGED
@@ -122,3 +122,8 @@ export const MessagingError = createError(
122
122
  `${ERROR_PREFIX}_MESSAGING_ERROR`,
123
123
  'Cannot send a message to application "%s": %s'
124
124
  )
125
+
126
+ export const MissingPprofCapture = createError(
127
+ `${ERROR_PREFIX}_MISSING_PPROF_CAPTURE`,
128
+ 'Please install @platformatic/wattpm-pprof-capture'
129
+ )
@@ -4,6 +4,7 @@ import { createDirectory, safeRemove } from '@platformatic/foundation'
4
4
  import fastify from 'fastify'
5
5
  import { platform, tmpdir } from 'node:os'
6
6
  import { join } from 'node:path'
7
+ import { setTimeout as sleep } from 'node:timers/promises'
7
8
  import { createWebSocketStream } from 'ws'
8
9
 
9
10
  const PLATFORMATIC_TMP_DIR = join(tmpdir(), 'platformatic', 'runtimes')
@@ -111,6 +112,23 @@ export async function managementApiPlugin (app, opts) {
111
112
  reply.code(res.statusCode).headers(res.headers).send(res.body)
112
113
  })
113
114
 
115
+ app.post('/applications/:id/pprof/start', async (request, reply) => {
116
+ const { id } = request.params
117
+ app.log.debug('start profiling', { id })
118
+
119
+ const options = request.body || {}
120
+ await runtime.startApplicationProfiling(id, options)
121
+ reply.code(200).send({})
122
+ })
123
+
124
+ app.post('/applications/:id/pprof/stop', async (request, reply) => {
125
+ const { id } = request.params
126
+ app.log.debug('stop profiling', { id })
127
+
128
+ const profileData = await runtime.stopApplicationProfiling(id)
129
+ reply.type('application/octet-stream').code(200).send(profileData)
130
+ })
131
+
114
132
  app.get('/metrics', { logLevel: 'debug' }, async (req, reply) => {
115
133
  const accepts = req.accepts()
116
134
 
@@ -152,40 +170,53 @@ export async function managementApiPlugin (app, opts) {
152
170
  })
153
171
  }
154
172
 
155
- export async function startManagementApi (runtime, root) {
173
+ export async function startManagementApi (runtime) {
156
174
  const runtimePID = process.pid
157
175
 
158
- try {
159
- const runtimePIDDir = join(PLATFORMATIC_TMP_DIR, runtimePID.toString())
176
+ const runtimePIDDir = join(PLATFORMATIC_TMP_DIR, runtimePID.toString())
177
+ if (platform() !== 'win32') {
178
+ await createDirectory(runtimePIDDir, true)
179
+ }
180
+
181
+ let socketPath = null
182
+ if (platform() === 'win32') {
183
+ socketPath = '\\\\.\\pipe\\platformatic-' + runtimePID.toString()
184
+ } else {
185
+ socketPath = join(runtimePIDDir, 'socket')
186
+ }
187
+
188
+ const managementApi = fastify()
189
+ managementApi.register(fastifyWebsocket)
190
+ managementApi.register(managementApiPlugin, { runtime, prefix: '/api/v1' })
191
+
192
+ managementApi.addHook('onClose', async () => {
160
193
  if (platform() !== 'win32') {
161
- await createDirectory(runtimePIDDir, true)
194
+ await safeRemove(runtimePIDDir)
162
195
  }
196
+ })
163
197
 
164
- let socketPath = null
165
- if (platform() === 'win32') {
166
- socketPath = '\\\\.\\pipe\\platformatic-' + runtimePID.toString()
167
- } else {
168
- socketPath = join(runtimePIDDir, 'socket')
169
- }
198
+ // When the runtime closes, close the management API as well
199
+ runtime.on('closed', managementApi.close.bind(managementApi))
170
200
 
171
- const managementApi = fastify()
172
- managementApi.register(fastifyWebsocket)
173
- managementApi.register(managementApiPlugin, { runtime, prefix: '/api/v1' })
201
+ /*
202
+ If runtime are started multiple times in a short
203
+ period of time (like in tests), there is a chance that the pipe is still in use
204
+ as the manament API server is closed after the runtime is closed (see event handler above).
174
205
 
175
- managementApi.addHook('onClose', async () => {
176
- if (platform() !== 'win32') {
177
- await safeRemove(runtimePIDDir)
206
+ Since it's a very rare case, we simply retry couple of times.
207
+ */
208
+ for (let i = 0; i < 5; i++) {
209
+ try {
210
+ await managementApi.listen({ path: socketPath })
211
+ break
212
+ } catch (e) {
213
+ if (i === 5) {
214
+ throw e
178
215
  }
179
- })
180
-
181
- // When the runtime closes, close the management API as well
182
- runtime.on('closed', managementApi.close.bind(managementApi))
183
216
 
184
- await managementApi.listen({ path: socketPath })
185
- return managementApi
186
- /* c8 ignore next 4 */
187
- } catch (err) {
188
- console.error(err)
189
- process.exit(1)
217
+ await sleep(100)
218
+ }
190
219
  }
220
+
221
+ return managementApi
191
222
  }
@@ -19,13 +19,21 @@ const DEFAULT_LIVENESS_FAIL_BODY = 'ERR'
19
19
  async function checkReadiness (runtime) {
20
20
  const workers = await runtime.getWorkers()
21
21
 
22
- // check if all workers are started
22
+ // Make sure there is at least one started worker
23
+ const applications = new Set()
24
+ const started = new Set()
23
25
  for (const worker of Object.values(workers)) {
24
- if (worker.status !== 'started') {
25
- return { status: false }
26
+ applications.add(worker.application)
27
+
28
+ if (worker.status === 'started') {
29
+ started.add(worker.application)
26
30
  }
27
31
  }
28
32
 
33
+ if (started.size !== applications.size) {
34
+ return { status: false }
35
+ }
36
+
29
37
  // perform custom readiness checks, get custom response content if any
30
38
  const checks = await runtime.getCustomReadinessChecks()
31
39