返回 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 onShutdownFailed?: (message: string) => Promise<boolean>;
30 onCloseAllowed(): void;
31 cleanup?: Array<{ name: string; run(): void }>;
32 schedule?: (run: () => void, milliseconds: number) => void;
33 now?: () => number;
34 log: Logger;
35 }
36
37 // Electron's before-quit fires on every app.quit(); this drives it through
38 // beforeClose (Go may veto) and shutdown exactly once, then lets it through.
39 export class QuitSequencer {
40 private phase: QuitPhase = "idle";
41 private approved = false;
42 private relaunchArgs: string[] | null = null;
43 private relaunchExecPath: string | undefined;
44 private attempt = "";
45 private reason: "user_quit" | "update_restart" | "system_signal" = "user_quit";
46 private reasonClaimed = false;
47 private preparing: Promise<void> | null = null;
48 private finishing: Promise<void> | null = null;
49 private quitRequested = false;
50 private rendererFlushed = false;
51 private attemptStartedAt = 0;
52 private draftSaveMs = 0;
53
54 constructor(private readonly deps: QuitSequencerDeps) {}
55
56 get currentPhase(): QuitPhase {
57 return this.phase;
58 }
59
60 get isQuitting(): boolean {
61 return (
62 this.approved ||
63 this.phase === "saving" ||
64 this.phase === "closing" ||
65 this.phase === "failed" ||
66 this.phase === "completed"
67 );
68 }
69
70 onBeforeQuit(): boolean {
71 if (this.phase === "completed") return true;
72 if (this.phase === "failed") {
73 if (!this.finishing) this.phase = "saving";
74 void this.startFinishing();
75 return false;
76 }
77 if (this.phase === "preparing") {
78 this.quitRequested = true;
79 this.claimReason("user_quit");
80 return false;
81 }
82 if (this.phase !== "idle") return false;
83 this.claimReason("user_quit");
84 this.quitRequested = true;
85 this.beginAttempt();
86 this.deps.log.info(`exit ${this.attempt}: ${this.approved ? "saving" : "preparing"}`);
87 if (!this.approved) {
88 this.phase = "preparing";
89 this.startPreparing(() => this.ask());
90 return false;
91 }
92 this.phase = "saving";
93 void this.startFinishing();
94 return false;
95 }
96
97 requestQuit(reason: "user_quit" | "system_signal" = "user_quit"): void {
98 this.claimReason(reason);
99 this.quitRequested = true;
100 this.deps.app.quit();
101 }
102
103 requestWindowClose(): Promise<void> {
104 if (this.phase === "completed") return Promise.resolve();
105 if (this.phase === "failed") {
106 if (!this.finishing) this.phase = "saving";
107 return this.startFinishing();
108 }
109 if (this.isQuitting) return this.preparing ?? Promise.resolve();
110 if (this.phase === "preparing") return this.preparing ?? Promise.resolve();
111 if (this.phase !== "idle") return Promise.resolve();
112 this.beginAttempt();
113 this.phase = "preparing";
114 this.deps.log.info(`exit ${this.attempt}: preparing window close`);
115 return this.startPreparing(() => this.prepareWindowClose());
116 }
117
118 approve(): void {
119 this.approved = true;
120 this.quitRequested = true;
121 this.deps.app.quit();
122 }
123
124 relaunch(args: string[], execPath?: string): void {
125 this.relaunchArgs = args;
126 this.relaunchExecPath = execPath;
127 this.claimReason("update_restart");
128 this.approve();
129 }
130
131 private async ask(): Promise<void> {
132 let prevent = false;
133 if (!(await this.flushRenderer("quit cancelled"))) return;
134 try {
135 prevent = await this.deps.service.beforeClose("quit");
136 } catch (error) {
137 this.deps.log.warn(`beforeClose(quit) failed, quitting anyway: ${errorText(error)}`);
138 }
139 if (prevent && !this.approved) {
140 this.deps.log.info(`exit ${this.attempt}: cancelled`);
141 this.quitRequested = false;
142 await this.resumeRenderer();
143 this.rendererFlushed = false;
144 if (!this.quitRequested && !this.approved) this.resetTrigger();
145 return;
146 }
147 this.approved = true;
148 }
149
150 private async prepareWindowClose(): Promise<void> {
151 if (!(await this.flushRenderer("window close cancelled"))) return;
152 let prevent = false;
153 try {
154 prevent = await this.deps.service.beforeClose("window");
155 } catch (error) {
156 this.deps.log.warn(`beforeClose(window) failed, quitting anyway: ${errorText(error)}`);
157 }
158 if (this.quitRequested || this.approved || !prevent) {
159 this.approved = true;
160 this.quitRequested = true;
161 this.claimReason("user_quit");
162 return;
163 }
164 await this.resumeRenderer();
165 this.rendererFlushed = false;
166 // Resuming editing crosses the renderer boundary. A quit received during
167 // that await owns the next transition and must flush any resumed edits.
168 if (this.quitRequested || this.approved) {
169 this.approved = true;
170 return;
171 }
172 this.deps.onWindowClosePrevented?.();
173 this.deps.log.info(`exit ${this.attempt}: window hidden`);
174 this.resetTrigger();
175 }
176
177 private async finish(): Promise<void> {
178 if (!(await this.flushRenderer("shutdown cancelled"))) return;
179 const serviceStartedAt = this.now();
180 try {
181 await this.deps.service.shutdown(this.reason, (phase) => {
182 if (phase === "preparing" || phase === "saving" || phase === "closing") this.phase = phase;
183 });
184 } catch (error) {
185 const message = errorText(error);
186 this.deps.log.warn(
187 `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}`,
188 );
189 this.phase = "failed";
190 this.approved = true;
191 let retry = false;
192 try {
193 retry = await this.deps.onShutdownFailed?.(message) === true;
194 } catch (promptError) {
195 this.deps.log.warn(`exit ${this.attempt}: shutdown failure prompt failed: ${errorText(promptError)}`);
196 }
197 if (retry) {
198 this.phase = "saving";
199 await this.finish();
200 }
201 return;
202 }
203 this.deps.log.info(
204 `exit ${this.attempt}: shutdown complete request=${this.shutdownRequestIdentity()} reason=${this.reason} draft_ms=${this.draftSaveMs} service_ms=${this.now() - serviceStartedAt} total_ms=${this.totalMs()}`,
205 );
206 this.phase = "closing";
207 for (const step of [{ name: "close permission", run: () => this.deps.onCloseAllowed() }, ...(this.deps.cleanup ?? [])]) {
208 try {
209 step.run();
210 this.deps.log.info(`exit ${this.attempt}: cleanup ${step.name} complete`);
211 } catch (error) {
212 this.deps.log.warn(`exit ${this.attempt}: cleanup ${step.name} failed: ${errorText(error)}`);
213 }
214 }
215 this.phase = "completed";
216 this.deps.log.info(`exit ${this.attempt}: resources cleaned; requesting final shell exit`);
217 if (this.deps.app.exit) {
218 const schedule = this.deps.schedule ?? ((run, ms) => {
219 setTimeout(run, ms).unref();
220 });
221 schedule(() => {
222 this.deps.log.error("shell exit deadline exceeded after service shutdown");
223 this.deps.app.exit?.(1);
224 }, 5000);
225 }
226 try {
227 if (this.relaunchArgs) this.deps.app.relaunch(this.relaunchArgs, this.relaunchExecPath);
228 } catch (error) {
229 this.deps.log.error(`relaunch failed: ${errorText(error)}`);
230 } finally {
231 this.deps.app.quit();
232 }
233 }
234
235 private async resumeRenderer(): Promise<void> {
236 try {
237 await this.deps.resumeRenderer?.();
238 } catch (error) {
239 this.deps.log.warn(`exit ${this.attempt}: could not resume draft editing: ${errorText(error)}`);
240 }
241 }
242
243 private startPreparing(run: () => Promise<void>): Promise<void> {
244 if (this.preparing) return this.preparing;
245 this.preparing = run().finally(() => {
246 this.preparing = null;
247 this.settlePreparation();
248 });
249 return this.preparing;
250 }
251
252 private startFinishing(): Promise<void> {
253 if (this.finishing) return this.finishing;
254 this.finishing = this.finish().finally(() => {
255 this.finishing = null;
256 this.settlePreparation();
257 });
258 return this.finishing;
259 }
260
261 private settlePreparation(): void {
262 if (this.phase !== "preparing") return;
263 // Publish idle only after the previous promise releases ownership. A new
264 // transaction must never attach to a cancelled preparation's promise.
265 this.phase = "idle";
266 if (this.approved || this.quitRequested) this.deps.app.quit();
267 }
268
269 private async flushRenderer(cancelled: string): Promise<boolean> {
270 if (this.rendererFlushed) return true;
271 const startedAt = this.now();
272 try {
273 await this.deps.flushRenderer?.();
274 this.draftSaveMs = this.now() - startedAt;
275 this.rendererFlushed = true;
276 this.deps.log.info(`exit ${this.attempt}: draft saved draft_ms=${this.draftSaveMs}`);
277 return true;
278 } catch (error) {
279 const message = errorText(error);
280 this.deps.log.warn(`exit ${this.attempt}: draft flush failed; ${cancelled}: ${message}`);
281 this.phase = "preparing";
282 this.approved = false;
283 this.quitRequested = false;
284 this.rendererFlushed = false;
285 try {
286 await this.deps.onPrepareFailed?.(message);
287 } catch (promptError) {
288 this.deps.log.warn(`exit ${this.attempt}: draft failure prompt failed: ${errorText(promptError)}`);
289 }
290 if (!this.quitRequested && !this.approved) this.resetTrigger();
291 return false;
292 }
293 }
294
295 private claimReason(reason: "user_quit" | "update_restart" | "system_signal"): void {
296 if (this.reasonClaimed) return;
297 this.reason = reason;
298 this.reasonClaimed = true;
299 }
300
301 private resetTrigger(): void {
302 this.attempt = "";
303 this.reason = "user_quit";
304 this.reasonClaimed = false;
305 this.quitRequested = false;
306 this.attemptStartedAt = 0;
307 this.draftSaveMs = 0;
308 }
309
310 private beginAttempt(): void {
311 if (this.attempt) return;
312 this.attempt = randomUUID();
313 this.attemptStartedAt = this.now();
314 }
315
316 private now(): number {
317 return this.deps.now?.() ?? Date.now();
318 }
319
320 private totalMs(): number {
321 return this.attemptStartedAt > 0 ? Math.max(0, this.now() - this.attemptStartedAt) : 0;
322 }
323
324 private shutdownRequestIdentity(): string {
325 return this.deps.service.shutdownRequestIdentity?.() || "none";
326 }
327 }
328
328 lines TYPESCRIPT