-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathinstrumentation.ts
More file actions
421 lines (374 loc) · 13.2 KB
/
Copy pathinstrumentation.ts
File metadata and controls
421 lines (374 loc) · 13.2 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
/**
* 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 { shutdownHomeKit, startHomeKitIfEnabled } from '@/src/homekit'
let isInitialized = false
let isShuttingDown = false
let handlersRegistered = false
/**
* 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()
// 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++) {
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()
}
catch (error) {
console.warn(
'WARNING: DacMonitor failed to start:',
error instanceof Error ? error.message : error
)
}
}
/**
* Wait until the system clock is plausible (year >= 2024).
* The Pod can boot with its clock reset to ~2010 before NTP syncs.
* Schedulers must not start until the date is valid or cron jobs fire at wrong times.
*/
async function waitForValidSystemDate(
maxAttempts: number = 24,
intervalMs: number = 5_000
): Promise<void> {
const MIN_VALID_YEAR = 2024
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (new Date().getFullYear() >= MIN_VALID_YEAR) return
console.warn(
`System clock is invalid (${new Date().toISOString()}), waiting for NTP sync...`,
`(${attempt + 1}/${maxAttempts})`
)
await new Promise(resolve => setTimeout(resolve, intervalMs))
}
console.error(
'System clock never synced after waiting — proceeding anyway.',
'Scheduled jobs may fire at incorrect times.'
)
}
/**
* Initialize the job scheduler
* Safe to call multiple times - will only initialize once
*/
export async function initializeScheduler(): Promise<void> {
if (isInitialized) return
try {
await waitForValidSystemDate()
console.log('Initializing job scheduler...')
const jobManager = await withRetry(
() => getJobManager(),
'Job manager initialization'
)
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
// 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)
}
// Start DAC monitor (non-blocking — waits for frankenfirmware to connect)
initializeDacMonitor()
// Initialize temperature keepalive timers for sides with alwaysOn enabled
initializeKeepalives()
// Start piezo WebSocket stream server (non-blocking)
try {
startPiezoStreamServer()
}
catch (error) {
console.warn(
'WARNING: Piezo stream server failed to start:',
error instanceof Error ? error.message : error
)
}
// Start MQTT bridge (non-blocking; no-op when disabled in settings/env)
try {
await startMqttBridge()
}
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().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().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()
// Run pending database migrations before starting the app
const { runMigrations, seedDefaultData } = await import('@/src/db/migrate')
await runMigrations()
await seedDefaultData()
// 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 initializeScheduler()
}
}