MISC: Export crash report when a fatal error occurs (#2106)

This commit is contained in:
catloversg
2025-05-11 12:45:37 +07:00
committed by GitHub
parent b1b560b6c6
commit eea6733e3b
8 changed files with 127 additions and 48 deletions

View File

@@ -219,6 +219,9 @@ export function prestigeAllServers(): void {
export function loadAllServers(saveString: string): void {
const allServersData: unknown = JSON.parse(saveString, Reviver);
assertObject(allServersData);
if (Object.keys(allServersData).length === 0) {
throw new Error("Server list is empty.");
}
for (const [serverName, server] of Object.entries(allServersData)) {
if (!(server instanceof Server) && !(server instanceof HacknetServer)) {
throw new Error(`Server ${serverName} is not an instance of Server or HacknetServer.`);

View File

@@ -1,4 +1,4 @@
import { SaveData } from "./types";
import type { SaveData } from "./types";
function getDB(): Promise<IDBObjectStore> {
return new Promise((resolve, reject) => {

View File

@@ -48,6 +48,7 @@ import { Go } from "./Go/Go";
import { EventEmitter } from "./utils/EventEmitter";
import { Companies } from "./Company/Companies";
import { resetGoPromises } from "./Go/boardAnalysis/goAI";
import { getRecordEntries } from "./Types/Record";
declare global {
// This property is only available in the dev build
@@ -67,30 +68,8 @@ declare global {
export const GameCycleEvents = new EventEmitter<[]>();
/** Game engine. Handles the main game loop. */
const Engine: {
_lastUpdate: number;
updateGame: (numCycles?: number) => void;
Counters: {
[key: string]: number | undefined;
autoSaveCounter: number;
updateSkillLevelsCounter: number;
updateDisplays: number;
updateDisplaysLong: number;
updateActiveScriptsDisplay: number;
createProgramNotifications: number;
augmentationsNotifications: number;
checkFactionInvitations: number;
passiveFactionGrowth: number;
messages: number;
mechanicProcess: number;
contractGeneration: number;
achievementsCounter: number;
};
decrementAllCounters: (numCycles?: number) => void;
checkCounters: () => void;
load: (saveData: SaveData) => Promise<void>;
start: () => void;
} = {
const Engine = {
isRunning: false,
// Time variables (milliseconds unix epoch time)
_lastUpdate: new Date().getTime(),
updateGame: function (numCycles = 1) {
@@ -170,7 +149,7 @@ const Engine: {
},
decrementAllCounters: function (numCycles = 1) {
for (const [counterName, counter] of Object.entries(Engine.Counters)) {
for (const [counterName, counter] of getRecordEntries(Engine.Counters)) {
if (counter === undefined) {
exceptionAlert(new Error(`counter value is undefined. counterName: ${counterName}.`), true);
continue;
@@ -246,7 +225,7 @@ const Engine: {
}
},
load: async function (saveData) {
load: async function (saveData: SaveData) {
startExploits();
setupUncaughtPromiseHandler();
// Source files must be initialized early because save-game translation in
@@ -420,6 +399,7 @@ const Engine: {
},
start: function () {
this.isRunning = true;
// Get time difference
const _thisUpdate = new Date().getTime();
let diff = _thisUpdate - Engine._lastUpdate;

View File

@@ -35,6 +35,19 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
console.error(error, errorInfo);
}
/**
* When an error is thrown, this function is called twice and renders RecoveryRoot with two different errorData, even
* when there is only one error. The flow is roughly like this:
* - The error is thrown.
* - getDerivedStateFromError() -> Set hasError and error
* - render() -> Render RecoveryRoot with errorData1, which does not contain errorInfo and page
* - componentDidCatch() -> Set errorInfo and page
* - render() -> Render RecoveryRoot with errorData2, which contains errorInfo and page
*
* This means that if we use useEffect(()=>{}, [errorData]) in RecoveryRoot, that hook will be called twice with 2
* different errorData. The second errorData, which contains errorInfo and page, is the "final" value that is shown on
* the recovery screen.
*/
render(): React.ReactNode {
if (this.state.hasError) {
let errorData: IErrorData | undefined;

View File

@@ -61,7 +61,7 @@ import { AlertManager } from "./React/AlertManager";
import { PromptManager } from "./React/PromptManager";
import { FactionInvitationManager } from "../Faction/ui/FactionInvitationManager";
import { calculateAchievements } from "../Achievements/Achievements";
import { RecoveryMode, RecoveryRoot } from "./React/RecoveryRoot";
import { ActivateRecoveryMode, RecoveryMode, RecoveryRoot } from "./React/RecoveryRoot";
import { AchievementsRoot } from "../Achievements/AchievementsRoot";
import { ErrorBoundary } from "./ErrorBoundary";
import { ThemeBrowser } from "../Themes/ui/ThemeBrowser";
@@ -116,9 +116,18 @@ function determineStartPage(): PageWithContext {
if (RecoveryMode) {
return { page: Page.Recovery };
}
if (isBitNodeFinished()) {
// Go to BitVerse UI without animation.
return { page: Page.BitVerse, flume: false, quick: true };
/**
* If the save data contains the server list, but WD data is invalid, isBitNodeFinished() will throw an error, and the
* main UI will show a black screen instead of the recovery screen.
*/
try {
if (isBitNodeFinished()) {
// Go to BitVerse UI without animation.
return { page: Page.BitVerse, flume: false, quick: true };
}
} catch (error) {
ActivateRecoveryMode(error);
return { page: Page.Recovery };
}
if (Player.currentWork !== null) {
return { page: Page.Work };

View File

@@ -40,10 +40,9 @@ export function LoadingScreen(): React.ReactElement {
pushGameReady();
setLoaded(true);
})
.catch(async (error) => {
.catch((error) => {
console.error(error);
ActivateRecoveryMode(error);
await Engine.load("");
setLoaded(true);
});
}, []);

View File

@@ -14,6 +14,8 @@ import GitHubIcon from "@mui/icons-material/GitHub";
import { isBinaryFormat } from "../../../electron/saveDataBinaryFormat";
import { InvalidSaveData, UnsupportedSaveData } from "../../utils/SaveDataUtils";
import { downloadContentAsFile } from "../../utils/FileUtils";
import { debounce } from "lodash";
import { Engine } from "../../engine";
export let RecoveryMode = false;
let sourceError: unknown;
@@ -32,9 +34,8 @@ interface IProps {
function exportSaveFile(): void {
load()
.then((content) => {
const epochTime = Math.round(Date.now() / 1000);
const extension = isBinaryFormat(content) ? "json.gz" : "json";
const filename = `RECOVERY_BITBURNER_${epochTime}.${extension}`;
const filename = `RECOVERY_BITBURNER_${Date.now()}.${extension}`;
downloadContentAsFile(content, filename);
})
.catch((err) => {
@@ -42,6 +43,25 @@ function exportSaveFile(): void {
});
}
const debouncedExportSaveFile = debounce(exportSaveFile, 1000);
const debouncedExportCrashReport = debounce((crashReport: unknown) => {
const content = typeof crashReport === "object" ? JSON.stringify(crashReport) : String(crashReport);
downloadContentAsFile(content, `CRASH_REPORT_BITBURNER_${Date.now()}.txt`);
}, 2000);
/**
* The recovery screen can be activated in 2 ways:
* - Call ActivateRecoveryMode() [1].
* - Before loading the save data: An error is thrown in src\ui\LoadingScreen.tsx (e.g., cannot load SWC wasm files,
* cannot access IndexedDB and load the save data, Engine.load() throws an error).
* - isBitNodeFinished() throws an error in src\ui\GameRoot.tsx.
* - ErrorBoundary [2]: After loading the save data and GameRoot is rendered, an error is thrown anywhere else.
*
* [1]: errorData is undefined and sourceError, which is the error thrown in LoadingScreen.tsx, is set via ActivateRecoveryMode().
* [2]: RecoveryRoot is rendered twice with 2 different errorData. For more information, please check the comment in
* src\ui\ErrorBoundary.tsx.
*/
export function RecoveryRoot({ softReset, errorData, resetError }: IProps): React.ReactElement {
function recover(): void {
if (resetError) resetError();
@@ -51,16 +71,28 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac
}
Settings.AutosaveInterval = 0;
// The architecture around RecoveryRoot is awkward, and it can be invoked in
// a number of ways. If we are invoked via a save error, sourceError will be set
// and we won't have decoded the information into errorData.
// This happens in [1] mentioned above. errorData is undefined, so we need to parse sourceError to get errorData.
if (errorData == null && sourceError) {
errorData = getErrorForDisplay(sourceError, undefined, Page.LoadingScreen);
}
useEffect(() => {
exportSaveFile();
}, []);
// This hook is called twice in [2], so we need to debounce exportSaveFile().
debouncedExportSaveFile();
/**
* This hook can be called with 3 types of errorData:
* - In [1]: errorData.metadata.page is Page.LoadingScreen
* - In [2]:
* - First render: errorData.metadata.errorInfo is undefined
* - Second render: errorData.metadata.errorInfo contains componentStack
*
* The following check makes sure that we do not write the crash report in the "first render" of [2].
*/
if (errorData && (errorData.metadata.errorInfo || errorData.metadata.page === Page.LoadingScreen)) {
debouncedExportCrashReport(errorData.body);
}
}, [errorData]);
let instructions;
if (sourceError instanceof UnsupportedSaveData) {
@@ -88,11 +120,18 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac
Post in the #bug-report channel on Discord.
</Link>
</Typography>
<Typography>Please include your save file.</Typography>
<Typography>Please include your save file and the crash report.</Typography>
</Box>
);
}
/**
* If Engine.isRunning is false, it means that the loading process in src\ui\LoadingScreen.tsx failed, and the loaded
* data is either empty or corrupted (partially or fully). In this case, there is no reason to allow the player to
* disable the recovery mode and go back to the main UI.
*/
const canDisableRecoveryMode = Engine.isRunning;
return (
<Box sx={{ padding: "8px 16px", minHeight: "100vh", maxWidth: "1200px", boxSizing: "border-box" }}>
<Typography variant="h3">RECOVERY MODE ACTIVATED</Typography>
@@ -114,13 +153,19 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac
<Button onClick={exportSaveFile}>Export save file</Button>
<br />
<br />
<Typography>You can disable the recovery mode, but the game may not work correctly.</Typography>
{canDisableRecoveryMode && (
<Typography>
You can disable the recovery mode, but the game may not work correctly, and your save data may be corrupted.
</Typography>
)}
<ButtonGroup sx={{ my: 2 }}>
<Tooltip title="Disable the recovery mode and attempt to head back to the terminal page. This may or may not work. Ensure you saved the recovery file.">
<Button onClick={recover} startIcon={<DirectionsRunIcon />}>
Disable Recovery Mode
</Button>
</Tooltip>
{canDisableRecoveryMode && (
<Tooltip title="Disable the recovery mode and attempt to head back to the terminal page. This may or may not work. Ensure you saved the recovery file.">
<Button onClick={recover} startIcon={<DirectionsRunIcon />}>
Disable Recovery Mode
</Button>
</Tooltip>
)}
<SoftResetButton color="warning" onTriggered={softReset} />
<DeleteGameButton color="error" />
</ButtonGroup>

View File

@@ -4,6 +4,29 @@ import Typography from "@mui/material/Typography";
import { parseUnknownError } from "../ErrorHelper";
import { cyrb53 } from "../HashUtils";
import { commitHash } from "./commitHash";
import { Player } from "@player";
import type { TextFilePath } from "../../Paths/TextFilePath";
const crashReports = new Set<string>();
export function writeCrashReportToHome(errorData: unknown): void {
// Put all code in try-catch to make sure that this function never crashes.
try {
const crashReport = typeof errorData === "object" ? JSON.stringify(errorData) : String(errorData);
/**
* Crash reports are written to the home server, and they increase the size of save data. Therefore, it's best to
* ensure that we don't write duplicate reports. It may happen if a bug triggers exceptionAlert, and we forgot
* passing showOnlyOnce = true.
*/
if (crashReports.has(crashReport)) {
return;
}
Player.getHomeComputer().writeToTextFile(`CRASH_REPORT_${Date.now()}.txt` as TextFilePath, crashReport);
crashReports.add(crashReport);
} catch (error) {
console.error(error);
}
}
const errorSet = new Set<string>();
@@ -29,6 +52,13 @@ export function exceptionAlert(error: unknown, showOnlyOnce = false): void {
errorSet.add(errorId);
}
const commitId = commitHash();
writeCrashReportToHome({
errorData,
commitId,
userAgent: navigator.userAgent,
});
dialogBoxCreate(
<>
Caught an exception: {errorData.errorAsString}
@@ -54,7 +84,7 @@ export function exceptionAlert(error: unknown, showOnlyOnce = false): void {
</>
)}
<br />
Commit: {commitHash()}
Commit: {commitId}
<br />
UserAgent: {navigator.userAgent}
<br />