initial commit
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
// worker/client.go — the core-side dialer.
|
||||
//
|
||||
// Core is the CLIENT of its stt/tts worker modules: it dials them, ships
|
||||
// audio bytes (Transcribe) and reply text (Synthesize), reads back the
|
||||
// result. One Client ⇒ one conn ⇒ one mutex ⇒ no frame interleaving by
|
||||
// construction (a module needing parallelism opens N clients, but stt/tts
|
||||
// jobs are serial at single-user scale — the model is the bottleneck, not
|
||||
// the wire). ctx cancel ⇒ conn close (a half-sent frame desyncs the stream;
|
||||
// teardown is the clean recovery, a fresh Dial is the caller's job on next
|
||||
// call). Same instinct as internal/ipc/client.go.
|
||||
//
|
||||
// The daemon holds two Clients — one for the stt module, one for tts — each
|
||||
// behind a stt.Transcriber / tts.Synthesizer interface (see internal/stt,
|
||||
// internal/tts). The interface IS the swap seam: a Stub impl satisfies the
|
||||
// same interface in-process; the Remote wraps this Client. Core never sees
|
||||
// a difference.
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client — one connection to one worker module. NOT goroutine-safe for
|
||||
// concurrent calls on the same conn: the mutex serializes writes per
|
||||
// request so a frame doesn't interleave with another, but parallel calls
|
||||
// block on each other. Open N clients for N parallel jobs.
|
||||
type Client struct {
|
||||
path string
|
||||
|
||||
mu sync.Mutex
|
||||
c net.Conn
|
||||
dial func() (net.Conn, error)
|
||||
}
|
||||
|
||||
// Dial opens a Client to the worker socket at path. The first call lazily
|
||||
// dials; subsequent calls reuse the conn (a fresh dial happens on next call
|
||||
// after a teardown). Lazy dial keeps a worker that's restarting from
|
||||
// blocking core's startup; core attempts the dial on first use.
|
||||
func Dial(path string) *Client {
|
||||
return &Client{
|
||||
path: path,
|
||||
dial: func() (net.Conn, error) {
|
||||
return net.Dial("unix", path)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Close releases the conn. Idempotent; subsequent calls re-dial on demand.
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.c == nil {
|
||||
return nil
|
||||
}
|
||||
err := c.c.Close()
|
||||
c.c = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// Transcribe calls the transcribe verb on the connected worker module. Raises
|
||||
// ErrUnknownMethod when the worker serves synthesize only; the daemon wiring
|
||||
// has pointed this Client at the wrong socket, surfaced as a clean error.
|
||||
func (c *Client) Transcribe(ctx context.Context, req TranscribeReq) (TranscribeResp, error) {
|
||||
var resp TranscribeResp
|
||||
err := c.call(ctx, MethodTranscribe, req, &resp)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// Synthesize calls the synthesize verb.
|
||||
func (c *Client) Synthesize(ctx context.Context, req SynthesizeReq) (SynthesizeResp, error) {
|
||||
var resp SynthesizeResp
|
||||
err := c.call(ctx, MethodSynthesize, req, &resp)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (c *Client) call(ctx context.Context, m Method, params any, out any) error {
|
||||
body, err := marshalParams(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := Request{Method: m, Params: body}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if err := c.ensureConnLocked(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// ctx cancel ⇒ close conn so a stalled peer doesn't hang the caller; a
|
||||
// half-sent frame would desync the stream. Teardown is the clean
|
||||
// recovery; the next call re-dials.
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = c.c.SetDeadline(dl)
|
||||
} else {
|
||||
_ = c.c.SetDeadline(time.Now().Add(defaultCallTimeout))
|
||||
}
|
||||
defer c.c.SetDeadline(time.Time{})
|
||||
|
||||
if err := writeFrame(c.c, req); err != nil {
|
||||
c.teardownLocked()
|
||||
return err
|
||||
}
|
||||
var resp Response
|
||||
if err := readFrame(c.c, &resp); err != nil {
|
||||
c.teardownLocked()
|
||||
return err
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return hydrate(resp.Error)
|
||||
}
|
||||
if err := json.Unmarshal(resp.Result, out); err != nil {
|
||||
return fmt.Errorf("worker: unmarshal result: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureConnLocked(ctx context.Context) error {
|
||||
if c.c != nil {
|
||||
return nil
|
||||
}
|
||||
connCh := make(chan net.Conn, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
nc, err := c.dial()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
connCh <- nc
|
||||
}()
|
||||
select {
|
||||
case nc := <-connCh:
|
||||
c.c = nc
|
||||
return nil
|
||||
case err := <-errCh:
|
||||
return fmt.Errorf("worker: dial %s: %w", c.path, err)
|
||||
case <-ctx.Done():
|
||||
// the dial goroutine will finish or not; the conn if any is leaked
|
||||
// to GC. acceptable — dial failures are rare, and a leaked closed
|
||||
// socket is harmless.
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) teardownLocked() {
|
||||
if c.c != nil {
|
||||
_ = c.c.Close()
|
||||
c.c = nil
|
||||
}
|
||||
}
|
||||
|
||||
// defaultCallTimeout — a worker that hangs > 60s is dead or stuck on a
|
||||
// model forward pass that's gone off the rails. Surface as a timeout
|
||||
// instead of hanging the daemon's tick / voice path. Production may override
|
||||
// via ctx (a long meeting-record transcription, post-MVP).
|
||||
const defaultCallTimeout = 60 * time.Second
|
||||
|
||||
func hydrate(e *RpcError) error {
|
||||
switch e.Code {
|
||||
case codeUnknownMethod:
|
||||
return fmt.Errorf("%w: %s", ErrUnknownMethod, e.Message)
|
||||
case codeBadParams:
|
||||
return fmt.Errorf("%w: %s", ErrBadParams, e.Message)
|
||||
default:
|
||||
if e.Message != "" {
|
||||
return fmt.Errorf("worker: %s: %s", e.Code, e.Message)
|
||||
}
|
||||
return fmt.Errorf("worker: %s", e.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func marshalParams(v any) (json.RawMessage, error) {
|
||||
if v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("worker: marshal params: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// worker/frame.go — length-prefixed JSON framing.
|
||||
//
|
||||
// Identical shape to internal/ipc/frame.go with one difference: the cap is
|
||||
// 64 MiB here vs 4 there. Audio bytes (base64-encoded in JSON) push frame
|
||||
// sizes up to ~80% over their decoded size; 64 MiB on the wire comfortably
|
||||
// allows minutes-long single utterances, while still bounding a confused
|
||||
// peer's length prefix from allocating an unbounded buffer.
|
||||
//
|
||||
// Binary bytes are base64-encoded inside the JSON envelope. Local socket
|
||||
// + single-user scale ⇒ the 33% expansion cost is invisible vs. a model
|
||||
// forward pass; the benefit is one debuggable wire shape (socat shows a
|
||||
// readable JSON frame, no separate byte-pump to inspect audio).
|
||||
package worker
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ErrFrameTooLarge — a frame exceeded maxFrame; the conn is now
|
||||
// desynchronized (we read the length but not the body), so the caller must
|
||||
// close it.
|
||||
var ErrFrameTooLarge = errors.New("worker: frame too large")
|
||||
|
||||
func writeFrame(w io.Writer, v any) error {
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("worker: marshal frame: %w", err)
|
||||
}
|
||||
if len(body) > maxFrame {
|
||||
return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, len(body))
|
||||
}
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
|
||||
if _, err := w.Write(hdr[:]); err != nil {
|
||||
return fmt.Errorf("worker: write frame header: %w", err)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return fmt.Errorf("worker: write frame body: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readFrame(r io.Reader, v any) error {
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return io.EOF
|
||||
}
|
||||
return fmt.Errorf("worker: read frame header: %w", err)
|
||||
}
|
||||
n := binary.BigEndian.Uint32(hdr[:])
|
||||
if n > maxFrame {
|
||||
return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, n)
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return fmt.Errorf("worker: read frame body: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(buf, v); err != nil {
|
||||
return fmt.Errorf("worker: unmarshal frame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// worker/handler.go — the worker-side seam. A module process implements
|
||||
// either Transcriber or Synthesizer (rarely both — stt and tts are
|
||||
// independent units with independent model weights and restart lifecycles).
|
||||
// Server dispatches a Method to the matching handler.
|
||||
package worker
|
||||
|
||||
import "context"
|
||||
|
||||
// Transcriber — the stt module's contract. Implementations:
|
||||
// - the daemon's in-process Stub (built-in stt stub handler, for tests +
|
||||
// for the "no models on disk yet" floor; the daemon decides which to wire
|
||||
// from config),
|
||||
// - a real faster-whisper / vosk process's main (cmd/mavsttd/main.go today
|
||||
// ships the Stub handler; production swaps in onnxruntime-backed code in
|
||||
// that same main, no worker-package change).
|
||||
type Transcriber interface {
|
||||
Transcribe(ctx context.Context, req TranscribeReq) (TranscribeResp, error)
|
||||
}
|
||||
|
||||
// Synthesizer — the tts module's contract. Mirrors Transcriber (separate
|
||||
// interface so the two modules can be in separate packages / binaries, and
|
||||
// so a server asked for the wrong verb refuses cleanly).
|
||||
type Synthesizer interface {
|
||||
Synthesize(ctx context.Context, req SynthesizeReq) (SynthesizeResp, error)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// worker/jobs.go — the two verb payloads (Transcribe + Synthesize).
|
||||
//
|
||||
// Audio bytes are base64-encoded by encoding/json automatically via the
|
||||
// []byte type — the wire shape is a string field carrying base64. Local
|
||||
// unix socket ⇒ expansion cost is invisible; the JSON envelope stays
|
||||
// debuggable per the package doc.
|
||||
//
|
||||
// Input shape (Transcribe): core has captured audio (push-to-talk) or
|
||||
// synthesised it (TTS round-trip test path — non-production). The worker
|
||||
// module consumes audio bytes with the declared Format. Lang is a hint
|
||||
// ("ru"/"en"/"mixed"); faster-whisper is multilingual and treats the hint
|
||||
// as a soft bias, vosk ru ignores it (one-model, one-language). The reference
|
||||
// stt stub ignores all params and returns a canned phrase so the loop is
|
||||
// exercisable without a model on disk.
|
||||
//
|
||||
// Output shape (Transcribe): Text is the recognized string. Confidence is
|
||||
// the model's own estimate when available; 0 ⇒ unknown. The router downstream
|
||||
// does its own confidence scoring (the cosine-sim classifier), so worker-side
|
||||
// confidence is for logging/gating, not for routing.
|
||||
//
|
||||
// Input shape (Synthesize): text to render. Lang is the requested voice
|
||||
// language ("ru"/"en"). Voice ID is a named voice when supported, "" ⇒ the
|
||||
// worker's configured default. Speed is a 1.0 = normal multiplier; out of
|
||||
// range is clamped by the worker, not the caller.
|
||||
//
|
||||
// Output shape (Synthesize): Audio is the rendered PCM bytes in Format.
|
||||
package worker
|
||||
|
||||
import "github.com/kami/maven/internal/audio"
|
||||
|
||||
// TranscribeReq — the transcribe verb args.
|
||||
type TranscribeReq struct {
|
||||
Audio audio.Audio `json:"audio"` // capture bytes (raw PCM, format declared)
|
||||
Lang string `json:"lang"` // "ru" | "en" | "mixed" | "" ⇒ module default
|
||||
}
|
||||
|
||||
// TranscribeResp — the transcribe verb result.
|
||||
type TranscribeResp struct {
|
||||
Text string `json:"text"`
|
||||
Confidence float64 `json:"confidence,omitempty"` // 0 ⇒ unknown
|
||||
}
|
||||
|
||||
// SynthesizeReq — the synthesize verb args.
|
||||
type SynthesizeReq struct {
|
||||
Text string `json:"text"`
|
||||
Lang string `json:"lang"` // "ru" | "en" | ""
|
||||
Voice string `json:"voice"` // named voice or "" ⇒ worker default
|
||||
Speed float64 `json:"speed"` // 1.0 = normal; clamped server-side
|
||||
}
|
||||
|
||||
// SynthesizeResp — the synthesize verb result.
|
||||
type SynthesizeResp struct {
|
||||
Audio audio.Audio `json:"audio"` // rendered PCM
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// worker/server.go — the worker-side listener + accept loop.
|
||||
//
|
||||
// Each call is dispatched to a single handler (Transcriber OR Synthesizer,
|
||||
// depending on what the module process);
|
||||
// the other verb returns ErrUnknownMethod — a stt process won't serve
|
||||
// synthesize. robust to a misconfigured client (the daemon wiring chooses
|
||||
// which module to dial; mixing the two is a config error caught cleanly by
|
||||
// the wire, not a runtime goroutine panic). One Server per module process.
|
||||
//
|
||||
// Socket perms mirror ipc.Server: dir 0700, socket 0600 ⇒ same unix user.
|
||||
// The module has no key, so the floor is "same user"; the wg/mTLS layers
|
||||
// are out of scope here (this socket never crosses the network radius —
|
||||
// it's local-only, point-to-point between two processes on the box).
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Server — a worker module process's listener. Wires either a Transcriber,
|
||||
// a Synthesizer, or both (the both case is unusual; the daemon typically
|
||||
// runs two separate module processes). The unset verb returns
|
||||
// ErrUnknownMethod.
|
||||
type Server struct {
|
||||
t Transcriber
|
||||
s Synthesizer
|
||||
|
||||
path string
|
||||
ln net.Listener
|
||||
|
||||
wg sync.WaitGroup
|
||||
done chan struct{}
|
||||
|
||||
// connCount — assigned per accepted conn, used in logs to distinguish
|
||||
// concurrent connections. Monotonic; not load-bearing for correctness.
|
||||
connCount atomic.Uint64
|
||||
}
|
||||
|
||||
// NewServer builds a Server with a Transcriber. The caller wires a
|
||||
// Synthesizer via SetSynthesizer if this process serves tts. Use
|
||||
// NewSynthesizerServer for the tts-only case (mirrors this constructor).
|
||||
func NewServer(path string, t Transcriber) *Server {
|
||||
return &Server{t: t, path: path, done: make(chan struct{})}
|
||||
}
|
||||
|
||||
// NewSynthesizerServer builds a Server with a Synthesizer (the tts module).
|
||||
func NewSynthesizerServer(path string, s Synthesizer) *Server {
|
||||
return &Server{s: s, path: path, done: make(chan struct{})}
|
||||
}
|
||||
|
||||
// SetSynthesizer wires the synthesize verb on a Transcriber-built Server.
|
||||
// Used only when one process serves both (non-default; the daemon prefers
|
||||
// two separate processes per the restart-free / fail-independent invariant).
|
||||
func (srv *Server) SetSynthesizer(s Synthesizer) { srv.s = s }
|
||||
|
||||
// Listen binds the unix socket with 0700 dir + 0600 socket perms (same floor
|
||||
// as internal/ipc). A stale socket at path is removed first so the worker
|
||||
// process restarts cleanly after a crash, no manual cleanup needed.
|
||||
func (srv *Server) Listen() error {
|
||||
_ = os.Remove(srv.path)
|
||||
if err := os.MkdirAll(parentDir(srv.path), 0o700); err != nil {
|
||||
return fmt.Errorf("worker: mkdir socket dir: %w", err)
|
||||
}
|
||||
oldMask := unix.Umask(0o077)
|
||||
ln, err := net.Listen("unix", srv.path)
|
||||
unix.Umask(oldMask)
|
||||
if err != nil {
|
||||
return fmt.Errorf("worker: listen %s: %w", srv.path, err)
|
||||
}
|
||||
if err := os.Chmod(srv.path, 0o600); err != nil {
|
||||
_ = ln.Close()
|
||||
_ = os.Remove(srv.path)
|
||||
return fmt.Errorf("worker: chmod socket: %w", err)
|
||||
}
|
||||
srv.ln = ln
|
||||
return nil
|
||||
}
|
||||
|
||||
// Path returns the bound socket path (after Listen; "" before).
|
||||
func (srv *Server) Path() string { return srv.path }
|
||||
|
||||
// Serve accepts connections until the listener closes. Each connection is
|
||||
// served in its own goroutine; a panicking handler tears down only that conn
|
||||
// (the rest of the module keeps serving, restart-free per spec).
|
||||
func (srv *Server) Serve() error {
|
||||
if srv.ln == nil {
|
||||
return fmt.Errorf("worker: serve before listen")
|
||||
}
|
||||
for {
|
||||
c, err := srv.ln.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-srv.done:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("worker: accept: %w", err)
|
||||
}
|
||||
}
|
||||
srv.wg.Add(1)
|
||||
go func(c net.Conn) {
|
||||
defer srv.wg.Done()
|
||||
defer c.Close()
|
||||
srv.serveConn(c)
|
||||
}(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *Server) serveConn(c net.Conn) {
|
||||
id := srv.connCount.Add(1)
|
||||
for {
|
||||
var req Request
|
||||
if err := readFrame(c, &req); err != nil {
|
||||
return // EOF / malformed ⇒ end this conn
|
||||
}
|
||||
result, err := srv.safeDispatch(c.RemoteAddr(), id, req)
|
||||
resp := Response{}
|
||||
if err != nil {
|
||||
resp.Error = rpcErr(err)
|
||||
} else {
|
||||
resp.Result = result
|
||||
}
|
||||
if err := writeFrame(c, resp); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *Server) safeDispatch(addr net.Addr, id uint64, req Request) (result json.RawMessage, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("worker: panic dispatching %s (conn %d): %v", req.Method, id, r)
|
||||
}
|
||||
}()
|
||||
return srv.dispatch(req)
|
||||
}
|
||||
|
||||
func (srv *Server) dispatch(req Request) (json.RawMessage, error) {
|
||||
switch req.Method {
|
||||
case MethodTranscribe:
|
||||
if srv.t == nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
}
|
||||
var p TranscribeReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := srv.t.Transcribe(context.Background(), p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
|
||||
case MethodSynthesize:
|
||||
if srv.s == nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
}
|
||||
var p SynthesizeReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := srv.s.Synthesize(context.Background(), p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops accepting and waits for in-flight connections to drain. The
|
||||
// socket file is removed so a restart can rebind cleanly. Idempotent.
|
||||
func (srv *Server) Close() error {
|
||||
select {
|
||||
case <-srv.done:
|
||||
return nil
|
||||
default:
|
||||
close(srv.done)
|
||||
}
|
||||
if srv.ln == nil {
|
||||
return nil
|
||||
}
|
||||
err := srv.ln.Close()
|
||||
srv.wg.Wait()
|
||||
_ = os.Remove(srv.path)
|
||||
return err
|
||||
}
|
||||
|
||||
func unmarshalParams(raw json.RawMessage, v any) error {
|
||||
if len(raw) == 0 {
|
||||
raw = []byte("null")
|
||||
}
|
||||
if err := json.Unmarshal(raw, v); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrBadParams, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalResult(v any) json.RawMessage {
|
||||
if v == nil {
|
||||
return json.RawMessage("null")
|
||||
}
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
|
||||
func parentDir(p string) string {
|
||||
for i := len(p) - 1; i >= 0; i-- {
|
||||
if p[i] == '/' {
|
||||
if i == 0 {
|
||||
return "/"
|
||||
}
|
||||
return p[:i]
|
||||
}
|
||||
}
|
||||
return "."
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Package worker is the audio-job boundary between core and its stt/tts
|
||||
// modules.
|
||||
//
|
||||
// It is a SIBLING to internal/ipc, not a reuse of it:
|
||||
//
|
||||
// - internal/ipc is the core↔module *state* boundary. Modules call INTO
|
||||
// core to write facts / read presence. core is the server; the module
|
||||
// is the client. The verbs (WriteFact, Since, ...) are authority-bound;
|
||||
// the auth layer scopes them per Caller.
|
||||
// - internal/worker is the core↔module *job* boundary. Core calls OUT to
|
||||
// the stt/tts module: "transcribe these bytes", "synthesize this text."
|
||||
// Core is the client here; the worker module is the server. The verbs
|
||||
// are not authority-bound — they are pure compute over the payload core
|
||||
// already owns (audio bytes / reply text). Nothing about authority moves
|
||||
// across this boundary because the worker has no key, no facts, nothing
|
||||
// stateful to read or write.
|
||||
//
|
||||
// The reversal matters: a module that ships audio work to core would have
|
||||
// to hold a Caller identity, an enrolled daemon surface, etc.; core that
|
||||
// ships work to a module ships only the work. Same unix-socket floor (0600
|
||||
// dir + socket perms carry "same user") but the flow is reversed, and the
|
||||
// surface stays out of the auth cascade — the worker never reaches into
|
||||
// core's state, so it has nothing to be capped against.
|
||||
//
|
||||
// Transport: unix domain socket, local-only, same-host as core. Same wire
|
||||
// shape as ipc (4-byte big-endian length + JSON body) so `socat`/`nc` can
|
||||
// debug it identically. Frame cap is 64 MiB (vs ipc's 4) so audio blobs fit
|
||||
// — a minute of 16k mono int16 is ~1.9 MiB, comfortably under; an hour's
|
||||
// sleep-clip transcription is implausible to send as one frame but the cap
|
||||
// permits long-enough live clips without framing the worker into chunks.
|
||||
package worker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// maxFrame — 64 MiB. Audio up to ~3 minutes @ 16k mono int16 fits one frame
|
||||
// with headroom; longer audio is chunked by the caller (meeting-record mode
|
||||
// is post-MVP) or rejected with ErrFrameTooLarge. Sized for stt inputs and
|
||||
// tts outputs at single-utterance scale.
|
||||
const maxFrame = 64 << 20
|
||||
|
||||
// Method — one RPC verb. Adding one is a worker-API change, not an authority
|
||||
// change (worker has no authority surface — see package doc). The two verbs
|
||||
// mirror the stt/tts interfaces the daemon wires; new verbs go with a new
|
||||
// module kind (e.g. translation), not a new feature of an existing one.
|
||||
type Method string
|
||||
|
||||
const (
|
||||
MethodTranscribe Method = "transcribe"
|
||||
MethodSynthesize Method = "synthesize"
|
||||
)
|
||||
|
||||
// Request — one frame from core to a worker module.
|
||||
type Request struct {
|
||||
Method Method `json:"m"`
|
||||
Params json.RawMessage `json:"p,omitempty"`
|
||||
}
|
||||
|
||||
// Response — one frame back from the worker. Exactly one of Result/Error is set.
|
||||
type Response struct {
|
||||
Result json.RawMessage `json:"r,omitempty"`
|
||||
Error *RpcError `json:"e,omitempty"`
|
||||
}
|
||||
|
||||
// RpcError — a typed wire error. Mirrors internal/ipc's shape for tooling
|
||||
// parity (same args to socat, same `errors.Is` rehydration pattern); worker
|
||||
// sentinels are independent since this boundary is independent.
|
||||
type RpcError struct {
|
||||
Code string `json:"c"`
|
||||
Message string `json:"m,omitempty"`
|
||||
}
|
||||
|
||||
func (e *RpcError) Error() string {
|
||||
if e.Message != "" {
|
||||
return fmt.Sprintf("worker: %s: %s", e.Code, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("worker: %s", e.Code)
|
||||
}
|
||||
|
||||
// Sentinel codes. Stable over the wire — do not rename. Mirror the package
|
||||
// sentinels 1:1.
|
||||
const (
|
||||
codeUnknownMethod = "unknown_method"
|
||||
codeBadParams = "bad_params"
|
||||
codeInternal = "internal"
|
||||
)
|
||||
|
||||
// Sentinel errors. The client rehydrates a wire RpcError into one of these so
|
||||
// callers use errors.Is the same way they would in-process.
|
||||
var (
|
||||
ErrUnknownMethod = errors.New("worker: unknown method")
|
||||
ErrBadParams = errors.New("worker: bad params")
|
||||
)
|
||||
|
||||
// codeOf maps a server-side sentinel to its wire code. Anything not matched
|
||||
// is codeInternal — internal Go error text never ships to the caller; the
|
||||
// server logs the real text and the client sees a generic code.
|
||||
func codeOf(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, ErrUnknownMethod):
|
||||
return codeUnknownMethod
|
||||
case errors.Is(err, ErrBadParams):
|
||||
return codeBadParams
|
||||
default:
|
||||
return codeInternal
|
||||
}
|
||||
}
|
||||
|
||||
// rpcErr builds the wire error for a server-side error. message is omitted
|
||||
// for sentinel codes (the Code carries the meaning). internal errors carry
|
||||
// the message text — it's not authority-bearing text, just a diagnostic.
|
||||
func rpcErr(err error) *RpcError {
|
||||
c := codeOf(err)
|
||||
if c == codeInternal || c == codeBadParams {
|
||||
return &RpcError{Code: c, Message: err.Error()}
|
||||
}
|
||||
return &RpcError{Code: c}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// stubTranscriber returns a fixed string; satisfies worker.Transcriber.
|
||||
type stubTranscriber struct {
|
||||
mu sync.Mutex
|
||||
gotLast audio.Audio
|
||||
}
|
||||
|
||||
func (s *stubTranscriber) Transcribe(_ context.Context, req TranscribeReq) (TranscribeResp, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.gotLast = req.Audio
|
||||
return TranscribeResp{Text: "hello from stt", Confidence: 0.9}, nil
|
||||
}
|
||||
|
||||
type errTranscriber struct{}
|
||||
|
||||
func (errTranscriber) Transcribe(context.Context, TranscribeReq) (TranscribeResp, error) {
|
||||
return TranscribeResp{}, errors.New("synth failed")
|
||||
}
|
||||
|
||||
type stubSynthesizer struct{}
|
||||
|
||||
func (stubSynthesizer) Synthesize(_ context.Context, req SynthesizeReq) (SynthesizeResp, error) {
|
||||
pcm := make([]byte, 3200) // 100ms of silence @ 16k mono int16
|
||||
_ = req
|
||||
return SynthesizeResp{Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}}, nil
|
||||
}
|
||||
|
||||
// newServer builds a Server on a temp socket, starts Serve in a goroutine,
|
||||
// returns the Server + path + a cleanup. Tests use this to get a real
|
||||
// round-trip over a unix socket.
|
||||
func newServer(t *testing.T, srv *Server) (*Server, string, func()) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
sock := filepath.Join(dir, "worker.sock")
|
||||
srv.path = sock
|
||||
if err := srv.Listen(); err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
go func() {
|
||||
if err := srv.Serve(); err != nil {
|
||||
t.Logf("serve ended: %v", err)
|
||||
}
|
||||
}()
|
||||
cleanup := func() {
|
||||
_ = srv.Close()
|
||||
}
|
||||
return srv, sock, cleanup
|
||||
}
|
||||
|
||||
func TestServerRejectsUnknownMethod(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := NewSynthesizerServer("", stubSynthesizer{})
|
||||
_, sock, cleanup := newServer(t, srv)
|
||||
defer cleanup()
|
||||
c := Dial(sock)
|
||||
defer c.Close()
|
||||
_, err := c.Transcribe(context.Background(), TranscribeReq{Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: []byte{1, 2}}})
|
||||
if err == nil {
|
||||
t.Fatalf("Transcribe on a tts server should error")
|
||||
}
|
||||
if !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Fatalf("err should be ErrUnknownMethod, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerTranscribeRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := &stubTranscriber{}
|
||||
srv := NewServer("", tr)
|
||||
_, sock, cleanup := newServer(t, srv)
|
||||
defer cleanup()
|
||||
c := Dial(sock)
|
||||
defer c.Close()
|
||||
|
||||
in := audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("hello audio payload")}
|
||||
resp, err := c.Transcribe(context.Background(), TranscribeReq{Audio: in, Lang: "ru"})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe: %v", err)
|
||||
}
|
||||
if resp.Text != "hello from stt" {
|
||||
t.Fatalf("Text: %q", resp.Text)
|
||||
}
|
||||
if resp.Confidence != 0.9 {
|
||||
t.Fatalf("Confidence: %v", resp.Confidence)
|
||||
}
|
||||
if !bytes.Equal(tr.gotLast.Bytes, in.Bytes) {
|
||||
t.Fatalf("audio bytes did not round-trip: in=%v got=%v", in.Bytes, tr.gotLast.Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerSynthesizeRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := NewSynthesizerServer("", stubSynthesizer{})
|
||||
_, sock, cleanup := newServer(t, srv)
|
||||
defer cleanup()
|
||||
c := Dial(sock)
|
||||
defer c.Close()
|
||||
|
||||
resp, err := c.Synthesize(context.Background(), SynthesizeReq{Text: "привет", Lang: "ru"})
|
||||
if err != nil {
|
||||
t.Fatalf("Synthesize: %v", err)
|
||||
}
|
||||
if !resp.Audio.Format.IsValid() {
|
||||
t.Fatalf("audio format invalid: %+v", resp.Audio.Format)
|
||||
}
|
||||
if len(resp.Audio.Bytes) != 3200 {
|
||||
t.Fatalf("audio bytes len: %d, want 3200", len(resp.Audio.Bytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerUnsetVerbReturnsUnknownMethod(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := NewServer("", &stubTranscriber{}) // transcriber-only
|
||||
_, sock, cleanup := newServer(t, srv)
|
||||
defer cleanup()
|
||||
c := Dial(sock)
|
||||
defer c.Close()
|
||||
_, err := c.Synthesize(context.Background(), SynthesizeReq{Text: "x"})
|
||||
if !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Fatalf("Synthesize on a stt-only server: want ErrUnknownMethod, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerDispatchesBadParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := NewServer("", &stubTranscriber{})
|
||||
_, sock, cleanup := newServer(t, srv)
|
||||
defer cleanup()
|
||||
|
||||
// raw socket: ship a Request frame with malformed params JSON. We can't
|
||||
// use writeFrame because Request marshals RawMessage and rejects invalid
|
||||
// JSON itself — so build the bytes by hand: a valid Request envelope
|
||||
// whose `p` field is a syntactically broken JSON string.
|
||||
conn, err := net.Dial("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
// {"m":"transcribe","p":{not json}} — but the `p` value is invalid JSON.
|
||||
// We instead send `{"m":"transcribe","p":"<not an object>"}` — a valid
|
||||
// JSON frame whose params unmarshal fails into TranscribeReq (string
|
||||
// into a struct). That path hits unmarshalParams' error ⇒ codeBadParams.
|
||||
body := []byte(`{"m":"transcribe","p":"not an object"}`)
|
||||
var hdr [4]byte
|
||||
hdr[0] = byte(len(body) >> 24)
|
||||
hdr[1] = byte(len(body) >> 16)
|
||||
hdr[2] = byte(len(body) >> 8)
|
||||
hdr[3] = byte(len(body))
|
||||
if _, err := conn.Write(hdr[:]); err != nil {
|
||||
t.Fatalf("write hdr: %v", err)
|
||||
}
|
||||
if _, err := conn.Write(body); err != nil {
|
||||
t.Fatalf("write body: %v", err)
|
||||
}
|
||||
var resp Response
|
||||
if err := readFrame(conn, &resp); err != nil {
|
||||
t.Fatalf("readFrame: %v", err)
|
||||
}
|
||||
if resp.Error == nil || resp.Error.Code != codeBadParams {
|
||||
t.Fatalf("want codeBadParams, got %+v", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerInternalErrorPropagates(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := NewServer("", errTranscriber{})
|
||||
_, sock, cleanup := newServer(t, srv)
|
||||
defer cleanup()
|
||||
c := Dial(sock)
|
||||
defer c.Close()
|
||||
_, err := c.Transcribe(context.Background(), TranscribeReq{Audio: audio.Audio{Format: audio.PCM16kMono}})
|
||||
if err == nil {
|
||||
t.Fatalf("want error from errTranscriber")
|
||||
}
|
||||
if errors.Is(err, ErrUnknownMethod) || errors.Is(err, ErrBadParams) {
|
||||
t.Fatalf("internal error should not match known sentinels: %v", err)
|
||||
}
|
||||
if !contains(err.Error(), "synth failed") {
|
||||
t.Fatalf("err should carry internal message text, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientContextCancelStopsCall(t *testing.T) {
|
||||
t.Parallel()
|
||||
// a server that never replies — transcriber blocked on a chan.
|
||||
hang := &hangTranscriber{ok: make(chan struct{})}
|
||||
srv := NewServer("", hang)
|
||||
_, sock, cleanup := newServer(t, srv)
|
||||
defer cleanup()
|
||||
defer close(hang.ok)
|
||||
|
||||
c := Dial(sock)
|
||||
defer c.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := c.Transcribe(ctx, TranscribeReq{Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: []byte("x")}})
|
||||
if err == nil {
|
||||
t.Fatalf("want ctx timeout error")
|
||||
}
|
||||
if !contains(err.Error(), "dial") && !errors.Is(err, context.DeadlineExceeded) {
|
||||
// dial may succeed within 100ms; if so, the deadline set on the conn
|
||||
// surfaces as an i/o timeout from writeFrame/readFrame. Either path
|
||||
// is acceptable; we just assert the call returned in finite time.
|
||||
}
|
||||
}
|
||||
|
||||
type hangTranscriber struct {
|
||||
ok chan struct{}
|
||||
}
|
||||
|
||||
func (h *hangTranscriber) Transcribe(ctx context.Context, _ TranscribeReq) (TranscribeResp, error) {
|
||||
select {
|
||||
case <-h.ok:
|
||||
return TranscribeResp{}, nil
|
||||
case <-ctx.Done():
|
||||
return TranscribeResp{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerCloseIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := NewServer("", &stubTranscriber{})
|
||||
_, _, cleanup := newServer(t, srv)
|
||||
cleanup() // first close
|
||||
if err := srv.Close(); err != nil {
|
||||
t.Fatalf("second Close should be no-op, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameTooLargeRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
// build a frame whose length prefix exceeds maxFrame; ensure readFrame
|
||||
// returns ErrFrameTooLarge.
|
||||
r, w := io.Pipe()
|
||||
go func() {
|
||||
var hdr [4]byte
|
||||
hdr[0] = 0xff
|
||||
hdr[1] = 0xff
|
||||
hdr[2] = 0xff
|
||||
hdr[3] = 0xff
|
||||
_, _ = w.Write(hdr[:])
|
||||
_ = w.Close()
|
||||
}()
|
||||
var v any
|
||||
err := readFrame(r, &v)
|
||||
if !errors.Is(err, ErrFrameTooLarge) {
|
||||
t.Fatalf("want ErrFrameTooLarge, got %v", err)
|
||||
}
|
||||
_ = r.Close()
|
||||
}
|
||||
|
||||
func TestPathAfterListen(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := NewServer("", &stubTranscriber{})
|
||||
dir := t.TempDir()
|
||||
sock := filepath.Join(dir, "x.sock")
|
||||
srv.path = sock
|
||||
if err := srv.Listen(); err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer srv.Close()
|
||||
if srv.Path() != sock {
|
||||
t.Fatalf("Path: got %q, want %q", srv.Path(), sock)
|
||||
}
|
||||
if _, err := os.Stat(sock); err != nil {
|
||||
t.Fatalf("socket file missing after listen: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack, needle string) bool {
|
||||
return len(haystack) >= len(needle) && (bytes.Contains([]byte(haystack), []byte(needle)))
|
||||
}
|
||||
Reference in New Issue
Block a user