diff --git a/CHANGELOG.md b/CHANGELOG.md index 777153ab..537772a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,11 @@ the frozen-backend fallback mirror it for their toolchains. ### Fixed +- Subtitle rows now provide 100 ms timing steppers and flag adjacent overlaps without requiring precise timeline dragging (#1710) - Repair-sync failures now retain uv's final dependency error instead of reporting only an opaque exit status (#1705) - YouTube ingest now retries yt-dlp's transient “page needs to be reloaded” response (#1706) - Dictation model readiness now follows the live Hugging Face cache selected in Settings (#1707) +- Desktop-contained backends now exit when their owning app disappears instead of surviving as stale port-3900 processes (#1707) ## [0.5.1] — 2026-08-28 diff --git a/backend/core/parent_liveness.py b/backend/core/parent_liveness.py new file mode 100644 index 00000000..e6707752 --- /dev/null +++ b/backend/core/parent_liveness.py @@ -0,0 +1,34 @@ +"""Terminate a desktop-contained backend when its owning shell disappears.""" +from __future__ import annotations + +import os +import sys +import threading +from typing import BinaryIO, Callable + + +def _watch_parent_pipe(reader: BinaryIO, exit_process: Callable[[int], None]) -> None: + """Block until the desktop-owned stdin pipe closes, then exit immediately.""" + try: + while reader.read(1): + pass + except (OSError, ValueError): + # A broken or already-closed parent-owned pipe is equivalent to EOF. + pass + exit_process(0) + + +def arm_desktop_parent_watchdog() -> bool: + """Use stdin EOF as an unforgeable parent-liveness signal for desktop runs.""" + if os.environ.get("OMNIVOICE_DESKTOP_CONTAINED") != "1": + return False + reader = getattr(sys.stdin, "buffer", None) + if reader is None: + return False + threading.Thread( + target=_watch_parent_pipe, + args=(reader, os._exit), + name="desktop-parent-watchdog", + daemon=True, + ).start() + return True diff --git a/backend/main.py b/backend/main.py index 8f582944..ade1cec3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -77,6 +77,7 @@ os.environ.setdefault("FOR_DISABLE_CONSOLE_CTRL_HANDLER", "1") # (utils.hf_progress.SafeFileWrapper — same wrapper the patched hub tqdm # already uses for its own fp.) from utils.hf_progress import SafeFileWrapper as _SafeStdio # noqa: E402 +from core.parent_liveness import arm_desktop_parent_watchdog # noqa: E402 # Force UTF-8 stdio before wrapping (#1155): on Windows the spawned backend's # stdout defaults to cp1252, and any library that prints user text (kittentts @@ -89,6 +90,11 @@ for _stream in (sys.stdout, sys.stderr): except Exception: # noqa: BLE001 — pythonw/frozen builds may lack reconfigure pass +# The desktop keeps the backend's stdin pipe open for its own lifetime. EOF is +# therefore a stable ownership signal that survives PID reuse and lets a child +# terminate even when the shell crashes before its normal process-tree teardown. +arm_desktop_parent_watchdog() + if not getattr(sys.stdout, "_is_safe_wrapper", False): sys.stdout = _SafeStdio(sys.stdout) if not getattr(sys.stderr, "_is_safe_wrapper", False): diff --git a/frontend/src-tauri/src/backend.rs b/frontend/src-tauri/src/backend.rs index f2b07b98..1756c21b 100644 --- a/frontend/src-tauri/src/backend.rs +++ b/frontend/src-tauri/src/backend.rs @@ -652,7 +652,12 @@ pub(crate) fn spawn_backend( ]); } } - cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + // Keep stdin piped but unwritten. The backend's parent-liveness watchdog + // blocks on it; desktop exit closes the handle and the child terminates, + // including on macOS where parent death alone does not reap descendants. + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); let mut contained = match crate::tools::spawn_process_tree(&mut cmd) { Ok(c) => { log::info!( diff --git a/frontend/src/components/DubSegmentRow.jsx b/frontend/src/components/DubSegmentRow.jsx index 02f71019..2f4a0c13 100644 --- a/frontend/src/components/DubSegmentRow.jsx +++ b/frontend/src/components/DubSegmentRow.jsx @@ -10,16 +10,19 @@ import { Scissors, Merge, MoreHorizontal, + Minus, Plus, Sparkles, } from 'lucide-react'; import { formatTime } from '../utils/format'; import { LANG_CODES } from '../utils/languages'; +import { MIN_SEG_DUR } from '../utils/timeline'; import { Menu, Button, Badge } from '../ui'; import VoiceSelector from './VoiceSelector'; const CHAR_BUDGET_RATIO = 1.3; const SENTENCE_END = /[.!?。!?]/; +const TIME_EPSILON = 1e-9; function rowClass(isActive, isDone, selected, isPlaying, timelineSelected) { return `segment-row${isActive ? ' segment-active' : ''}${isDone ? ' segment-done' : ''}${selected ? ' segment-selected' : ''}${isPlaying ? ' segment-playing' : ''}${timelineSelected ? ' segment-timeline-selected' : ''}`; @@ -85,6 +88,7 @@ function DubSegmentRow({ onDirect, onSeek, timelineSelected, + hasOverlap, }) { const { t } = useTranslation(); const textInputRef = useRef(null); @@ -188,7 +192,10 @@ function DubSegmentRow({ const commitTime = (edge) => (e) => { const v = parseTime(e.target.value); const current = seg[edge]; - const inRange = edge === 'start' ? v >= 0 && v < seg.end : v > seg.start; + const inRange = + edge === 'start' + ? v >= 0 && v <= seg.end - MIN_SEG_DUR + TIME_EPSILON + : v >= seg.start + MIN_SEG_DUR - TIME_EPSILON; if (v == null || !inRange) { e.target.value = formatTime(current); return; @@ -204,6 +211,21 @@ function DubSegmentRow({ }); }; + const nudgeTime = (edge, delta) => { + const current = seg[edge]; + const limit = edge === 'start' ? seg.end - MIN_SEG_DUR : seg.start + MIN_SEG_DUR; + const next = +( + edge === 'start' + ? Math.max(0, Math.min(limit, current + delta)) + : Math.max(limit, current + delta) + ).toFixed(3); + if (Math.abs(next - current) <= 1e-3) return; + onMoveResize(seg.id, { + start: edge === 'start' ? next : seg.start, + end: edge === 'end' ? next : seg.end, + }); + }; + const handleTextKeyDown = (e) => { if ((e.ctrlKey || e.metaKey) && (e.key === 'd' || e.key === 'D')) { e.preventDefault(); @@ -247,30 +269,39 @@ function DubSegmentRow({ title={t('segment.select_title')} /> + {['start', 'end'].map((edge) => ( + + + e.stopPropagation()} + onKeyDown={timeKeyDown(edge)} + onBlur={commitTime(edge)} + /> + + + ))} - e.stopPropagation()} - onKeyDown={timeKeyDown('start')} - onBlur={commitTime('start')} - /> - – - e.stopPropagation()} - onKeyDown={timeKeyDown('end')} - onBlur={commitTime('end')} - /> {seg.speed && seg.speed !== 1.0 && ( )} + {hasOverlap && ( + + {t('timeline.overlap_warning')} + + )} {fitBadge && ( Number(seg.start) + 0.001) || + (next && Number(seg.end) > Number(next.start) + 0.001); return ( :nth-child(3), diff --git a/frontend/src/test/DubSegmentRowTiming.test.jsx b/frontend/src/test/DubSegmentRowTiming.test.jsx index 828834f0..f1095529 100644 --- a/frontend/src/test/DubSegmentRowTiming.test.jsx +++ b/frontend/src/test/DubSegmentRowTiming.test.jsx @@ -83,6 +83,52 @@ describe('DubSegmentRow timing fields', () => { expect(props.onEditField).not.toHaveBeenCalled(); }); + it('nudges either edge by 100 ms with accessible controls', () => { + const props = makeProps(); + render(); + + const decrement = screen.getAllByRole('button', { name: /0\.1 seconds earlier/ }); + const increment = screen.getAllByRole('button', { name: /0\.1 seconds later/ }); + fireEvent.click(decrement[0]); + fireEvent.click(increment[1]); + + expect(props.onMoveResize).toHaveBeenNthCalledWith(1, 's1', { start: 0.9, end: 3 }); + expect(props.onMoveResize).toHaveBeenNthCalledWith(2, 's1', { start: 1, end: 3.1 }); + }); + + it('preserves the timeline minimum duration for typed and stepped edits', () => { + const props = makeProps(); + render(); + const start = timeFields()[0]; + + fireEvent.change(start, { target: { value: '2.8' } }); + fireEvent.blur(start); + expect(props.onMoveResize).not.toHaveBeenCalled(); + + const nearLimit = makeProps({ seg: { id: 's1', start: 2.7, end: 3, text: 'x' } }); + render(); + fireEvent.click(screen.getAllByRole('button', { name: /start time 0\.1 seconds later/ })[1]); + expect(nearLimit.onMoveResize).not.toHaveBeenCalled(); + }); + + it('accepts an exact 300 ms boundary despite decimal rounding', () => { + const props = makeProps({ seg: { id: 's1', start: 1, end: 3.3, text: 'x' } }); + render(); + const start = timeFields()[0]; + + fireEvent.change(start, { target: { value: '3.0' } }); + fireEvent.blur(start); + + expect(props.onMoveResize).toHaveBeenCalledWith('s1', { start: 3, end: 3.3 }); + }); + + it('surfaces an adjacent overlap beside the timing controls', () => { + render(); + expect( + screen.getByText('Overlaps an adjacent segment — both lines will play together'), + ).toBeInTheDocument(); + }); + it('accepts raw seconds as well as m:ss.s', () => { const props = makeProps(); render(); diff --git a/tests/backend/core/test_parent_liveness.py b/tests/backend/core/test_parent_liveness.py new file mode 100644 index 00000000..f33eeb2c --- /dev/null +++ b/tests/backend/core/test_parent_liveness.py @@ -0,0 +1,55 @@ +import io +import os +import subprocess +import sys +from pathlib import Path + +def test_parent_pipe_eof_exits_cleanly(): + from core.parent_liveness import _watch_parent_pipe + + exits = [] + _watch_parent_pipe(io.BytesIO(b""), exits.append) + assert exits == [0] + + +def test_parent_pipe_ignores_bytes_until_eof(): + from core.parent_liveness import _watch_parent_pipe + + exits = [] + _watch_parent_pipe(io.BytesIO(b"keepalive"), exits.append) + assert exits == [0] + + +def test_watchdog_is_disabled_outside_desktop(monkeypatch): + from core.parent_liveness import arm_desktop_parent_watchdog + + monkeypatch.delenv("OMNIVOICE_DESKTOP_CONTAINED", raising=False) + assert arm_desktop_parent_watchdog() is False + + +def test_desktop_child_exits_when_parent_closes_stdin(): + env = os.environ.copy() + env["OMNIVOICE_DESKTOP_CONTAINED"] = "1" + child = subprocess.Popen( + [ + sys.executable, + "-c", + "from core.parent_liveness import arm_desktop_parent_watchdog; " + "arm_desktop_parent_watchdog(); print('ready', flush=True); " + "__import__('time').sleep(30)", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + cwd=Path(__file__).parents[3] / "backend", + text=True, + ) + try: + assert child.stdout.readline().strip() == "ready" + child.stdin.close() + assert child.wait(timeout=3) == 0 + finally: + if child.poll() is None: + child.kill() + child.wait()