From 30a02d27828cc38053e72ddf5a34b861fdb5271b Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sun, 27 Sep 2026 17:57:45 +0000 Subject: [PATCH] Rudder's corner shows a live mic when collapsed, and dev backends survive Vite restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps found while checking the corner widget (1775f5ac: "Needs eyes: open, HMR, collapse" — all three verified in Firefox against the running Plugin Studio: opens, keeps the SAME element across a Studio hot update, collapses). - LIVE MIC WHEN COLLAPSED: rudder-voice emits `recording` (true/false); the Studio's corner button turns red with "● " while it listens, so closing the panel never hides that the mic is on. - DEV BACKENDS SURVIVE VITE RESTARTS: Vite restarts itself when vite.config.js or anything it imports changes. The backend was stopped on every restart and a new one started at once; the old process still held the port, the new one died "address already in use", and Rudder showed "engine offline" until `bun run dev` was restarted by hand (measured twice). Backends now live in a process-wide registry: reused across restarts, stopped only when the dev process exits, restarted if they crash. The "engine not running, quietly" answers (89ff2238) are unchanged and read the same registry. --- internal/backend-dev-plugin.mjs | 77 ++++++++++++++----- .../plugin-studio/plugin-studio.vue | 12 +++ plugin-designs/rudder-voice/rudder-voice.vue | 7 +- 3 files changed, 75 insertions(+), 21 deletions(-) diff --git a/internal/backend-dev-plugin.mjs b/internal/backend-dev-plugin.mjs index 5a7dd82ca..9a81be79f 100644 --- a/internal/backend-dev-plugin.mjs +++ b/internal/backend-dev-plugin.mjs @@ -7,6 +7,14 @@ * /api// routes, and stops it when the dev server stops. A backend that isn't built is skipped * with a hint: plugins without a backend never notice this file. * + * ★ ONE BACKEND PER `bun run dev`, SURVIVING VITE RESTARTS. Vite restarts itself whenever vite.config.js + * or anything it imports changes, and re-runs configureServer. Stopping the backend on every restart and + * starting a new one at once raced the old process for its port: the new one died with "address already + * in use" and Rudder showed "engine offline" until `bun run dev` was restarted by hand (measured + * 2026-09-27, twice in one session). Each backend now lives in a process-wide registry (globalThis — the + * config module itself is re-imported on a restart): a restart REUSES the running backend, it is stopped + * only when the dev process exits, and it is restarted (up to 5 times) if it crashes. + * * DEV ONLY (`apply: 'serve'`). A production build never starts or bundles a backend. */ import { spawn } from 'node:child_process' @@ -30,9 +38,40 @@ export function devBackendProxy() { return Object.fromEntries(DEV_BACKENDS.map((b) => [b.route, { target: `http://127.0.0.1:${b.port}`, changeOrigin: false }])) } +const REG = (globalThis.__dxDevBackends ||= { procs: new Map(), exitHooked: false, stopping: false }) +const isAlive = (name) => { const e = REG.procs.get(name); return !!e && e.child.exitCode === null && e.child.signalCode === null } + +function stopAll() { + REG.stopping = true + for (const { child } of REG.procs.values()) { try { child.kill('SIGTERM') } catch {} } +} + +function start(root, b, logger, restarts = 0) { + const tag = `[${b.name}]` + const child = spawn(resolve(root, b.bin), [], { cwd: root, env: { ...process.env, ...b.env }, stdio: ['ignore', 'pipe', 'pipe'] }) + const entry = { child, restarts, logger } + REG.procs.set(b.name, entry) + const out = (d) => String(d).trim().split('\n').forEach((l) => l && entry.logger.info(`${tag} ${l}`)) + child.stdout.on('data', out) + child.stderr.on('data', out) + child.on('exit', (code) => { + if (REG.procs.get(b.name) !== entry) return + REG.procs.delete(b.name) + if (REG.stopping) return + entry.logger.warn(`${tag} exited (${code})`) + // Crashed on its own: restart it, after its port has had a moment to free up. + if (entry.restarts < 5) { + setTimeout(() => { + if (REG.stopping || REG.procs.has(b.name)) return + entry.logger.warn(`${tag} restarting (${entry.restarts + 1}/5)`) + start(root, b, entry.logger, entry.restarts + 1) + }, 2000) + } + }) + logger.info(`${tag} started → ${b.route} on 127.0.0.1:${b.port}`) +} + export function backendDevPlugin(root) { - const children = [] - const alive = new Set() // backends whose process is running right now return { name: 'dx-backend-dev', apply: 'serve', @@ -44,7 +83,7 @@ export function backendDevPlugin(root) { // list. Registered here, so it runs before vite's proxy. for (const b of DEV_BACKENDS) { server.middlewares.use(b.route, (req, res, next) => { - if (alive.has(b.name)) return next() + if (isAlive(b.name)) return next() if ((req.url || '').startsWith('/events')) { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }) res.write(`data: ${JSON.stringify({ type: 'status', status: { engine: 'offline', error: 'The desktop engine is not running.' } })}\n\n`) @@ -54,27 +93,25 @@ export function backendDevPlugin(root) { res.end('[]') }) } + const logger = server.config.logger + if (!REG.exitHooked) { + REG.exitHooked = true + process.once('exit', stopAll) + process.once('SIGINT', () => { stopAll(); process.exit(0) }) + process.once('SIGTERM', () => { stopAll(); process.exit(0) }) + } for (const b of DEV_BACKENDS) { - const bin = resolve(root, b.bin) - if (!existsSync(bin)) { - server.config.logger.warn(`[backend] ${b.name} not built — its plugin will show "engine offline". Build: ${b.build}`) + if (isAlive(b.name)) { + REG.procs.get(b.name).logger = logger // log through the restarted server + logger.info(`[${b.name}] still running → ${b.route} (kept across the dev-server restart)`) continue } - const child = spawn(bin, [], { cwd: root, env: { ...process.env, ...b.env }, stdio: ['ignore', 'pipe', 'pipe'] }) - const tag = `[${b.name}]` - const out = (d) => String(d).trim().split('\n').forEach((l) => l && server.config.logger.info(`${tag} ${l}`)) - child.stdout.on('data', out) - child.stderr.on('data', out) - child.on('exit', (code) => { alive.delete(b.name); server.config.logger.warn(`${tag} exited (${code})`) }) - children.push(child) - alive.add(b.name) - server.config.logger.info(`${tag} started → ${b.route} on 127.0.0.1:${b.port}`) + if (!existsSync(resolve(root, b.bin))) { + logger.warn(`[backend] ${b.name} not built — its plugin will show "engine offline". Build: ${b.build}`) + continue + } + start(root, b, logger) } - const stop = () => children.forEach((c) => { try { c.kill('SIGTERM') } catch {} }) - server.httpServer?.on('close', stop) - process.once('exit', stop) - process.once('SIGINT', () => { stop(); process.exit(0) }) - process.once('SIGTERM', () => { stop(); process.exit(0) }) }, } } diff --git a/plugin-designs/plugin-studio/plugin-studio.vue b/plugin-designs/plugin-studio/plugin-studio.vue index 0bc5e9ebc..6650e56c7 100644 --- a/plugin-designs/plugin-studio/plugin-studio.vue +++ b/plugin-designs/plugin-studio/plugin-studio.vue @@ -1125,6 +1125,18 @@ export default defineComponent({ el.setAttribute('profile-config', JSON.stringify(this.paneConfig(row, this.locale))) if (this.base) el.setAttribute('tree-base', this.base.replace(/\/+$/, '')) el.style.cssText = 'display:block;position:absolute;inset:0;width:100%;height:100%;' + // LIVE MIC, VISIBLE WHEN COLLAPSED: a widget that emits `recording` (Rudder does) turns its corner + // button red with a dot while it listens, so closing the panel never hides that the mic is on. + el.addEventListener('recording', (e) => { + const on = !!(Array.isArray(e.detail) ? e.detail[0] : e.detail) + const b = box.querySelector(':scope > button') + if (!b) return + if (!b.dataset.label) b.dataset.label = b.textContent + b.textContent = on ? '● ' + b.dataset.label : b.dataset.label + b.style.background = on ? '#7f1d1d' : '#0d1117' + b.style.borderColor = on ? '#ef4444' : '#30363d' + b.setAttribute('data-listening', on ? 'true' : 'false') + }) W.els[row.key] = el panel.appendChild(el) }).catch((e) => { panel.textContent = (e && e.message) || String(e) }) diff --git a/plugin-designs/rudder-voice/rudder-voice.vue b/plugin-designs/rudder-voice/rudder-voice.vue index 1038eba95..a5393e36c 100644 --- a/plugin-designs/rudder-voice/rudder-voice.vue +++ b/plugin-designs/rudder-voice/rudder-voice.vue @@ -12,11 +12,14 @@ * Everything is agent-interfaceable (the old Rudder's prime directive): the 🐞 panel copies the * engine log + status with a header telling an agent what to do with it. */ -import { computed, onBeforeUnmount, onMounted, ref, nextTick } from 'vue' +import { computed, onBeforeUnmount, onMounted, ref, nextTick, watch } from 'vue' defineOptions({ inheritAttrs: false }) const props = defineProps({ profileConfig: { type: String, default: '' } }) +// `recording` (true/false) tells a host that Rudder started or stopped listening — the Plugin Studio's +// corner button turns red with it, so a collapsed corner still shows the mic is live. +const emit = defineEmits(['recording']) const cfg = computed(() => { try { return props.profileConfig ? JSON.parse(props.profileConfig) : {} } catch { return {} } @@ -449,6 +452,8 @@ onBeforeUnmount(() => { window.removeEventListener('keydown', onKey) }) +watch(recording, (v) => emit('recording', v)) + const statusText = computed(() => ({ connecting: 'Connecting…', starting: 'Loading speech model… (about 20 s the first time)',