diff --git a/src/main/backend.ts b/src/main/backend.ts index 6faed29..b896aaf 100644 --- a/src/main/backend.ts +++ b/src/main/backend.ts @@ -1,7 +1,7 @@ import { ChildProcess, spawn } from 'child_process' -import { app, dialog } from 'electron' +import { app, dialog, Notification } from 'electron' import { join } from 'path' -import { existsSync } from 'fs' +import { existsSync, mkdirSync, createWriteStream, WriteStream } from 'fs' import * as http from 'http' const MAX_HEALTH_CHECK_RETRIES = 30 @@ -9,9 +9,35 @@ const HEALTH_CHECK_INTERVAL_MS = 500 const HEALTH_CHECK_TIMEOUT_MS = 1000 const GRACEFUL_SHUTDOWN_MS = 3000 +// 起動後の生存監視 (#49 調査用) +const HEALTH_WATCH_INTERVAL_MS = 30 * 1000 +const HEALTH_WATCH_TIMEOUT_MS = 5 * 1000 +const HEALTH_WATCH_FAIL_THRESHOLD = 3 + export const BACKEND_PORT = 8080 let backendProcess: ChildProcess | null = null +let logStream: WriteStream | null = null +let healthWatcherTimer: NodeJS.Timeout | null = null +let consecutiveHealthFailures = 0 +let healthNotifiedAt: number | null = null + +function getLogStream(): WriteStream { + if (logStream) return logStream + const dir = app.getPath('logs') + mkdirSync(dir, { recursive: true }) + const path = join(dir, 'backend.log') + // バックエンドの Echo middleware は method/uri/status/latency のみ出力する設定 (backend/cmd/main.go) + // であり Authorization ヘッダや API トークンを stdout に書き出さないため、生 pipe で問題ない。 + // TODO(#49): 調査完了後にローテーション or 期間トリムを検討する。 + logStream = createWriteStream(path, { flags: 'a' }) + return logStream +} + +function logEvent(line: string): void { + const stream = getLogStream() + stream.write(`[${new Date().toISOString()}] [main] ${line}\n`) +} function getBackendPath(): string { if (app.isPackaged) { @@ -26,6 +52,8 @@ function waitForHealth(port: number): Promise { const check = (): void => { const req = http.get(`http://localhost:${port}/api/health`, (res) => { + // body を消費して Keep-Alive socket を解放する (連続リトライ時の遅延防止) + res.resume() if (res.statusCode === 200) { resolve() } else { @@ -56,10 +84,65 @@ function waitForHealth(port: number): Promise { }) } +function startHealthWatcher(port: number): void { + if (healthWatcherTimer) clearInterval(healthWatcherTimer) + consecutiveHealthFailures = 0 + healthNotifiedAt = null + + const onSuccess = (): void => { + if (consecutiveHealthFailures > 0) { + logEvent(`health recovered after ${consecutiveHealthFailures} failure(s)`) + } + consecutiveHealthFailures = 0 + healthNotifiedAt = null + } + + const onFailure = (reason: string): void => { + consecutiveHealthFailures++ + logEvent(`health watch failed (${consecutiveHealthFailures}): ${reason}`) + + if ( + consecutiveHealthFailures >= HEALTH_WATCH_FAIL_THRESHOLD && + healthNotifiedAt === null + ) { + healthNotifiedAt = Date.now() + logEvent(`health watch: threshold reached, surfacing notification`) + try { + new Notification({ + title: 'Backnote バックエンド応答なし', + body: `${HEALTH_WATCH_FAIL_THRESHOLD} 回連続で /api/health に失敗しました。ログを確認してください。` + }).show() + } catch (e) { + logEvent(`notification failed: ${e instanceof Error ? e.message : String(e)}`) + } + } + } + + healthWatcherTimer = setInterval(() => { + const req = http.get(`http://localhost:${port}/api/health`, (res) => { + // body を消費して Keep-Alive socket を解放する + res.resume() + if (res.statusCode === 200) { + onSuccess() + } else { + onFailure(`status=${res.statusCode}`) + } + }) + req.on('error', (err) => onFailure(`error=${err.message}`)) + req.setTimeout(HEALTH_WATCH_TIMEOUT_MS, () => { + req.destroy() + onFailure('timeout') + }) + }, HEALTH_WATCH_INTERVAL_MS) +} + export async function startBackend(port: number = BACKEND_PORT): Promise { const backendPath = getBackendPath() + const stream = getLogStream() + stream.write(`\n===== ${new Date().toISOString()} startBackend port=${port} =====\n`) if (!existsSync(backendPath)) { + logEvent(`ERROR: backend binary not found: ${backendPath}`) throw new Error(`Backend binary not found: ${backendPath}`) } @@ -72,26 +155,61 @@ export async function startBackend(port: number = BACKEND_PORT): Promise { stdio: ['ignore', 'pipe', 'pipe'] }) + logEvent(`backend spawned pid=${backendProcess.pid} path=${backendPath}`) + + backendProcess.stdout?.pipe(stream, { end: false }) + backendProcess.stderr?.pipe(stream, { end: false }) + backendProcess.on('error', (err) => { + logEvent(`backend spawn error: ${err.message}`) backendProcess = null reject(new Error(`Backend spawn failed: ${err.message}`)) }) - backendProcess.on('exit', (code) => { + backendProcess.on('exit', (code, signal) => { + const detail = code !== null ? `code=${code}` : `signal=${signal}` + logEvent(`backend exited ${detail}`) backendProcess = null - if (code !== 0 && code !== null) { + + if (healthWatcherTimer) { + clearInterval(healthWatcherTimer) + healthWatcherTimer = null + } + + // 異常終了 (非 0 終了 or シグナル終了) は通知。 + // SIGTERM はアプリ終了時の正規シャットダウンなので除外。 + const abnormal = (code !== null && code !== 0) || (signal !== null && signal !== 'SIGTERM') + if (abnormal) { dialog.showErrorBox( 'Backnote エラー', - `処理が予期せず終了しました。アプリを再起動してください。\n(error code: ${code})` + `処理が予期せず終了しました。アプリを再起動してください。\n(${detail})` ) } }) - waitForHealth(port).then(resolve).catch(reject) + waitForHealth(port) + .then(() => { + logEvent('backend health OK, starting watcher') + startHealthWatcher(port) + resolve() + }) + .catch((err) => { + logEvent(`backend startup health timeout: ${err.message}`) + reject(err) + }) }) } export function stopBackend(): void { + if (healthWatcherTimer) { + clearInterval(healthWatcherTimer) + healthWatcherTimer = null + } + + if (logStream) { + logStream.write(`===== ${new Date().toISOString()} stopBackend =====\n`) + } + if (!backendProcess) return const proc = backendProcess