Rudder's corner shows a live mic when collapsed, and dev backends survive Vite restarts
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.
This commit is contained in:
parent
acc01676ec
commit
7ce2e2419a
3 changed files with 75 additions and 21 deletions
|
|
@ -7,6 +7,14 @@
|
|||
* /api/<name>/ 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) })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
Loading…
Reference in a new issue