Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: capture plugin errors during ready phase #5681

Merged
merged 1 commit into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/build/src/core/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@ const initAndRunBuild = async function ({
timers: timersA,
featureFlags,
quiet,
systemLog,
systemLogFile,
})

Expand Down
24 changes: 3 additions & 21 deletions packages/build/src/plugins/load.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { addPluginLoadErrorStatus } from '../status/load_error.js'
import { measureDuration } from '../time/main.js'

import { callChild } from './ipc.js'
import { captureStandardError } from './system_log.js'

const pSetTimeout = promisify(setTimeout)

Expand Down Expand Up @@ -79,20 +80,7 @@ const loadPlugin = async function (
) {
const { childProcess } = childProcesses[index]
const loadEvent = 'load'

// A buffer for any data piped into the child process' stderr. We'll pipe
// this to system logs if we fail to load the plugin.
const bufferedStdErr = []

let bufferedStdListener

if (featureFlags.netlify_build_plugin_system_log && childProcess.stderr) {
bufferedStdListener = (data) => {
bufferedStdErr.push(data.toString().trimEnd())
}

childProcess.stderr.on('data', bufferedStdListener)
}
const cleanup = captureStandardError(childProcess, systemLog, loadEvent, featureFlags)

try {
const { events } = await callChild({
Expand All @@ -115,10 +103,6 @@ const loadPlugin = async function (
if (featureFlags.netlify_build_plugin_system_log) {
// Wait for stderr to be flushed.
await pSetTimeout(0)

bufferedStdErr.forEach((line) => {
systemLog(line)
})
}

addErrorInfo(error, {
Expand All @@ -128,8 +112,6 @@ const loadPlugin = async function (
addPluginLoadErrorStatus({ error, packageName, version, debug })
throw error
} finally {
if (bufferedStdListener) {
childProcess.stderr.removeListener('data', bufferedStdListener)
}
cleanup()
}
}
45 changes: 41 additions & 4 deletions packages/build/src/plugins/spawn.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { createRequire } from 'module'
import { fileURLToPath, pathToFileURL } from 'url'
import { promisify } from 'util'

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

import { FeatureFlags } from '../core/feature_flags.js'
import { addErrorInfo } from '../error/info.js'
import { NetlifyConfig } from '../index.js'
import { BufferedLogs } from '../log/logger.js'
Expand All @@ -15,17 +17,19 @@ import {
logOutdatedPlugins,
logRuntime,
} from '../log/messages/compatibility.js'
import { SystemLogger } from '../plugins_core/types.js'
import { isTrustedPlugin } from '../steps/plugin.js'
import { measureDuration } from '../time/main.js'

import { callChild, getEventFromChild } from './ipc.js'
import { PluginsOptions } from './node_version.js'
import { getSpawnInfo } from './options.js'
import { captureStandardError } from './system_log.js'

export type ChildProcess = ExecaChildProcess<string>

const CHILD_MAIN_FILE = fileURLToPath(new URL('child/main.js', import.meta.url))

const pSetTimeout = promisify(setTimeout)
const require = createRequire(import.meta.url)

// Start child processes used by all plugins
Expand All @@ -34,7 +38,17 @@ const require = createRequire(import.meta.url)
// (for both security and safety reasons)
// - logs can be buffered which allows manipulating them for log shipping,
// transforming and parallel plugins
const tStartPlugins = async function ({ pluginsOptions, buildDir, childEnv, logs, debug, quiet, systemLogFile }) {
const tStartPlugins = async function ({
pluginsOptions,
buildDir,
childEnv,
logs,
debug,
quiet,
systemLog,
systemLogFile,
featureFlags,
}) {
if (!quiet) {
logRuntime(logs, pluginsOptions)
logLoadingPlugins(logs, pluginsOptions, debug)
Expand All @@ -46,7 +60,17 @@ const tStartPlugins = async function ({ pluginsOptions, buildDir, childEnv, logs

const childProcesses = await Promise.all(
pluginsOptions.map(({ pluginDir, nodePath, nodeVersion, pluginPackageJson }) =>
startPlugin({ pluginDir, nodePath, nodeVersion, buildDir, childEnv, systemLogFile, pluginPackageJson }),
startPlugin({
pluginDir,
nodePath,
nodeVersion,
buildDir,
childEnv,
systemLog,
systemLogFile,
pluginPackageJson,
featureFlags,
}),
),
)
return { childProcesses }
Expand All @@ -60,8 +84,10 @@ const startPlugin = async function ({
nodePath,
buildDir,
childEnv,
systemLog,
systemLogFile,
pluginPackageJson,
featureFlags,
}: {
nodeVersion: string
nodePath: string
Expand All @@ -70,7 +96,9 @@ const startPlugin = async function ({
buildDir: string
childEnv: Record<string, string>
pluginPackageJson: Record<string, string>
systemLog: SystemLogger
systemLogFile: number
featureFlags: FeatureFlags
}) {
const ctx = trace.getActiveSpan()?.spanContext()

Expand Down Expand Up @@ -117,14 +145,23 @@ const startPlugin = async function ({
? ['pipe', 'pipe', 'pipe', 'ipc', systemLogFile]
: undefined,
})
const readyEvent = 'ready'
const cleanup = captureStandardError(childProcess, systemLog, readyEvent, featureFlags)

try {
await getEventFromChild(childProcess, 'ready')
await getEventFromChild(childProcess, readyEvent)
return { childProcess }
} catch (error) {
if (featureFlags.netlify_build_plugin_system_log) {
// Wait for stderr to be flushed.
await pSetTimeout(0)
Copy link
Contributor

@Skn0tt Skn0tt May 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would setImmediate also work here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would, yes. Just trying to stay as conservative as possible with this change.

}

const spawnInfo = getSpawnInfo()
addErrorInfo(error, spawnInfo)
throw error
} finally {
cleanup()
}
}

Expand Down
37 changes: 37 additions & 0 deletions packages/build/src/plugins/system_log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { FeatureFlags } from '../core/feature_flags.js'
import type { SystemLogger } from '../plugins_core/types.js'

import type { ChildProcess } from './spawn.js'

export const captureStandardError = (
childProcess: ChildProcess,
systemLog: SystemLogger,
eventName: string,
featureFlags: FeatureFlags,
) => {
if (!featureFlags.netlify_build_plugin_system_log) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I failed to mention this on the last PR, but I think this flag should be called netlify_build_plugin_system_log_errors or similar. The current name makes it sound like it's related to #5391. Having said that, the flag is super short-lived, so it shouldn't matter too much.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I totally see that. Let's hope we can kill the flag soon.

return () => {
// no-op
}
}

let receivedChunks = false

const listener = (chunk: Buffer) => {
if (!receivedChunks) {
receivedChunks = true

systemLog(`Plugin failed to initialize during the "${eventName}" phase`)
}

systemLog(chunk.toString().trimEnd())
}

childProcess.stderr?.on('data', listener)

const cleanup = () => {
childProcess.stderr?.removeListener('data', listener)
}

return cleanup
}
Binary file modified packages/build/tests/plugins/snapshots/tests.js.snap
Binary file not shown.
4 changes: 3 additions & 1 deletion packages/build/tests/plugins/tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -360,8 +360,10 @@ test('Plugin errors that occur during the loading phase are piped to system logs

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

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

t.snapshot(normalizeOutput(output))
Expand Down
Loading