fix: 路徑穿越消毒(H2)+ 接上 LOBBY_WINDOW_S 自動清理(H1)+ 純邏輯回歸測試#1
Open
cutedogspark wants to merge 7 commits into
Open
Conversation
…y removal)
Root cause: the lobby (/meetings) listed only the in-memory _meetings dict,
which is populated lazily on WS-connect; startup never rescanned MEETING_DATA,
so a backend restart left the lobby empty (meetings "disappeared") even though
each track's audio + meta were on disk. Segments and the meeting title/created
were also not persisted.
Fix:
- session.py: persist self.segments in meta.json -> transcript is viewable after
a restart with no re-diarization.
- meeting.py: persist title/created in meeting.json -> lobby keeps the real name
+ order after restart.
- app.py: _resume_meetings() on startup scans MEETING_DATA and reloads every
meeting into _meetings (marking resumed tracks "ended" — viewable, not live).
/meetings now returns an `ended` flag. A meeting is removed ONLY by the manual
清理 (DELETE /meetings/{id}); no auto-expiry exists.
- frontend: lobby shows a 「已結束 · 可檢視」 tag for ended meetings. Cache-bust v=22.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GET /meetings now returns a reliable started_at per meeting: meeting.json created for new meetings, else the earliest audio.pcm mtime (real recording onset) — via min(created, earliest_audio_mtime), so legacy meetings whose created was overwritten by the persistence restart still show their true recording time. No-audio standby meetings keep created. - Backend sorts the full list by started_at descending (newest first). - Frontend lobby: show each meeting's date+time, sort the whole list then paginate 20/page with 上一頁/下一頁 + 第 X/Y 頁 · 共 N 場 (controls only when >20); 已結束·可檢視 tag and 加入/清理 buttons unchanged. Cache v=22→v=23. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Problem 1 — speaker/participant count grew with every "joined but never
started" person:
- Track dir + meta.json used to be created on WS join (get_track + set_device
-> _save_meta), and participants()/counts included every track regardless of
whether it recorded. A fresh trackId per join meant repeated in/out piled up
empty tracks.
- Fix: lazy track persistence — Track no longer mkdir/saves on construction;
the dir + meta are written only on the FIRST real audio frame (append_pcm).
_save_meta is a no-op while there is no audio.pcm, so standby joins leave zero
on-disk footprint. participants() and the lobby count now include only tracks
with has_audio(), so existing accumulated empty tracks also stop counting
(audio is never touched/deleted). Lobby "ended" ignores audio-less active
tracks so a standby viewer can't flip a meeting back to 進行中.
Problem 2 — in-meeting delete:
- New 刪除本會議 button in the meeting view (two-step confirm) calls the existing
DELETE /meetings/{id} (path-guarded) and returns to the lobby.
- It tears down the WS (wantOpen=false) BEFORE issuing DELETE, so an in-flight
recording socket can't auto-reconnect and resurrect the just-deleted meeting.
Cache v=23 -> v=25.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /meetings now returns a per-meeting `live` boolean: live = any track that is active AND has recorded audio AND received an audio frame within LIVE_WINDOW_S (default 10s, MEETING_LIVE_WINDOW_S env). Recording appends a PCM frame every ~250ms (updates t.updated), so a paused or left track drops out of the window within seconds, and a standby WS that merely connected (no audio) never counts. session._load_meta now restores `updated` from meta so a resumed (ended) meeting after a restart is never falsely live. Frontend lobby: a live meeting shows a red pulsing "🔴 進行中" pill (.livetag), which takes priority over the 已結束·可檢視 tag; non-live stays low-key (no badge). The existing 5s auto-refresh updates the state. Cache v=25 -> v=26. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a dedicated section at the top of the lobby listing only live (currently recording) meetings, each with the 🔴 進行中 tag + 加入/清理 (reuses the shared row style via a new makeMeetingRow helper). Hidden entirely when no meeting is live. The existing "全部會議" list below is unchanged (newest-first, 20/page); the live section heading there was renamed from 進行中的會議 to 全部會議 to avoid confusion. No dedup — a live meeting appears in both the top section and the full list (simplest; the top section is a quick-access highlight). The existing 5s auto-refresh re-renders the section, so it appears on record-start and auto-clears ~10s after stop. Backend unchanged (reuses the `live` flag). v=26->v=27. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 進行中的會議 section used to hide entirely when no meeting was live, so a user who refreshed saw nothing and assumed it vanished. Now the section (title) is always shown; with no live meetings it displays a low-key muted line "目前沒有進行中的會議"; when meetings are live it lists them with 🔴 進行中 as before. The 5s auto-refresh swaps between the two states. Frontend only; backend unchanged. v=27->v=28. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
H2 path traversal: meeting_id/track come straight from client query params and
become on-disk dir names. Add a valid_id() whitelist ([A-Za-z0-9_-]{1,64}) and
reject bad ids in the WS handshake (close 4000, before accept), in get_meeting,
Meeting.get_track, and DELETE — mirroring the resolve() guard already in delete.
H1 auto-cleanup: LOBBY_WINDOW_S was exposed in .env/start-meeting.sh but never
used. Wire it into the diarization worker via a should_evict() predicate +
Meeting.is_live/last_activity. A meeting is evicted from memory only when it is
idle past the TTL, NOT live, and has nobody connected. SAFETY (public demo):
eviction is memory-only — on-disk recordings are KEPT, so no live or still-
viewable meeting is ever destroyed; manual 清理/DELETE stays the only disk delete.
Also add a per-track recording cap (MEETING_MAX_TRACK_S, default 6h) to bound
runaway/abandoned streams. Refactor /meetings to share Meeting.is_live.
Tests (pure logic, no GPU): valid_id, should_evict, is_live/last_activity,
capacity cap, and a regression net around session._stitch; plus runtime app-glue
tests for eviction safety + id rejection. 27 passing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
依
analysis.md的建議下一步,修掉兩個高優先項目,並補上回歸測試。安全為最高原則(本服務為對外公開 demo,voice-demo.intemotech.com)。H2 —
meeting_id/track路徑穿越消毒meeting_id/track直接來自 client query 參數,並被當成磁碟目錄名(root/<id>/<track>/audio.pcm)。原本只有DELETE端有resolve()防護,建立路徑(WS / get_meeting / get_track)完全沒驗證。meeting.valid_id(s):白名單re.fullmatch(r"[A-Za-z0-9_-]{1,64}"),非字串安全回False,不丟例外。accept()之前驗證id與track(viewer 可省略track),不合 →close(4000)。get_meeting()/Meeting.get_track():不合 →raise ValueError(縱深防禦)。DELETE /meetings/{mid}:加上valid_id早退(原resolve()保留)。genId()本就只產生[A-Za-z0-9_-],正常流程不受影響。H1 — 接上
LOBBY_WINDOW_S自動清理 + 單場容量防爆LOBBY_WINDOW_S(預設 86400s)早已在.env.example/start-meeting.sh匯出,但後端從未引用。meeting.should_evict(idle_s, is_live, ttl_s)、Meeting.is_live()/last_activity();/meetings端點也改用is_live,消除重複的 live 判定。diarization_worker每輪呼叫_evict_idle_meetings(),三重安全閘才清:is_live為真絕不清);audio.pcm/meta.json原封保留(重啟由_resume_meetings載回)。符合.env.example「frees memory … on-disk recordings are kept」,滿足「絕不可誤刪 live 中或仍可檢視的會議」。唯一刪磁碟的仍是手動「清理 / DELETE」。time.time()(牆鐘,與t.updated一致),非loop.time()。session.over_capacity()+append_pcm在達MEETING_MAX_TRACK_S(預設 6h,設 0 停用)後丟棄後續音框,擋住失控 / 被遺棄分頁 / 惡意無限串流灌爆磁碟。測試(純邏輯,無 GPU/FunASR)
FunASR import 為 lazy,故只需
numpy + fastapi即可跑(另建.venv-test,已 gitignore)。tests/test_unit.py(18):valid_id、should_evict、is_live/last_activity、容量 cap,以及session._stitch回歸網(講者 id 跨輪穩定 / 命名黏著 / 重啟重錨)。tests/test_app_glue.py(9):app.py 接線 — eviction 三重安全閘、保留磁碟、清理字典簿記、id 拒絕。(2 warning 為既有 FastAPI
on_eventdeprecation,與本次改動無關。)TDD 證據:先 RED(
ImportError: valid_id收集失敗)→ 最小實作 GREEN → 變異測試移除is_live安全閘使test_should_evict_never_touches_live_meeting如預期失敗,還原後全綠,證明安全測試有牙齒。取捨(詳見
.agent-out/voice-meeting/fix-round1.md)MEETING_MAX_TRACK_S=6h為寬鬆預設,與「無時間上限」賣點的張力可再議(設 0 停用)。🤖 Generated with Claude Code