Skip to content

Commit 2e4f08c

Browse files
authored
fix(build): don't block SSG on telemetry flush, add persistence spans to trace-build (#91335)
### What? Two fixes for the Turbopack build tracing introduced in #90397: 1. **Don't block SSG on Turbopack shutdown**: `workerMain()` no longer awaits the shutdown promise before returning. Trace event collection is deferred to `waitForShutdown()`, which the parent process awaits *after* SSG completes. This allows static generation and Turbopack persistence/cache-flush to run in parallel. 2. **Add persistence spans to `trace-build` allowlist**: `turbopack-build-events`, `turbopack-persistence`, and `turbopack-compaction` are now included in the `to-json-build.ts` allowlist so they appear in `.next/trace-build`. ### Why? - The `await shutdownPromise` in `workerMain()` was too eager — it prevented the caller from acknowledging the build as complete and starting SSG until Turbopack persistence finished flushing to disk. - The persistence/compaction spans emitted by Rust (`turbopack-persistence`, `turbopack-compaction`) were not in the `to-json-build.ts` allowlist, so they were silently filtered out of `.next/trace-build`. ### How? **`impl.ts` (worker)**: - Removed `await shutdownPromise` from `workerMain()` — it now returns build results immediately - `waitForShutdown()` now returns `{ debugTraceEvents }` after awaiting shutdown, so trace events are collected only after all compilation events (including persistence spans) have been processed **`index.ts` (parent)**: - Moved `recordTraceEvents(debugTraceEvents)` from the `workerMain` result handler into the `shutdownPromise` `.then()` chain, so events are replayed into the parent reporter after shutdown completes **`to-json-build.ts`**: - Added `turbopack-build-events`, `turbopack-persistence`, `turbopack-compaction` to the allowlist **Test updates**: - Enabled `turbopackFileSystemCacheForBuild: true` in the trace-build test fixture - Updated the Turbopack inline snapshot to include `turbopack-build-events`
1 parent 136b77e commit 2e4f08c

File tree

13 files changed

+172
-223
lines changed

13 files changed

+172
-223
lines changed

crates/next-napi-bindings/src/next_api/project.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2152,6 +2152,16 @@ pub fn project_compilation_events_subscribe(
21522152
break;
21532153
}
21542154
}
2155+
// Signal the JS side that the subscription has ended (e.g. after
2156+
// project shutdown drops all senders). This allows the async
2157+
// iterator to exit promptly instead of hanging forever.
2158+
let _ = tsfn.call(
2159+
Err(napi::Error::new(
2160+
Status::Cancelled,
2161+
"compilation events subscription closed",
2162+
)),
2163+
ThreadsafeFunctionCallMode::Blocking,
2164+
);
21552165
});
21562166

21572167
Ok(())

packages/next/src/build/index.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,16 +1009,6 @@ export default async function build(
10091009
// Reading the config can modify environment variables that influence the bundler selection.
10101010
bundler = finalizeBundlerFromConfig(bundler)
10111011
nextBuildSpan.setAttribute('bundler', getBundlerForTelemetry(bundler))
1012-
// Install the native bindings early so we can have synchronous access later.
1013-
await installBindings(config.experimental?.useWasmBinary)
1014-
1015-
// Set up code frame renderer for error formatting
1016-
const { installCodeFrameSupport } =
1017-
require('../server/lib/install-code-frame') as typeof import('../server/lib/install-code-frame')
1018-
installCodeFrameSupport()
1019-
1020-
process.env.NEXT_DEPLOYMENT_ID = config.deploymentId || ''
1021-
NextBuildContext.config = config
10221012

10231013
let configOutDir = 'out'
10241014
if (hasCustomExportOutput(config)) {
@@ -1030,6 +1020,26 @@ export default async function build(
10301020
setGlobal('phase', PHASE_PRODUCTION_BUILD)
10311021
setGlobal('distDir', distDir)
10321022

1023+
// Check for build cache before initializing telemetry, because the
1024+
// Telemetry constructor creates the cache directory in CI environments.
1025+
const cacheDir = getCacheDir(distDir)
1026+
1027+
// Initialize telemetry before installBindings so that SWC load failure
1028+
// events are captured if native bindings fail to load.
1029+
const telemetry = new Telemetry({ distDir })
1030+
setGlobal('telemetry', telemetry)
1031+
1032+
// Install the native bindings early so we can have synchronous access later.
1033+
await installBindings(config.experimental?.useWasmBinary)
1034+
1035+
// Set up code frame renderer for error formatting
1036+
const { installCodeFrameSupport } =
1037+
require('../server/lib/install-code-frame') as typeof import('../server/lib/install-code-frame')
1038+
installCodeFrameSupport()
1039+
1040+
process.env.NEXT_DEPLOYMENT_ID = config.deploymentId || ''
1041+
NextBuildContext.config = config
1042+
10331043
const buildId = await getBuildId(
10341044
isGenerateMode,
10351045
distDir,
@@ -1117,12 +1127,6 @@ export default async function build(
11171127
)
11181128
}
11191129

1120-
const cacheDir = getCacheDir(distDir)
1121-
1122-
const telemetry = new Telemetry({ distDir })
1123-
1124-
setGlobal('telemetry', telemetry)
1125-
11261130
const publicDir = path.join(dir, 'public')
11271131
const { pagesDir, appDir } = findPagesDir(dir)
11281132

packages/next/src/build/turbopack-build/impl.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -146,15 +146,17 @@ export async function turbopackBuild(): Promise<{
146146
}
147147
: undefined
148148
)
149-
try {
150-
const buildEventsSpan = trace('turbopack-build-events')
151-
// Stop immediately: this span is only used as a parent for
152-
// manualTraceChild calls which carry their own timestamps.
153-
buildEventsSpan.stop()
154-
backgroundLogCompilationEvents(project, {
155-
parentSpan: buildEventsSpan,
156-
})
149+
const buildEventsSpan = trace('turbopack-build-events')
150+
// Stop immediately: this span is only used as a parent for
151+
// manualTraceChild calls which carry their own timestamps.
152+
buildEventsSpan.stop()
153+
const shutdownController = new AbortController()
154+
const compilationEvents = backgroundLogCompilationEvents(project, {
155+
parentSpan: buildEventsSpan,
156+
signal: shutdownController.signal,
157+
})
157158

159+
try {
158160
// Write an empty file in a known location to signal this was built with Turbopack
159161
await fs.writeFile(path.join(distDir, 'turbopack'), '')
160162

@@ -264,7 +266,14 @@ export async function turbopackBuild(): Promise<{
264266
await project.writeAnalyzeData(appDirOnly)
265267
}
266268

267-
const shutdownPromise = project.shutdown()
269+
// Shutdown may trigger final compilation events (e.g. persistence,
270+
// compaction trace spans). This is the last chance to capture them.
271+
// After shutdown resolves we abort the signal to close the iterator
272+
// and drain any remaining buffered events.
273+
const shutdownPromise = project.shutdown().then(() => {
274+
shutdownController.abort()
275+
return compilationEvents.catch(() => {})
276+
})
268277

269278
const time = process.hrtime(startTime)
270279
return {
@@ -274,6 +283,8 @@ export async function turbopackBuild(): Promise<{
274283
}
275284
} catch (err) {
276285
await project.shutdown()
286+
shutdownController.abort()
287+
await compilationEvents.catch(() => {})
277288
throw err
278289
}
279290
}
@@ -283,9 +294,7 @@ export async function workerMain(workerData: {
283294
buildContext: typeof NextBuildContext
284295
traceState: TraceState & { shouldSaveTraceEvents: boolean }
285296
}): Promise<
286-
Omit<Awaited<ReturnType<typeof turbopackBuild>>, 'shutdownPromise'> & {
287-
debugTraceEvents?: ReturnType<typeof getTraceEvents>
288-
}
297+
Omit<Awaited<ReturnType<typeof turbopackBuild>>, 'shutdownPromise'>
289298
> {
290299
// setup new build context from the serialized data passed from the parent
291300
Object.assign(NextBuildContext, workerData.buildContext)
@@ -324,14 +333,9 @@ export async function workerMain(workerData: {
324333
duration,
325334
} = await turbopackBuild()
326335
shutdownPromise = resultShutdownPromise
327-
// Wait for shutdown to complete so that all compilation events
328-
// (e.g. persistence trace spans) have been processed before we
329-
// collect the saved trace events.
330-
await shutdownPromise
331336
return {
332337
buildTraceContext,
333338
duration,
334-
debugTraceEvents: getTraceEvents(),
335339
}
336340
} finally {
337341
// Always flush telemetry before worker exits (waits for async operations like setTimeout in debug mode)
@@ -341,8 +345,13 @@ export async function workerMain(workerData: {
341345
}
342346
}
343347

344-
export async function waitForShutdown(): Promise<void> {
348+
export async function waitForShutdown(): Promise<{
349+
debugTraceEvents?: ReturnType<typeof getTraceEvents>
350+
}> {
345351
if (shutdownPromise) {
346352
await shutdownPromise
347353
}
354+
// Collect trace events after shutdown completes so that all compilation
355+
// events (e.g. persistence trace spans) have been processed.
356+
return { debugTraceEvents: getTraceEvents() }
348357
}

packages/next/src/build/turbopack-build/index.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,24 +35,22 @@ async function turbopackBuildWithWorker(): ReturnType<
3535
config: _config,
3636
...prunedBuildContext
3737
} = NextBuildContext
38-
const { buildTraceContext, duration, debugTraceEvents } =
39-
await worker.workerMain({
40-
buildContext: prunedBuildContext,
41-
traceState: {
42-
...exportTraceState(),
43-
defaultParentSpanId: nextBuildSpan.getId(),
44-
shouldSaveTraceEvents: true,
45-
},
46-
})
47-
48-
if (debugTraceEvents) {
49-
recordTraceEvents(debugTraceEvents)
50-
}
38+
const { buildTraceContext, duration } = await worker.workerMain({
39+
buildContext: prunedBuildContext,
40+
traceState: {
41+
...exportTraceState(),
42+
defaultParentSpanId: nextBuildSpan.getId(),
43+
shouldSaveTraceEvents: true,
44+
},
45+
})
5146

5247
return {
5348
// destroy worker when Turbopack has shutdown so it's not sticking around using memory
5449
// We need to wait for shutdown to make sure filesystem cache is flushed
55-
shutdownPromise: worker.waitForShutdown().then(() => {
50+
shutdownPromise: worker.waitForShutdown().then(({ debugTraceEvents }) => {
51+
if (debugTraceEvents) {
52+
recordTraceEvents(debugTraceEvents)
53+
}
5654
worker.end()
5755
}),
5856
buildTraceContext,

packages/next/src/shared/lib/turbopack/compilation-events.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ export function msToNs(ms: number): bigint {
1515
* When `parentSpan` is provided, `TraceEvent` compilation events are recorded
1616
* as trace spans in the `.next/trace` file.
1717
*
18-
* The `signal` argument is partially implemented. The abort may not happen until the next
19-
* compilation event arrives.
18+
* Returns a promise that resolves when the subscription ends. Abort the
19+
* `signal` to close the underlying async iterator and settle the promise
20+
* promptly. The iterator also closes automatically when the Rust side
21+
* drops the subscription (e.g. after project shutdown).
2022
*/
2123
export function backgroundLogCompilationEvents(
2224
project: Project,
@@ -26,12 +28,16 @@ export function backgroundLogCompilationEvents(
2628
parentSpan,
2729
}: { eventTypes?: string[]; signal?: AbortSignal; parentSpan?: Span } = {}
2830
): Promise<void> {
29-
const promise = (async function () {
30-
for await (const event of project.compilationEventsSubscribe(eventTypes)) {
31-
if (signal?.aborted) {
32-
return
33-
}
31+
const iterator = project.compilationEventsSubscribe(eventTypes)
3432

33+
// Close the iterator as soon as the signal fires so the for-await loop
34+
// exits without waiting for the next compilation event.
35+
signal?.addEventListener('abort', () => iterator.return?.(undefined as any), {
36+
once: true,
37+
})
38+
39+
const promise = (async function () {
40+
for await (const event of iterator) {
3541
// Record TraceEvent compilation events as trace spans in .next/trace.
3642
if (parentSpan && event.typeName === 'TraceEvent' && event.eventJson) {
3743
try {

packages/next/src/trace/report/to-json-build.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ const allowlistedEvents = new Set([
1515
'adapter-handle-build-complete',
1616
'output-standalone',
1717
'telemetry-flush',
18+
'turbopack-build-events',
19+
'turbopack-persistence',
20+
'turbopack-compaction',
1821
])
1922

2023
export default createJsonReporter({

test/development/app-dir/enabled-features-trace/enabled-features-trace.test.ts

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,46 +3,7 @@ import { join } from 'path'
33
import { existsSync, readFileSync } from 'fs'
44
import { createServer } from 'http'
55
import { spawn } from 'child_process'
6-
import type { TraceEvent } from 'next/dist/trace'
7-
8-
interface TraceStructure {
9-
events: TraceEvent[]
10-
eventsByName: Map<string, TraceEvent[]>
11-
eventsById: Map<string, TraceEvent>
12-
}
13-
14-
function parseTraceFile(tracePath: string): TraceStructure {
15-
const traceContent = readFileSync(tracePath, 'utf8')
16-
const traceLines = traceContent
17-
.trim()
18-
.split('\n')
19-
.filter((line) => line.trim())
20-
21-
const allEvents: TraceEvent[] = []
22-
23-
for (const line of traceLines) {
24-
const events = JSON.parse(line) as TraceEvent[]
25-
allEvents.push(...events)
26-
}
27-
28-
const eventsByName = new Map<string, TraceEvent[]>()
29-
const eventsById = new Map<string, TraceEvent>()
30-
31-
// Index all events
32-
for (const event of allEvents) {
33-
if (!eventsByName.has(event.name)) {
34-
eventsByName.set(event.name, [])
35-
}
36-
eventsByName.get(event.name)!.push(event)
37-
eventsById.set(event.id.toString(), event)
38-
}
39-
40-
return {
41-
events: allEvents,
42-
eventsByName,
43-
eventsById,
44-
}
45-
}
6+
import { parseTraceFile } from '../../../lib/parse-trace-file'
467

478
describe('enabled features in trace', () => {
489
const { next, isNextDev } = nextTestSetup({

test/development/app-dir/hmr-trace-timing/hmr-trace-timing.test.ts

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,7 @@
11
import { nextTestSetup } from 'e2e-utils'
22
import { join } from 'path'
3-
import { existsSync, readFileSync } from 'fs'
4-
import type { TraceEvent } from 'next/dist/trace'
5-
6-
interface TraceStructure {
7-
events: TraceEvent[]
8-
eventsByName: Map<string, TraceEvent[]>
9-
eventsById: Map<string, TraceEvent>
10-
}
11-
12-
function parseTraceFile(tracePath: string): TraceStructure {
13-
const traceContent = readFileSync(tracePath, 'utf8')
14-
const traceLines = traceContent
15-
.trim()
16-
.split('\n')
17-
.filter((line) => line.trim())
18-
19-
const allEvents: TraceEvent[] = []
20-
21-
for (const line of traceLines) {
22-
const events = JSON.parse(line) as TraceEvent[]
23-
allEvents.push(...events)
24-
}
25-
26-
const eventsByName = new Map<string, TraceEvent[]>()
27-
const eventsById = new Map<string, TraceEvent>()
28-
29-
// Index all events
30-
for (const event of allEvents) {
31-
if (!eventsByName.has(event.name)) {
32-
eventsByName.set(event.name, [])
33-
}
34-
eventsByName.get(event.name)!.push(event)
35-
eventsById.set(event.id.toString(), event)
36-
}
37-
38-
return {
39-
events: allEvents,
40-
eventsByName,
41-
eventsById,
42-
}
43-
}
3+
import { existsSync } from 'fs'
4+
import { parseTraceFile } from '../../../lib/parse-trace-file'
445

456
describe('render-path tracing', () => {
467
const { next, isNextDev } = nextTestSetup({
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
/**
22
* @type {import('next').NextConfig}
33
*/
4-
const nextConfig = {}
4+
const nextConfig = {
5+
experimental: {
6+
turbopackFileSystemCacheForBuild: true,
7+
},
8+
}
59

610
module.exports = nextConfig

0 commit comments

Comments
 (0)