Skip to content

Commit 00bba14

Browse files
committed
Fix desktop packaging and backend restart handling
1 parent c955688 commit 00bba14

4 files changed

Lines changed: 145 additions & 7 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
BACKEND_MAX_CONSECUTIVE_FAILURES,
5+
BACKEND_STABLE_RUN_MS,
6+
decideBackendRestart,
7+
INITIAL_BACKEND_RESTART_STATE,
8+
noteBackendLaunch,
9+
} from "./backendRestartPolicy";
10+
11+
describe("backendRestartPolicy", () => {
12+
it("backs off for consecutive fast crashes", () => {
13+
const firstLaunch = noteBackendLaunch(INITIAL_BACKEND_RESTART_STATE, 1_000);
14+
const firstFailure = decideBackendRestart(firstLaunch, 1_100);
15+
expect(firstFailure.type).toBe("restart");
16+
expect(firstFailure.delayMs).toBe(500);
17+
18+
const secondLaunch = noteBackendLaunch(firstFailure.nextState, 2_000);
19+
const secondFailure = decideBackendRestart(secondLaunch, 2_100);
20+
expect(secondFailure.type).toBe("restart");
21+
expect(secondFailure.delayMs).toBe(1_000);
22+
});
23+
24+
it("treats a stable run as a fresh failure streak", () => {
25+
const priorFailures = {
26+
consecutiveFailures: 4,
27+
lastLaunchAtMs: 10_000,
28+
};
29+
30+
const decision = decideBackendRestart(priorFailures, 10_000 + BACKEND_STABLE_RUN_MS);
31+
32+
expect(decision.type).toBe("restart");
33+
expect(decision.delayMs).toBe(500);
34+
expect(decision.nextState.consecutiveFailures).toBe(1);
35+
});
36+
37+
it("stops restarting after too many rapid failures", () => {
38+
let state = INITIAL_BACKEND_RESTART_STATE;
39+
40+
for (let index = 0; index < BACKEND_MAX_CONSECUTIVE_FAILURES - 1; index += 1) {
41+
state = noteBackendLaunch(state, 1_000 + index * 100);
42+
const decision = decideBackendRestart(state, 1_050 + index * 100);
43+
expect(decision.type).toBe("restart");
44+
state = decision.nextState;
45+
}
46+
47+
state = noteBackendLaunch(state, 5_000);
48+
const fatalDecision = decideBackendRestart(state, 5_050);
49+
50+
expect(fatalDecision.type).toBe("fatal");
51+
expect(fatalDecision.nextState.consecutiveFailures).toBe(BACKEND_MAX_CONSECUTIVE_FAILURES);
52+
});
53+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
export interface BackendRestartState {
2+
readonly consecutiveFailures: number;
3+
readonly lastLaunchAtMs: number | null;
4+
}
5+
6+
export interface BackendRestartDecision {
7+
readonly type: "restart" | "fatal";
8+
readonly delayMs: number;
9+
readonly nextState: BackendRestartState;
10+
readonly uptimeMs: number;
11+
}
12+
13+
export const BACKEND_STABLE_RUN_MS = 5_000;
14+
export const BACKEND_MAX_CONSECUTIVE_FAILURES = 5;
15+
const BACKEND_RESTART_BASE_DELAY_MS = 500;
16+
const BACKEND_RESTART_MAX_DELAY_MS = 10_000;
17+
18+
export const INITIAL_BACKEND_RESTART_STATE: BackendRestartState = {
19+
consecutiveFailures: 0,
20+
lastLaunchAtMs: null,
21+
};
22+
23+
export function noteBackendLaunch(
24+
state: BackendRestartState,
25+
launchedAtMs: number,
26+
): BackendRestartState {
27+
return {
28+
...state,
29+
lastLaunchAtMs: launchedAtMs,
30+
};
31+
}
32+
33+
export function decideBackendRestart(
34+
state: BackendRestartState,
35+
nowMs: number,
36+
): BackendRestartDecision {
37+
const uptimeMs = state.lastLaunchAtMs === null ? 0 : Math.max(0, nowMs - state.lastLaunchAtMs);
38+
const stableRun = uptimeMs >= BACKEND_STABLE_RUN_MS;
39+
const consecutiveFailures = stableRun ? 1 : state.consecutiveFailures + 1;
40+
const delayMs = Math.min(
41+
BACKEND_RESTART_BASE_DELAY_MS * 2 ** Math.max(consecutiveFailures - 1, 0),
42+
BACKEND_RESTART_MAX_DELAY_MS,
43+
);
44+
45+
return {
46+
type: consecutiveFailures >= BACKEND_MAX_CONSECUTIVE_FAILURES ? "fatal" : "restart",
47+
delayMs,
48+
nextState: {
49+
consecutiveFailures,
50+
lastLaunchAtMs: null,
51+
},
52+
uptimeMs,
53+
};
54+
}

apps/desktop/src/main.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ import {
4949
reduceDesktopUpdateStateOnUpdateAvailable,
5050
} from "./updateMachine";
5151
import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";
52+
import {
53+
decideBackendRestart,
54+
INITIAL_BACKEND_RESTART_STATE,
55+
noteBackendLaunch,
56+
} from "./backendRestartPolicy";
5257

5358
syncShellEnvironment();
5459

@@ -89,14 +94,14 @@ let backendProcess: ChildProcess.ChildProcess | null = null;
8994
let backendPort = 0;
9095
let backendAuthToken = "";
9196
let backendWsUrl = "";
92-
let restartAttempt = 0;
9397
let restartTimer: ReturnType<typeof setTimeout> | null = null;
9498
let isQuitting = false;
9599
let desktopProtocolRegistered = false;
96100
let aboutCommitHashCache: string | null | undefined;
97101
let desktopLogSink: RotatingFileSink | null = null;
98102
let backendLogSink: RotatingFileSink | null = null;
99103
let restoreStdIoCapture: (() => void) | null = null;
104+
let backendRestartState = INITIAL_BACKEND_RESTART_STATE;
100105

101106
let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
102107
let macDeveloperIdSigned: boolean | null = null;
@@ -1041,8 +1046,19 @@ function backendEnv(): NodeJS.ProcessEnv {
10411046
function scheduleBackendRestart(reason: string): void {
10421047
if (isQuitting || restartTimer) return;
10431048

1044-
const delayMs = Math.min(500 * 2 ** restartAttempt, 10_000);
1045-
restartAttempt += 1;
1049+
const decision = decideBackendRestart(backendRestartState, Date.now());
1050+
backendRestartState = decision.nextState;
1051+
if (decision.type === "fatal") {
1052+
handleFatalStartupError(
1053+
"backend",
1054+
new Error(
1055+
`The background server crashed ${decision.nextState.consecutiveFailures} times in a row within ${decision.uptimeMs}ms. Check ${Path.join(LOG_DIR, "server-child.log")} for details.`,
1056+
),
1057+
);
1058+
return;
1059+
}
1060+
1061+
const delayMs = decision.delayMs;
10461062
console.error(`[desktop] backend exited unexpectedly (${reason}); restarting in ${delayMs}ms`);
10471063

10481064
restartTimer = setTimeout(() => {
@@ -1061,6 +1077,7 @@ function startBackend(): void {
10611077
}
10621078

10631079
const captureBackendLogs = app.isPackaged && backendLogSink !== null;
1080+
backendRestartState = noteBackendLaunch(backendRestartState, Date.now());
10641081
const child = ChildProcess.spawn(process.execPath, [backendEntry], {
10651082
cwd: resolveBackendCwd(),
10661083
// In Electron main, process.execPath points to the Electron binary.
@@ -1084,10 +1101,6 @@ function startBackend(): void {
10841101
);
10851102
captureBackendOutput(child);
10861103

1087-
child.once("spawn", () => {
1088-
restartAttempt = 0;
1089-
});
1090-
10911104
child.on("error", (error) => {
10921105
if (backendProcess === child) {
10931106
backendProcess = null;
@@ -1115,6 +1128,8 @@ function stopBackend(): void {
11151128
restartTimer = null;
11161129
}
11171130

1131+
backendRestartState = INITIAL_BACKEND_RESTART_STATE;
1132+
11181133
const child = backendProcess;
11191134
backendProcess = null;
11201135
if (!child) return;
@@ -1135,6 +1150,8 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
11351150
restartTimer = null;
11361151
}
11371152

1153+
backendRestartState = INITIAL_BACKEND_RESTART_STATE;
1154+
11381155
const child = backendProcess;
11391156
backendProcess = null;
11401157
if (!child) return;

scripts/build-desktop-artifact.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,12 +188,14 @@ interface StagePackageJson {
188188
readonly version: string;
189189
readonly buildVersion: string;
190190
readonly t3codeCommitHash: string;
191+
readonly packageManager?: string;
191192
readonly private: true;
192193
readonly description: string;
193194
readonly author: string;
194195
readonly main: string;
195196
readonly build: Record<string, unknown>;
196197
readonly dependencies: Record<string, unknown>;
198+
readonly patchedDependencies?: Record<string, string>;
197199
readonly devDependencies: {
198200
readonly electron: string;
199201
};
@@ -647,11 +649,22 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
647649
// electron-builder is filtering out stageResourcesDir directory in the AppImage for production
648650
yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources"));
649651

652+
const rootPatchedDependencies = rootPackageJson.patchedDependencies;
653+
if (rootPatchedDependencies) {
654+
for (const relativePatchPath of Object.values(rootPatchedDependencies)) {
655+
const sourcePatchPath = path.join(repoRoot, relativePatchPath);
656+
const targetPatchPath = path.join(stageAppDir, relativePatchPath);
657+
yield* fs.makeDirectory(path.dirname(targetPatchPath), { recursive: true });
658+
yield* fs.copyFile(sourcePatchPath, targetPatchPath);
659+
}
660+
}
661+
650662
const stagePackageJson: StagePackageJson = {
651663
name: "t3-code-desktop",
652664
version: appVersion,
653665
buildVersion: appVersion,
654666
t3codeCommitHash: commitHash,
667+
packageManager: rootPackageJson.packageManager,
655668
private: true,
656669
description: "T3 Code desktop build",
657670
author: "T3 Tools",
@@ -667,6 +680,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
667680
...bundledCopilotPlatformDependencies,
668681
...resolvedDesktopRuntimeDependencies,
669682
},
683+
patchedDependencies: rootPatchedDependencies,
670684
devDependencies: {
671685
electron: electronVersion,
672686
},

0 commit comments

Comments
 (0)