Skip to content

Commit 3f573ce

Browse files
authored
Merge pull request #185 from Travisun/feat/desktop-bootloader
feat(desktop): bootloader hook chain for startup and shutdown
2 parents 5868fe9 + fd23d63 commit 3f573ce

8 files changed

Lines changed: 664 additions & 111 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* Register desktop boot / shutdown hooks (wired from main.cjs).
3+
* @param {ReturnType<import('./bootloader.cjs').createBootloader>} boot
4+
* @param {object} deps
5+
*/
6+
'use strict'
7+
8+
const { SIDECAR_GRACEFUL_MS } = require('./sidecar-supervisor.cjs')
9+
10+
/**
11+
* @param {ReturnType<import('./bootloader.cjs').createBootloader>} boot
12+
* @param {{
13+
* initResolvedPorts: () => Promise<boolean>
14+
* tryQuickPendingUpdate: () => Promise<boolean>
15+
* tryDeferredPendingUpdate: () => Promise<void>
16+
* startSidecarHealthWatchdog: () => void
17+
* startScheduleReconcilePoll: () => void
18+
* initUpdater: () => void
19+
* maybeBootstrapOfflineModelDownloads: () => void
20+
* requestNotificationPermission: () => void
21+
* stopScheduleReconcilePoll: () => void
22+
* stopSidecarAndWait: (ms?: number) => Promise<void>
23+
* disposeTranslation: () => Promise<void> | void
24+
* destroyTray: () => void
25+
* intentionalSidecarStopRef: { current: boolean }
26+
* stopSidecarSupervision: () => void
27+
* }} deps
28+
*/
29+
function registerDesktopBootloader(boot, deps) {
30+
boot.clear()
31+
32+
boot.registerBootCritical(
33+
'resolve-ports',
34+
async () => {
35+
const ok = await deps.initResolvedPorts()
36+
if (!ok) {
37+
throw new Error('无法解析本地 API 端口')
38+
}
39+
},
40+
{ timeoutMs: 20_000, required: true },
41+
)
42+
43+
boot.registerBootCritical(
44+
'pending-update-quick',
45+
async (ctx) => {
46+
const installing = await deps.tryQuickPendingUpdate()
47+
if (installing) {
48+
ctx.quittingForUpdate = true
49+
}
50+
},
51+
{ timeoutMs: 8_000, required: false },
52+
)
53+
54+
boot.registerBootDeferred('sidecar-health-watchdog', async () => {
55+
deps.startSidecarHealthWatchdog()
56+
})
57+
58+
boot.registerBootDeferred('schedule-reconcile', async () => {
59+
deps.startScheduleReconcilePoll()
60+
})
61+
62+
boot.registerBootDeferred('updater-init', async () => {
63+
deps.initUpdater()
64+
})
65+
66+
boot.registerBootDeferred('translation-bootstrap', async () => {
67+
deps.maybeBootstrapOfflineModelDownloads()
68+
})
69+
70+
boot.registerBootDeferred('notification-permission', async () => {
71+
deps.requestNotificationPermission()
72+
})
73+
74+
boot.registerBootDeferred('pending-update-deferred', async () => {
75+
await deps.tryDeferredPendingUpdate()
76+
})
77+
78+
// Registration order = shutdown reverse (LIFO).
79+
boot.registerShutdown('tray', async () => {
80+
deps.destroyTray()
81+
}, { timeoutMs: 2_000 })
82+
83+
boot.registerShutdown('translation', async () => {
84+
await deps.disposeTranslation()
85+
}, { timeoutMs: 4_000 })
86+
87+
boot.registerShutdown('sidecar', async () => {
88+
deps.intentionalSidecarStopRef.current = true
89+
deps.stopSidecarSupervision()
90+
await deps.stopSidecarAndWait(SIDECAR_GRACEFUL_MS)
91+
}, { timeoutMs: SIDECAR_GRACEFUL_MS + 3_000 })
92+
93+
boot.registerShutdown('schedule-poll', async () => {
94+
deps.stopScheduleReconcilePoll()
95+
}, { timeoutMs: 2_000 })
96+
}
97+
98+
module.exports = {
99+
registerDesktopBootloader,
100+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* Desktop boot / shutdown hook runner.
3+
* Pure Node — no electron at module load.
4+
*
5+
* - Critical boot hooks: sequential, block UI until done (per-hook timeout).
6+
* - Deferred boot hooks: fire-and-forget after shell ready; never block quit.
7+
* - Shutdown hooks: sequential LIFO registration order, global deadline anti-deadlock.
8+
*/
9+
'use strict'
10+
11+
/** Default per critical boot hook budget (sidecar cold start on Windows). */
12+
const BOOT_CRITICAL_DEFAULT_MS = 90_000
13+
14+
/** Per deferred hook is not awaited; this is only for logging. */
15+
const BOOT_DEFERRED_LABEL = 'deferred'
16+
17+
/** Must exceed SIDECAR_GRACEFUL_MS + SIDECAR_HARD_EXTRA_MS (~16.5s). */
18+
const SHUTDOWN_GLOBAL_MS = 20_000
19+
20+
/** Single shutdown hook soft budget before skip. */
21+
const SHUTDOWN_HOOK_DEFAULT_MS = 16_000
22+
23+
/**
24+
* @param {Promise<unknown>} promise
25+
* @param {number} ms
26+
* @param {string} label
27+
*/
28+
function withTimeout(promise, ms, label) {
29+
if (!Number.isFinite(ms) || ms <= 0) {
30+
return Promise.resolve(promise)
31+
}
32+
return new Promise((resolve, reject) => {
33+
const timer = setTimeout(() => {
34+
reject(new Error(`Hook timeout: ${label} (${ms}ms)`))
35+
}, ms)
36+
Promise.resolve(promise).then(
37+
(value) => {
38+
clearTimeout(timer)
39+
resolve(value)
40+
},
41+
(err) => {
42+
clearTimeout(timer)
43+
reject(err)
44+
},
45+
)
46+
})
47+
}
48+
49+
/**
50+
* @returns {{
51+
* registerBootCritical: (name: string, run: (ctx: object) => Promise<void> | void, opts?: { timeoutMs?: number, required?: boolean }) => void
52+
* registerBootDeferred: (name: string, run: (ctx: object) => Promise<void> | void) => void
53+
* registerShutdown: (name: string, run: (ctx: object) => Promise<void> | void, opts?: { timeoutMs?: number }) => void
54+
* runBootCritical: (ctx?: object) => Promise<void>
55+
* runBootDeferred: (ctx?: object) => void
56+
* runShutdown: (ctx?: object) => Promise<void>
57+
* clear: () => void
58+
* }}
59+
*/
60+
function createBootloader() {
61+
/** @type {Array<{ name: string, run: Function, timeoutMs: number, required: boolean }>} */
62+
const bootCritical = []
63+
/** @type {Array<{ name: string, run: Function }>} */
64+
const bootDeferred = []
65+
/** @type {Array<{ name: string, run: Function, timeoutMs: number }>} */
66+
const shutdownHooks = []
67+
68+
return {
69+
registerBootCritical(name, run, opts = {}) {
70+
bootCritical.push({
71+
name,
72+
run,
73+
timeoutMs: opts.timeoutMs ?? BOOT_CRITICAL_DEFAULT_MS,
74+
required: opts.required !== false,
75+
})
76+
},
77+
78+
registerBootDeferred(name, run) {
79+
bootDeferred.push({ name, run })
80+
},
81+
82+
registerShutdown(name, run, opts = {}) {
83+
shutdownHooks.push({
84+
name,
85+
run,
86+
timeoutMs: opts.timeoutMs ?? SHUTDOWN_HOOK_DEFAULT_MS,
87+
})
88+
},
89+
90+
async runBootCritical(ctx = {}) {
91+
for (const hook of bootCritical) {
92+
const started = Date.now()
93+
console.log(`[boot] critical → ${hook.name}`)
94+
try {
95+
await withTimeout(Promise.resolve().then(() => hook.run(ctx)), hook.timeoutMs, hook.name)
96+
console.log(`[boot] critical ✓ ${hook.name} (${Date.now() - started}ms)`)
97+
} catch (err) {
98+
const msg = err instanceof Error ? err.message : String(err)
99+
console.error(`[boot] critical ✗ ${hook.name}: ${msg}`)
100+
if (hook.required) {
101+
throw err instanceof Error ? err : new Error(msg)
102+
}
103+
}
104+
}
105+
},
106+
107+
runBootDeferred(ctx = {}) {
108+
for (const hook of bootDeferred) {
109+
void Promise.resolve()
110+
.then(() => hook.run(ctx))
111+
.then(() => {
112+
console.log(`[boot] ${BOOT_DEFERRED_LABEL}${hook.name}`)
113+
})
114+
.catch((err) => {
115+
const msg = err instanceof Error ? err.message : String(err)
116+
console.warn(`[boot] ${BOOT_DEFERRED_LABEL}${hook.name}: ${msg}`)
117+
})
118+
}
119+
},
120+
121+
async runShutdown(ctx = {}) {
122+
const deadline = Date.now() + SHUTDOWN_GLOBAL_MS
123+
console.log('[shutdown] begin')
124+
// LIFO — last registered runs first (UI-adjacent before infrastructure).
125+
const chain = [...shutdownHooks].reverse()
126+
for (const hook of chain) {
127+
const remaining = deadline - Date.now()
128+
if (remaining <= 0) {
129+
console.warn(`[shutdown] global deadline (${SHUTDOWN_GLOBAL_MS}ms); skip ${hook.name}`)
130+
break
131+
}
132+
const budget = Math.min(hook.timeoutMs, remaining)
133+
const started = Date.now()
134+
console.log(`[shutdown] → ${hook.name}`)
135+
try {
136+
await withTimeout(Promise.resolve().then(() => hook.run(ctx)), budget, hook.name)
137+
console.log(`[shutdown] ✓ ${hook.name} (${Date.now() - started}ms)`)
138+
} catch (err) {
139+
const msg = err instanceof Error ? err.message : String(err)
140+
console.warn(`[shutdown] ✗ ${hook.name}: ${msg}`)
141+
}
142+
}
143+
console.log('[shutdown] end')
144+
},
145+
146+
clear() {
147+
bootCritical.length = 0
148+
bootDeferred.length = 0
149+
shutdownHooks.length = 0
150+
},
151+
}
152+
}
153+
154+
module.exports = {
155+
createBootloader,
156+
withTimeout,
157+
BOOT_CRITICAL_DEFAULT_MS,
158+
SHUTDOWN_GLOBAL_MS,
159+
SHUTDOWN_HOOK_DEFAULT_MS,
160+
}

0 commit comments

Comments
 (0)