From 96f6faa109c875d6f2315230ac15a1b92c7d8ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AD=E3=83=A0=E3=83=86=E3=82=BD=E3=83=B3?= Date: Thu, 14 May 2026 22:57:33 +0900 Subject: [PATCH] =?UTF-8?q?[#49]chore=EF=BC=9A=E3=83=90=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=82=A8=E3=83=B3=E3=83=89=E5=81=A5=E5=85=A8=E6=80=A7=E8=AA=BF?= =?UTF-8?q?=E6=9F=BB=E3=81=AE=E3=81=9F=E3=82=81=E3=81=AE=E3=83=AD=E3=82=B0?= =?UTF-8?q?=E4=BB=95=E8=BE=BC=E3=81=BF=E3=81=A8=20exit/health=20=E7=9B=A3?= =?UTF-8?q?=E8=A6=96=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 長時間稼働後にバックエンドが応答しなくなり「更新を押すとローディングが 終わらない」事象の原因を切り分けるための観測強化。根本修正ではなく、 再現を待ってログから仮説を絞り込めるようにすることが目的。 - バックエンドの stdout/stderr を app.getPath('logs')/backend.log に追記 - spawn / exit / health 状態など主要イベントも同ログに記録 - exit ハンドラを (code, signal) で受け、シグナル終了 (SIGTERM を除く) も "処理が予期せず終了しました" のアラート対象に含める - 起動完了後に 30 秒間隔の health watcher を開始し、3 回連続失敗で OS 通知を一度だけ表示。復旧したら状態リセット - waitForHealth / watcher の HTTP レスポンスを res.resume() で消費して Keep-Alive socket の解放を保証 --- src/main/backend.ts | 130 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 124 insertions(+), 6 deletions(-) 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