mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2026-04-29 20:37:05 +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 {
|
export function loadAllServers(saveString: string): void {
|
||||||
const allServersData: unknown = JSON.parse(saveString, Reviver);
|
const allServersData: unknown = JSON.parse(saveString, Reviver);
|
||||||
assertObject(allServersData);
|
assertObject(allServersData);
|
||||||
|
if (Object.keys(allServersData).length === 0) {
|
||||||
|
throw new Error("Server list is empty.");
|
||||||
|
}
|
||||||
for (const [serverName, server] of Object.entries(allServersData)) {
|
for (const [serverName, server] of Object.entries(allServersData)) {
|
||||||
if (!(server instanceof Server) && !(server instanceof HacknetServer)) {
|
if (!(server instanceof Server) && !(server instanceof HacknetServer)) {
|
||||||
throw new Error(`Server ${serverName} is not an instance of Server or 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> {
|
function getDB(): Promise<IDBObjectStore> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
+6
-26
@@ -48,6 +48,7 @@ import { Go } from "./Go/Go";
|
|||||||
import { EventEmitter } from "./utils/EventEmitter";
|
import { EventEmitter } from "./utils/EventEmitter";
|
||||||
import { Companies } from "./Company/Companies";
|
import { Companies } from "./Company/Companies";
|
||||||
import { resetGoPromises } from "./Go/boardAnalysis/goAI";
|
import { resetGoPromises } from "./Go/boardAnalysis/goAI";
|
||||||
|
import { getRecordEntries } from "./Types/Record";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// This property is only available in the dev build
|
// This property is only available in the dev build
|
||||||
@@ -67,30 +68,8 @@ declare global {
|
|||||||
export const GameCycleEvents = new EventEmitter<[]>();
|
export const GameCycleEvents = new EventEmitter<[]>();
|
||||||
|
|
||||||
/** Game engine. Handles the main game loop. */
|
/** Game engine. Handles the main game loop. */
|
||||||
const Engine: {
|
const Engine = {
|
||||||
_lastUpdate: number;
|
isRunning: false,
|
||||||
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;
|
|
||||||
} = {
|
|
||||||
// Time variables (milliseconds unix epoch time)
|
// Time variables (milliseconds unix epoch time)
|
||||||
_lastUpdate: new Date().getTime(),
|
_lastUpdate: new Date().getTime(),
|
||||||
updateGame: function (numCycles = 1) {
|
updateGame: function (numCycles = 1) {
|
||||||
@@ -170,7 +149,7 @@ const Engine: {
|
|||||||
},
|
},
|
||||||
|
|
||||||
decrementAllCounters: function (numCycles = 1) {
|
decrementAllCounters: function (numCycles = 1) {
|
||||||
for (const [counterName, counter] of Object.entries(Engine.Counters)) {
|
for (const [counterName, counter] of getRecordEntries(Engine.Counters)) {
|
||||||
if (counter === undefined) {
|
if (counter === undefined) {
|
||||||
exceptionAlert(new Error(`counter value is undefined. counterName: ${counterName}.`), true);
|
exceptionAlert(new Error(`counter value is undefined. counterName: ${counterName}.`), true);
|
||||||
continue;
|
continue;
|
||||||
@@ -246,7 +225,7 @@ const Engine: {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
load: async function (saveData) {
|
load: async function (saveData: SaveData) {
|
||||||
startExploits();
|
startExploits();
|
||||||
setupUncaughtPromiseHandler();
|
setupUncaughtPromiseHandler();
|
||||||
// Source files must be initialized early because save-game translation in
|
// Source files must be initialized early because save-game translation in
|
||||||
@@ -420,6 +399,7 @@ const Engine: {
|
|||||||
},
|
},
|
||||||
|
|
||||||
start: function () {
|
start: function () {
|
||||||
|
this.isRunning = true;
|
||||||
// Get time difference
|
// Get time difference
|
||||||
const _thisUpdate = new Date().getTime();
|
const _thisUpdate = new Date().getTime();
|
||||||
let diff = _thisUpdate - Engine._lastUpdate;
|
let diff = _thisUpdate - Engine._lastUpdate;
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
|
|||||||
console.error(error, errorInfo);
|
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 {
|
render(): React.ReactNode {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
let errorData: IErrorData | undefined;
|
let errorData: IErrorData | undefined;
|
||||||
|
|||||||
+13
-4
@@ -61,7 +61,7 @@ import { AlertManager } from "./React/AlertManager";
|
|||||||
import { PromptManager } from "./React/PromptManager";
|
import { PromptManager } from "./React/PromptManager";
|
||||||
import { FactionInvitationManager } from "../Faction/ui/FactionInvitationManager";
|
import { FactionInvitationManager } from "../Faction/ui/FactionInvitationManager";
|
||||||
import { calculateAchievements } from "../Achievements/Achievements";
|
import { calculateAchievements } from "../Achievements/Achievements";
|
||||||
import { RecoveryMode, RecoveryRoot } from "./React/RecoveryRoot";
|
import { ActivateRecoveryMode, RecoveryMode, RecoveryRoot } from "./React/RecoveryRoot";
|
||||||
import { AchievementsRoot } from "../Achievements/AchievementsRoot";
|
import { AchievementsRoot } from "../Achievements/AchievementsRoot";
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
import { ErrorBoundary } from "./ErrorBoundary";
|
||||||
import { ThemeBrowser } from "../Themes/ui/ThemeBrowser";
|
import { ThemeBrowser } from "../Themes/ui/ThemeBrowser";
|
||||||
@@ -116,9 +116,18 @@ function determineStartPage(): PageWithContext {
|
|||||||
if (RecoveryMode) {
|
if (RecoveryMode) {
|
||||||
return { page: Page.Recovery };
|
return { page: Page.Recovery };
|
||||||
}
|
}
|
||||||
if (isBitNodeFinished()) {
|
/**
|
||||||
// Go to BitVerse UI without animation.
|
* If the save data contains the server list, but WD data is invalid, isBitNodeFinished() will throw an error, and the
|
||||||
return { page: Page.BitVerse, flume: false, quick: true };
|
* 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) {
|
if (Player.currentWork !== null) {
|
||||||
return { page: Page.Work };
|
return { page: Page.Work };
|
||||||
|
|||||||
@@ -40,10 +40,9 @@ export function LoadingScreen(): React.ReactElement {
|
|||||||
pushGameReady();
|
pushGameReady();
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
})
|
})
|
||||||
.catch(async (error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
ActivateRecoveryMode(error);
|
ActivateRecoveryMode(error);
|
||||||
await Engine.load("");
|
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import GitHubIcon from "@mui/icons-material/GitHub";
|
|||||||
import { isBinaryFormat } from "../../../electron/saveDataBinaryFormat";
|
import { isBinaryFormat } from "../../../electron/saveDataBinaryFormat";
|
||||||
import { InvalidSaveData, UnsupportedSaveData } from "../../utils/SaveDataUtils";
|
import { InvalidSaveData, UnsupportedSaveData } from "../../utils/SaveDataUtils";
|
||||||
import { downloadContentAsFile } from "../../utils/FileUtils";
|
import { downloadContentAsFile } from "../../utils/FileUtils";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import { Engine } from "../../engine";
|
||||||
|
|
||||||
export let RecoveryMode = false;
|
export let RecoveryMode = false;
|
||||||
let sourceError: unknown;
|
let sourceError: unknown;
|
||||||
@@ -32,9 +34,8 @@ interface IProps {
|
|||||||
function exportSaveFile(): void {
|
function exportSaveFile(): void {
|
||||||
load()
|
load()
|
||||||
.then((content) => {
|
.then((content) => {
|
||||||
const epochTime = Math.round(Date.now() / 1000);
|
|
||||||
const extension = isBinaryFormat(content) ? "json.gz" : "json";
|
const extension = isBinaryFormat(content) ? "json.gz" : "json";
|
||||||
const filename = `RECOVERY_BITBURNER_${epochTime}.${extension}`;
|
const filename = `RECOVERY_BITBURNER_${Date.now()}.${extension}`;
|
||||||
downloadContentAsFile(content, filename);
|
downloadContentAsFile(content, filename);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.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 {
|
export function RecoveryRoot({ softReset, errorData, resetError }: IProps): React.ReactElement {
|
||||||
function recover(): void {
|
function recover(): void {
|
||||||
if (resetError) resetError();
|
if (resetError) resetError();
|
||||||
@@ -51,16 +71,28 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac
|
|||||||
}
|
}
|
||||||
Settings.AutosaveInterval = 0;
|
Settings.AutosaveInterval = 0;
|
||||||
|
|
||||||
// The architecture around RecoveryRoot is awkward, and it can be invoked in
|
// This happens in [1] mentioned above. errorData is undefined, so we need to parse sourceError to get errorData.
|
||||||
// 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.
|
|
||||||
if (errorData == null && sourceError) {
|
if (errorData == null && sourceError) {
|
||||||
errorData = getErrorForDisplay(sourceError, undefined, Page.LoadingScreen);
|
errorData = getErrorForDisplay(sourceError, undefined, Page.LoadingScreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
let instructions;
|
||||||
if (sourceError instanceof UnsupportedSaveData) {
|
if (sourceError instanceof UnsupportedSaveData) {
|
||||||
@@ -88,11 +120,18 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac
|
|||||||
Post in the #bug-report channel on Discord.
|
Post in the #bug-report channel on Discord.
|
||||||
</Link>
|
</Link>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography>Please include your save file.</Typography>
|
<Typography>Please include your save file and the crash report.</Typography>
|
||||||
</Box>
|
</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 (
|
return (
|
||||||
<Box sx={{ padding: "8px 16px", minHeight: "100vh", maxWidth: "1200px", boxSizing: "border-box" }}>
|
<Box sx={{ padding: "8px 16px", minHeight: "100vh", maxWidth: "1200px", boxSizing: "border-box" }}>
|
||||||
<Typography variant="h3">RECOVERY MODE ACTIVATED</Typography>
|
<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>
|
<Button onClick={exportSaveFile}>Export save file</Button>
|
||||||
<br />
|
<br />
|
||||||
<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 }}>
|
<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.">
|
{canDisableRecoveryMode && (
|
||||||
<Button onClick={recover} startIcon={<DirectionsRunIcon />}>
|
<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.">
|
||||||
Disable Recovery Mode
|
<Button onClick={recover} startIcon={<DirectionsRunIcon />}>
|
||||||
</Button>
|
Disable Recovery Mode
|
||||||
</Tooltip>
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<SoftResetButton color="warning" onTriggered={softReset} />
|
<SoftResetButton color="warning" onTriggered={softReset} />
|
||||||
<DeleteGameButton color="error" />
|
<DeleteGameButton color="error" />
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
|||||||
@@ -4,6 +4,29 @@ import Typography from "@mui/material/Typography";
|
|||||||
import { parseUnknownError } from "../ErrorHelper";
|
import { parseUnknownError } from "../ErrorHelper";
|
||||||
import { cyrb53 } from "../HashUtils";
|
import { cyrb53 } from "../HashUtils";
|
||||||
import { commitHash } from "./commitHash";
|
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>();
|
const errorSet = new Set<string>();
|
||||||
|
|
||||||
@@ -29,6 +52,13 @@ export function exceptionAlert(error: unknown, showOnlyOnce = false): void {
|
|||||||
errorSet.add(errorId);
|
errorSet.add(errorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const commitId = commitHash();
|
||||||
|
writeCrashReportToHome({
|
||||||
|
errorData,
|
||||||
|
commitId,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
dialogBoxCreate(
|
dialogBoxCreate(
|
||||||
<>
|
<>
|
||||||
Caught an exception: {errorData.errorAsString}
|
Caught an exception: {errorData.errorAsString}
|
||||||
@@ -54,7 +84,7 @@ export function exceptionAlert(error: unknown, showOnlyOnce = false): void {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<br />
|
<br />
|
||||||
Commit: {commitHash()}
|
Commit: {commitId}
|
||||||
<br />
|
<br />
|
||||||
UserAgent: {navigator.userAgent}
|
UserAgent: {navigator.userAgent}
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
Reference in New Issue
Block a user