Skip to content

Commit 65cf5ed

Browse files
ozgesolidkeyclaude
andcommitted
Perf: parse MF4 in a worker thread + stream records (fixes UI freeze & OOM)
MF4 normalize() ran a fully synchronous parse (readFileSync + tight decode loop + inflateSync) on the Electron main process, blocking the event loop so the UI froze during processing. It also resolved the entire decompressed dataset into one Buffer (Buffer.concat of every chunk), which on large recordings overflowed V8's allocation limit — the "RangeError: Array buffer allocation failed" the user hit. - Extracted the parser into mf4Parse.ts and run it in a worker thread (mf4Worker.ts) spawned by normalize(), which now just relays progress/done/ error. The main/UI loop stays responsive. - Stream records chunk-by-chunk (forEachMdf4DataChunk + streamMdf4Records, carrying a sub-record remainder across chunk boundaries) instead of concatenating all data. Peak memory ≈ file + one decompressed chunk. Output is written incrementally via buffered writeSync (no giant result string). - Tests target the parse core directly (worker .js isn't present under vitest); added a capabilities test. Verified the real worker end-to-end against a compiled ##DZ fixture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent efa81d4 commit 65cf5ed

4 files changed

Lines changed: 386 additions & 306 deletions

File tree

src/main/mf4Parse.ts

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import * as zlib from 'zlib';
4+
5+
/**
6+
* ── ASAM MDF4 (MF4) parser core ──────────────────────────────────────────────
7+
* MF4 is a binary automotive measurement format: channel groups of named numeric
8+
* signals sampled against a time master. This is a MINIMAL, dependency-free MDF
9+
* 4.x reader for the COMMON case:
10+
* • data in ##DT (raw), ##DZ (deflate / transposed-deflate) or ##DL/##HL lists,
11+
* • sorted groups (one channel group per data group),
12+
* • fixed-length numeric channels (int/uint/float, LE/BE),
13+
* • raw values (channel conversions/##CC are NOT applied yet).
14+
* Anything outside that is reported inline (a `[mf4] …` line) and skipped rather
15+
* than producing wrong numbers.
16+
*
17+
* Each record becomes a synthetic line `t=<master> <name>=<value> …` so the
18+
* existing viewer + Trends field-discovery chart the signals with zero new UI.
19+
*
20+
* Records are STREAMED chunk-by-chunk (never the whole decompressed dataset at
21+
* once), so memory stays bounded even for multi-GB recordings. This module is
22+
* run inside a worker thread (see mf4Worker.ts) so parsing never blocks the
23+
* Electron main/UI event loop.
24+
*/
25+
26+
interface Mdf4Block { id: string; links: number[]; dataStart: number; dataEnd: number; }
27+
28+
function readMdf4Block(buf: Buffer, offset: number): Mdf4Block {
29+
const id = buf.toString('latin1', offset, offset + 4); // e.g. "##HD"
30+
const length = Number(buf.readBigUInt64LE(offset + 8));
31+
const linkCount = Number(buf.readBigUInt64LE(offset + 16));
32+
const links: number[] = [];
33+
let p = offset + 24;
34+
for (let i = 0; i < linkCount; i++) { links.push(Number(buf.readBigUInt64LE(p))); p += 8; }
35+
return { id, links, dataStart: p, dataEnd: offset + length };
36+
}
37+
38+
/** Read a ##TX/##MD block's text (null-terminated UTF-8). 0 link → ''. */
39+
function readMdf4Text(buf: Buffer, offset: number): string {
40+
if (!offset) return '';
41+
try {
42+
const b = readMdf4Block(buf, offset);
43+
if (b.id !== '##TX' && b.id !== '##MD') return '';
44+
let s = buf.toString('utf8', b.dataStart, b.dataEnd);
45+
const nul = s.indexOf('\0');
46+
if (nul >= 0) s = s.slice(0, nul);
47+
return s.trim();
48+
} catch { return ''; }
49+
}
50+
51+
interface Mdf4Channel { name: string; type: number; dataType: number; bitOffset: number; byteOffset: number; bitCount: number; }
52+
53+
/** Decode one channel's value from a record slice. Returns null if unsupported. */
54+
function decodeMdf4Value(rec: Buffer, base: number, cn: Mdf4Channel): number | null {
55+
const off = base + cn.byteOffset;
56+
const { dataType: dt, bitCount: bits, bitOffset } = cn;
57+
if (off < 0 || off >= rec.length) return null;
58+
59+
// Byte-aligned, whole-byte fields → use Buffer's native readers.
60+
if (bitOffset === 0 && bits % 8 === 0) {
61+
const n = bits / 8;
62+
if (off + n > rec.length) return null;
63+
try {
64+
switch (dt) {
65+
case 0: return n <= 6 ? rec.readUIntLE(off, n) : Number(rec.readBigUInt64LE(off)); // uint LE
66+
case 1: return n <= 6 ? rec.readUIntBE(off, n) : Number(rec.readBigUInt64BE(off)); // uint BE
67+
case 2: return n <= 6 ? rec.readIntLE(off, n) : Number(rec.readBigInt64LE(off)); // int LE
68+
case 3: return n <= 6 ? rec.readIntBE(off, n) : Number(rec.readBigInt64BE(off)); // int BE
69+
case 4: return n === 4 ? rec.readFloatLE(off) : n === 8 ? rec.readDoubleLE(off) : null; // float LE
70+
case 5: return n === 4 ? rec.readFloatBE(off) : n === 8 ? rec.readDoubleBE(off) : null; // float BE
71+
default: return null; // strings/bytes/complex — not charted
72+
}
73+
} catch { return null; }
74+
}
75+
76+
// Non-aligned integer bit-field (little-endian only).
77+
if (dt === 0 || dt === 2) {
78+
const totalBits = bitOffset + bits;
79+
const n = Math.ceil(totalBits / 8);
80+
if (n > 8 || off + n > rec.length) return null;
81+
let v = 0n;
82+
for (let i = 0; i < n; i++) v |= BigInt(rec[off + i]) << BigInt(8 * i);
83+
v = (v >> BigInt(bitOffset)) & ((1n << BigInt(bits)) - 1n);
84+
if (dt === 2 && (v & (1n << BigInt(bits - 1)))) v -= (1n << BigInt(bits)); // sign-extend
85+
return Number(v);
86+
}
87+
return null;
88+
}
89+
90+
/**
91+
* Decompress a ##DZ block (MDF4 compressed data). zip_type 0 = raw deflate,
92+
* 1 = transposition + deflate (used for record data so columns compress better).
93+
*/
94+
function inflateMdf4Dz(buf: Buffer, block: Mdf4Block): Buffer {
95+
const d = block.dataStart;
96+
const zipType = buf.readUInt8(d + 2);
97+
const zipParam = buf.readUInt32LE(d + 4);
98+
const orgLen = Number(buf.readBigUInt64LE(d + 8));
99+
const dataLen = Number(buf.readBigUInt64LE(d + 16));
100+
const comp = buf.subarray(d + 24, d + 24 + dataLen);
101+
let out: Buffer = zlib.inflateSync(comp);
102+
if (zipType === 1 && zipParam > 0) out = untransposeMdf4(out, zipParam, orgLen);
103+
return out;
104+
}
105+
106+
/**
107+
* Reverse MDF4 column-transposition. The original byte matrix (orgLen bytes,
108+
* `cols` columns, N full rows) was stored column-major; any trailing remainder
109+
* bytes are appended untransposed.
110+
*/
111+
function untransposeMdf4(data: Buffer, cols: number, orgLen: number): Buffer {
112+
const N = Math.floor(orgLen / cols);
113+
const out = Buffer.alloc(orgLen);
114+
for (let j = 0; j < cols; j++) {
115+
for (let i = 0; i < N; i++) {
116+
out[i * cols + j] = data[j * N + i];
117+
}
118+
}
119+
for (let k = N * cols; k < orgLen && k < data.length; k++) out[k] = data[k];
120+
return out;
121+
}
122+
123+
/**
124+
* Walk a data-section pointer and hand each contiguous chunk of raw record bytes
125+
* to `onChunk` — following ##DT/##RD (raw), ##DZ (decompressed), ##DL (each listed
126+
* chunk, then dl_dl_next) and ##HL (unwrap to its DL). Only ONE chunk is held in
127+
* memory at a time. `onChunk` returning false stops the walk early. Returns false
128+
* if stopped. `depth` guards cyclic links.
129+
*/
130+
function forEachMdf4DataChunk(
131+
buf: Buffer,
132+
off: number,
133+
onChunk: (chunk: Buffer) => boolean,
134+
depth = 0
135+
): boolean {
136+
if (!off || depth > 64) return true;
137+
let block: Mdf4Block;
138+
try { block = readMdf4Block(buf, off); } catch { return true; }
139+
140+
if (block.id === '##DT' || block.id === '##RD') {
141+
return onChunk(buf.subarray(block.dataStart, block.dataEnd)) !== false;
142+
}
143+
if (block.id === '##DZ') {
144+
let dec: Buffer;
145+
try { dec = inflateMdf4Dz(buf, block); } catch { return true; }
146+
return onChunk(dec) !== false;
147+
}
148+
if (block.id === '##HL') {
149+
return forEachMdf4DataChunk(buf, block.links[0], onChunk, depth + 1); // hl_dl_first
150+
}
151+
if (block.id === '##DL') {
152+
let dlOff = off;
153+
let guard = 0;
154+
while (dlOff && guard++ < 1_000_000) {
155+
const dl = readMdf4Block(buf, dlOff);
156+
const count = buf.readUInt32LE(dl.dataStart + 4); // dl_flags(1)+reserved(3), then dl_count
157+
for (let i = 0; i < count; i++) {
158+
const link = dl.links[1 + i]; // links[0] is dl_dl_next; data links follow
159+
if (link && !forEachMdf4DataChunk(buf, link, onChunk, depth + 1)) return false;
160+
}
161+
dlOff = dl.links[0]; // dl_dl_next
162+
}
163+
return true;
164+
}
165+
return true;
166+
}
167+
168+
/**
169+
* Stream fixed-size records (recSize bytes each) across all data chunks, carrying
170+
* a small remainder across chunk boundaries. `onRecord(rec, base)` returning false
171+
* stops iteration. Peak memory ≈ one decompressed chunk + (< recSize) leftover.
172+
*/
173+
function streamMdf4Records(
174+
buf: Buffer,
175+
dataOff: number,
176+
recSize: number,
177+
onRecord: (rec: Buffer, base: number) => boolean
178+
): void {
179+
let leftover: Buffer | null = null;
180+
let stop = false;
181+
forEachMdf4DataChunk(buf, dataOff, (chunk) => {
182+
if (stop) return false;
183+
const data = leftover && leftover.length ? Buffer.concat([leftover, chunk]) : chunk;
184+
let i = 0;
185+
for (; i + recSize <= data.length; i += recSize) {
186+
if (onRecord(data, i) === false) { stop = true; break; }
187+
}
188+
leftover = stop ? null : data.subarray(i);
189+
return !stop;
190+
});
191+
}
192+
193+
/**
194+
* Parse an MF4 file at `filePath` into newline-delimited `t=… name=value` text at
195+
* `outPath`. Synchronous CPU work — call it from a worker thread. Internal parse
196+
* errors are written as a `[mf4] …` note rather than thrown; only a non-MDF file
197+
* throws. Output is written incrementally so a large input never materialises as
198+
* one giant string.
199+
*/
200+
export async function parseMdf4ToFile(
201+
filePath: string,
202+
outPath: string,
203+
onProgress?: (percent: number) => void
204+
): Promise<void> {
205+
const buf = fs.readFileSync(filePath);
206+
if (buf.length < 64 || buf.toString('latin1', 0, 3) !== 'MDF') {
207+
throw new Error(`Not an MDF/MF4 file: ${path.basename(filePath)}`);
208+
}
209+
210+
const fd = fs.openSync(outPath, 'w');
211+
let first = false;
212+
let pending = '';
213+
const flush = (): void => {
214+
if (pending) { fs.writeSync(fd, pending); pending = ''; }
215+
};
216+
const writeLine = (line: string): void => {
217+
pending += first ? '\n' + line : line;
218+
first = true;
219+
if (pending.length >= 1 << 20) flush(); // flush every ~1 MB
220+
};
221+
222+
try {
223+
const hd = readMdf4Block(buf, 64); // HDBLOCK directly after the 64-byte IDBLOCK
224+
let dgOff = hd.links[0] || 0; // hd_dg_first
225+
let totalRecords = 0;
226+
227+
while (dgOff) {
228+
const dg = readMdf4Block(buf, dgOff);
229+
const recIdSize = buf.readUInt8(dg.dataStart);
230+
const cgFirst = dg.links[1];
231+
const dgData = dg.links[2];
232+
233+
if (cgFirst) {
234+
const cg = readMdf4Block(buf, cgFirst);
235+
if (cg.links[0]) writeLine('[mf4] note: data group has multiple channel groups; reading the first only');
236+
const cycleCount = Number(buf.readBigUInt64LE(cg.dataStart + 8));
237+
const cgFlags = buf.readUInt16LE(cg.dataStart + 16);
238+
const dataBytes = buf.readUInt32LE(cg.dataStart + 24);
239+
const invalBytes = buf.readUInt32LE(cg.dataStart + 28);
240+
241+
if (cgFlags & 0x1) {
242+
writeLine('[mf4] variable-length (VLSD) channel group not supported — skipped');
243+
} else {
244+
// Collect channels.
245+
const channels: Mdf4Channel[] = [];
246+
let master: Mdf4Channel | null = null;
247+
let cnOff = cg.links[1]; // cg_cn_first
248+
while (cnOff) {
249+
const cn = readMdf4Block(buf, cnOff);
250+
const d = cn.dataStart;
251+
const ch: Mdf4Channel = {
252+
name: readMdf4Text(buf, cn.links[2]) || `ch${buf.readUInt32LE(d + 4)}`,
253+
type: buf.readUInt8(d),
254+
dataType: buf.readUInt8(d + 2),
255+
bitOffset: buf.readUInt8(d + 3),
256+
byteOffset: buf.readUInt32LE(d + 4),
257+
bitCount: buf.readUInt32LE(d + 8),
258+
};
259+
if (ch.type === 2 || ch.type === 3) master = ch; // master / virtual master
260+
else channels.push(ch);
261+
cnOff = cn.links[0]; // cn_cn_next
262+
}
263+
264+
const recSize = recIdSize + dataBytes + invalBytes;
265+
let groupRecords = 0;
266+
if (dgData && recSize > 0 && cycleCount > 0) {
267+
// Stream records chunk-by-chunk (handles ##DT/##DZ/##DL/##HL).
268+
streamMdf4Records(buf, dgData, recSize, (rec, recStart) => {
269+
const base = recStart + recIdSize;
270+
if (base + dataBytes > rec.length) return false;
271+
const parts: string[] = [];
272+
if (master) {
273+
const mv = decodeMdf4Value(rec, base, master);
274+
if (mv !== null) parts.push(`t=${mv}`);
275+
}
276+
for (const ch of channels) {
277+
const v = decodeMdf4Value(rec, base, ch);
278+
if (v !== null) parts.push(`${ch.name}=${v}`);
279+
}
280+
if (parts.length) writeLine(parts.join(' '));
281+
if (++totalRecords % 5000 === 0 && onProgress) {
282+
onProgress(Math.min(99, Math.round((dgOff / buf.length) * 100)));
283+
}
284+
return ++groupRecords < cycleCount;
285+
});
286+
}
287+
if (groupRecords === 0) {
288+
const blk = dgData ? readMdf4Block(buf, dgData) : null;
289+
writeLine(`[mf4] data block ${blk ? blk.id : 'missing'} produced no readable records — skipped`);
290+
}
291+
}
292+
}
293+
dgOff = dg.links[0]; // dg_dg_next
294+
}
295+
296+
if (!first) writeLine('[mf4] no readable records found (file may use compression or an unsupported layout)');
297+
} catch (err) {
298+
writeLine(`[mf4] parse stopped: ${String(err)}`);
299+
} finally {
300+
flush();
301+
fs.closeSync(fd);
302+
}
303+
304+
onProgress?.(100);
305+
}

src/main/mf4Worker.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { parentPort, workerData } from 'worker_threads';
2+
import { parseMdf4ToFile } from './mf4Parse';
3+
4+
/**
5+
* Worker-thread entry for MF4 parsing. Runs the CPU-heavy parse off the Electron
6+
* main/UI event loop. Messages back to the parent:
7+
* { type: 'progress', percent } — periodic progress
8+
* { type: 'done' } — finished, output written to outPath
9+
* { type: 'error', message } — fatal (e.g. not an MDF file, OOM)
10+
*/
11+
const { filePath, outPath } = workerData as { filePath: string; outPath: string };
12+
13+
parseMdf4ToFile(filePath, outPath, (percent) => {
14+
parentPort?.postMessage({ type: 'progress', percent });
15+
})
16+
.then(() => parentPort?.postMessage({ type: 'done' }))
17+
.catch((err) => parentPort?.postMessage({
18+
type: 'error',
19+
message: err instanceof Error ? err.message : String(err),
20+
}));

0 commit comments

Comments
 (0)