Skip to content

Commit 623301d

Browse files
authored
feat(diagnostics): support compressed .mcstats streams, add tests for processing stream. (#273)
- tests for ReplayStatsProvider - optional support for base64 encoded gzip diag file. MC will always produce this type.
1 parent c7fd5c7 commit 623301d

9 files changed

+205
-27
lines changed

src/extension.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,10 @@ export function activate(context: vscode.ExtensionContext) {
7777
async () => {
7878
const fileUri = await vscode.window.showOpenDialog({
7979
canSelectMany: false,
80-
openLabel: 'Select diagnostics capture to replay',
80+
openLabel: 'Open',
8181
filters: {
82-
'MC Stats files': ['mcstats'],
83-
'All files': ['*'],
82+
'MC Stats Files': ['mcstats'],
83+
'All Files': ['*'],
8484
},
8585
});
8686
if (!fileUri || fileUri.length === 0) {

src/panels/home-view-provider.ts

-1
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,6 @@ export class HomeViewProvider implements vscode.WebviewViewProvider {
129129

130130
private _refreshProfilerCaptures(capturesBasePath: string, newCaptureFileName?: string) {
131131
if (!capturesBasePath) {
132-
console.error('Captures path is invalid.');
133132
return;
134133
}
135134
fs.readdir(capturesBasePath, (err, files) => {

src/panels/minecraft-diagnostics.ts

+3
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ export class MinecraftDiagnosticsPanel {
9191
};
9292
this._panel.webview.postMessage(message);
9393
},
94+
onNotification: (message: string) => {
95+
window.showInformationMessage(message);
96+
},
9497
};
9598

9699
this._statsTracker.addStatListener(this._statsCallback);
+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { ReplayStatsProvider } from './replay-stats-provider';
3+
import { StatData, StatsListener } from './stats-provider';
4+
import path from 'path';
5+
6+
describe('ReplayStatsProvider', () => {
7+
it('should load base64-gzip encoded replay data and trigger events', async () => {
8+
const replayFilePath = path.resolve('./test/diagnostics-replay-compressed.mcstats');
9+
const replay = new ReplayStatsProvider(replayFilePath);
10+
let statCount = 0;
11+
let statsCallback: StatsListener = {
12+
onStatUpdated: (stat: StatData) => {
13+
statCount++;
14+
expect(stat).toBeDefined();
15+
},
16+
};
17+
replay.addStatListener(statsCallback);
18+
let results = await replay.start();
19+
expect(results.statLinesRead).toBe(3);
20+
expect(results.statEventsSent).toBe(3);
21+
expect(statCount).toBeGreaterThan(0); // no idea how many are in there
22+
});
23+
24+
it('should load uncompressed replay and trigger events', async () => {
25+
const replayFilePath = path.resolve('./test/diagnostics-replay-uncompressed.mcstats');
26+
const replay = new ReplayStatsProvider(replayFilePath);
27+
let statCount = 0;
28+
let statsCallback: StatsListener = {
29+
onStatUpdated: (stat: StatData) => {
30+
statCount++;
31+
expect(stat).toBeDefined();
32+
},
33+
};
34+
replay.addStatListener(statsCallback);
35+
let results = await replay.start();
36+
expect(results.statLinesRead).toBe(3);
37+
expect(results.statEventsSent).toBe(3);
38+
expect(statCount).toBeGreaterThan(0);
39+
});
40+
41+
it('should load no-header uncompressed replay and trigger events', async () => {
42+
const replayFilePath = path.resolve('./test/diagnostics-replay-uncompressed-no-header.mcstats');
43+
const replay = new ReplayStatsProvider(replayFilePath);
44+
let statCount = 0;
45+
let statsCallback: StatsListener = {
46+
onStatUpdated: (stat: StatData) => {
47+
statCount++;
48+
expect(stat).toBeDefined();
49+
},
50+
};
51+
replay.addStatListener(statsCallback);
52+
let results = await replay.start();
53+
expect(results.statLinesRead).toBe(3);
54+
expect(results.statEventsSent).toBe(3);
55+
expect(statCount).toBeGreaterThan(0);
56+
});
57+
58+
it('should fire notification on invalid file read', async () => {
59+
const replayFilePath = './not-a-real-file.mcstats';
60+
const replay = new ReplayStatsProvider(replayFilePath);
61+
let notification = '';
62+
let statsCallback: StatsListener = {
63+
onNotification: (message: string) => {
64+
notification = message;
65+
},
66+
};
67+
replay.addStatListener(statsCallback);
68+
let results = await replay.start();
69+
expect(results.statLinesRead).toBe(0);
70+
expect(notification).toBe('Failed to read replay file.');
71+
});
72+
});

src/stats/replay-stats-provider.ts

+110-19
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,38 @@
33
import * as fs from 'fs';
44
import * as readline from 'readline';
55
import * as path from 'path';
6+
import * as zlib from 'zlib';
67
import { StatMessageModel, StatsProvider, StatsListener } from './stats-provider';
78

9+
interface ReplayStatMessageHeader {
10+
encoding?: string;
11+
}
12+
13+
export class ReplayResults {
14+
statLinesRead: number = 0;
15+
statEventsSent: number = 0;
16+
}
17+
818
export class ReplayStatsProvider extends StatsProvider {
919
private _replayFilePath: string;
1020
private _replayStreamReader: readline.Interface | null;
1121
private _simTickFreqency: number;
1222
private _simTickPeriod: number;
1323
private _simTickCurrent: number;
1424
private _simTimeoutId: NodeJS.Timeout | null;
25+
private _replayHeader: ReplayStatMessageHeader | undefined;
26+
private _base64Gzipped: boolean;
1527
private _pendingStats: StatMessageModel[];
28+
private _replayResults: ReplayResults;
29+
private _onComplete: ((results: ReplayResults) => void) | undefined;
1630

1731
// resume stream when lines drop below this threshold
1832
private static readonly PENDING_STATS_BUFFER_MIN = 256;
1933
// pause stream when lines exceed this threshold
2034
private static readonly PENDING_STATS_BUFFER_MAX = ReplayStatsProvider.PENDING_STATS_BUFFER_MIN * 2;
35+
// supported encodings
36+
private readonly ENCODING_BASE64_GZIP = 'base64-gzip';
37+
private readonly ENCODING_UTF8 = 'utf8';
2138

2239
// ticks per second (frequency)
2340
private readonly MILLIS_PER_SECOND = 1000;
@@ -33,38 +50,53 @@ export class ReplayStatsProvider extends StatsProvider {
3350
this._simTickPeriod = this._calcSimPeriod(this._simTickFreqency);
3451
this._simTickCurrent = 0;
3552
this._simTimeoutId = null;
53+
this._base64Gzipped = false;
3654
this._pendingStats = [];
55+
this._replayResults = new ReplayResults();
56+
this._onComplete = undefined;
3757
}
3858

39-
public override start() {
59+
public override start(): Promise<ReplayResults> {
4060
this.stop();
4161

4262
const fileStream = fs.createReadStream(this._replayFilePath);
4363
this._replayStreamReader = readline.createInterface({
4464
input: fileStream,
4565
crlfDelay: Infinity,
4666
});
47-
48-
this._replayStreamReader.on('line', line => this._onReadNextStatMessage(line));
49-
this._replayStreamReader.on('close', () => this._onCloseStream());
67+
this._replayStreamReader.on('line', line => this._onReadNextLineFromReplayStream(line));
68+
this._replayStreamReader.on('close', () => this._onCloseReplayStream());
69+
this._replayStreamReader.on('error', () => this._errorCloseReplayStream('Failed to read replay file.'));
5070

5171
// begin simulation
5272
this._simTimeoutId = setTimeout(() => this._updateSim(), this._simTickPeriod);
5373
this._fireSpeedChanged();
5474
this._firePauseChanged();
75+
76+
return new Promise<ReplayResults>(resolve => {
77+
this._onComplete = resolve;
78+
});
5579
}
5680

5781
public override stop() {
82+
this._fireStopped();
5883
if (this._simTimeoutId) {
5984
clearTimeout(this._simTimeoutId);
6085
}
61-
this._replayStreamReader?.close();
86+
if (this._onComplete) {
87+
this._onComplete(this._replayResults);
88+
this._onComplete = undefined;
89+
}
90+
if (this._replayStreamReader) {
91+
this._replayStreamReader.close();
92+
this._replayStreamReader = null;
93+
}
6294
this._simTickFreqency = this.DEFAULT_SPEED;
6395
this._simTickPeriod = this._calcSimPeriod(this._simTickFreqency);
6496
this._simTickCurrent = 0;
6597
this._simTimeoutId = null;
98+
this._base64Gzipped = false;
6699
this._pendingStats = [];
67-
this._firePauseChanged();
68100
}
69101

70102
public override pause() {
@@ -127,6 +159,7 @@ export class ReplayStatsProvider extends StatsProvider {
127159
} else if (nextStatsMessage.tick === this._simTickCurrent) {
128160
// process and remove the message, then increment sim tick
129161
this.setStats(nextStatsMessage);
162+
this._replayResults.statEventsSent++;
130163
this._pendingStats.shift();
131164
this._simTickCurrent++;
132165
}
@@ -138,37 +171,95 @@ export class ReplayStatsProvider extends StatsProvider {
138171
// schedule next update as long as we have pending data to process or there's still a stream to read
139172
if (this._replayStreamReader || this._pendingStats.length > 0) {
140173
this._simTimeoutId = setTimeout(() => this._updateSim(), this._simTickPeriod);
174+
} else {
175+
// no more data to process
176+
this.stop();
141177
}
142178
}
143179

144-
private _onReadNextStatMessage(line: string) {
145-
const statsMessageJson = JSON.parse(line);
146-
// seed sim tick with first message
147-
if (this._simTickCurrent === 0) {
148-
this._simTickCurrent = statsMessageJson.tick;
180+
private _onReadNextLineFromReplayStream(rawLine: string) {
181+
if (this._replayHeader === undefined) {
182+
try {
183+
const headerJson = JSON.parse(rawLine);
184+
if (headerJson.tick) {
185+
this._replayHeader = {}; // no header, fall through to process this line as stat data
186+
} else {
187+
// first line was header, set encoding and return
188+
this._replayHeader = headerJson as ReplayStatMessageHeader;
189+
const encoding = this._replayHeader.encoding ?? this.ENCODING_UTF8;
190+
this._base64Gzipped = encoding === this.ENCODING_BASE64_GZIP;
191+
return;
192+
}
193+
} catch (error) {
194+
this._errorCloseReplayStream('Failed to parse replay header.');
195+
return;
196+
}
197+
}
198+
199+
let decodedLine = rawLine;
200+
if (this._base64Gzipped) {
201+
try {
202+
const buffer = Buffer.from(rawLine, 'base64');
203+
decodedLine = zlib.gunzipSync(buffer).toString('utf-8');
204+
} catch (error) {
205+
this._errorCloseReplayStream('Failed to decode replay data.');
206+
return;
207+
}
208+
}
209+
210+
try {
211+
const jsonLine = JSON.parse(decodedLine);
212+
const statMessage = jsonLine as StatMessageModel;
213+
// seed sim tick with first message
214+
if (this._simTickCurrent === 0) {
215+
this._simTickCurrent = statMessage.tick;
216+
}
217+
this._replayResults.statLinesRead++;
218+
// add stats messages to queue
219+
this._pendingStats.push(statMessage);
220+
// pause stream reader if we've got enough data for now
221+
if (this._pendingStats.length > ReplayStatsProvider.PENDING_STATS_BUFFER_MAX) {
222+
this._replayStreamReader?.pause();
223+
}
224+
} catch (error) {
225+
this._errorCloseReplayStream('Failed to process replay data.');
149226
}
150-
// add stats messages to queue
151-
this._pendingStats.push(statsMessageJson as StatMessageModel);
152-
// pause stream reader if we've got enough data for now
153-
if (this._pendingStats.length > ReplayStatsProvider.PENDING_STATS_BUFFER_MAX) {
154-
this._replayStreamReader?.pause();
227+
}
228+
229+
private _errorCloseReplayStream(message: string) {
230+
if (this._replayStreamReader) {
231+
this._replayStreamReader.close();
232+
this._replayStreamReader = null;
155233
}
234+
this._fireNotification(message);
156235
}
157236

158-
private _onCloseStream() {
237+
private _onCloseReplayStream() {
159238
this._replayStreamReader = null;
160239
}
161240

162241
private _fireSpeedChanged() {
163242
this._statListeners.forEach((listener: StatsListener) => {
164-
listener.onSpeedUpdated(this._simTickFreqency);
243+
listener.onSpeedUpdated?.(this._simTickFreqency);
165244
});
166245
}
167246

168247
private _firePauseChanged() {
169248
this._statListeners.forEach((listener: StatsListener) => {
170249
// paused if no timeout id
171-
listener.onPauseUpdated(this._simTimeoutId == null);
250+
listener.onPauseUpdated?.(this._simTimeoutId == null);
251+
});
252+
}
253+
254+
private _fireStopped() {
255+
this._statListeners.forEach((listener: StatsListener) => {
256+
listener.onStopped?.();
257+
});
258+
}
259+
260+
private _fireNotification(message: string) {
261+
this._statListeners.forEach((listener: StatsListener) => {
262+
listener.onNotification?.(message);
172263
});
173264
}
174265

src/stats/stats-provider.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ export interface StatMessageModel {
2424
}
2525

2626
export interface StatsListener {
27-
onStatUpdated: (stat: StatData) => void;
28-
onSpeedUpdated: (speed: number) => void;
29-
onPauseUpdated: (paused: boolean) => void;
27+
onStatUpdated?: (stat: StatData) => void;
28+
onSpeedUpdated?: (speed: number) => void;
29+
onPauseUpdated?: (paused: boolean) => void;
30+
onStopped?: () => void;
31+
onNotification?: (message: string) => void;
3032
}
3133

3234
export class StatsProvider {
@@ -89,7 +91,7 @@ export class StatsProvider {
8991
values: stat.values ?? [],
9092
tick: tick,
9193
};
92-
listener.onStatUpdated(statData);
94+
listener.onStatUpdated?.(statData);
9395

9496
if (stat.children) {
9597
stat.children.forEach((child: StatDataModel) => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{"encoding":"base64-gzip"}
2+
H4sIAAAAAAAACq2RMQvCMBCF/0q5ORRaULSbQ3EVlDqIyJEebTBeS3JVRPzvJg4iiIPS8fE+vntwNxCjj1BM5rNsMs0VyLUnKGAtKOWZWHJQ4EPwUOxuwHiKbYtcWzrobuBQKNCtsbUjfmcaYnIonUuEvHyBShYj11Ce0Q4Ub2T5/r6/qxdRIRtrMVmGuAkinyxs3+J4vhGXVdnfsi06j5cRVjny3eA0rVAfU6oHjWI6TpvQxj+kT+w3+bueImPIf1IP7xkEAUoCAAA=
3+
H4sIAAAAAAAACq2SzWrDMBCEX8XoLEzkn6TxLYfQa6ElPRQTtvJiq5ElI61dQsi7V84hGIyhLbkIlhl9O1rthZGSJ1bk2yeRr1PO6NwhK9grAe0HNJQwznwoPCs+LsxAO6oNmErjUdreBIEz2ShdOTRTT40GHZB1EaGnBdPekKJzEAfQPY49RFJeyyu/Ow5glNYQPYfyLYB8tNNdA4/jPTDZQfwb9g7Ow/cDUjn0tncSX0CeYqx6CaSsieugjv8Q32x/g0/xHt2A7jguTThaZeqlqBoH1DfjlJjlK8EzscnL+6q1SjrrUVpTeTZtJZ3qaEbYrNZ8vc1+ff/Lfs4YQohkxZM0Txc500fjOBiFfj6aH/azqd4/AwAA
4+
H4sIAAAAAAAACq2RQQuCQBCF/4rMWQSlorx1kK5BUYeQGNZBl7YxdsdCxP/e2iGE6FB4fLyPbx5MB6LVBdL5ahnPF7MQpL0RpLATlOxOLAmE4HxwkJ46YLwObYVcGDqrumFfhKAqbQpLPGZKYrIotQ2EnHyBMhYtrS/vaBoabsRJ3ud9+CYOyNoYDDY+7r3IBWtzq3A634TLDvHfsiNah48JVllydWMVbVFdIioahaJrjkrfDn+IXthv8rGeBkaT+6SeqVlmQ0oCAAA=

test/diagnostics-replay-uncompressed-no-header.mcstats

+3
Large diffs are not rendered by default.

test/diagnostics-replay-uncompressed.mcstats

+4
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)