From ea151b9a5641aed75e494632392dd8506675442d Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 01:22:06 +0000 Subject: [PATCH] rudder-engine builds for macOS and Windows, finds its runtime per platform, and accepts the desktop webview's stream The engine did not compile outside Linux: Pdeathsig (kill children with the engine) is Linux-only. It now sits behind a build tag, with a no-op elsewhere where the desktop shell owns the engine's lifetime. The default speech-server path names this platform's NeMo-Speech.cpp release folder instead of the Linux one. The /stream WebSocket accepts the Tauri origins (tauri://localhost, http://tauri.localhost) and RUDDER_ALLOW_ORIGIN as well as the page's own host, and still refuses any other site. Cross-compiled for darwin/arm64, darwin/amd64 and windows/amd64. --- backend/rudder-engine/devices.go | 5 ++-- backend/rudder-engine/main.go | 35 ++++++++++++++++++++++--- backend/rudder-engine/procattr_linux.go | 12 +++++++++ backend/rudder-engine/procattr_other.go | 12 +++++++++ 4 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 backend/rudder-engine/procattr_linux.go create mode 100644 backend/rudder-engine/procattr_other.go diff --git a/backend/rudder-engine/devices.go b/backend/rudder-engine/devices.go index be2be71cb..f9b77c46d 100644 --- a/backend/rudder-engine/devices.go +++ b/backend/rudder-engine/devices.go @@ -24,7 +24,6 @@ import ( "os/exec" "strings" "sync" - "syscall" "time" ) @@ -215,7 +214,7 @@ func (w *deviceWatcher) run() { for { cmd := exec.Command("pactl", "subscribe") cmd.Env = audioEnv() - cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL} + cmd.SysProcAttr = childAttr() out, err := cmd.StdoutPipe() if err == nil && cmd.Start() == nil { sc := bufio.NewScanner(out) @@ -255,7 +254,7 @@ func (e *engine) micTest(device string) event { return event{"ok": false, "verdict": "No recorder found (need parec, pw-record or arecord)."} } cmd.Env = audioEnv() - cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL} + cmd.SysProcAttr = childAttr() var errBuf strings.Builder cmd.Stderr = &errBuf out, err := cmd.StdoutPipe() diff --git a/backend/rudder-engine/main.go b/backend/rudder-engine/main.go index a1b8408d2..1f792d8bc 100644 --- a/backend/rudder-engine/main.go +++ b/backend/rudder-engine/main.go @@ -29,11 +29,13 @@ import ( "io" "log" "net/http" + "net/url" "os" "os/exec" "os/signal" "path/filepath" "regexp" + "runtime" "strings" "sync" "sync/atomic" @@ -61,6 +63,21 @@ func envOr(k, d string) string { return d } +// defaultNemoBin: where setup puts NVIDIA's NeMo-Speech.cpp 0.1.0 CPU release for THIS platform — the +// release folders are named nemo-speech-0.1.0---cpu. +func defaultNemoBin(base string) string { + osName := map[string]string{"darwin": "macos"}[runtime.GOOS] + if osName == "" { + osName = runtime.GOOS + } + arch := map[string]string{"amd64": "x86_64", "arm64": "aarch64"}[runtime.GOARCH] + exe := "nemo-speech" + if runtime.GOOS == "windows" { + exe += ".exe" + } + return filepath.Join(base, "bin", "nemo-speech-0.1.0-"+osName+"-"+arch+"-cpu", "bin", exe) +} + func loadConfig() config { home, _ := os.UserHomeDir() // RUDDER_HOME is where setup-runtime.sh installed the runtime and model (a server keeps it on a volume). @@ -68,7 +85,7 @@ func loadConfig() config { return config{ Port: envOr("RUDDER_PORT", "5193"), NemoPort: envOr("RUDDER_NEMO_PORT", "5194"), - NemoBin: envOr("RUDDER_NEMO_BIN", filepath.Join(base, "bin", "nemo-speech-0.1.0-linux-x86_64-cpu", "bin", "nemo-speech")), + NemoBin: envOr("RUDDER_NEMO_BIN", defaultNemoBin(base)), Model: envOr("RUDDER_MODEL", filepath.Join(base, "models", "nemotron-3.5-asr-streaming-0.6b.q8_0.gguf")), Language: envOr("RUDDER_LANG", "en-US"), EndpointMs: 800, @@ -207,7 +224,7 @@ func (e *engine) startNemo() { cmd := exec.Command(e.cfg.NemoBin, "serve", "--host", "127.0.0.1", "--port", e.cfg.NemoPort, "--asr-model", e.cfg.Model, "--device", "cpu", "--no-ui", "--api-key", e.nemoKey) - cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL} // dies with us + cmd.SysProcAttr = childAttr() // dies with us out, _ := cmd.StdoutPipe() cmd.Stderr = cmd.Stdout if err := cmd.Start(); err != nil { @@ -669,7 +686,7 @@ func spawnRecorder(device string, logs *ring) (*recorderProc, error) { } cmd := exec.Command(c[0], c[1:]...) cmd.Env = audioEnv() - cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL} + cmd.SysProcAttr = childAttr() errBuf := &bytes.Buffer{} cmd.Stderr = errBuf out, err := cmd.StdoutPipe() @@ -813,7 +830,17 @@ func main() { // /api/rudder/stream (RUDDER_AUDIO=browser): a WebSocket of binary 16 kHz mono s16le PCM frames from the // page's microphone. Opening it starts a session, closing it stops one; the text comes back on /events. // The upgrader's default origin check (Origin must match Host) keeps other sites out. - upgrader := websocket.Upgrader{ReadBufferSize: 8192} + // Origin: the page's own host (the dev server proxy), a desktop app's webview (tauri://localhost, or + // http://tauri.localhost on Windows — the same origins guard() answers CORS for), or RUDDER_ALLOW_ORIGIN. + // Anything else is another site and is refused. + upgrader := websocket.Upgrader{ReadBufferSize: 8192, CheckOrigin: func(r *http.Request) bool { + o := r.Header.Get("Origin") + if o == "" || o == "tauri://localhost" || o == "http://tauri.localhost" || (os.Getenv("RUDDER_ALLOW_ORIGIN") != "" && o == os.Getenv("RUDDER_ALLOW_ORIGIN")) { + return true + } + u, err := url.Parse(o) + return err == nil && strings.EqualFold(u.Host, r.Host) + }} mux.HandleFunc("/api/rudder/stream", func(w http.ResponseWriter, r *http.Request) { if !browserAudio() { writeJSON(w, 409, event{"error": "this engine records a local microphone (RUDDER_AUDIO is not browser)"}) diff --git a/backend/rudder-engine/procattr_linux.go b/backend/rudder-engine/procattr_linux.go new file mode 100644 index 000000000..c0d6b0121 --- /dev/null +++ b/backend/rudder-engine/procattr_linux.go @@ -0,0 +1,12 @@ +//go:build linux + +package main + +import "syscall" + +// childAttr: a child process (the speech server, a recorder) is killed with the engine, so a crash +// never leaves a speech server holding a gigabyte of memory. Linux only: Pdeathsig does not exist on +// macOS or Windows (see procattr_other.go). +func childAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL} +} diff --git a/backend/rudder-engine/procattr_other.go b/backend/rudder-engine/procattr_other.go new file mode 100644 index 000000000..2c17ee968 --- /dev/null +++ b/backend/rudder-engine/procattr_other.go @@ -0,0 +1,12 @@ +//go:build !linux + +package main + +import "syscall" + +// childAttr: macOS and Windows have no parent-death signal. In the desktop app the Tauri shell owns the +// engine's lifetime and the engine kills its children on a normal exit (shutdown); a crashed engine can +// leave its speech server running until the app is next closed. +func childAttr() *syscall.SysProcAttr { + return nil +}