Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions server/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/spf13/viper"

"github.com/m1k1o/neko/server/internal/api"
"github.com/m1k1o/neko/server/internal/benchmarks"
"github.com/m1k1o/neko/server/internal/capture"
"github.com/m1k1o/neko/server/internal/config"
"github.com/m1k1o/neko/server/internal/desktop"
Expand Down Expand Up @@ -176,11 +177,19 @@ func (c *serve) Start(cmd *cobra.Command) {
)
c.managers.webSocket.Start()

// Create benchmark collector with target metrics
// Typical WebRTC targets: 30 FPS, 2500 kbps
benchmarkCollector := benchmarks.NewWebRTCStatsCollector(30.0, 2500.0)

// Set the benchmark collector in WebRTC manager
c.managers.webRTC.SetBenchmarkCollector(benchmarkCollector)

c.managers.api = api.New(
c.managers.session,
c.managers.member,
c.managers.desktop,
c.managers.capture,
benchmarkCollector,
)

c.managers.plugins = plugins.New(
Expand Down
98 changes: 98 additions & 0 deletions server/internal/api/benchmark/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package benchmark

import (
"context"
"encoding/json"
"net/http"
"strconv"
"time"

"github.com/m1k1o/neko/server/internal/benchmarks"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/utils"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)

type BenchmarkHandlerCtx struct {
logger zerolog.Logger
collector *benchmarks.WebRTCStatsCollector
}

func New(collector *benchmarks.WebRTCStatsCollector) *BenchmarkHandlerCtx {
return &BenchmarkHandlerCtx{
logger: log.With().Str("module", "benchmark-api").Logger(),
collector: collector,
}
}

func (h *BenchmarkHandlerCtx) Route(r types.Router) {
// Internal benchmark endpoints (unauthenticated)
r.Post("/start", h.StartBenchmark)
}

// StartBenchmarkRequest represents the benchmark start request
type StartBenchmarkRequest struct {
Duration int `json:"duration"` // Duration in seconds
}

// StartBenchmarkResponse represents the benchmark start response
type StartBenchmarkResponse struct {
Status string `json:"status"`
Duration int `json:"duration"`
}

// StartBenchmark handles POST /internal/benchmark/start
func (h *BenchmarkHandlerCtx) StartBenchmark(w http.ResponseWriter, r *http.Request) error {
// Parse duration from query parameter
durationParam := r.URL.Query().Get("duration")
duration := 10 // default 10 seconds

if durationParam != "" {
if d, err := strconv.Atoi(durationParam); err == nil && d > 0 && d <= 60 {
duration = d
}
}

h.logger.Info().
Int("duration", duration).
Msg("starting WebRTC benchmark")

// Run benchmark collection in background
go func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(duration+5)*time.Second)
defer cancel()

stats, err := h.collector.CollectStats(ctx, time.Duration(duration)*time.Second)
if err != nil {
h.logger.Error().Err(err).Msg("benchmark collection failed")
return
}

// Export stats to file for kernel-images to read
if err := h.collector.ExportStats(stats); err != nil {
h.logger.Error().Err(err).Msg("failed to export benchmark stats")
return
}

h.logger.Info().
Float64("avg_fps", stats.FrameRateFPS.Achieved).
Int("viewers", stats.ConcurrentViewers).
Msg("benchmark completed and exported")
}()

// Return immediate response
response := StartBenchmarkResponse{
Status: "started",
Duration: duration,
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)

if err := json.NewEncoder(w).Encode(response); err != nil {
return utils.HttpInternalServerError().WithInternalErr(err)
}

return nil
}
31 changes: 21 additions & 10 deletions server/internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,52 @@ import (
"errors"
"net/http"

"github.com/m1k1o/neko/server/internal/api/benchmark"
"github.com/m1k1o/neko/server/internal/api/members"
"github.com/m1k1o/neko/server/internal/api/room"
"github.com/m1k1o/neko/server/internal/api/sessions"
"github.com/m1k1o/neko/server/internal/benchmarks"
"github.com/m1k1o/neko/server/pkg/auth"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/utils"
)

type ApiManagerCtx struct {
sessions types.SessionManager
members types.MemberManager
desktop types.DesktopManager
capture types.CaptureManager
routers map[string]func(types.Router)
sessions types.SessionManager
members types.MemberManager
desktop types.DesktopManager
capture types.CaptureManager
benchmarkCollector *benchmarks.WebRTCStatsCollector
routers map[string]func(types.Router)
}

func New(
sessions types.SessionManager,
members types.MemberManager,
desktop types.DesktopManager,
capture types.CaptureManager,
benchmarkCollector *benchmarks.WebRTCStatsCollector,
) *ApiManagerCtx {

return &ApiManagerCtx{
sessions: sessions,
members: members,
desktop: desktop,
capture: capture,
routers: make(map[string]func(types.Router)),
sessions: sessions,
members: members,
desktop: desktop,
capture: capture,
benchmarkCollector: benchmarkCollector,
routers: make(map[string]func(types.Router)),
}
}

func (api *ApiManagerCtx) Route(r types.Router) {
r.Post("/login", api.Login)

// Internal benchmark endpoint (unauthenticated)
if api.benchmarkCollector != nil {
benchmarkHandler := benchmark.New(api.benchmarkCollector)
r.Route("/internal/benchmark", benchmarkHandler.Route)
}

// Authenticated area
r.Group(func(r types.Router) {
r.Use(api.Authenticate)
Expand Down
147 changes: 147 additions & 0 deletions server/internal/benchmarks/cpu_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
//go:build linux

package benchmarks

import (
"bufio"
"fmt"
"os"
"runtime"
"strconv"
"strings"
)

// CPUStats represents CPU usage statistics
type CPUStats struct {
User uint64
System uint64
Idle uint64
Total uint64
}

// GetProcessCPUStats retrieves CPU stats for the current process
func GetProcessCPUStats() (*CPUStats, error) {
// Read /proc/self/stat
data, err := os.ReadFile("/proc/self/stat")
if err != nil {
return nil, fmt.Errorf("failed to read /proc/self/stat: %w", err)
}

// Parse the stat file
// Fields: pid comm state ... utime stime ...
// utime is field 14 (index 13), stime is field 15 (index 14)
fields := strings.Fields(string(data))
if len(fields) < 15 {
return nil, fmt.Errorf("unexpected /proc/self/stat format")
}

utime, err := strconv.ParseUint(fields[13], 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse utime: %w", err)
}

stime, err := strconv.ParseUint(fields[14], 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse stime: %w", err)
}

return &CPUStats{
User: utime,
System: stime,
Idle: 0,
Total: utime + stime,
}, nil
}

// GetSystemCPUStats retrieves system-wide CPU stats
func GetSystemCPUStats() (*CPUStats, error) {
file, err := os.Open("/proc/stat")
if err != nil {
return nil, fmt.Errorf("failed to open /proc/stat: %w", err)
}
defer file.Close()

scanner := bufio.NewScanner(file)
if !scanner.Scan() {
return nil, fmt.Errorf("failed to read /proc/stat")
}

line := scanner.Text()
if !strings.HasPrefix(line, "cpu ") {
return nil, fmt.Errorf("unexpected /proc/stat format")
}

// cpu user nice system idle iowait irq softirq ...
fields := strings.Fields(line)
if len(fields) < 5 {
return nil, fmt.Errorf("not enough fields in /proc/stat")
}

user, _ := strconv.ParseUint(fields[1], 10, 64)
nice, _ := strconv.ParseUint(fields[2], 10, 64)
system, _ := strconv.ParseUint(fields[3], 10, 64)
idle, _ := strconv.ParseUint(fields[4], 10, 64)

total := user + nice + system + idle
if len(fields) >= 8 {
iowait, _ := strconv.ParseUint(fields[5], 10, 64)
irq, _ := strconv.ParseUint(fields[6], 10, 64)
softirq, _ := strconv.ParseUint(fields[7], 10, 64)
total += iowait + irq + softirq
}

return &CPUStats{
User: user + nice,
System: system,
Idle: idle,
Total: total,
}, nil
}

// CalculateCPUPercent calculates CPU usage percentage from two snapshots
func CalculateCPUPercent(before, after *CPUStats) float64 {
if before == nil || after == nil {
return 0.0
}

deltaTotal := after.Total - before.Total
if deltaTotal == 0 {
return 0.0
}

deltaUsed := (after.User + after.System) - (before.User + before.System)
return (float64(deltaUsed) / float64(deltaTotal)) * 100.0
}

// GetProcessMemoryMB returns current process memory usage in MB
func GetProcessMemoryMB() float64 {
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
return float64(memStats.Alloc) / 1024 / 1024
}

// GetProcessRSSMemoryMB returns RSS memory from /proc/self/status
func GetProcessRSSMemoryMB() (float64, error) {
file, err := os.Open("/proc/self/status")
if err != nil {
return 0, fmt.Errorf("failed to open /proc/self/status: %w", err)
}
defer file.Close()

scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "VmRSS:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
rssKB, err := strconv.ParseFloat(fields[1], 64)
if err != nil {
return 0, fmt.Errorf("failed to parse RSS: %w", err)
}
return rssKB / 1024, nil // Convert KB to MB
}
}
}

return 0, fmt.Errorf("VmRSS not found in /proc/self/status")
}
Loading
Loading