13.8 KB443 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {ReactNode} from 'react';
9import type {ExclusiveOr} from 'shared/typeUtils';
10import type {Heartbeat} from '../heartbeat';
11
12import * as stylex from '@stylexjs/stylex';
13import {Badge} from 'isl-components/Badge';
14import {Banner, BannerKind} from 'isl-components/Banner';
15import {Button} from 'isl-components/Button';
16import {Checkbox} from 'isl-components/Checkbox';
17import {InlineErrorBadge} from 'isl-components/ErrorNotice';
18import {Icon} from 'isl-components/Icon';
19import {Subtle} from 'isl-components/Subtle';
20import {Tooltip} from 'isl-components/Tooltip';
21import {atom, useAtom, useAtomValue} from 'jotai';
22import {useCallback, useEffect, useState} from 'react';
23import {colors} from '../../../components/theme/tokens.stylex';
24import serverApi, {debugLogMessageTraffic} from '../ClientToServerAPI';
25import {Column, Row} from '../ComponentUtils';
26import {DropdownField, DropdownFields} from '../DropdownFields';
27import {enableReactTools, enableReduxTools} from '../atoms/debugToolAtoms';
28import {holdingCtrlAtom} from '../atoms/keyboardAtoms';
29import {DagCommitInfo} from '../dag/dagCommitInfo';
30import {useHeartbeat} from '../heartbeat';
31import {t, T} from '../i18n';
32import {atomWithOnChange} from '../jotaiUtils';
33import {NopOperation} from '../operations/NopOperation';
34import {useRunOperation} from '../operationsState';
35import platform from '../platform';
36import {dagWithPreviews} from '../previews';
37import {RelativeDate} from '../relativeDate';
38import {
39 latestCommitsData,
40 latestUncommittedChangesData,
41 mainCommandName,
42 mergeConflicts,
43} from '../serverAPIState';
44import {showToast} from '../toast';
45import {isDev} from '../utils';
46import {ComponentExplorerButton} from './ComponentExplorer';
47import {readInterestingAtoms, serializeAtomsState} from './getInterestingAtoms';
48
49import './DebugToolsMenu.css';
50
51/* eslint-disable no-console */
52
53export default function DebugToolsMenu({dismiss}: {dismiss: () => unknown}) {
54 return (
55 <DropdownFields
56 title={<T>Internal Debugging Tools</T>}
57 icon="pulse"
58 data-testid="internal-debug-tools-dropdown"
59 className="internal-debug-tools-dropdown">
60 <Subtle>
61 <T>
62 This data is only intended for debugging Interactive Smartlog and may not capture all
63 issues.
64 </T>
65 </Subtle>
66 <DropdownField title={<T>Performance</T>}>
67 <DebugPerfInfo />
68 </DropdownField>
69 <DropdownField title={<T>Commit graph</T>}>
70 <DebugDagInfo />
71 </DropdownField>
72 <DropdownField title={<T>Internal State</T>}>
73 <InternalState />
74 </DropdownField>
75 <DropdownField title={<T>Server/Client Messages</T>}>
76 <Column alignStart>
77 <ServerClientMessageLogging />
78 <Row>
79 <ForceDisconnectButton />
80 <NopOperationButtons />
81 </Row>
82 <Row>
83 <StressTestMessages />
84 </Row>
85 </Column>
86 </DropdownField>
87 <DropdownField title={<T>Component Explorer</T>}>
88 <ComponentExplorerButton dismiss={dismiss} />
89 </DropdownField>
90 </DropdownFields>
91 );
92}
93
94function nextTick(): Promise<void> {
95 return new Promise(res => setTimeout(res, 0));
96}
97
98const stressTestAtom = atom<{progressPct: number | null; mismatches: Array<number>}>({
99 progressPct: null,
100 mismatches: [],
101});
102/** Look for out of order message passing by sending thousands of messages and verifying their ordering */
103function StressTestMessages() {
104 const [result, setResult] = useAtom(stressTestAtom);
105
106 const N = 100_000;
107 // how many messages to send before pausing an async tick
108 const tickEvery = 5_000;
109
110 const ONE_KILOBYTE = 1000;
111 // how large of a string payload to add to each message
112 const payloadSize = 1.0 * ONE_KILOBYTE;
113
114 const enableLogging = false;
115
116 return (
117 <>
118 <Button
119 disabled={result.progressPct != null && result.progressPct !== 100}
120 onClick={async () => {
121 const log = enableLogging ? console.log : () => null;
122
123 setResult({
124 progressPct: null,
125 mismatches: [],
126 });
127 await nextTick();
128
129 log(' ------ Begin Stress ------');
130 const payload = 'a'.repeat(payloadSize);
131
132 let lastReceivedId = 0;
133 const dispose = serverApi.onMessageOfType('stress', ({id, time}) => {
134 log(' < ', id, time);
135 if (id !== lastReceivedId + 1) {
136 setResult(last => ({...last, mismatches: [...last.mismatches, id]}));
137 }
138 lastReceivedId = id;
139
140 setResult(last => ({...last, progressPct: (100 * id) / N}));
141 if (id == N) {
142 dispose.dispose();
143 setResult(last => ({...last, progressPct: 100})); // if last message was out of order, we'd get stuck
144 log(' ------ End Stress ------');
145 }
146 });
147
148 for (let id = 1; id <= N; id++) {
149 if (id % tickEvery === 0) {
150 // eslint-disable-next-line no-await-in-loop
151 await nextTick();
152 }
153 serverApi.postMessage({
154 type: 'stress',
155 id,
156 time: new Date().valueOf(),
157 message: payload,
158 });
159 log(' > ', id);
160 }
161 }}>
162 <T>Message Stress Test</T>
163 </Button>
164 {result.progressPct == null ? null : result.progressPct < 100 ? (
165 <Row style={{fontVariant: 'tabular-nums'}}>
166 <Icon icon="loading" /> {Math.round(result.progressPct)}%
167 </Row>
168 ) : (
169 <Tooltip title={t(`Sent ${N} messages, with ${result.mismatches.length} out of order`)}>
170 {result.mismatches.length === 0 ? (
171 <Icon icon="pass" color="green" />
172 ) : (
173 <Icon icon="error" color="red" />
174 )}
175 </Tooltip>
176 )}
177 {result.mismatches.length === 0 ? null : (
178 <Banner kind={BannerKind.error}>{result.mismatches.join(',')}</Banner>
179 )}
180 </>
181 );
182}
183
184function InternalState() {
185 const [reduxTools, setReduxTools] = useAtom(enableReduxTools);
186 const [reactTools, setReactTools] = useAtom(enableReactTools);
187 const needSerialize = useAtomValue(holdingCtrlAtom);
188
189 const generate = () => {
190 // No need for useAtomValue - no need to re-render or recalculate this function.
191 const atomsState = readInterestingAtoms();
192 const value = needSerialize ? serializeAtomsState(atomsState) : atomsState;
193 console.log(`jotai state (${needSerialize ? 'JSON' : 'objects'}):`, value);
194 showToast(`logged jotai state to console!${needSerialize ? ' (serialized)' : ''}`);
195 };
196
197 return (
198 <Column alignStart>
199 <Row>
200 <Tooltip
201 placement="bottom"
202 title={t(
203 'Capture a snapshot of selected Jotai atom states, log it to the dev tools console.\n\n' +
204 'Hold Ctrl to use serialized JSON instead of Javascript objects.',
205 )}>
206 <Button onClick={generate}>
207 {needSerialize ? <T>Take Snapshot (JSON)</T> : <T>Take Snapshot (objects)</T>}
208 </Button>
209 </Tooltip>
210 <Tooltip
211 placement="bottom"
212 title={t(
213 'Log persisted state (localStorage or vscode storage) to the dev tools console.',
214 )}>
215 <Button
216 onClick={() => {
217 console.log('persisted state:', platform.getAllPersistedState());
218 showToast('logged persisted state to console!');
219 }}>
220 <T>Log Persisted State</T>
221 </Button>
222 </Tooltip>
223 <Tooltip
224 placement="bottom"
225 title={t(
226 'Clear any persisted state (localStorage or vscode storage). Usually only matters after restarting.',
227 )}>
228 <Button
229 onClick={() => {
230 platform.clearPersistedState();
231 console.log('--- cleared isl persisted state ---');
232 showToast('cleared persisted state');
233 }}>
234 <T>Clear Persisted State</T>
235 </Button>
236 </Tooltip>
237 </Row>
238 {isDev && (
239 <Row>
240 <T>Integrate with: </T>
241 <Checkbox checked={reduxTools} onChange={setReduxTools}>
242 <T>Redux DevTools</T>
243 </Checkbox>
244 <Checkbox checked={reactTools} onChange={setReactTools}>
245 {t('React <DebugAtoms/>')}
246 </Checkbox>
247 </Row>
248 )}
249 </Column>
250 );
251}
252
253const logMessagesState = atomWithOnChange(
254 atom(debugLogMessageTraffic.shouldLog),
255 newValue => {
256 debugLogMessageTraffic.shouldLog = newValue;
257 console.log(`----- ${newValue ? 'Enabled' : 'Disabled'} Logging Messages -----`);
258 },
259 /* skipInitialCall */ true,
260);
261
262function ServerClientMessageLogging() {
263 const [shouldLog, setShouldLog] = useAtom(logMessagesState);
264 return (
265 <div>
266 <Checkbox checked={shouldLog} onChange={checked => setShouldLog(checked)}>
267 <T>Log messages</T>
268 </Checkbox>
269 </div>
270 );
271}
272
273function DebugPerfInfo() {
274 const latestStatus = useAtomValue(latestUncommittedChangesData);
275 const latestLog = useAtomValue(latestCommitsData);
276 const latestConflicts = useAtomValue(mergeConflicts);
277 const heartbeat = useHeartbeat();
278 const commandName = useAtomValue(mainCommandName);
279 return (
280 <div>
281 {heartbeat.type === 'timeout' ? (
282 <InlineErrorBadge error={new Error(t('Heartbeat timeout'))}>
283 <T>Heartbeat timed out</T>
284 </InlineErrorBadge>
285 ) : (
286 <FetchDurationInfo
287 name={<T>ISL Server Ping</T>}
288 duration={(heartbeat as Heartbeat & {type: 'success'})?.rtt}
289 />
290 )}
291 <FetchDurationInfo
292 name={<T replace={{$cmd: commandName}}>$cmd status</T>}
293 start={latestStatus.fetchStartTimestamp}
294 end={latestStatus.fetchCompletedTimestamp}
295 />
296 <FetchDurationInfo
297 name={<T replace={{$cmd: commandName}}>$cmd log</T>}
298 start={latestLog.fetchStartTimestamp}
299 end={latestLog.fetchCompletedTimestamp}
300 />
301 <FetchDurationInfo
302 name={<T>Merge Conflicts</T>}
303 start={latestConflicts?.fetchStartTimestamp}
304 end={latestConflicts?.fetchCompletedTimestamp}
305 />
306 </div>
307 );
308}
309
310const styles = stylex.create({
311 slow: {
312 color: colors.signalFg,
313 backgroundColor: colors.signalBadBg,
314 },
315 ok: {
316 color: colors.signalFg,
317 backgroundColor: colors.signalMediumBg,
318 },
319 fast: {
320 color: colors.signalFg,
321 backgroundColor: colors.signalGoodBg,
322 },
323});
324
325function FetchDurationInfo(
326 props: {name: ReactNode} & ExclusiveOr<{start?: number; end?: number}, {duration: number}>,
327) {
328 const {name} = props;
329 const {end, start, duration} = props;
330 const deltaMs = duration != null ? duration : end == null || start == null ? null : end - start;
331 const xstyle =
332 deltaMs == null
333 ? undefined
334 : deltaMs < 1000
335 ? styles.fast
336 : deltaMs < 3000
337 ? styles.ok
338 : styles.slow;
339 return (
340 <div className={`fetch-duration-info`}>
341 {name} <Badge xstyle={xstyle}>{deltaMs == null ? 'N/A' : `${deltaMs}ms`}</Badge>
342 {end == null ? null : (
343 <Subtle>
344 <Tooltip title={new Date(end).toLocaleString()} placement="right">
345 <RelativeDate date={end} />
346 </Tooltip>
347 </Subtle>
348 )}
349 </div>
350 );
351}
352
353function useMeasureDuration(slowOperation: () => void): number | null {
354 const [measured, setMeasured] = useState<null | number>(null);
355 useEffect(() => {
356 requestIdleCallback(() => {
357 const startTime = performance.now();
358 slowOperation();
359 const endTime = performance.now();
360 setMeasured(endTime - startTime);
361 });
362 return () => setMeasured(null);
363 }, [slowOperation]);
364 return measured;
365}
366
367function DebugDagInfo() {
368 const dag = useAtomValue(dagWithPreviews);
369 const dagRenderBenchmark = useCallback(() => {
370 // Slightly change the dag to invalidate its caches.
371 const noise = performance.now();
372 const newDag = dag.add([DagCommitInfo.fromCommitInfo({hash: `dummy-${noise}`, parents: []})]);
373 newDag.renderToRows(newDag.subsetForRendering());
374 }, [dag]);
375
376 const dagSize = dag.all().size;
377 const dagDisplayedSize = dag.subsetForRendering().size;
378 const dagSortMs = useMeasureDuration(dagRenderBenchmark);
379
380 return (
381 <div>
382 <T>Size: </T>
383 {dagSize}
384 <br />
385 <T>Displayed: </T>
386 {dagDisplayedSize}
387 <br />
388 <>
389 <T>Render calculation: </T>
390 {dagSortMs == null ? (
391 <T>(Measuring)</T>
392 ) : (
393 <>
394 {dagSortMs.toFixed(1)} <T>ms</T>
395 </>
396 )}
397 <br />
398 </>
399 </div>
400 );
401}
402
403const forceDisconnectDuration = atom<number>(3000);
404
405function ForceDisconnectButton() {
406 const [duration, setDuration] = useAtom(forceDisconnectDuration);
407 const forceDisconnect = platform.messageBus.forceDisconnect?.bind(platform.messageBus);
408 if (forceDisconnect == null) {
409 return null;
410 }
411 return (
412 <Button
413 onClick={() => forceDisconnect(duration)}
414 onWheel={e => {
415 // deltaY is usually -100 +100 per event.
416 const dy = e.deltaY;
417 const scale = duration < 20000 ? 10 : 100;
418 if (dy > 0) {
419 setDuration(v => Math.max(v - dy * scale, 1000));
420 } else if (dy < 0) {
421 setDuration(v => Math.min(v - dy * scale, 300000));
422 }
423 }}>
424 <T replace={{$sec: (duration / 1000).toFixed(1)}}>Force disconnect for $sec seconds</T>
425 </Button>
426 );
427}
428
429function NopOperationButtons() {
430 const runOperation = useRunOperation();
431 return (
432 <>
433 {[2, 5, 20].map(durationSeconds => (
434 <Button
435 key={durationSeconds}
436 onClick={() => runOperation(new NopOperation(durationSeconds))}>
437 <T replace={{$sec: durationSeconds}}>Nop $sec s</T>
438 </Button>
439 ))}
440 </>
441 );
442}
443