返回 DeepSeek-Reasonix
lifecycle.ts
根目录 / desktop / electron / src / main / lifecycle.ts
1 import { randomUUID } from "node:crypto";
2 import { errorText, type Logger } from "./log.js";
3 import type { ShutdownPhase } from "./service.js";
4
5 export type QuitPhase = "idle" | "preparing" | "saving" | "closing" | "failed" | "completed";
6
7 export interface LifecycleService {
8 beforeClose(reason: string): Promise<boolean>;
9 shutdown(
10 reason?: "user_quit" | "update_restart" | "system_signal",
11 onProgress?: (phase: ShutdownPhase) => void,
12 ): Promise<void>;
13 shutdownRequestIdentity?(): string;
14 }
15
16 export interface LifecycleApp {
17 quit(): void;
18 exit?(code: number): void;
19 relaunch(args: string[], execPath?: string): void;
20 }
21
22 export interface QuitSequencerDeps {
23 service: LifecycleService;
24 app: LifecycleApp;
25 flushRenderer?: () => Promise<void>;
26 resumeRenderer?: () => Promise<void>;
27 onWindowClosePrevented?: () => void;
28 onPrepareFailed?: (message: string) => Promise<void> | void;
29 confirmRecoveryDraftLoss?: (message: string) => Promise<boolean>;
30 recoveryDraftTimeoutMs?: number;
31 onShutdownFailed?: (message: string) => Promise<boolean>;
32 onCloseAllowed(): void;
33 cleanup?: Array<{ name: string; run(): void }>;
34 schedule?: (run: () => void, milliseconds: number) => void;
35 now?: () => number;
36 log: Logger;
37 }
38
39 // Electron's before-quit fires on every app.quit(); this drives it through
40 // beforeClose (Go may veto) and shutdown exactly once, then lets it through.
41 export class QuitSequencer {
42 private phase: QuitPhase = "idle";
43 private approved = false;
44 private relaunchArgs: string[] | null = null;
45 private relaunchExecPath: string | undefined;
46 private attempt = "";
47 private reason: "user_quit" | "update_restart" | "system_signal" = "user_quit";
48 private reasonClaimed = false;
49 private preparing: Promise<void> | null = null;
50 private finishing: Promise<void> | null = null;
51 private quitRequested = false;
52 private rendererFlushed = false;
53 private recoveryRestart = false;
54 private attemptStartedAt = 0;
55 private draftSaveMs = 0;
56
57 constructor(private readonly deps: QuitSequencerDeps) {}
58
59 get currentPhase(): QuitPhase {
60 return this.phase;
61 }
62
63 get isQuitting(): boolean {
64 return (
65 this.approved ||
66 this.phase === "saving" ||
67 this.phase === "closing" ||
68 this.phase === "failed" ||
69 this.phase === "completed"
70 );
71 }
72
73 onBeforeQuit(): boolean {
74 if (this.phase === "completed") return true;
75 if (this.phase === "failed") {
76 if (!this.finishing) this.phase = "saving";
77 void this.startFinishing();
78 return false;
79 }
80 if (this.phase === "preparing") {
81 this.quitRequested = true;
82 this.claimReason("user_quit");
83 return false;
84 }
85 if (this.phase !== "idle") return false;
86 this.claimReason("user_quit");
87 this.quitRequested = true;
88 this.beginAttempt();
89 this.deps.log.info(`exit ${this.attempt}: ${this.approved ? "saving" : "preparing"}`);
90 if (!this.approved) {
91 this.phase = "preparing";
92 this.startPreparing(() => this.ask());
93 return false;
94 }
95 this.phase = "saving";
96 void this.startFinishing();
97 return false;
98 }
99
100 requestQuit(reason: "user_quit" | "system_signal" = "user_quit"): void {
101 this.claimReason(reason);
102 this.quitRequested = true;
103 this.deps.app.quit();
104 }
105
106 requestWindowClose(): Promise<void> {
107 if (this.phase === "completed") return Promise.resolve();
108 if (this.phase === "failed") {
109 if (!this.finishing) this.phase = "saving";
110 return this.startFinishing();
111 }
112 if (this.isQuitting) return this.preparing ?? Promise.resolve();
113 if (this.phase === "preparing") return this.preparing ?? Promise.resolve();
114 if (this.phase !== "idle") return Promise.resolve();
115 this.beginAttempt();
116 this.phase = "preparing";
117 this.deps.log.info(`exit ${this.attempt}: preparing window close`);
118 return this.startPreparing(() => this.prepareWindowClose());
119 }
120
121 approve(): void {
122 this.approved = true;
123 this.quitRequested = true;
124 this.deps.app.quit();
125 }
126
127 relaunch(args: string[], execPath?: string): void {
128 this.relaunchArgs = args;
129 this.relaunchExecPath = execPath;
130 this.claimReason("update_restart");
131 this.approve();
132 }
133
134 recoverRenderer(args: string[]): void {
135 if (this.phase !== "idle") return;
136 this.recoveryRestart = true;
137 this.relaunch(args);
138 }
139
140 private async ask(): Promise<void> {
141 let prevent = false;
142 if (!(await this.flushRenderer("quit cancelled"))) return;
143 try {
144 prevent = await this.deps.service.beforeClose("quit");
145 } catch (error) {
146 this.deps.log.warn(`beforeClose(quit) failed, quitting anyway: ${errorText(error)}`);
147 }
148 if (prevent && !this.approved) {
149 this.deps.log.info(`exit ${this.attempt}: cancelled`);
150 this.quitRequested = false;
151 await this.resumeRenderer();
152 this.rendererFlushed = false;
153 if (!this.quitRequested && !this.approved) this.resetTrigger();
154 return;
155 }
156 this.approved = true;
157 }
158
159 private async prepareWindowClose(): Promise<void> {
160 if (!(await this.flushRenderer("window close cancelled"))) return;
161 let prevent = false;
162 try {
163 prevent = await this.deps.service.beforeClose("window");
164 } catch (error) {
165 this.deps.log.warn(`beforeClose(window) failed, quitting anyway: ${errorText(error)}`);
166 }
167 if (this.quitRequested || this.approved || !prevent) {
168 this.approved = true;
169 this.quitRequested = true;
170 this.claimReason("user_quit");
171 return;
172 }
173 await this.resumeRenderer();
174 this.rendererFlushed = false;
175 // Resuming editing crosses the renderer boundary. A quit received during
176 // that await owns the next transition and must flush any resumed edits.
177 if (this.quitRequested || this.approved) {
178 this.approved = true;
179 return;
180 }
181 this.deps.onWindowClosePrevented?.();
182 this.deps.log.info(`exit ${this.attempt}: window hidden`);
183 this.resetTrigger();
184 }
185
186 private async finish(): Promise<void> {
187 if (!(await this.flushRenderer("shutdown cancelled"))) return;
188 const serviceStartedAt = this.now();
189 try {
190 await this.deps.service.shutdown(this.reason, (phase) => {
191 if (phase === "preparing" || phase === "saving" || phase === "closing") this.phase = phase;
192 });
193 } catch (error) {
194 const message = errorText(error);
195 this.deps.log.warn(
196 `exit ${this.attempt}: shutdown failed request=${this.shutdownRequestIdentity()} reason=${this.reason} draft_ms=${this.draftSaveMs} service_ms=${this.now() - serviceStartedAt} total_ms=${this.totalMs()}: ${message}`,
197 );
198 this.phase = "failed";
199 this.approved = true;
200 let retry = false;
201 try {
202 retry = await this.deps.onShutdownFailed?.(message) === true;
203 } catch (promptError) {
204 this.deps.log.warn(`exit ${this.attempt}: shutdown failure prompt failed: ${errorText(promptError)}`);
205 }
206 if (retry) {
207 this.phase = "saving";
208 await this.finish();
209 }
210 return;
211 }
212 this.deps.log.info(
213 `exit ${this.attempt}: shutdown complete request=${this.shutdownRequestIdentity()} reason=${this.reason} draft_ms=${this.draftSaveMs} service_ms=${this.now() - serviceStartedAt} total_ms=${this.totalMs()}`,
214 );
215 this.phase = "closing";
216 for (const step of [{ name: "close permission", run: () => this.deps.onCloseAllowed() }, ...(this.deps.cleanup ?? [])]) {
217 try {
218 step.run();
219 this.deps.log.info(`exit ${this.attempt}: cleanup ${step.name} complete`);
220 } catch (error) {
221 this.deps.log.warn(`exit ${this.attempt}: cleanup ${step.name} failed: ${errorText(error)}`);
222 }
223 }
224 this.phase = "completed";
225 this.deps.log.info(`exit ${this.attempt}: resources cleaned; requesting final shell exit`);
226 if (this.deps.app.exit) {
227 const schedule = this.deps.schedule ?? ((run, ms) => {
228 setTimeout(run, ms).unref();
229 });
230 schedule(() => {
231 this.deps.log.error("shell exit deadline exceeded after service shutdown");
232 this.deps.app.exit?.(1);
233 }, 5000);
234 }
235 try {
236 if (this.relaunchArgs) this.deps.app.relaunch(this.relaunchArgs, this.relaunchExecPath);
237 } catch (error) {
238 this.deps.log.error(`relaunch failed: ${errorText(error)}`);
239 } finally {
240 this.deps.app.quit();
241 }
242 }
243
244 private async resumeRenderer(): Promise<void> {
245 try {
246 await this.deps.resumeRenderer?.();
247 } catch (error) {
248 this.deps.log.warn(`exit ${this.attempt}: could not resume draft editing: ${errorText(error)}`);
249 }
250 }
251
252 private startPreparing(run: () => Promise<void>): Promise<void> {
253 if (this.preparing) return this.preparing;
254 this.preparing = run().finally(() => {
255 this.preparing = null;
256 this.settlePreparation();
257 });
258 return this.preparing;
259 }
260
261 private startFinishing(): Promise<void> {
262 if (this.finishing) return this.finishing;
263 this.finishing = this.finish().finally(() => {
264 this.finishing = null;
265 this.settlePreparation();
266 });
267 return this.finishing;
268 }
269
270 private settlePreparation(): void {
271 if (this.phase !== "preparing") return;
272 // Publish idle only after the previous promise releases ownership. A new
273 // transaction must never attach to a cancelled preparation's promise.
274 this.phase = "idle";
275 if (this.approved || this.quitRequested) this.deps.app.quit();
276 }
277
278 private async flushRenderer(cancelled: string): Promise<boolean> {
279 if (this.rendererFlushed) return true;
280 const startedAt = this.now();
281 let timeout: ReturnType<typeof setTimeout> | undefined;
282 try {
283 const flush = this.deps.flushRenderer?.();
284 if (this.recoveryRestart) {
285 await Promise.race([flush, new Promise<never>((_, reject) => {
286 timeout = setTimeout(() => reject(new Error("Draft save timed out during recovery")), this.deps.recoveryDraftTimeoutMs ?? 5000);
287 })]);
288 } else await flush;
289 this.draftSaveMs = this.now() - startedAt;
290 this.rendererFlushed = true;
291 this.deps.log.info(`exit ${this.attempt}: draft saved draft_ms=${this.draftSaveMs}`);
292 return true;
293 } catch (error) {
294 const message = errorText(error);
295 const recoveryCancelled = this.recoveryRestart;
296 if (this.recoveryRestart) {
297 let proceed = false;
298 try { proceed = await this.deps.confirmRecoveryDraftLoss?.(message) === true; }
299 catch (promptError) { this.deps.log.warn(`recovery draft confirmation failed: ${errorText(promptError)}`); }
300 if (proceed) {
301 this.rendererFlushed = true;
302 this.deps.log.warn("recovery restart: user accepted unsaved draft loss");
303 return true;
304 }
305 this.recoveryRestart = false;
306 this.relaunchArgs = null;
307 this.relaunchExecPath = undefined;
308 }
309 this.deps.log.warn(`exit ${this.attempt}: draft flush failed; ${cancelled}: ${message}`);
310 this.phase = "preparing";
311 this.approved = false;
312 this.quitRequested = false;
313 this.rendererFlushed = false;
314 if (recoveryCancelled) {
315 // Queue barrier release even if the page is still hung; do not make
316 // cancellation wait for that same unresponsive page a second time.
317 void this.resumeRenderer();
318 } else {
319 try {
320 await this.deps.onPrepareFailed?.(message);
321 } catch (promptError) {
322 this.deps.log.warn(`exit ${this.attempt}: draft failure prompt failed: ${errorText(promptError)}`);
323 }
324 }
325 if (!this.quitRequested && !this.approved) this.resetTrigger();
326 return false;
327 } finally {
328 clearTimeout(timeout);
329 }
330 }
331
332 private claimReason(reason: "user_quit" | "update_restart" | "system_signal"): void {
333 if (this.reasonClaimed) return;
334 this.reason = reason;
335 this.reasonClaimed = true;
336 }
337
338 private resetTrigger(): void {
339 this.attempt = "";
340 this.reason = "user_quit";
341 this.reasonClaimed = false;
342 this.quitRequested = false;
343 this.attemptStartedAt = 0;
344 this.draftSaveMs = 0;
345 }
346
347 private beginAttempt(): void {
348 if (this.attempt) return;
349 this.attempt = randomUUID();
350 this.attemptStartedAt = this.now();
351 }
352
353 private now(): number {
354 return this.deps.now?.() ?? Date.now();
355 }
356
357 private totalMs(): number {
358 return this.attemptStartedAt > 0 ? Math.max(0, this.now() - this.attemptStartedAt) : 0;
359 }
360
361 private shutdownRequestIdentity(): string {
362 return this.deps.service.shutdownRequestIdentity?.() || "none";
363 }
364 }
365
365 lines TYPESCRIPT