@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 +2 -0
- package/index.js +24 -3
- package/lib/config.js +81 -2
- package/lib/errors.js +5 -0
- package/lib/management-api.js +57 -26
- package/lib/prom-server.js +11 -3
- package/lib/runtime.js +142 -96
- package/lib/worker/controller.js +23 -8
- package/lib/worker/itc.js +38 -32
- package/lib/worker/main.js +17 -9
- package/package.json +18 -17
- package/schema.json +32 -1
- package/lib/dependencies.js +0 -63
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 = {
|
|
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
|
+
)
|
package/lib/management-api.js
CHANGED
|
@@ -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
|
|
173
|
+
export async function startManagementApi (runtime) {
|
|
156
174
|
const runtimePID = process.pid
|
|
157
175
|
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
194
|
+
await safeRemove(runtimePIDDir)
|
|
162
195
|
}
|
|
196
|
+
})
|
|
163
197
|
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
}
|
package/lib/prom-server.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|