Repository navigation
Expand file tree
/
Copy pathinstrumentation.ts
More file actions
456 lines (405 loc) · 14.8 KB
/
Copy pathinstrumentation.ts
File metadata and controls
456 lines (405 loc) · 14.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
/**
* Server startup and process lifecycle management.
*
* Handles:
* - Job scheduler initialization with exponential-backoff retry
* - DAC hardware monitor initialization (non-blocking)
* - Hardware daemon pre-flight validation
* - Centralized SIGTERM/SIGINT signal handling
* - Global unhandled rejection/exception handlers
* - Graceful shutdown sequencing with 10 s force-exit watchdog
*
* Entry points:
* - `register()` — Next.js instrumentation hook, called automatically on server start.
* Runs only in the Node.js runtime; skipped on the Edge runtime.
* - `initializeScheduler()` — idempotent, safe to call from app code as a fallback.
*/
import { getJobManager, shutdownJobManager } from '@/src/scheduler'
import { getAutomationEngine, shutdownAutomationEngine } from '@/src/automation'
import { closeDatabase, closeBiometricsDatabase } from '@/src/db'
import { startBiometricsRetention, stopBiometricsRetention } from '@/src/db/retention'
import { getDacMonitor, shutdownDacMonitor } from '@/src/hardware/dacMonitor.instance'
import { startPiezoStreamServer, shutdownPiezoStreamServer } from '@/src/streaming/piezoStream'
import { startBonjourAnnouncement, stopBonjourAnnouncement } from '@/src/streaming/bonjourAnnounce'
import { startMqttBridge, shutdownMqttBridge } from '@/src/streaming/mqttBridge'
import { initializeKeepalives, shutdownKeepalives } from '@/src/services/temperatureKeepalive'
import { startAutoOffWatcher, stopAutoOffWatcher } from '@/src/services/autoOffWatcher'
import { startDatabaseIntegrityChecks, stopDatabaseIntegrityChecks } from '@/src/db/integrity'
import { startPerformanceMonitoring, stopPerformanceMonitoring, recordStartupPhase } from '@/src/lib/serverPerformance'
import { shutdownHomeKit, startHomeKitIfEnabled } from '@/src/homekit'
let isInitialized = false
let isShuttingDown = false
let handlersRegistered = false
let initializationPromise: Promise<void> | null = null
let hardwareReady = false
let hardwarePromise: Promise<void> | null = null
/**
* Centralized graceful shutdown coordinator.
* Sequences: wait for in-flight jobs → shutdown scheduler → close database → exit
*/
async function gracefulShutdown(signal: string): Promise<void> {
if (isShuttingDown) return
isShuttingDown = true
console.log(`Received ${signal}, starting graceful shutdown...`)
// Force exit after 10s if graceful shutdown hangs
const forceExitTimer = setTimeout(() => {
console.error('Graceful shutdown timed out after 10s, forcing exit')
process.exit(1)
}, 10_000)
forceExitTimer.unref()
stopPerformanceMonitoring()
await stopDatabaseIntegrityChecks()
// Step 0: Stop keepalive timers
try {
shutdownKeepalives()
}
catch (error) {
console.error('Error shutting down keepalives:', error)
}
// Step 1: Shutdown scheduler (waits for in-flight jobs internally)
try {
await shutdownJobManager()
}
catch (error) {
console.error('Error shutting down scheduler:', error)
}
// Step 1a: Stop the Autopilot rules engine tick
try {
await shutdownAutomationEngine()
}
catch (error) {
console.error('Error shutting down automation engine:', error)
}
// Step 2: Shutdown piezo stream server
try {
await shutdownPiezoStreamServer()
}
catch (error) {
console.error('Error shutting down piezo stream server:', error)
}
// Step 2b: Shutdown MQTT bridge (publish offline + close client cleanly)
try {
await shutdownMqttBridge()
}
catch (error) {
console.error('Error shutting down MQTT bridge:', error)
}
// Step 3: Stop Bonjour announcement
try {
stopBonjourAnnouncement()
}
catch (error) {
console.error('Error stopping Bonjour:', error)
}
// Step 3a: Stop HomeKit bridge (unpublish HAP mDNS, close TCP listener)
try {
await shutdownHomeKit()
}
catch (error) {
console.error('Error shutting down HomeKit:', error)
}
// Step 4: Stop auto-off watcher (await in-flight power-off calls)
try {
await stopAutoOffWatcher()
}
catch (error) {
console.error('Error stopping auto-off watcher:', error)
}
// Step 5: Shutdown DAC monitor
try {
await shutdownDacMonitor()
}
catch (error) {
console.error('Error shutting down DacMonitor:', error)
}
// Step 6: Stop biometrics retention loop before closing DB
try {
stopBiometricsRetention()
}
catch (error) {
console.error('Error stopping biometrics retention:', error)
}
// Step 7: Close database connections
try {
closeDatabase()
closeBiometricsDatabase()
}
catch (error) {
console.error('Error closing database:', error)
}
process.exit(0)
}
/**
* Register global process handlers (signal handlers, error handlers).
* Safe to call multiple times - only registers once.
*/
function registerGlobalHandlers(): void {
if (handlersRegistered) return
handlersRegistered = true
// Centralized signal handlers
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
// Global unhandled rejection handler - log but don't crash
process.on('unhandledRejection', (reason: unknown) => {
console.error('Unhandled promise rejection:', reason)
// Don't exit - let the process continue serving other requests
})
// Global uncaught exception handler - log and attempt graceful shutdown
process.on('uncaughtException', (error: Error & { code?: string, address?: string, port?: number }) => {
// mDNS EPERM is non-fatal — iptables blocks multicast, just log and continue
if (error.code === 'EPERM' && error.address === '224.0.0.251' && error.port === 5353) {
console.warn('[bonjour] mDNS send blocked by iptables (non-fatal):', error.message)
return
}
console.error('Uncaught exception:', error)
// Process state may be corrupted, attempt graceful shutdown
gracefulShutdown('uncaughtException')
})
}
/**
* Retry a function with exponential backoff.
*/
async function withRetry<T>(
fn: () => Promise<T>,
label: string,
maxAttempts: number = 3,
baseDelayMs: number = 500
): Promise<T> {
let lastError: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (isShuttingDown) throw new Error('Startup cancelled during shutdown')
try {
return await fn()
}
catch (error) {
lastError = error
if (attempt < maxAttempts) {
const delay = baseDelayMs * Math.pow(2, attempt - 1)
console.warn(
`${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms:`,
error instanceof Error ? error.message : error
)
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
throw lastError
}
/**
* Validate hardware daemon connectivity on startup.
* Logs a warning if unavailable but does not crash.
*/
// Hardware validation removed — the DacMonitor handles connection lifecycle.
// The DAC socket server starts first, then the monitor waits for frankenfirmware.
/**
* Initialize the DAC hardware monitor.
* Non-blocking — logs a warning on failure but does not crash.
*/
const initializeDacMonitor = async (): Promise<void> => {
try {
await getDacMonitor()
if (isShuttingDown) await shutdownDacMonitor()
}
catch (error) {
console.warn(
'WARNING: DacMonitor failed to start:',
error instanceof Error ? error.message : error
)
}
}
async function initializeHardware(): Promise<void> {
if (hardwareReady || isShuttingDown) return
if (hardwarePromise) return hardwarePromise
hardwarePromise = prepareHardware().finally(() => {
hardwarePromise = null
})
return hardwarePromise
}
async function prepareHardware(): Promise<void> {
// Rehydrate the pump stall guard from persisted un-acknowledged alerts
// before anything that consults shouldBlock (keepalives, scheduler jobs)
// can re-energize a side whose fault predates this boot.
try {
const { rehydrate } = await import('@/src/hardware/pumpStallGuard')
rehydrate()
}
catch (error) {
console.warn(
'[pumpStallGuard] rehydration failed:',
error instanceof Error ? error.message : error
)
}
if (isShuttingDown) return
// Start DAC socket server FIRST — this is the single listener on dac.sock.
// frankenfirmware will connect to it. Everything else (DacMonitor, device
// router, health checks) uses this server's connection.
try {
const { startDacServer } = await import('@/src/hardware/dacMonitor.instance')
await startDacServer()
}
catch (error) {
console.warn('[DAC] Socket server failed to start:', error instanceof Error ? error.message : error)
}
if (isShuttingDown) return
// Start DAC monitor (non-blocking — waits for frankenfirmware to connect)
initializeDacMonitor()
if (isShuttingDown) return
hardwareReady = true
try {
startPiezoStreamServer()
}
catch (error) {
console.warn('WARNING: Piezo stream server failed to start:', error instanceof Error ? error.message : error)
}
recordStartupPhase('hardware-services-started')
}
/**
* Initialize the job scheduler
* Safe to call multiple times - will only initialize once
*/
export async function initializeScheduler(): Promise<void> {
if (isInitialized || isShuttingDown) return
if (initializationPromise) return initializationPromise
initializationPromise = initializeBackgroundServices().finally(() => {
initializationPromise = null
})
return initializationPromise
}
async function initializeBackgroundServices(): Promise<void> {
try {
await initializeHardware()
if (isShuttingDown) return
const schedulerStartedAt = performance.now()
console.log('Initializing job scheduler...')
const jobManager = await withRetry(
() => getJobManager(),
'Job manager initialization'
)
if (isShuttingDown) return
recordStartupPhase('scheduler-ready', schedulerStartedAt)
const scheduler = jobManager.getScheduler()
const jobs = scheduler.getJobs()
console.log(`Job scheduler initialized with ${jobs.length} scheduled jobs`)
// Log next scheduled jobs for visibility
const upcomingJobs = jobs
.map((job) => {
const nextRun = scheduler.getNextInvocation(job.id)
return {
id: job.id,
type: job.type,
nextRun: nextRun ? nextRun.toISOString() : 'N/A',
}
})
.filter(job => job.nextRun !== 'N/A')
.sort((a, b) => {
if (a.nextRun === 'N/A' || b.nextRun === 'N/A') return 0
return new Date(a.nextRun).getTime() - new Date(b.nextRun).getTime()
})
.slice(0, 5)
if (upcomingJobs.length > 0) {
console.log('Next scheduled jobs:')
for (const job of upcomingJobs) {
console.log(` - ${job.id}: ${job.nextRun}`)
}
}
isInitialized = true
// Initialize temperature keepalive timers for sides with alwaysOn enabled
initializeKeepalives()
// Optional integrations do not hold up HTTP or the remaining services.
void startMqttBridge().then(() => {
if (isShuttingDown) return shutdownMqttBridge()
recordStartupPhase('mqtt-started')
}).catch((error) => {
console.warn('WARNING: MQTT bridge failed to start:', error instanceof Error ? error.message : error)
})
// Start auto-off watcher (polls biometrics DB for bed-exit events)
startAutoOffWatcher()
// Start Bonjour/mDNS announcement (non-blocking)
startBonjourAnnouncement()
// Start HomeKit bridge if user has opted in (non-blocking)
startHomeKitIfEnabled().then(() => {
if (isShuttingDown) return shutdownHomeKit()
}).catch((error) => {
console.warn(
'[homekit] startup failed:',
error instanceof Error ? error.message : error,
)
})
// Start biometrics time-series retention loop (non-blocking)
startBiometricsRetention()
// Boot the Autopilot rules engine beside the scheduler (non-blocking).
// Shares the same hardware path; no-op until automations are created.
getAutomationEngine().then(() => {
if (isShuttingDown) return shutdownAutomationEngine()
}).catch((error) => {
console.warn(
'[automation] engine failed to start:',
error instanceof Error ? error.message : error,
)
})
}
catch (error) {
console.error('Failed to initialize job scheduler:', error)
// Don't crash the app if scheduler fails to initialize
}
}
/**
* Next.js instrumentation hook (if supported)
* Automatically called by Next.js on server startup
*/
/**
* Gate for `register()`. Returns true only when the instrumentation hook
* should execute its body.
*
* Next.js calls the instrumentation hook **once per server runtime** — both
* Node and Edge bundles trigger it. The previous gate (`NEXT_RUNTIME==='nodejs'
* || typeof window === 'undefined'`) was too permissive: `typeof window` is
* undefined in every server runtime, so Edge slipped through and caused
* double-init (most visibly: two MQTT bridge clients connecting from the
* same pod). Strict check on `NEXT_RUNTIME` when set; allow when unset
* (tests, scripts).
*/
export function shouldRunInstrumentation(env: Record<string, string | undefined> = process.env): boolean {
if (env.NEXT_RUNTIME && env.NEXT_RUNTIME !== 'nodejs') return false
return true
}
export async function register(): Promise<void> {
if (shouldRunInstrumentation()) {
// Register global handlers first (before any initialization that could fail)
registerGlobalHandlers()
startPerformanceMonitoring()
const migrationsStartedAt = performance.now()
// Run pending database migrations before starting the app
const { runMigrations, seedDefaultData } = await import('@/src/db/migrate')
await runMigrations()
await seedDefaultData()
recordStartupPhase('migrations-ready', migrationsStartedAt)
// Skip hardware initialization in CI — no dac.sock, no sensors, no scheduler needed.
// The server still starts and serves API routes (including /api/openapi.json).
if (process.env.CI) {
console.log('CI environment detected — skipping hardware initialization')
return
}
// Validate and auto-repair iptables rules (mDNS, LAN access, NTP)
try {
const { checkAndRepairIptables } = await import('@/src/hardware/iptablesCheck')
const result = checkAndRepairIptables()
if (result.repaired.length > 0) {
console.warn(`[startup] Repaired ${result.repaired.length} missing iptables rules:`, result.repaired.join(', '))
}
else if (result.ok) {
console.log('[startup] iptables rules verified')
}
}
catch (e) {
console.warn('[startup] iptables check skipped:', e instanceof Error ? e.message : e)
}
await initializeHardware()
if (isShuttingDown) return
startDatabaseIntegrityChecks()
// The clock gate and scheduler loading still finish before scheduled writers
// start, but no longer prevent clients from loading settings or diagnostics.
void initializeScheduler()
recordStartupPhase('http-initialization-ready')
}
}