SSE の切断を 5 秒以内に検出してスレッドを片付ける

これまではタブを閉じた接続に気付くのが次の ping (最大20秒後) の書き込み
失敗時だった。受信側の EOF を select で見て、待ち受けの合間に確認する。
ping は経過時間で送るようにしたので、間隔 (20秒) は変わらない。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 09:48:37 +09:00
co-authored by Claude Opus 5
parent efc7180317
commit 0c312afaa5
2 changed files with 25 additions and 4 deletions
+1
View File
@@ -95,6 +95,7 @@ python chktime.py
動かしながら開いておく用途向けです。オン/オフの状態はブラウザに記憶されます(既定はオン)。
- サーバが `--interval` 秒ごとに jsonl を stat して変化を検出し、SSE(`/api/watch`)でブラウザに通知します。ブラウザ側は 1.2 秒まとめてから読み直します。
- タブを閉じた接続は 5 秒以内に検出して片付けます(20 秒ごとの ping を待ちません)。
- 更新時も**選択中のプロジェクト・タスク・スクロール位置・絞り込み・開いているタブは保たれます**。やりとりを末尾まで見ていた場合は、追記された行に追従して末尾に留まります。
- ヘッダ右の表示が状態です。`● 追従中` → 変化を検出すると `● 更新あり…` → 反映後に `● 更新 hh:mm:ss`。切断されると `● 切断(再接続待ち)` となり、自動で再接続します。
- オフの間に増えたぶんは、次にオンにした時点でまとめて取り込みます。
+24 -4
View File
@@ -20,6 +20,8 @@ import io
import json
import os
import re
import select
import socket
import threading
import time
import webbrowser
@@ -38,6 +40,9 @@ TAG_RE = re.compile(r"<([a-z-]+)>(.*?)</\1>", re.S)
PROMPT_LIMIT = 4000
SUMMARY_LIMIT = 200
WATCH_POLL = 5.0 # SSE: 切断を見に行く間隔(秒)
PING_INTERVAL = 20.0 # SSE: 接続維持のコメントを送る間隔(秒)
# ---------------------------------------------------------------- 解析ロジック
@@ -912,7 +917,11 @@ class Handler(BaseHTTPRequestHandler):
raise FileNotFoundError(route)
def watch(self):
"""SSE。ログに変更があるまでブロックし、あれば change イベントを流す。"""
"""SSE。ログに変更があるまでブロックし、あれば change イベントを流す。
タブを閉じた接続はソケットの EOF で気付いて抜ける。書き込みが失敗する
まで (ping 最大20秒ぶん) スレッドを抱えたままにしないため。
"""
watcher = self.server.watcher
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
@@ -920,18 +929,29 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("X-Accel-Buffering", "no")
self.end_headers()
rev = watcher.rev
last_ping = time.monotonic()
try:
self.stream(f"retry: 3000\n\nevent: hello\ndata: {json.dumps(watcher.info())}\n\n")
while True:
current = watcher.wait_change(rev, 20)
while not self.disconnected():
current = watcher.wait_change(rev, WATCH_POLL)
if current != rev:
rev = current
self.stream(f"event: change\ndata: {json.dumps(watcher.info())}\n\n")
else:
last_ping = time.monotonic()
elif time.monotonic() - last_ping >= PING_INTERVAL:
self.stream(": ping\n\n") # 接続維持
last_ping = time.monotonic()
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError, ValueError):
return # タブを閉じた等
def disconnected(self):
"""クライアントが切断済みかを見る (受信側の EOF で判定)。"""
try:
ready, _, _ = select.select([self.connection], [], [], 0)
return bool(ready) and not self.connection.recv(1, socket.MSG_PEEK)
except (OSError, ValueError):
return True
def stream(self, text):
self.wfile.write(text.encode("utf-8"))
self.wfile.flush()