mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2026-04-16 06:18:42 +02:00
MISC: Export crash report when a fatal error occurs (#2106)
This commit is contained in:
@@ -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.`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SaveData } from "./types";
|
||||
import type { SaveData } from "./types";
|
||||
|
||||
function getDB(): Promise<IDBObjectStore> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user