package voice import ( "context" "encoding/json" "net" "os" "syscall" "testing" "time" "github.com/kami/maven/internal/audio" ) // newTestListener returns a TCP listener on a random port with SO_REUSEADDR. // It retries a few times if the port is temporarily unavailable (e.g., TIME_WAIT). func newTestListener(t *testing.T) net.Listener { t.Helper() lc := net.ListenConfig{ Control: func(network, address string, c syscall.RawConn) error { var err error c.Control(func(fd uintptr) { err = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) }) return err }, } var ln net.Listener var err error for i := 0; i < 5; i++ { ln, err = lc.Listen(context.Background(), "tcp", "127.0.0.1:0") if err == nil { break } if !isAddrInUse(err) { t.Fatalf("listen: %v", err) } time.Sleep(100 * time.Millisecond) } if err != nil { t.Fatalf("listen after retries: %v", err) } // Close the probe before returning: it only existed to reserve a free port // (127.0.0.1:0 → a concrete port). The Server rebinds that exact addr in its // own Listen(), which fails with EADDRINUSE while the probe still holds it. // Addr() keeps returning the address after Close, and an unconnected listener // leaves no TIME_WAIT, so the rebind is immediate and clean. _ = ln.Close() return ln } func isAddrInUse(err error) bool { if err == nil { return false } if opErr, ok := err.(*net.OpError); ok { if sysErr, ok := opErr.Err.(*os.SyscallError); ok { if err := sysErr.Err; err == syscall.EADDRINUSE { return true } } } return false } // wait a bit for OS to release the port after close. func waitPort() { time.Sleep(500 * time.Millisecond) } // stubHandler — satisfies voice.Handler for tests. type stubHandler struct { lastReq PushToTalkReq } func (h *stubHandler) HandlePushToTalk(_ context.Context, req PushToTalkReq, _ uint64) (PushToTalkResp, error) { h.lastReq = req return PushToTalkResp{ ReplyAudio: audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("reply")}, ReplyText: "got it", }, nil } func TestServerAcceptsAndRemovesSession(t *testing.T) { l := newTestListener(t) sess := NewSessions() h := &stubHandler{} srv := NewServer(l.Addr().String(), h, sess) if err := srv.Listen(); err != nil { t.Fatalf("listen: %v", err) } defer func() { _ = srv.Close() waitPort() }() // Accept connections in the background; Serve blocks until Close. Without // it the listener binds but never accepts, so a client round-trip hangs. go func() { _ = srv.Serve() }() if sess.Active() != 0 { t.Fatalf("active before connect: %d", sess.Active()) } conn, err := net.Dial("tcp", l.Addr().String()) if err != nil { t.Fatalf("dial: %v", err) } conn.Close() // quick disconnect // Give serveConn a moment to register then remove. time.Sleep(50 * time.Millisecond) if sess.Active() != 0 { t.Fatalf("active after disconnect: %d, want 0", sess.Active()) } } func TestClientPushToTalkRoundTrip(t *testing.T) { l := newTestListener(t) sess := NewSessions() h := &stubHandler{} srv := NewServer(l.Addr().String(), h, sess) if err := srv.Listen(); err != nil { t.Fatalf("listen: %v", err) } defer func() { _ = srv.Close() waitPort() }() // Accept connections in the background; Serve blocks until Close. Without // it the listener binds but never accepts, so a client round-trip hangs. go func() { _ = srv.Serve() }() c := Dial(l.Addr().String()) defer c.Close() resp, err := c.PushToTalk(context.Background(), audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("hello")}, "ru") if err != nil { t.Fatalf("PushToTalk: %v", err) } if resp.ReplyText != "got it" { t.Fatalf("ReplyText: %q, want %q", resp.ReplyText, "got it") } if len(resp.ReplyAudio.Bytes) == 0 { t.Fatalf("ReplyAudio empty") } if h.lastReq.Audio.Bytes == nil { t.Fatalf("handler never got audio") } } func TestClientListenModeReceivesPush(t *testing.T) { l := newTestListener(t) sess := NewSessions() h := &stubHandler{} srv := NewServer(l.Addr().String(), h, sess) if err := srv.Listen(); err != nil { t.Fatalf("listen: %v", err) } defer func() { _ = srv.Close() waitPort() }() // Accept connections in the background; Serve blocks until Close. Without // it the listener binds but never accepts, so a client round-trip hangs. go func() { _ = srv.Serve() }() c := Dial(l.Addr().String()) defer c.Close() // Run the push receiver in the background: it blocks reading frames until // the conn closes (ctx cancel alone can't interrupt a blocking read). A // proactive push from the server is delivered to the handler, which hands // the audio to a channel the test waits on. got := make(chan audio.Audio, 1) ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { _ = c.RunPushReceiver(ctx, pushHandlerFunc(func(p Push) { if p.Kind != PushKindAudioNudge { return } var ap AudioNudgePush if err := json.Unmarshal(p.Params, &ap); err == nil { select { case got <- ap.Audio: default: } } })) }() // serveConn registers the session on accept, but Accept→Add is async — wait // for it before pushing, or PushToMostRecent finds no session. for i := 0; i < 100 && sess.Active() < 1; i++ { time.Sleep(10 * time.Millisecond) } if sess.Active() < 1 { t.Fatal("session never registered") } // Server pushes to the session. push := AudioNudgePush{ RuleName: "test", Severity: 3, Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("proactive")}, Text: "proactive text", Ts: time.Now(), } if err := sess.PushToMostRecent(context.Background(), push); err != nil { t.Fatalf("PushToMostRecent: %v", err) } select { case received := <-got: if string(received.Bytes) != "proactive" { t.Fatalf("received audio mismatch: %q", string(received.Bytes)) } case <-time.After(2 * time.Second): t.Fatal("never received pushed audio") } } type pushHandlerFunc func(Push) func (f pushHandlerFunc) OnPush(p Push) { f(p) }