Skip to content
This repository was archived by the owner on Jul 31, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e082c8d
test: Go Tailscale 반복 실행과 분석 지원
Finefinee Jul 17, 2026
691a8d4
docs: Go bypass Tailscale 반복 결과 반영
Finefinee Jul 17, 2026
ef3e36f
test: Pion 버퍼 비교 수집 자동화 확장
Finefinee Jul 17, 2026
b76c55e
docs: Go Pion 버퍼 계측 smoke 결과 반영
Finefinee Jul 17, 2026
26312b7
docs: Go Tailscale 16MiB 재검증 결과 반영
Finefinee Jul 17, 2026
a80337e
docs: AI 경계 성능 시험 계획 정리
Finefinee Jul 18, 2026
120a1b3
test: Tailscale bypass 공통 기준선 수집 자동화
Finefinee Jul 18, 2026
ef5dd0b
test: 반복 부하 결과 원장 정리
Finefinee Jul 18, 2026
66ab899
fix: bypass 종료 시 AI 런타임 지연 로드 유지
Finefinee Jul 18, 2026
d78b712
feat: echo 구조 비교 모드와 반복 수집 자동화 추가
Finefinee Jul 18, 2026
1da01bc
docs: Go RTT 재수집과 echo 비교 계획 정리
Finefinee Jul 18, 2026
c26fc90
test: Go RTT 기준선과 누적 FPS 집계 반영
Finefinee Jul 18, 2026
cc13218
test: bypass 반복 결과와 수명 주기 비교 문서화
Finefinee Jul 18, 2026
0106f3b
test: Go echo 반복 결과 문서화
Finefinee Jul 19, 2026
1453b1b
docs: Python echo 반복 결과 동기화
Finefinee Jul 19, 2026
50b5232
test: Go resource-governor bypass 기준선 기록
Finefinee Jul 19, 2026
6dcd8a9
test: 짧은 부하 실행 결과 분석 지원
Finefinee Jul 19, 2026
3d51fc7
test: 8세션 재검증 절차와 예열 지원
Finefinee Jul 20, 2026
3c50b09
merge: test/load-test
Finefinee Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class Settings(BaseSettings):
webrtc_udp_port_max: int | None = 60000
webrtc_disconnected_grace_seconds: float = 10.0
ai_privacy_enabled: bool = True
ai_privacy_mode: Literal["real", "bypass", "fixed_delay"] = "real"
ai_privacy_mode: Literal["real", "bypass", "fixed_delay", "echo"] = "real"
ai_privacy_fixed_delay_ms: float = 20.0
ai_privacy_device: str | None = None
ai_privacy_require_gpu: bool = False
Expand Down
35 changes: 30 additions & 5 deletions app/services/ai/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from app.core.config import Settings, get_settings
from app.services.metrics import AI_STAGE_DURATION
from privacy_blur import (
FacePrivacyFilter,
PrivacyBlurConfig,
PrivacyBlurNotReadyError,
initialize_runtime,
Expand All @@ -18,6 +17,7 @@
)

if TYPE_CHECKING:
from privacy_blur import FacePrivacyFilter as FacePrivacyFilterType
from privacy_blur.runtime import SharedGpuRuntime

logger = logging.getLogger(__name__)
Expand All @@ -29,7 +29,10 @@
_config_initialized = False
_runtime: SharedGpuRuntime | None = None
_state_lock = threading.RLock()
_filters: list[FacePrivacyFilter] = []
# Importing FacePrivacyFilter resolves privacy_blur.core, which imports Torch.
# Keep bypass and fixed-delay server startup independent of that runtime.
FacePrivacyFilter: Any | None = None
_filters: list[FacePrivacyFilterType] = []
_filters_lock = threading.Lock()
_identity_features_by_client: dict[str, Any] = {}

Expand Down Expand Up @@ -152,7 +155,18 @@ def initialize_privacy_runtime() -> PrivacyBlurConfig | None:
return config


def get_privacy_filter(client_id: str | None = None) -> FacePrivacyFilter | None:
def get_privacy_filter(client_id: str | None = None) -> Any | None:
global FacePrivacyFilter

settings = get_settings()
if settings.ai_privacy_enabled and settings.ai_privacy_mode == "echo":
from app.services.ai.track import EchoPrivacyFilter

privacy_filter = EchoPrivacyFilter()
with _filters_lock:
_filters.append(privacy_filter)
return privacy_filter

from app.services.ai.identity import (
_reference_face_lock,
normalize_reference_client_id,
Expand Down Expand Up @@ -186,7 +200,13 @@ def get_privacy_filter(client_id: str | None = None) -> FacePrivacyFilter | None
}
if reference_features is not None:
kwargs["reference_features"] = reference_features
privacy_filter = FacePrivacyFilter(**kwargs)
filter_type = FacePrivacyFilter
if filter_type is None:
from privacy_blur import FacePrivacyFilter as imported_filter_type

filter_type = imported_filter_type
FacePrivacyFilter = filter_type
privacy_filter = filter_type(**kwargs)
with _filters_lock:
_filters.append(privacy_filter)
return privacy_filter
Expand Down Expand Up @@ -408,12 +428,17 @@ def close_privacy_filter() -> None:
):
return
_filters.clear()
runtime_started = _runtime is not None
for privacy_filter in snapshot:
try:
privacy_filter.close()
except Exception:
logger.exception("Failed to close privacy filter instance")
shutdown_runtimes()
# bypass/fixed_delay intentionally never load the model runtime. Calling
# shutdown_runtimes() in that state imports the model stack only to
# discover that there is nothing to close.
if runtime_started:
shutdown_runtimes()
_runtime = None
_config = None
_config_initialized = False
Expand Down
68 changes: 53 additions & 15 deletions app/services/ai/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,79 @@
import asyncio
import logging
from time import perf_counter
from typing import TYPE_CHECKING
from typing import Protocol

import numpy as np
from aiortc.mediastreams import MediaStreamTrack, VideoStreamTrack
from av import VideoFrame

from app.services.metrics import (
AI_STAGE_DURATION,
FRAME_FAILURES,
FRAME_PROCESSING_DURATION,
FRAMES_DROPPED,
FRAMES_PROCESSED,
FRAMES_RECEIVED,
)

if TYPE_CHECKING:
from privacy_blur import FacePrivacyFilter

logger = logging.getLogger(__name__)


class PrivacyFilter(Protocol):
"""Minimal per-track filter contract used by real and benchmark echo modes."""

async def apply_async(self, frame: VideoFrame) -> VideoFrame: ...

def close(self) -> None: ...


class EchoPrivacyFilter:
"""No-op filter that retains the real track's BGR frame conversions.

It deliberately creates a new ``VideoFrame`` from the BGR ndarray. This
preserves the Python in-process conversion cost measured by ``real`` while
omitting model initialization, detection, identity matching, and blur.
"""

def __init__(self) -> None:
self._closed = False

async def apply_async(self, frame: VideoFrame) -> VideoFrame:
if self._closed:
raise RuntimeError("The echo privacy filter is closed")

decode_started = perf_counter()
image = frame.to_ndarray(format="bgr24")
AI_STAGE_DURATION.labels("decode").observe(perf_counter() - decode_started)

encode_started = perf_counter()
echoed = VideoFrame.from_ndarray(image, format="bgr24")
AI_STAGE_DURATION.labels("encode").observe(perf_counter() - encode_started)
echoed.pts = frame.pts
if frame.time_base is not None:
echoed.time_base = frame.time_base
return echoed

def close(self) -> None:
self._closed = True


class ProtectedVideoTrack(VideoStreamTrack):
kind = "video"

def __init__(
self,
source: MediaStreamTrack,
privacy_filter: FacePrivacyFilter | None,
privacy_filter: PrivacyFilter | None,
*,
session_id: str,
mode: str = "real",
) -> None:
super().__init__()
self._source = source
self._filter = privacy_filter
self._session_id = session_id
self._mode = mode
self._fallback = privacy_filter is None

@property
Expand All @@ -45,34 +84,33 @@ def is_fallback_active(self) -> bool:

async def recv(self):
frame, dropped = await _recv_latest_frame_with_drop_count(self._source)
mode = "real"
FRAMES_RECEIVED.labels(mode).inc()
FRAMES_RECEIVED.labels(self._mode).inc()
if dropped:
FRAMES_DROPPED.labels(mode).inc(dropped)
FRAMES_DROPPED.labels(self._mode).inc(dropped)
started = perf_counter()
if self._fallback:
result = _blackout_frame(frame)
FRAMES_PROCESSED.labels(mode).inc()
FRAME_PROCESSING_DURATION.labels(mode).observe(perf_counter() - started)
FRAMES_PROCESSED.labels(self._mode).inc()
FRAME_PROCESSING_DURATION.labels(self._mode).observe(perf_counter() - started)
return result
try:
assert self._filter is not None
result = await self._filter.apply_async(frame)
FRAMES_PROCESSED.labels(mode).inc()
FRAME_PROCESSING_DURATION.labels(mode).observe(perf_counter() - started)
FRAMES_PROCESSED.labels(self._mode).inc()
FRAME_PROCESSING_DURATION.labels(self._mode).observe(perf_counter() - started)
return result
except Exception:
self._fallback = True
FRAME_FAILURES.labels(mode).inc()
FRAME_FAILURES.labels(self._mode).inc()
logger.warning(
"AI filter failed, blocking video instead of exposing raw frames: "
"session_id=%s",
self._session_id,
exc_info=True,
)
result = _blackout_frame(frame)
FRAMES_PROCESSED.labels(mode).inc()
FRAME_PROCESSING_DURATION.labels(mode).observe(perf_counter() - started)
FRAMES_PROCESSED.labels(self._mode).inc()
FRAME_PROCESSING_DURATION.labels(self._mode).observe(perf_counter() - started)
return result

def stop(self) -> None:
Expand Down
1 change: 1 addition & 0 deletions app/services/sessions/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,7 @@ def _wrap_with_ai(
source=track,
privacy_filter=privacy_filter,
session_id=session_id,
mode=settings.ai_privacy_mode,
)


Expand Down
70 changes: 50 additions & 20 deletions loadtest/pion-client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,31 +118,41 @@ func (c *controller) scaleTo(target int, ramp time.Duration) error {
if ramp > 0 {
delay = ramp / time.Duration(target-current)
}
ordinals := make([]int, target-current)
for attempt := range ordinals {
c.mu.Lock()
c.nextOrdinal++
ordinals[attempt] = c.nextOrdinal
c.mu.Unlock()
}

if delay == 0 {
// A spike is intended to create all PeerConnections concurrently. Starting
// them serially multiplies the per-client connect timeout and makes high
// session counts a load-generator setup benchmark rather than a server test.
results := make(chan error, len(ordinals))
for _, ordinal := range ordinals {
go func(ordinal int) { results <- c.startClient(ordinal) }(ordinal)
}
failures := 0
for range ordinals {
if err := <-results; err != nil {
failures++
}
}
if failures > 0 {
return fmt.Errorf("%d of %d new clients failed", failures, len(ordinals))
}
return nil
}

var failures int
for attempt := 0; attempt < target-current; attempt++ {
for attempt, ordinal := range ordinals {
if c.ctx.Err() != nil {
return c.ctx.Err()
}
c.mu.Lock()
c.nextOrdinal++
ordinal := c.nextOrdinal
c.mu.Unlock()
client, err := startLoadClient(c.ctx, c.cfg, ordinal, c.iceServers, c.http)
if err != nil {
if err := c.startClient(ordinal); err != nil {
failures++
log.Printf("client=%d start failed: %v", ordinal, err)
_, cancel := context.WithCancel(c.ctx)
failed := &loadClient{ordinal: ordinal, startedAt: time.Now(), pcState: "failed", iceState: "failed", cancel: cancel, http: c.http, baseURL: c.cfg.BaseURL}
failed.setError(err)
c.mu.Lock()
c.attempts = append(c.attempts, failed)
c.mu.Unlock()
} else {
c.mu.Lock()
c.clients = append(c.clients, client)
c.attempts = append(c.attempts, client)
c.mu.Unlock()
log.Printf("client=%d session=%s connected", ordinal, client.sessionID)
}
if delay > 0 && attempt+1 < target-current {
if err := waitContext(c.ctx, delay); err != nil {
Expand All @@ -156,6 +166,26 @@ func (c *controller) scaleTo(target int, ramp time.Duration) error {
return nil
}

func (c *controller) startClient(ordinal int) error {
client, err := startLoadClient(c.ctx, c.cfg, ordinal, c.iceServers, c.http)
if err != nil {
log.Printf("client=%d start failed: %v", ordinal, err)
_, cancel := context.WithCancel(c.ctx)
failed := &loadClient{ordinal: ordinal, startedAt: time.Now(), pcState: "failed", iceState: "failed", cancel: cancel, http: c.http, baseURL: c.cfg.BaseURL}
failed.setError(err)
c.mu.Lock()
c.attempts = append(c.attempts, failed)
c.mu.Unlock()
return err
}
c.mu.Lock()
c.clients = append(c.clients, client)
c.attempts = append(c.attempts, client)
c.mu.Unlock()
log.Printf("client=%d session=%s connected", ordinal, client.sessionID)
return nil
}

func (c *controller) collectStats() {
ticker := time.NewTicker(c.cfg.StatsInterval)
defer ticker.Stop()
Expand Down
Loading
Loading