From 43c10d83db060041ac3e76407f001dbbe94ad7f0 Mon Sep 17 00:00:00 2001 From: arlo Date: Wed, 4 Nov 2026 13:52:01 +0800 Subject: [PATCH 1/2] perf(rolldown): speed up reopening session logs --- .../__tests__/reader-index-cache.test.ts | 106 ++++++++ .../src/node/rolldown/events-reader.ts | 9 +- .../rolldown/src/node/rolldown/log-cache.ts | 129 +++++----- .../src/node/rolldown/reader-index-codec.ts | 228 ++++++++++++++++++ 4 files changed, 402 insertions(+), 70 deletions(-) create mode 100644 packages/rolldown/src/node/rolldown/__tests__/reader-index-cache.test.ts create mode 100644 packages/rolldown/src/node/rolldown/reader-index-codec.ts diff --git a/packages/rolldown/src/node/rolldown/__tests__/reader-index-cache.test.ts b/packages/rolldown/src/node/rolldown/__tests__/reader-index-cache.test.ts new file mode 100644 index 00000000..b9fef384 --- /dev/null +++ b/packages/rolldown/src/node/rolldown/__tests__/reader-index-cache.test.ts @@ -0,0 +1,106 @@ +import type { ReaderIndex } from '../reader-index-codec' +import { Buffer } from 'node:buffer' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'pathe' +import { afterEach, describe, expect, it } from 'vitest' +import { RolldownEventsReader } from '../events-reader' +import { decodeReaderIndex, encodeReaderIndex } from '../reader-index-codec' + +const dirs: string[] = [] +afterEach(() => { + for (const dir of dirs.splice(0)) + rmSync(dir, { recursive: true, force: true }) +}) + +function createIndex(): ReaderIndex { + const location = (offset: number, length = 10) => ({ offset, length }) + return { + lastBytes: 5 * 1024 ** 3, + lastTimestamp: 1793700062071, + moduleEventIndex: new Map([ + ['/src/รค/main.ts', { + resolveIds: new Map([['r1', { start: location(0), end: location(10) }]]), + loads: new Map(), + // Offsets past 4 GiB must survive the round trip. + transforms: new Map([['t1', { start: location(4.5 * 1024 ** 3), end: location(4.5 * 1024 ** 3 + 20, 300) }]]), + }], + ]), + stringRefIndex: new Map([['ref-1', location(42, 7)]]), + pendingHookCallStarts: new Map([ + ['p1', { module: '\0virtual', location: location(1) }], + ['p2', { location: location(2) }], + ]), + assetsReadyLocations: [location(99, 3)], + } +} + +describe('reader index codec', () => { + const stat = { size: 123, mtime: 456 } + + it('round-trips every index field', async () => { + const index = createIndex() + const decoded = await decodeReaderIndex(Buffer.concat(await encodeReaderIndex(index, stat)), stat) + expect(decoded).toEqual(index) + }) + + it('rejects an index written for another log version', async () => { + const buffer = Buffer.concat(await encodeReaderIndex(createIndex(), stat)) + expect(await decodeReaderIndex(buffer, { size: 124, mtime: 456 })).toBeUndefined() + expect(await decodeReaderIndex(buffer, { size: 123, mtime: 457 })).toBeUndefined() + }) + + it('rejects truncated or padded data', async () => { + const buffer = Buffer.concat(await encodeReaderIndex(createIndex(), stat)) + expect(await decodeReaderIndex(buffer.subarray(0, buffer.length - 1), stat)).toBeUndefined() + expect(await decodeReaderIndex(Buffer.concat([buffer, Buffer.from([0])]), stat)).toBeUndefined() + }) +}) + +describe('rolldown log cache', () => { + function writeLog() { + const dir = mkdtempSync(join(tmpdir(), 'rolldown-log-cache-')) + dirs.push(dir) + const base = { build_id: 'bid_1', session_id: 'sid_1' } + const lines = [ + { timestamp: 1, action: 'BuildStart', ...base }, + { timestamp: 2, action: 'HookResolveIdCallStart', importer: null, module_request: '/src/main.ts', import_kind: 'import-statement', plugin_name: 'p', plugin_id: 0, trigger: 'automatic', call_id: 'c1', ...base }, + { timestamp: 3, action: 'HookResolveIdCallEnd', resolved_id: '/src/main.ts', is_external: false, plugin_name: 'p', plugin_id: 0, trigger: 'automatic', call_id: 'c1', ...base }, + { timestamp: 4, action: 'HookTransformCallStart', module_id: '/src/main.ts', content: 'a', plugin_name: 'p', plugin_id: 0, trigger: 'automatic', call_id: 'c2', ...base }, + { timestamp: 5, action: 'HookTransformCallEnd', module_id: '/src/main.ts', content: 'b', plugin_name: 'p', plugin_id: 0, trigger: 'automatic', call_id: 'c2', ...base }, + { timestamp: 6, action: 'BuildEnd', ...base }, + ] + const filepath = join(dir, 'logs.json') + writeFileSync(filepath, `${lines.map(line => JSON.stringify(line)).join('\n')}\n`) + return filepath + } + + const indexOf = (reader: RolldownEventsReader) => { + const state = reader as unknown as Record + return [state.moduleEventIndex, state.stringRefIndex, state.pendingHookCallStarts, state.assetsReadyLocations] + } + + it('restores the summary without loading the index, then restores the same index on a full read', async () => { + const filepath = writeLog() + const cold = RolldownEventsReader.get(filepath) + await cold.read() + expect((cold as unknown as { moduleEventIndex: Map }).moduleEventIndex.size).toBeGreaterThan(0) + const coldIndex = structuredClone(indexOf(cold)) + const coldEvents = cold.manager.eventCount + cold.dispose() + + const warm = RolldownEventsReader.get(filepath) + try { + await warm.readSummary() + expect(warm.manager.eventCount).toBe(coldEvents) + expect((warm as unknown as { moduleEventIndex: Map }).moduleEventIndex.size).toBe(0) + + await warm.read() + expect(indexOf(warm)).toEqual(coldIndex) + expect(warm.manager.eventCount).toBe(coldEvents) + } + finally { + warm.dispose() + } + }) +}) diff --git a/packages/rolldown/src/node/rolldown/events-reader.ts b/packages/rolldown/src/node/rolldown/events-reader.ts index 66a41ea4..cb1c55ac 100644 --- a/packages/rolldown/src/node/rolldown/events-reader.ts +++ b/packages/rolldown/src/node/rolldown/events-reader.ts @@ -15,6 +15,7 @@ const MAX_READERS = 32 const MAX_MODULE_METRICS_CACHE = 32 const MAX_MODULE_METRICS_CACHE_BYTES = 64 * 1024 * 1024 const READ_STREAM_HIGH_WATER_MARK = 1024 * 1024 +const PARSE_SLICE_MS = 8 const LINE_FEED = '\n'.charCodeAt(0) const CARRIAGE_RETURN = '\r'.charCodeAt(0) const ACTION_PREVIEW_BYTES = 512 @@ -341,7 +342,7 @@ export class RolldownEventsReader { if (restoredSummary) { this.clearModuleMetricsCache() this.pendingModuleMetrics.clear() - this.summaryOnly = restoredSummary !== 'complete' + this.summaryOnly = true this.packageSummaryOnly = false this.assetsHydrated = false return @@ -455,6 +456,7 @@ export class RolldownEventsReader { lineStartOffset = nextLineStartOffset } + let sliceStart = performance.now() for await (const chunk of stream) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) let segmentStart = 0 @@ -482,6 +484,11 @@ export class RolldownEventsReader { } await processLine(line, location) } + // Large logs take seconds to parse; yield so the server keeps answering meanwhile. + if (performance.now() - sliceStart > PARSE_SLICE_MS) { + await new Promise(resolve => setImmediate(resolve)) + sliceStart = performance.now() + } segmentStart = newline + 1 resetLine(absoluteOffset + segmentStart) diff --git a/packages/rolldown/src/node/rolldown/log-cache.ts b/packages/rolldown/src/node/rolldown/log-cache.ts index f4efc850..defdb9ab 100644 --- a/packages/rolldown/src/node/rolldown/log-cache.ts +++ b/packages/rolldown/src/node/rolldown/log-cache.ts @@ -4,14 +4,15 @@ import type { ModuleBuildMetrics, PluginBuildMetrics } from '../../shared/types' import type { RolldownEventsManager } from './events-manager' import fs from 'node:fs/promises' import { basename, dirname, join } from 'pathe' +import { decodeReaderIndex, encodeReaderIndex } from './reader-index-codec' export const ROLLDOWN_LOG_CACHE_DIR = '.cache' export const ROLLDOWN_LOG_SESSION_SUMMARY_CACHE_FILENAME = 'session-summary.json' -export const ROLLDOWN_LOG_READER_INDEX_CACHE_FILENAME = 'reader-index.json' +export const ROLLDOWN_LOG_READER_INDEX_CACHE_FILENAME = 'reader-index.bin' export const ROLLDOWN_LOG_PACKAGE_SUMMARY_CACHE_FILENAME = 'package-summary.json' export const ROLLDOWN_LOG_PLUGIN_SUMMARY_CACHE_FILENAME = 'plugin-summary.json' export const ROLLDOWN_LOG_MODULE_METRICS_SUMMARY_CACHE_FILENAME = 'module-metrics-summary.json' -const ROLLDOWN_LOG_CACHE_VERSION = 2 +const ROLLDOWN_LOG_CACHE_VERSION = 3 type ManagerSnapshot = ReturnType @@ -91,21 +92,6 @@ interface RolldownPluginSummaryCacheSnapshot { plugins: Array<[number, PluginBuildMetricsSummary]> } -interface RolldownReaderIndexCacheSnapshot { - reader: { - lastBytes: number - lastTimestamp: number - moduleEventIndex: Array<[string, { - resolveIds: Array<[string, IndexedHookCall]> - loads: Array<[string, IndexedHookCall]> - transforms: Array<[string, IndexedHookCall]> - }]> - stringRefIndex: Array<[string, LineLocation]> - pendingHookCallStarts: Array<[string, PendingHookCallStart]> - assetsReadyLocations: LineLocation[] - } -} - interface RolldownPackageSummaryCacheSnapshot { packageSummaryTimestamp: number manager: ManagerSnapshot @@ -167,6 +153,46 @@ class RolldownLogCacheFile { } } +class RolldownReaderIndexCacheFile { + private constructor( + readonly filepath: string, + ) { + } + + static forLogFile(logFilepath: string) { + if (basename(logFilepath) !== 'logs.json') + return undefined + + return new RolldownReaderIndexCacheFile(join(dirname(logFilepath), ROLLDOWN_LOG_CACHE_DIR, ROLLDOWN_LOG_READER_INDEX_CACHE_FILENAME)) + } + + async read(stat: Stats) { + try { + return await decodeReaderIndex(await fs.readFile(this.filepath), toLogStat(stat)) + } + catch { + return undefined + } + } + + async write(stat: Stats, state: RolldownLogCacheState) { + try { + const chunks = await encodeReaderIndex(state, toLogStat(stat)) + const temporaryFilepath = `${this.filepath}.tmp` + await fs.mkdir(dirname(this.filepath), { recursive: true }) + await fs.writeFile(temporaryFilepath, chunks) + await fs.rename(temporaryFilepath, this.filepath) + } + catch { + // The cache is best-effort. The reader can always rebuild from logs.json. + } + } +} + +function toLogStat(stat: Stats) { + return { size: stat.size, mtime: stat.mtime.getTime() } +} + function createMetricSummary(): PluginMetricSummary { return { count: 0, @@ -297,7 +323,7 @@ export class RolldownLogCache { private sessionSummaryCache: RolldownLogCacheFile | undefined private moduleMetricsSummaryCache: RolldownLogCacheFile | undefined private pluginSummaryCache: RolldownLogCacheFile | undefined - private readerIndexCache: RolldownLogCacheFile | undefined + private readerIndexCache: RolldownReaderIndexCacheFile | undefined private packageSummaryCache: RolldownLogCacheFile | undefined private completeSessionWriteAttempted = false private summaryWriteAttempted = false @@ -308,7 +334,7 @@ export class RolldownLogCache { this.sessionSummaryCache = RolldownLogCacheFile.forLogFile(logFilepath, ROLLDOWN_LOG_SESSION_SUMMARY_CACHE_FILENAME) this.moduleMetricsSummaryCache = RolldownLogCacheFile.forLogFile(logFilepath, ROLLDOWN_LOG_MODULE_METRICS_SUMMARY_CACHE_FILENAME) this.pluginSummaryCache = RolldownLogCacheFile.forLogFile(logFilepath, ROLLDOWN_LOG_PLUGIN_SUMMARY_CACHE_FILENAME) - this.readerIndexCache = RolldownLogCacheFile.forLogFile(logFilepath, ROLLDOWN_LOG_READER_INDEX_CACHE_FILENAME) + this.readerIndexCache = RolldownReaderIndexCacheFile.forLogFile(logFilepath) } this.packageSummaryCache = RolldownLogCacheFile.forLogFile(logFilepath, ROLLDOWN_LOG_PACKAGE_SUMMARY_CACHE_FILENAME) } @@ -351,11 +377,14 @@ export class RolldownLogCache { if (!sessionSummaryCache || !moduleMetricsCache || !pluginSummaryCacheExists || !indexCache) return false - if (sessionSummaryCache.reader.lastBytes !== stat.size || indexCache.reader.lastBytes !== stat.size) + if (sessionSummaryCache.reader.lastBytes !== stat.size || indexCache.lastBytes !== stat.size) return false this.restoreSessionSnapshot(sessionSummaryCache, state, moduleMetricsCache) - this.restoreReaderIndexSnapshot(indexCache, state) + state.moduleEventIndex = indexCache.moduleEventIndex + state.stringRefIndex = indexCache.stringRefIndex + state.pendingHookCallStarts = indexCache.pendingHookCallStarts + state.assetsReadyLocations = indexCache.assetsReadyLocations this.completeSessionWriteAttempted = true this.summaryWriteAttempted = true return true @@ -365,27 +394,15 @@ export class RolldownLogCache { if (state.lastBytes || state.manager.eventCount) return false - const [sessionSummaryCache, moduleMetricsCache, _pluginSummaryCache, indexCache] = await Promise.all([ - this.sessionSummaryCache?.read(stat), - this.moduleMetricsSummaryCache?.read(stat), - this.pluginSummaryCache?.read(stat), - this.readerIndexCache?.read(stat), - ]) - if (!sessionSummaryCache) + // The summary only needs the session summary; the index and metrics are loaded on a full read. + const sessionSummaryCache = await this.sessionSummaryCache?.read(stat) + if (!sessionSummaryCache || sessionSummaryCache.reader.lastBytes !== stat.size) return false - if (sessionSummaryCache.reader.lastBytes !== stat.size) - return false - - this.restoreSessionSnapshot(sessionSummaryCache, state, moduleMetricsCache) - const hasCompleteCache = !!moduleMetricsCache && !!_pluginSummaryCache && !!indexCache - if (indexCache?.reader.lastBytes === stat.size) - this.restoreReaderIndexSnapshot(indexCache, state) - else - this.clearReaderIndexState(state) + this.restoreSessionSnapshot(sessionSummaryCache, state) + this.clearReaderIndexState(state) this.summaryWriteAttempted = true - this.completeSessionWriteAttempted = hasCompleteCache - return hasCompleteCache ? 'complete' : 'summary' + return 'summary' as const } async restorePackageSummary(stat: Stats, state: RolldownLogCacheState) { @@ -416,17 +433,19 @@ export class RolldownLogCache { this.sessionSummaryCache?.write(stat, this.createSessionSummarySnapshot(state)), this.moduleMetricsSummaryCache?.write(stat, this.createModuleMetricsSummarySnapshot(state)), this.pluginSummaryCache?.write(stat, this.createPluginSummarySnapshot(state)), - this.readerIndexCache?.write(stat, this.createReaderIndexSnapshot(state)), + this.readerIndexCache?.write(stat, state), ]) } async writeSummary(stat: Stats, state: RolldownLogCacheState) { this.summaryWriteAttempted = true + // Writes the same sidecars as a complete session, so don't write them again for the full read. + this.completeSessionWriteAttempted = true await Promise.all([ this.sessionSummaryCache?.write(stat, this.createSessionSummarySnapshot(state)), this.moduleMetricsSummaryCache?.write(stat, this.createModuleMetricsSummarySnapshot(state)), this.pluginSummaryCache?.write(stat, this.createPluginSummarySnapshot(state)), - this.readerIndexCache?.write(stat, this.createReaderIndexSnapshot(state)), + this.readerIndexCache?.write(stat, state), ]) } @@ -471,23 +490,6 @@ export class RolldownLogCache { } } - private createReaderIndexSnapshot(state: RolldownLogCacheState): RolldownReaderIndexCacheSnapshot { - return { - reader: { - lastBytes: state.lastBytes, - lastTimestamp: state.lastTimestamp, - moduleEventIndex: Array.from(state.moduleEventIndex.entries()).map(([module, index]) => [module, { - resolveIds: Array.from(index.resolveIds.entries()), - loads: Array.from(index.loads.entries()), - transforms: Array.from(index.transforms.entries()), - }]), - stringRefIndex: Array.from(state.stringRefIndex.entries()), - pendingHookCallStarts: Array.from(state.pendingHookCallStarts.entries()), - assetsReadyLocations: state.assetsReadyLocations, - }, - } - } - private createPackageSummarySnapshot(state: RolldownLogCacheState): RolldownPackageSummaryCacheSnapshot { return { packageSummaryTimestamp: state.packageSummaryTimestamp, @@ -521,17 +523,6 @@ export class RolldownLogCache { }) } - private restoreReaderIndexSnapshot(snapshot: RolldownReaderIndexCacheSnapshot, state: RolldownLogCacheState) { - state.moduleEventIndex = new Map(snapshot.reader.moduleEventIndex.map(([module, item]) => [module, { - resolveIds: new Map(item.resolveIds), - loads: new Map(item.loads), - transforms: new Map(item.transforms), - }])) - state.stringRefIndex = new Map(snapshot.reader.stringRefIndex) - state.pendingHookCallStarts = new Map(snapshot.reader.pendingHookCallStarts) - state.assetsReadyLocations = snapshot.reader.assetsReadyLocations - } - private restorePackageSummarySnapshot(snapshot: RolldownPackageSummaryCacheSnapshot, state: RolldownLogCacheState) { state.manager.restore(snapshot.manager) state.packageSummaryTimestamp = snapshot.packageSummaryTimestamp diff --git a/packages/rolldown/src/node/rolldown/reader-index-codec.ts b/packages/rolldown/src/node/rolldown/reader-index-codec.ts new file mode 100644 index 00000000..99f71748 --- /dev/null +++ b/packages/rolldown/src/node/rolldown/reader-index-codec.ts @@ -0,0 +1,228 @@ +import type { IndexedHookCall, LineLocation, ModuleEventIndex, PendingHookCallStart } from './log-cache' +import { Buffer } from 'node:buffer' + +// Binary encoding of the reader index. Large logs have millions of index entries, and a JSON +// round trip of that size blocks the event loop for hundreds of milliseconds. Encoding and +// decoding here both yield regularly, so the DevTools server keeps answering meanwhile. + +export interface ReaderIndex { + lastBytes: number + lastTimestamp: number + moduleEventIndex: Map + stringRefIndex: Map + pendingHookCallStarts: Map + assetsReadyLocations: LineLocation[] +} + +export interface ReaderIndexLogStat { + size: number + mtime: number +} + +const MAGIC = 0x58494452 // "RDIX" +export const READER_INDEX_CODEC_VERSION = 1 +const SLICE_MS = 8 +const CHUNK_BYTES = 4 * 1024 * 1024 +const HOOK_KINDS = ['resolveIds', 'loads', 'transforms'] as const + +function createYielder() { + let sliceStart = performance.now() + return async () => { + if (performance.now() - sliceStart <= SLICE_MS) + return + await new Promise(resolve => setImmediate(resolve)) + sliceStart = performance.now() + } +} + +class Writer { + readonly chunks: Buffer[] = [] + private buffer = Buffer.allocUnsafe(CHUNK_BYTES) + private offset = 0 + + private reserve(bytes: number) { + if (this.offset + bytes <= this.buffer.length) + return + this.chunks.push(this.buffer.subarray(0, this.offset)) + this.buffer = Buffer.allocUnsafe(Math.max(CHUNK_BYTES, bytes)) + this.offset = 0 + } + + u8(value: number) { + this.reserve(1) + this.offset = this.buffer.writeUInt8(value, this.offset) + } + + u32(value: number) { + this.reserve(4) + this.offset = this.buffer.writeUInt32LE(value, this.offset) + } + + f64(value: number) { + this.reserve(8) + this.offset = this.buffer.writeDoubleLE(value, this.offset) + } + + string(value: string) { + const length = Buffer.byteLength(value) + this.u32(length) + this.reserve(length) + this.offset += this.buffer.write(value, this.offset, length, 'utf8') + } + + location(value: LineLocation) { + this.f64(value.offset) + this.f64(value.length) + } + + finish(): Buffer[] { + this.chunks.push(this.buffer.subarray(0, this.offset)) + return this.chunks + } +} + +class Reader { + private offset = 0 + + constructor(private readonly buffer: Buffer) {} + + private check(bytes: number) { + if (this.offset + bytes > this.buffer.length) + throw new RangeError('Truncated reader index cache') + } + + u8() { + this.check(1) + return this.buffer.readUInt8(this.offset++) + } + + u32() { + this.check(4) + const value = this.buffer.readUInt32LE(this.offset) + this.offset += 4 + return value + } + + f64() { + this.check(8) + const value = this.buffer.readDoubleLE(this.offset) + this.offset += 8 + return value + } + + string() { + const length = this.u32() + this.check(length) + const value = this.buffer.toString('utf8', this.offset, this.offset + length) + this.offset += length + return value + } + + location(): LineLocation { + return { offset: this.f64(), length: this.f64() } + } + + get done() { + return this.offset === this.buffer.length + } +} + +export async function encodeReaderIndex(index: ReaderIndex, stat: ReaderIndexLogStat): Promise { + const writer = new Writer() + const pause = createYielder() + writer.u32(MAGIC) + writer.u32(READER_INDEX_CODEC_VERSION) + writer.f64(stat.size) + writer.f64(stat.mtime) + writer.f64(index.lastBytes) + writer.f64(index.lastTimestamp) + + writer.u32(index.moduleEventIndex.size) + for (const [module, events] of index.moduleEventIndex) { + writer.string(module) + for (const kind of HOOK_KINDS) { + writer.u32(events[kind].size) + for (const [id, call] of events[kind]) { + writer.string(id) + writer.location(call.start) + writer.location(call.end) + } + } + await pause() + } + + writer.u32(index.stringRefIndex.size) + for (const [id, location] of index.stringRefIndex) { + writer.string(id) + writer.location(location) + await pause() + } + + writer.u32(index.pendingHookCallStarts.size) + for (const [id, start] of index.pendingHookCallStarts) { + writer.string(id) + writer.u8(start.module == null ? 0 : 1) + if (start.module != null) + writer.string(start.module) + writer.location(start.location) + } + + writer.u32(index.assetsReadyLocations.length) + for (const location of index.assetsReadyLocations) + writer.location(location) + + return writer.finish() +} + +/** Returns `undefined` when the buffer isn't a valid index for this log version. */ +export async function decodeReaderIndex(buffer: Buffer, stat: ReaderIndexLogStat): Promise { + try { + const reader = new Reader(buffer) + const pause = createYielder() + if (reader.u32() !== MAGIC || reader.u32() !== READER_INDEX_CODEC_VERSION) + return undefined + if (reader.f64() !== stat.size || reader.f64() !== stat.mtime) + return undefined + + const lastBytes = reader.f64() + const lastTimestamp = reader.f64() + + const moduleEventIndex = new Map() + for (let count = reader.u32(); count > 0; count--) { + const module = reader.string() + const events = {} as ModuleEventIndex + for (const kind of HOOK_KINDS) { + const calls = new Map() + for (let remaining = reader.u32(); remaining > 0; remaining--) + calls.set(reader.string(), { start: reader.location(), end: reader.location() }) + events[kind] = calls + } + moduleEventIndex.set(module, events) + await pause() + } + + const stringRefIndex = new Map() + for (let count = reader.u32(); count > 0; count--) { + stringRefIndex.set(reader.string(), reader.location()) + await pause() + } + + const pendingHookCallStarts = new Map() + for (let count = reader.u32(); count > 0; count--) { + const id = reader.string() + const module = reader.u8() ? reader.string() : undefined + pendingHookCallStarts.set(id, module == null ? { location: reader.location() } : { module, location: reader.location() }) + } + + const assetsReadyLocations: LineLocation[] = [] + for (let count = reader.u32(); count > 0; count--) + assetsReadyLocations.push(reader.location()) + + if (!reader.done) + return undefined + return { lastBytes, lastTimestamp, moduleEventIndex, stringRefIndex, pendingHookCallStarts, assetsReadyLocations } + } + catch { + return undefined + } +} From 1d3a5ad30c0f2e871a4895c76442a79d5364a130 Mon Sep 17 00:00:00 2001 From: arlo Date: Wed, 4 Nov 2026 14:57:47 +0800 Subject: [PATCH 2/2] fix(rolldown): restore module metrics when reopening a session summary Restoring a cached session summary skipped module-metrics-summary.json, so every module's build_metrics was undefined after reopening. The module list lost its metrics and plugin badges, and chunk sizes computed from transforms were zero; a later full read did not refresh the client's module list. --- .../node/rolldown/__tests__/reader-index-cache.test.ts | 10 +++++++++- packages/rolldown/src/node/rolldown/log-cache.ts | 9 ++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/rolldown/src/node/rolldown/__tests__/reader-index-cache.test.ts b/packages/rolldown/src/node/rolldown/__tests__/reader-index-cache.test.ts index b9fef384..9cae4637 100644 --- a/packages/rolldown/src/node/rolldown/__tests__/reader-index-cache.test.ts +++ b/packages/rolldown/src/node/rolldown/__tests__/reader-index-cache.test.ts @@ -80,13 +80,16 @@ describe('rolldown log cache', () => { return [state.moduleEventIndex, state.stringRefIndex, state.pendingHookCallStarts, state.assetsReadyLocations] } - it('restores the summary without loading the index, then restores the same index on a full read', async () => { + it('restores the summary with module metrics but without the index, then restores the same index on a full read', async () => { const filepath = writeLog() const cold = RolldownEventsReader.get(filepath) await cold.read() expect((cold as unknown as { moduleEventIndex: Map }).moduleEventIndex.size).toBeGreaterThan(0) const coldIndex = structuredClone(indexOf(cold)) const coldEvents = cold.manager.eventCount + const coldModules = cold.manager.module_build_metrics.size + const coldMetrics = cold.manager.module_build_metrics.get('/src/main.ts')! + expect(coldMetrics.transforms).toHaveLength(1) cold.dispose() const warm = RolldownEventsReader.get(filepath) @@ -94,6 +97,11 @@ describe('rolldown log cache', () => { await warm.readSummary() expect(warm.manager.eventCount).toBe(coldEvents) expect((warm as unknown as { moduleEventIndex: Map }).moduleEventIndex.size).toBe(0) + // The summary keeps the compacted metrics the module list and chunk sizes rely on. + const warmMetrics = warm.manager.module_build_metrics.get('/src/main.ts') + expect(warm.manager.module_build_metrics.size).toBe(coldModules) + expect(warmMetrics?.transforms.at(-1)?.transformed_code_size).toBe(coldMetrics.transforms.at(-1)!.transformed_code_size) + expect(warmMetrics?.resolve_ids[0]?.duration).toBe(coldMetrics.resolve_ids[0]!.duration) await warm.read() expect(indexOf(warm)).toEqual(coldIndex) diff --git a/packages/rolldown/src/node/rolldown/log-cache.ts b/packages/rolldown/src/node/rolldown/log-cache.ts index defdb9ab..e91b4e7e 100644 --- a/packages/rolldown/src/node/rolldown/log-cache.ts +++ b/packages/rolldown/src/node/rolldown/log-cache.ts @@ -394,12 +394,15 @@ export class RolldownLogCache { if (state.lastBytes || state.manager.eventCount) return false - // The summary only needs the session summary; the index and metrics are loaded on a full read. - const sessionSummaryCache = await this.sessionSummaryCache?.read(stat) + // The module list needs the metrics; the index is loaded on a full read. + const [sessionSummaryCache, moduleMetricsCache] = await Promise.all([ + this.sessionSummaryCache?.read(stat), + this.moduleMetricsSummaryCache?.read(stat), + ]) if (!sessionSummaryCache || sessionSummaryCache.reader.lastBytes !== stat.size) return false - this.restoreSessionSnapshot(sessionSummaryCache, state) + this.restoreSessionSnapshot(sessionSummaryCache, state, moduleMetricsCache) this.clearReaderIndexState(state) this.summaryWriteAttempted = true return 'summary' as const