Skip to content

Commit afb6816

Browse files
feat: capture plugin errors during ready phase (#5681)
1 parent bd868b8 commit afb6816

File tree

6 files changed

+85
-26
lines changed

6 files changed

+85
-26
lines changed

packages/build/src/core/build.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,7 @@ const initAndRunBuild = async function ({
481481
timers: timersA,
482482
featureFlags,
483483
quiet,
484+
systemLog,
484485
systemLogFile,
485486
})
486487

packages/build/src/plugins/load.js

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { addPluginLoadErrorStatus } from '../status/load_error.js'
55
import { measureDuration } from '../time/main.js'
66

77
import { callChild } from './ipc.js'
8+
import { captureStandardError } from './system_log.js'
89

910
const pSetTimeout = promisify(setTimeout)
1011

@@ -79,20 +80,7 @@ const loadPlugin = async function (
7980
) {
8081
const { childProcess } = childProcesses[index]
8182
const loadEvent = 'load'
82-
83-
// A buffer for any data piped into the child process' stderr. We'll pipe
84-
// this to system logs if we fail to load the plugin.
85-
const bufferedStdErr = []
86-
87-
let bufferedStdListener
88-
89-
if (featureFlags.netlify_build_plugin_system_log && childProcess.stderr) {
90-
bufferedStdListener = (data) => {
91-
bufferedStdErr.push(data.toString().trimEnd())
92-
}
93-
94-
childProcess.stderr.on('data', bufferedStdListener)
95-
}
83+
const cleanup = captureStandardError(childProcess, systemLog, loadEvent, featureFlags)
9684

9785
try {
9886
const { events } = await callChild({
@@ -115,10 +103,6 @@ const loadPlugin = async function (
115103
if (featureFlags.netlify_build_plugin_system_log) {
116104
// Wait for stderr to be flushed.
117105
await pSetTimeout(0)
118-
119-
bufferedStdErr.forEach((line) => {
120-
systemLog(line)
121-
})
122106
}
123107

124108
addErrorInfo(error, {
@@ -128,8 +112,6 @@ const loadPlugin = async function (
128112
addPluginLoadErrorStatus({ error, packageName, version, debug })
129113
throw error
130114
} finally {
131-
if (bufferedStdListener) {
132-
childProcess.stderr.removeListener('data', bufferedStdListener)
133-
}
115+
cleanup()
134116
}
135117
}

packages/build/src/plugins/spawn.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { createRequire } from 'module'
22
import { fileURLToPath, pathToFileURL } from 'url'
3+
import { promisify } from 'util'
34

45
import { trace } from '@opentelemetry/api'
56
import { ExecaChildProcess, execaNode } from 'execa'
67
import { gte } from 'semver'
78

9+
import { FeatureFlags } from '../core/feature_flags.js'
810
import { addErrorInfo } from '../error/info.js'
911
import { NetlifyConfig } from '../index.js'
1012
import { BufferedLogs } from '../log/logger.js'
@@ -15,17 +17,19 @@ import {
1517
logOutdatedPlugins,
1618
logRuntime,
1719
} from '../log/messages/compatibility.js'
20+
import { SystemLogger } from '../plugins_core/types.js'
1821
import { isTrustedPlugin } from '../steps/plugin.js'
1922
import { measureDuration } from '../time/main.js'
2023

2124
import { callChild, getEventFromChild } from './ipc.js'
2225
import { PluginsOptions } from './node_version.js'
2326
import { getSpawnInfo } from './options.js'
27+
import { captureStandardError } from './system_log.js'
2428

2529
export type ChildProcess = ExecaChildProcess<string>
2630

2731
const CHILD_MAIN_FILE = fileURLToPath(new URL('child/main.js', import.meta.url))
28-
32+
const pSetTimeout = promisify(setTimeout)
2933
const require = createRequire(import.meta.url)
3034

3135
// Start child processes used by all plugins
@@ -34,7 +38,17 @@ const require = createRequire(import.meta.url)
3438
// (for both security and safety reasons)
3539
// - logs can be buffered which allows manipulating them for log shipping,
3640
// transforming and parallel plugins
37-
const tStartPlugins = async function ({ pluginsOptions, buildDir, childEnv, logs, debug, quiet, systemLogFile }) {
41+
const tStartPlugins = async function ({
42+
pluginsOptions,
43+
buildDir,
44+
childEnv,
45+
logs,
46+
debug,
47+
quiet,
48+
systemLog,
49+
systemLogFile,
50+
featureFlags,
51+
}) {
3852
if (!quiet) {
3953
logRuntime(logs, pluginsOptions)
4054
logLoadingPlugins(logs, pluginsOptions, debug)
@@ -46,7 +60,17 @@ const tStartPlugins = async function ({ pluginsOptions, buildDir, childEnv, logs
4660

4761
const childProcesses = await Promise.all(
4862
pluginsOptions.map(({ pluginDir, nodePath, nodeVersion, pluginPackageJson }) =>
49-
startPlugin({ pluginDir, nodePath, nodeVersion, buildDir, childEnv, systemLogFile, pluginPackageJson }),
63+
startPlugin({
64+
pluginDir,
65+
nodePath,
66+
nodeVersion,
67+
buildDir,
68+
childEnv,
69+
systemLog,
70+
systemLogFile,
71+
pluginPackageJson,
72+
featureFlags,
73+
}),
5074
),
5175
)
5276
return { childProcesses }
@@ -60,8 +84,10 @@ const startPlugin = async function ({
6084
nodePath,
6185
buildDir,
6286
childEnv,
87+
systemLog,
6388
systemLogFile,
6489
pluginPackageJson,
90+
featureFlags,
6591
}: {
6692
nodeVersion: string
6793
nodePath: string
@@ -70,7 +96,9 @@ const startPlugin = async function ({
7096
buildDir: string
7197
childEnv: Record<string, string>
7298
pluginPackageJson: Record<string, string>
99+
systemLog: SystemLogger
73100
systemLogFile: number
101+
featureFlags: FeatureFlags
74102
}) {
75103
const ctx = trace.getActiveSpan()?.spanContext()
76104

@@ -117,14 +145,23 @@ const startPlugin = async function ({
117145
? ['pipe', 'pipe', 'pipe', 'ipc', systemLogFile]
118146
: undefined,
119147
})
148+
const readyEvent = 'ready'
149+
const cleanup = captureStandardError(childProcess, systemLog, readyEvent, featureFlags)
120150

121151
try {
122-
await getEventFromChild(childProcess, 'ready')
152+
await getEventFromChild(childProcess, readyEvent)
123153
return { childProcess }
124154
} catch (error) {
155+
if (featureFlags.netlify_build_plugin_system_log) {
156+
// Wait for stderr to be flushed.
157+
await pSetTimeout(0)
158+
}
159+
125160
const spawnInfo = getSpawnInfo()
126161
addErrorInfo(error, spawnInfo)
127162
throw error
163+
} finally {
164+
cleanup()
128165
}
129166
}
130167

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { FeatureFlags } from '../core/feature_flags.js'
2+
import type { SystemLogger } from '../plugins_core/types.js'
3+
4+
import type { ChildProcess } from './spawn.js'
5+
6+
export const captureStandardError = (
7+
childProcess: ChildProcess,
8+
systemLog: SystemLogger,
9+
eventName: string,
10+
featureFlags: FeatureFlags,
11+
) => {
12+
if (!featureFlags.netlify_build_plugin_system_log) {
13+
return () => {
14+
// no-op
15+
}
16+
}
17+
18+
let receivedChunks = false
19+
20+
const listener = (chunk: Buffer) => {
21+
if (!receivedChunks) {
22+
receivedChunks = true
23+
24+
systemLog(`Plugin failed to initialize during the "${eventName}" phase`)
25+
}
26+
27+
systemLog(chunk.toString().trimEnd())
28+
}
29+
30+
childProcess.stderr?.on('data', listener)
31+
32+
const cleanup = () => {
33+
childProcess.stderr?.removeListener('data', listener)
34+
}
35+
36+
return cleanup
37+
}
Binary file not shown.

packages/build/tests/plugins/tests.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,8 +360,10 @@ test('Plugin errors that occur during the loading phase are piped to system logs
360360

361361
if (platform !== 'win32') {
362362
const systemLog = await fs.readFile(systemLogFile.path, { encoding: 'utf8' })
363+
const lines = systemLog.split('\n')
363364

364-
t.is(systemLog.trim(), 'An error message thrown by Node.js')
365+
t.is(lines[0].trim(), 'Plugin failed to initialize during the "load" phase')
366+
t.is(lines[1].trim(), 'An error message thrown by Node.js')
365367
}
366368

367369
t.snapshot(normalizeOutput(output))

0 commit comments

Comments
 (0)