fix: preserve GPUI lifetime and avoid WASAPI silence warnings
This commit is contained in:
@@ -10,7 +10,7 @@ The recording foundation is being validated (see [docs/validation.md](docs/valid
|
|||||||
|
|
||||||
The desktop app detects Steam games, owns the recorder on a background thread, persists settings and sessions, and loads recorded sessions from disk. Linux detects Steam’s `reaper` processes and watches their pidfds; Windows reads Steam’s registry state and watches native process handles (cross-checked, not run). Game exit finalises a session; closing the window leaves recording, game detection, hotkeys and cleanup running in the tray. Open Clipbin from the tray or launch it again to reopen the same process and restore its last screen. Tray Quit, the app-menu Quit action or Ctrl+Q finalises the current session and exits. Without an available tray, closing quits cleanly so the process stays reachable.
|
The desktop app detects Steam games, owns the recorder on a background thread, persists settings and sessions, and loads recorded sessions from disk. Linux detects Steam’s `reaper` processes and watches their pidfds; Windows reads Steam’s registry state and watches native process handles (cross-checked, not run). Game exit finalises a session; closing the window leaves recording, game detection, hotkeys and cleanup running in the tray. Open Clipbin from the tray or launch it again to reopen the same process and restore its last screen. Tray Quit, the app-menu Quit action or Ctrl+Q finalises the current session and exits. Without an available tray, closing quits cleanly so the process stays reachable.
|
||||||
|
|
||||||
The desktop interface follows the web Clipbin's look (zinc dark theme, purple accent, Geist Mono): a session library, a session timeline with bookmarks and a clip selection, clips, and settings. It shows sessions from disk; `CLIPBIN_SAMPLE_LIBRARY=1` fills it with example sessions for interface work. Playback and frame seeking work across completed segments, including active sessions and missing footage, with combined audio, mute/volume, Space/K to play/pause and J/L or arrows to step frames. Sessions and clips use cached frame thumbnails. Exports copy the recorded codec to MP4 in a background job, save local clip metadata, and support opening/deleting clips. Ctrl+Shift+F9 clips the last configured interval; Ctrl+Shift+F10 protects a bookmark. Both also have sidebar buttons. Wayland uses the GlobalShortcuts portal (first-use approval, portal rebinding); X11 uses native key grabs and Windows uses RegisterHotKey. Settings offer independent alternate function keys. Storage cleanup keeps unprotected footage within the budget. The tray shows idle, recording, failed and degraded audio states and offers clip/bookmark actions. Start on login writes an XDG autostart entry on Linux or an HKCU Run value on Windows. `--background` starts with only a tray; without a tray it opens the window. Audio capture delivery loss produces an “audio capture lost” warning while video continues.
|
The desktop interface follows the web Clipbin's look (zinc dark theme, purple accent, Geist Mono): a session library, a session timeline with bookmarks and a clip selection, clips, and settings. It shows sessions from disk; `CLIPBIN_SAMPLE_LIBRARY=1` fills it with example sessions for interface work. Playback and frame seeking work across completed segments, including active sessions and missing footage, with combined audio, mute/volume, Space/K to play/pause and J/L or arrows to step frames. Sessions and clips use cached frame thumbnails. Exports copy the recorded codec to MP4 in a background job, save local clip metadata, and support opening/deleting clips. Ctrl+Shift+F9 clips the last configured interval; Ctrl+Shift+F10 protects a bookmark. Both also have sidebar buttons. Wayland uses the GlobalShortcuts portal (first-use approval, portal rebinding); X11 uses native key grabs and Windows uses RegisterHotKey. Settings offer independent alternate function keys. Storage cleanup keeps unprotected footage within the budget. The tray shows idle, recording, failed and degraded audio states and offers clip/bookmark actions. Start on login writes an XDG autostart entry on Linux or an HKCU Run value on Windows. `--background` starts with only a tray; without a tray it opens the window. Linux capture delivery loss or Windows WASAPI device errors produce an “audio capture lost” warning while video continues.
|
||||||
|
|
||||||
Windows is a first-class target. Every change must keep the Windows code paths building (`cargo clippy --target x86_64-pc-windows-msvc`) and list what still needs validating on Windows.
|
Windows is a first-class target. Every change must keep the Windows code paths building (`cargo clippy --target x86_64-pc-windows-msvc`) and list what still needs validating on Windows.
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ Keep capture and session operations out of the UI thread. Core uses serde/JSON,
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Use Rust **1.98.0**, as pinned in rust-toolchain.toml. GPUI is pinned to **0.2.2**, with Wayland and X11 enabled. Cargo.lock pins transitive dependencies.
|
Use Rust **1.98.0**, as pinned in rust-toolchain.toml. GPUI is pinned to **0.2.2**, with Wayland and X11 enabled. Its vendored [background-loop patch](vendor/gpui/CLIPBIN.md) keeps one application alive while its windows close and reopen. Cargo.lock pins transitive dependencies.
|
||||||
|
|
||||||
Keep the lockfile checked in and use `--locked`. It currently pins `libc` to 0.2.186 because GPUI’s transitive `xattr` 0.2.3 dependency fails to compile against 0.2.190 on Linux.
|
Keep the lockfile checked in and use `--locked`. It currently pins `libc` to 0.2.186 because GPUI’s transitive `xattr` 0.2.3 dependency fails to compile against 0.2.190 on Linux.
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use clipbin_core::background::{Action, Instance, Tray};
|
use clipbin_core::background::{Action, Instance, Tray};
|
||||||
use std::{
|
use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration};
|
||||||
cell::Cell,
|
|
||||||
rc::Rc,
|
|
||||||
sync::{
|
|
||||||
Arc, Mutex,
|
|
||||||
mpsc::{self, Receiver},
|
|
||||||
},
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod assets;
|
mod assets;
|
||||||
@@ -36,7 +28,7 @@ fn main() {
|
|||||||
Some(format!("Could not load settings: {error}")),
|
Some(format!("Could not load settings: {error}")),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
let (actions, receiver) = mpsc::channel();
|
let (actions, receiver) = std::sync::mpsc::channel();
|
||||||
let _instance = match Instance::acquire(actions.clone()) {
|
let _instance = match Instance::acquire(actions.clone()) {
|
||||||
Ok(Some(instance)) => instance,
|
Ok(Some(instance)) => instance,
|
||||||
Ok(None) => return,
|
Ok(None) => return,
|
||||||
@@ -57,82 +49,18 @@ fn main() {
|
|||||||
.map(|e| format!("Could not configure start on login: {e}"))
|
.map(|e| format!("Could not configure start on login: {e}"))
|
||||||
.or(initial_error);
|
.or(initial_error);
|
||||||
let (service, worker) = recorder_service::Client::start(settings.clone(), initial_error);
|
let (service, worker) = recorder_service::Client::start(settings.clone(), initial_error);
|
||||||
let receiver = Arc::new(Mutex::new(receiver));
|
let interface_service = service.clone();
|
||||||
let tray = tray.map(Arc::new);
|
let tray = tray.map(Arc::new);
|
||||||
let route = Rc::new(Cell::new(app::Route::Sessions));
|
let background = std::env::args().any(|arg| arg == "--background");
|
||||||
let quitting = Rc::new(Cell::new(false));
|
|
||||||
let mut show = !std::env::args().any(|arg| arg == "--background") || tray.is_none();
|
|
||||||
let mut last_state = None;
|
|
||||||
loop {
|
|
||||||
if show {
|
|
||||||
run_window(
|
|
||||||
service.clone(),
|
|
||||||
receiver.clone(),
|
|
||||||
tray.clone(),
|
|
||||||
route.clone(),
|
|
||||||
quitting.clone(),
|
|
||||||
);
|
|
||||||
if quitting.get() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if !tray.as_ref().is_some_and(|tray| tray.available()) {
|
|
||||||
service.quit();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
eprintln!("Window closed: Clipbin is running in the tray");
|
|
||||||
show = false;
|
|
||||||
}
|
|
||||||
let state = service.background_state();
|
|
||||||
if last_state.as_ref() != Some(&state) {
|
|
||||||
if let Some(tray) = &tray {
|
|
||||||
tray.update(state.clone());
|
|
||||||
}
|
|
||||||
last_state = Some(state);
|
|
||||||
}
|
|
||||||
if service.exited() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if tray.as_ref().is_some_and(|tray| !tray.available()) {
|
|
||||||
show = true;
|
|
||||||
}
|
|
||||||
if let Ok(action) = receiver
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.recv_timeout(Duration::from_millis(200))
|
|
||||||
{
|
|
||||||
match action {
|
|
||||||
Action::Open => show = true,
|
|
||||||
Action::Clip => service.clip(),
|
|
||||||
Action::Bookmark => service.bookmark(),
|
|
||||||
Action::Quit => {
|
|
||||||
service.quit();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = worker.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_window(
|
|
||||||
service: recorder_service::Client,
|
|
||||||
receiver: Arc<Mutex<Receiver<Action>>>,
|
|
||||||
tray: Option<Arc<Tray>>,
|
|
||||||
route: Rc<Cell<app::Route>>,
|
|
||||||
quitting: Rc<Cell<bool>>,
|
|
||||||
) {
|
|
||||||
Application::new()
|
Application::new()
|
||||||
.with_assets(assets::Assets)
|
.with_assets(assets::Assets)
|
||||||
.run(move |cx: &mut App| {
|
.run(move |cx: &mut App| {
|
||||||
|
cx.set_quit_on_last_window_close(false);
|
||||||
if let Err(error) = assets::load_fonts(cx) {
|
if let Err(error) = assets::load_fonts(cx) {
|
||||||
eprintln!("Could not load the interface font: {error}");
|
eprintln!("Could not load the interface font: {error}");
|
||||||
}
|
}
|
||||||
let quitter = service.clone();
|
let quitter = interface_service.clone();
|
||||||
let quit_flag = quitting.clone();
|
cx.on_action(move |_: &Quit, _| quitter.quit());
|
||||||
cx.on_action(move |_: &Quit, _| {
|
|
||||||
quit_flag.set(true);
|
|
||||||
quitter.quit();
|
|
||||||
});
|
|
||||||
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
|
cx.bind_keys([KeyBinding::new("ctrl-q", Quit, None)]);
|
||||||
cx.set_menus(vec![gpui::Menu {
|
cx.set_menus(vec![gpui::Menu {
|
||||||
name: "Clipbin".into(),
|
name: "Clipbin".into(),
|
||||||
@@ -140,6 +68,66 @@ fn run_window(
|
|||||||
}]);
|
}]);
|
||||||
text_field::bind_keys(cx);
|
text_field::bind_keys(cx);
|
||||||
playback::bind_keys(cx);
|
playback::bind_keys(cx);
|
||||||
|
let ui = Rc::new(RefCell::new(Interface {
|
||||||
|
window: None,
|
||||||
|
route: app::Route::Sessions,
|
||||||
|
}));
|
||||||
|
if !background || tray.is_none() {
|
||||||
|
open_window(cx, &interface_service, &ui, &tray);
|
||||||
|
}
|
||||||
|
cx.spawn(async move |cx| {
|
||||||
|
let mut last_state = None;
|
||||||
|
loop {
|
||||||
|
gpui::Timer::after(Duration::from_millis(100)).await;
|
||||||
|
let state = interface_service.background_state();
|
||||||
|
if last_state.as_ref() != Some(&state) {
|
||||||
|
if let Some(tray) = &tray {
|
||||||
|
tray.update(state.clone());
|
||||||
|
}
|
||||||
|
last_state = Some(state);
|
||||||
|
}
|
||||||
|
while let Ok(action) = receiver.try_recv() {
|
||||||
|
match action {
|
||||||
|
Action::Open => {
|
||||||
|
let _ =
|
||||||
|
cx.update(|cx| open_window(cx, &interface_service, &ui, &tray));
|
||||||
|
}
|
||||||
|
Action::Clip => interface_service.clip(),
|
||||||
|
Action::Bookmark => interface_service.bookmark(),
|
||||||
|
Action::Quit => interface_service.quit(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if interface_service.exited() {
|
||||||
|
let _ = cx.update(|cx| cx.quit());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
});
|
||||||
|
service.quit();
|
||||||
|
let _ = worker.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Interface {
|
||||||
|
window: Option<gpui::WindowHandle<app::ClipbinApp>>,
|
||||||
|
route: app::Route,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_window(
|
||||||
|
cx: &mut App,
|
||||||
|
service: &recorder_service::Client,
|
||||||
|
ui: &Rc<RefCell<Interface>>,
|
||||||
|
tray: &Option<Arc<Tray>>,
|
||||||
|
) {
|
||||||
|
let current = ui.borrow().window;
|
||||||
|
if let Some(window) = current
|
||||||
|
&& window
|
||||||
|
.update(cx, |_, window, _| window.activate_window())
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
let settings = clipbin_core::store::load_settings().unwrap_or_default();
|
let settings = clipbin_core::store::load_settings().unwrap_or_default();
|
||||||
let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx);
|
let bounds = Bounds::centered(None, size(px(1360.), px(860.)), cx);
|
||||||
let window_service = service.clone();
|
let window_service = service.clone();
|
||||||
@@ -154,72 +142,38 @@ fn run_window(
|
|||||||
app_id: Some("dev.clipbin.Clipbin".into()),
|
app_id: Some("dev.clipbin.Clipbin".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
move |window, cx| {
|
move |window, cx| cx.new(|cx| app::ClipbinApp::new(window, cx, settings, window_service)),
|
||||||
cx.new(|cx| app::ClipbinApp::new(window, cx, settings, window_service))
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
let window = match result {
|
let window = match result {
|
||||||
Ok(window) => window,
|
Ok(window) => window,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
eprintln!("Could not open Clipbin: {error}");
|
eprintln!("Could not open Clipbin: {error}");
|
||||||
quitting.set(true);
|
|
||||||
service.quit();
|
service.quit();
|
||||||
cx.quit();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let saved_route = route.clone();
|
let route = ui.borrow().route;
|
||||||
|
ui.borrow_mut().window = Some(window);
|
||||||
let close_service = service.clone();
|
let close_service = service.clone();
|
||||||
let close_tray = tray.clone();
|
let close_tray = tray.clone();
|
||||||
let close_quitting = quitting.clone();
|
let close_ui = ui.clone();
|
||||||
let _ = window.update(cx, |app, window, cx| {
|
let _ = window.update(cx, |app, window, cx| {
|
||||||
if saved_route.get() != app::Route::Sessions {
|
if route != app::Route::Sessions {
|
||||||
app.navigate(saved_route.get(), cx);
|
app.navigate(route, cx);
|
||||||
}
|
}
|
||||||
let entity = cx.entity();
|
let entity = cx.entity();
|
||||||
window.on_window_should_close(cx, move |_, cx| {
|
window.on_window_should_close(cx, move |_, cx| {
|
||||||
saved_route.set(entity.read(cx).route);
|
|
||||||
if !close_tray.as_ref().is_some_and(|tray| tray.available()) {
|
if !close_tray.as_ref().is_some_and(|tray| tray.available()) {
|
||||||
close_quitting.set(true);
|
|
||||||
close_service.quit();
|
close_service.quit();
|
||||||
false
|
return false;
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
|
let mut ui = close_ui.borrow_mut();
|
||||||
|
ui.route = entity.read(cx).route;
|
||||||
|
ui.window = None;
|
||||||
|
eprintln!("Window closed: Clipbin is running in the tray");
|
||||||
|
true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
playback::bind_actions(window, cx);
|
playback::bind_actions(window, cx);
|
||||||
cx.activate(true);
|
cx.activate(true);
|
||||||
cx.spawn(async move |cx| {
|
|
||||||
let mut last_state = None;
|
|
||||||
loop {
|
|
||||||
gpui::Timer::after(Duration::from_millis(100)).await;
|
|
||||||
let state = service.background_state();
|
|
||||||
if last_state.as_ref() != Some(&state) {
|
|
||||||
if let Some(tray) = &tray {
|
|
||||||
tray.update(state.clone());
|
|
||||||
}
|
|
||||||
last_state = Some(state);
|
|
||||||
}
|
|
||||||
while let Ok(action) = receiver.lock().unwrap().try_recv() {
|
|
||||||
match action {
|
|
||||||
Action::Open => {
|
|
||||||
let _ = window.update(cx, |_, window, _| window.activate_window());
|
|
||||||
}
|
|
||||||
Action::Clip => service.clip(),
|
|
||||||
Action::Bookmark => service.bookmark(),
|
|
||||||
Action::Quit => {
|
|
||||||
quitting.set(true);
|
|
||||||
service.quit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if service.exited() {
|
|
||||||
let _ = cx.update(|cx| cx.quit());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
+112
-10
@@ -153,6 +153,8 @@ unsafe extern "C" fn log_to_stderr(
|
|||||||
if let Some(state) = pipewire_state(&text) {
|
if let Some(state) = pipewire_state(&text) {
|
||||||
PIPEWIRE_STATE.store(state as u8, Ordering::Relaxed);
|
PIPEWIRE_STATE.store(state as u8, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
record_wasapi_event(&text);
|
||||||
eprintln!("obs {label}: {text}");
|
eprintln!("obs {label}: {text}");
|
||||||
}
|
}
|
||||||
// SAFETY: frees the buffer dstr_vprintf allocated (null is allowed).
|
// SAFETY: frees the buffer dstr_vprintf allocated (null is allowed).
|
||||||
@@ -612,9 +614,89 @@ pub(crate) fn segment_index(path: &str) -> Option<u64> {
|
|||||||
stem.strip_prefix("segment-")?.parse().ok()
|
stem.strip_prefix("segment-")?.parse().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
static AUDIO_FAULTS: Mutex<Vec<(String, std::sync::Weak<std::sync::atomic::AtomicBool>)>> =
|
||||||
|
Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn wasapi_event(message: &str) -> Option<(&str, bool)> {
|
||||||
|
let (message, source) = message.rsplit_once(" (source: ")?;
|
||||||
|
let source = source.strip_suffix(')')?;
|
||||||
|
if !source.starts_with("clipbin-") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if message.starts_with("WASAPI: Device '") && message.contains(" initialized") {
|
||||||
|
Some((source, false))
|
||||||
|
} else if (message.starts_with("WASAPI: Device '") && message.contains(" failed to start"))
|
||||||
|
|| (message.starts_with("Device '") && message.contains(" invalidated. Retrying"))
|
||||||
|
{
|
||||||
|
Some((source, true))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn record_wasapi_event(message: &str) {
|
||||||
|
if let Some((source, failed)) = wasapi_event(message)
|
||||||
|
&& let Ok(faults) = AUDIO_FAULTS.lock()
|
||||||
|
&& let Some(flag) = faults
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| name == source)
|
||||||
|
.and_then(|(_, flag)| flag.upgrade())
|
||||||
|
{
|
||||||
|
flag.store(failed, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct AudioClock {
|
struct AudioClock {
|
||||||
started: Instant,
|
started: Instant,
|
||||||
last_millis: AtomicU64,
|
last_millis: AtomicU64,
|
||||||
|
#[cfg(windows)]
|
||||||
|
source_name: String,
|
||||||
|
#[cfg(windows)]
|
||||||
|
failed: Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
}
|
||||||
|
impl AudioClock {
|
||||||
|
fn new(source_name: &str) -> Self {
|
||||||
|
#[cfg(windows)]
|
||||||
|
let failed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
#[cfg(windows)]
|
||||||
|
AUDIO_FAULTS
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((source_name.into(), Arc::downgrade(&failed)));
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let _ = source_name;
|
||||||
|
Self {
|
||||||
|
started: Instant::now(),
|
||||||
|
last_millis: AtomicU64::new(0),
|
||||||
|
#[cfg(windows)]
|
||||||
|
source_name: source_name.into(),
|
||||||
|
#[cfg(windows)]
|
||||||
|
failed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn lost(&self) -> bool {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
self.failed.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
(self.started.elapsed().as_millis() as u64)
|
||||||
|
.saturating_sub(self.last_millis.load(Ordering::Relaxed))
|
||||||
|
>= 5_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
impl Drop for AudioClock {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Ok(mut faults) = AUDIO_FAULTS.lock() {
|
||||||
|
faults.retain(|(name, _)| name != &self.source_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe extern "C" fn audio_captured(
|
unsafe extern "C" fn audio_captured(
|
||||||
@@ -626,6 +708,8 @@ unsafe extern "C" fn audio_captured(
|
|||||||
// SAFETY: each callback holds the stable AudioClock allocation until removal;
|
// SAFETY: each callback holds the stable AudioClock allocation until removal;
|
||||||
// OBS serialises removal against callbacks with source->audio_cb_mutex.
|
// OBS serialises removal against callbacks with source->audio_cb_mutex.
|
||||||
let clock = unsafe { &*param.cast::<AudioClock>() };
|
let clock = unsafe { &*param.cast::<AudioClock>() };
|
||||||
|
#[cfg(windows)]
|
||||||
|
clock.failed.store(false, Ordering::Relaxed);
|
||||||
clock.last_millis.store(
|
clock.last_millis.store(
|
||||||
clock.started.elapsed().as_millis() as u64,
|
clock.started.elapsed().as_millis() as u64,
|
||||||
Ordering::Relaxed,
|
Ordering::Relaxed,
|
||||||
@@ -693,12 +777,10 @@ impl Pipeline {
|
|||||||
(&capture.microphone, MICROPHONE_CHANNEL, MICROPHONE_TRACK),
|
(&capture.microphone, MICROPHONE_CHANNEL, MICROPHONE_TRACK),
|
||||||
] {
|
] {
|
||||||
if let Some(source_spec) = source_spec {
|
if let Some(source_spec) = source_spec {
|
||||||
let source = create_source(source_spec, &name(&format!("audio-{channel}")))?;
|
let source_name = format!("{name_prefix}-audio-{channel}");
|
||||||
|
let mut clock = Box::new(AudioClock::new(&source_name));
|
||||||
|
let source = create_source(source_spec, &cstring(&source_name))?;
|
||||||
pipeline.sources.push((Some(channel), source));
|
pipeline.sources.push((Some(channel), source));
|
||||||
let mut clock = Box::new(AudioClock {
|
|
||||||
started: Instant::now(),
|
|
||||||
last_millis: AtomicU64::new(0),
|
|
||||||
});
|
|
||||||
sys::obs_source_add_audio_capture_callback(
|
sys::obs_source_add_audio_capture_callback(
|
||||||
source,
|
source,
|
||||||
Some(audio_captured),
|
Some(audio_captured),
|
||||||
@@ -802,11 +884,7 @@ impl Pipeline {
|
|||||||
lagged_frames: unsafe { sys::obs_get_lagged_frames() } as u64,
|
lagged_frames: unsafe { sys::obs_get_lagged_frames() } as u64,
|
||||||
written_bytes: 0,
|
written_bytes: 0,
|
||||||
disk_available_bytes: None,
|
disk_available_bytes: None,
|
||||||
audio_lost: self.audio_clocks.iter().any(|(_, clock)| {
|
audio_lost: self.audio_clocks.iter().any(|(_, clock)| clock.lost()),
|
||||||
(clock.started.elapsed().as_millis() as u64)
|
|
||||||
.saturating_sub(clock.last_millis.load(Ordering::Relaxed))
|
|
||||||
>= 5_000
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1033,6 +1111,30 @@ unsafe fn create_source(spec: &SourceSpec, name: &CStr) -> Result<*mut sys::obs_
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wasapi_events_are_scoped_and_distinguish_failure_from_silence() {
|
||||||
|
assert_eq!(
|
||||||
|
wasapi_event(
|
||||||
|
"WASAPI: Device 'Speakers' [48000 Hz] initialized (source: clipbin-7-audio-1)"
|
||||||
|
),
|
||||||
|
Some(("clipbin-7-audio-1", false))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
wasapi_event("Device 'Speakers' invalidated. Retrying (source: clipbin-7-audio-1)"),
|
||||||
|
Some(("clipbin-7-audio-1", true))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
wasapi_event("WASAPI: Device 'default' failed to start (source: clipbin-7-audio-3)"),
|
||||||
|
Some(("clipbin-7-audio-3", true))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
wasapi_event("Device 'Speakers' invalidated. Retrying (source: another-app)"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(wasapi_event("WASAPI: Device 'Speakers' Terminated"), None);
|
||||||
|
assert_eq!(wasapi_event("WASAPI: Default output device changed"), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pipewire_state_is_read_from_the_plugin_log() {
|
fn pipewire_state_is_read_from_the_plugin_log() {
|
||||||
let state = |line| pipewire_state(line);
|
let state = |line| pipewire_state(line);
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ pub struct Health {
|
|||||||
/// PipeWire capture's portal and stream state. `None` for other sources,
|
/// PipeWire capture's portal and stream state. `None` for other sources,
|
||||||
/// which report loss through their size, and before the portal answers.
|
/// which report loss through their size, and before the portal answers.
|
||||||
pub pipewire: Option<PipeWireState>,
|
pub pipewire: Option<PipeWireState>,
|
||||||
/// A configured capture source has delivered no audio for five seconds.
|
/// A configured capture source lost delivery (Linux) or reported a device error (Windows).
|
||||||
pub audio_lost: bool,
|
pub audio_lost: bool,
|
||||||
pub output_active: bool,
|
pub output_active: bool,
|
||||||
pub total_frames: u64,
|
pub total_frames: u64,
|
||||||
|
|||||||
+116
-29
@@ -8,7 +8,7 @@ pass/fail results.
|
|||||||
|
|
||||||
| Where | Script | Status |
|
| Where | Script | Status |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Linux, headless rig (Wayland portal + Xwayland) | `scripts/validate/linux-rig.sh` | All 10 scenarios pass on devbox (Intel UHD 770, VA-API), 2026-10-13 |
|
| Linux, headless rig (Wayland portal + Xwayland) | `scripts/validate/linux-rig.sh` | All 12 scenarios pass on devbox (Intel UHD 770, VA-API), 2026-10-13 |
|
||||||
| Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | Passed 2026-10-08 (run 4, dc9cbd6) |
|
| Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | Passed 2026-10-08 (run 4, dc9cbd6) |
|
||||||
| Linux desktop automatic sessions (headless Wayland) | `scripts/validate/linux-session.sh` | Passed 2026-10-13: recording sidebar, close-to-tray, game exit, second-launch reopen and active Quit |
|
| Linux desktop automatic sessions (headless Wayland) | `scripts/validate/linux-session.sh` | Passed 2026-10-13: recording sidebar, close-to-tray, game exit, second-launch reopen and active Quit |
|
||||||
| Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested |
|
| Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested |
|
||||||
@@ -175,9 +175,10 @@ Windows status:
|
|||||||
record portrait). If the window is resized mid-session the size stays and
|
record portrait). If the window is resized mid-session the size stays and
|
||||||
the picture is scaled to fit.
|
the picture is scaled to fit.
|
||||||
- **Audio delivery health.** The configured system/microphone capture sources
|
- **Audio delivery health.** The configured system/microphone capture sources
|
||||||
are monitored directly through OBS audio capture callbacks. After five seconds
|
are monitored through OBS audio capture callbacks on Linux (five seconds
|
||||||
without delivery, recording is degraded with “audio capture lost”; video
|
without delivery) and source-scoped WASAPI device errors on Windows. Either
|
||||||
continues. The silent muxer track cannot mask absent source callbacks. See
|
degrades recording with “audio capture lost”; video continues. The silent
|
||||||
|
muxer track cannot mask capture failure. See
|
||||||
Background operation below for validation and limits.
|
Background operation below for validation and limits.
|
||||||
- **Linux runtime dependencies.** The Linux OBS build links the system's
|
- **Linux runtime dependencies.** The Linux OBS build links the system's
|
||||||
FFmpeg, PipeWire and other libraries, so it is tied to the distribution it
|
FFmpeg, PipeWire and other libraries, so it is tied to the distribution it
|
||||||
@@ -258,15 +259,16 @@ manifest ("Escape from Duckov") and recorded the session. Audio delivery loss
|
|||||||
is now detected as described below; the new warning still needs real hardware
|
is now detected as described below; the new warning still needs real hardware
|
||||||
and Windows validation.
|
and Windows validation.
|
||||||
|
|
||||||
GPUI 0.2.2 ends its Linux and Windows event loops when the last window is
|
GPUI 0.2.2 normally ends its Linux and Windows event loops when the last
|
||||||
removed. Clipbin keeps the recorder, tray and single-instance endpoint alive
|
window is removed. Clipbin now keeps one Application and native loop alive
|
||||||
outside each GPUI run, and constructs a fresh Application on the main thread
|
until explicit Quit, with a small vendored backend policy patch (see
|
||||||
when reopened. The previous route is restored and the library/settings are
|
[vendor/gpui/CLIPBIN.md](../vendor/gpui/CLIPBIN.md)). The visible window is
|
||||||
reloaded; unavailable sessions fall back to the library. This also releases
|
removed on close and recreated on Open, restoring its previous route and
|
||||||
playback resources while the interface is closed. No fake or hidden GPUI window
|
reloading the library/settings; unavailable sessions fall back to the library.
|
||||||
is kept alive: GPUI's Linux backends ignore WindowOptions::show. Window bounds,
|
Playback resources are released while closed. No invisible GPUI window is
|
||||||
scroll offsets and unsaved editor selection are not restored. Explicit Quit
|
needed. Bounds, scroll offsets and unsaved editor selection are not restored.
|
||||||
waits for the existing worker's clean session finish before joining/exiting.
|
Quit waits for the existing worker's clean session finish before joining/exiting.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Clips, bookmarks and storage cleanup
|
## Clips, bookmarks and storage cleanup
|
||||||
@@ -547,22 +549,42 @@ on every configured system/microphone source, independently of the encoder,
|
|||||||
mixed audio counters and silent output track. A boxed callback context stores
|
mixed audio counters and silent output track. A boxed callback context stores
|
||||||
an atomic monotonic delivery timestamp; it performs no allocation or locking.
|
an atomic monotonic delivery timestamp; it performs no allocation or locking.
|
||||||
The context is removed before source release: OBS's audio_cb_mutex serialises
|
The context is removed before source release: OBS's audio_cb_mutex serialises
|
||||||
callback removal with callbacks. Five seconds without delivery (including a
|
callback removal with callbacks. On Linux, five seconds without delivery
|
||||||
source that never starts) is a warning-level RecordingStatus::Degraded,
|
(including a source that never starts) is a warning-level RecordingStatus::Degraded,
|
||||||
with “audio capture lost” in the sidebar and tray. A new callback clears the
|
with “audio capture lost” in the sidebar and tray. A new callback clears the
|
||||||
warning; video/source/output/disk failures still take precedence and stop
|
warning; video/source/output/disk failures still take precedence and stop
|
||||||
recording. Intentionally disabled audio has no callback and no warning. This
|
recording. Disabled audio has no callback and no warning. Valid silent samples
|
||||||
checks delivery, not amplitude: valid silent samples are healthy. It cannot
|
are healthy; an endless synthetic silence stream cannot reveal a driver failure.
|
||||||
detect a driver delivering endless synthetic silence, and WASAPI silence or
|
|
||||||
idle-device callback behaviour needs a Windows runtime check. No automatic
|
Windows does not infer loss from absent buffers. In the pinned
|
||||||
source restart is added; video continues and audio can recover if OBS resumes
|
[OBS 32.0.4 win-wasapi source](https://github.com/obsproject/obs-studio/blob/32.0.4/plugins/win-wasapi/win-wasapi.cpp),
|
||||||
its source. Source identity is not displayed separately for system vs mic loss.
|
WASAPISource::Initialize calls ClearBuffer before InitCapture for DeviceOutput. ClearBuffer
|
||||||
|
creates a temporary shared renderer and fills its buffer with silence, but
|
||||||
|
neither starts nor retains that renderer. ProcessCaptureData accepts silent
|
||||||
|
packets and exits normally when GetNextPacketSize reports no packets. This is
|
||||||
|
not evidence of uninterrupted callbacks during idle loopback capture. Instead,
|
||||||
|
Clipbin tracks the plugin's source-scoped “failed to start” and device
|
||||||
|
“invalidated. Retrying” logs; successful initialization or a delivered capture
|
||||||
|
buffer clears the warning. The callback remains allocation/lock free; the log
|
||||||
|
handler separately updates weak registered fault flags. Flags are registered
|
||||||
|
before source creation and unregistered on cleanup, so unrelated/stale sources
|
||||||
|
cannot affect a recording. The parser has a unit test. Windows failures that
|
||||||
|
neither deliver buffers nor produce these pinned log messages remain undetected;
|
||||||
|
this is preferable to reporting ordinary silence as lost capture. Actual WASAPI
|
||||||
|
silence, mute, unplug/recovery and changed-device behavior require Windows runtime
|
||||||
|
validation. No automatic source restart is added; OBS's recovery can clear the
|
||||||
|
warning. Source identity is not displayed separately for system vs mic loss.
|
||||||
|
|
||||||
The rig's audio failure injection kills its private pipewire-pulse daemon,
|
The rig's audio failure injection kills its private pipewire-pulse daemon,
|
||||||
leaving PipeWire video and the portal alive. Unloading the null sink and
|
leaving PipeWire video and the portal alive. Unloading the null sink and
|
||||||
requesting kill-source-output did not reliably stop delivery in PipeWire 1.6.8;
|
requesting kill-source-output did not reliably stop delivery in PipeWire 1.6.8;
|
||||||
killing that private audio server deterministically stops source callbacks.
|
killing that private audio server deterministically stops source callbacks.
|
||||||
The original nine scenarios are retained alongside this tenth warning case.
|
The original nine scenarios are retained alongside audio loss, idle silence
|
||||||
|
and muted-microphone cases (twelve total). Silence cases omit the ffplay tone,
|
||||||
|
assert there are no playback streams, wait twelve seconds and require the null
|
||||||
|
sink to be SUSPENDED before capture. They then record for sixteen seconds with
|
||||||
|
no degraded/failed status. The muted-mic case selects the null sink monitor as
|
||||||
|
the default input and mutes it; physical microphone hardware remains untested.
|
||||||
The rig now uses an explicit private SWAYSOCK and waits for it; discovery by
|
The rig now uses an explicit private SWAYSOCK and waits for it; discovery by
|
||||||
the previous sway-ipc glob did not locate the installed Sway 1.12 endpoint.
|
the previous sway-ipc glob did not locate the installed Sway 1.12 endpoint.
|
||||||
CLIPBIN_RIG_KEEP_LOGS also preserves startup-failure logs.
|
CLIPBIN_RIG_KEEP_LOGS also preserves startup-failure logs.
|
||||||
@@ -573,10 +595,11 @@ callback registration/removal. Nothing new has been linked or run on Windows.
|
|||||||
GPUI desktop and clipbin-media remain excluded from that lint set because the
|
GPUI desktop and clipbin-media remain excluded from that lint set because the
|
||||||
MSVC C toolchain/FFmpeg development import libraries are unavailable here.
|
MSVC C toolchain/FFmpeg development import libraries are unavailable here.
|
||||||
Unvalidated: tray rendering/menu focus/Explorer restart, icon/tooltips, singleton
|
Unvalidated: tray rendering/menu focus/Explorer restart, icon/tooltips, singleton
|
||||||
startup races and multi-user/desktop behaviour, foreground activation, repeated
|
startup races and multi-user/desktop behaviour, foreground activation,
|
||||||
GPUI Application creation after close, clean Quit/file closure, --background,
|
GPUI persistent-loop policy (including the vendored Windows backend, which is
|
||||||
registry writes and real login launch, WASAPI delivery-loss/recovery/idle
|
not cross-linted), window recreation, clean Quit/file closure, --background,
|
||||||
semantics, and the Clips layout on Windows. Real Linux desktop trays (Hyprland
|
registry writes and real login launch, WASAPI error-log/recovery/idle semantics, and the Clips layout on Windows.
|
||||||
|
Real Linux desktop trays (Hyprland
|
||||||
Waybar, KDE, GNOME extensions), login launch, SNI host replacement/re-registration
|
Waybar, KDE, GNOME extensions), login launch, SNI host replacement/re-registration
|
||||||
and native X11 tray lifecycle still need runtime validation.
|
and native X11 tray lifecycle still need runtime validation.
|
||||||
|
|
||||||
@@ -646,6 +669,70 @@ commit: removed the obsolete window-close shutdown mode, reused existing clip,
|
|||||||
bookmark and clean-finish paths, avoided copying the full library for tray
|
bookmark and clean-finish paths, avoided copying the full library for tray
|
||||||
polling, limited icon updates to state changes, kept callbacks allocation/lock
|
polling, limited icon updates to state changes, kept callbacks allocation/lock
|
||||||
free, and made native tray/thread cleanup explicit. The text helper is limited
|
free, and made native tray/thread cleanup explicit. The text helper is limited
|
||||||
to the demonstrated GPUI 0.2.2 rendering bug; no dependency fork or general UI
|
to the demonstrated GPUI 0.2.2 rendering bug. The subsequent lifecycle review
|
||||||
framework was added. Native Windows runtime and real desktop limits above
|
required the small vendored GPUI policy patch described below. Native Windows
|
||||||
remain unchanged.
|
runtime and real desktop limits above remain unchanged.
|
||||||
|
|
||||||
|
|
||||||
|
## Background review follow-up (2026-10-13)
|
||||||
|
|
||||||
|
The first five-cycle close/Open test exposed a leak in repeated Application
|
||||||
|
creation: process thread counts grew 99, 114, 129, 144, 159, 174. GPUI's
|
||||||
|
LinuxDispatcher creates a worker pool and Timer per platform; the Timer ignores
|
||||||
|
channel closure and keeps running. This invalidates repeated-run ownership even
|
||||||
|
though each reopened window worked. Keeping one Application removes the repeated
|
||||||
|
platform construction. The final five-cycle test measured 71 threads initially
|
||||||
|
and 69 after every reopen; RSS was 243228, 242580, 247344, 243032, 243136,
|
||||||
|
243364 KiB. The rig saves reopen-metrics.tsv and asserts bounded thread/RSS growth.
|
||||||
|
|
||||||
|
The pinned Windows source was also examined: WindowsPlatform::new calls
|
||||||
|
OleInitialize; Drop destroys the message-only platform window and calls
|
||||||
|
OleUninitialize. The platform RegisterClassW result is ignored; the visible
|
||||||
|
window class uses Once, so repeated class registration itself is not a panic.
|
||||||
|
The VSync thread uses weak platform state and can exit on drop, and Windows
|
||||||
|
uses the OS thread pool rather than Linux's owned worker pool. Both dispatchers,
|
||||||
|
however, forget scheduled runnables when the main channel has closed, assuming
|
||||||
|
process shutdown. Repeated Application lifetimes are therefore not established
|
||||||
|
as safe on Windows. Clipbin avoids them entirely: fonts, dispatcher, OLE,
|
||||||
|
classes, DirectX state and native loop initialize once. The Windows last-window
|
||||||
|
policy is patched in the same backend decision as Linux; its runtime/build
|
||||||
|
remains unvalidated because the complete GPUI desktop cannot be cross-checked
|
||||||
|
here. The unmodified published dependency is retained with its Apache license;
|
||||||
|
clipbin-background.patch records the complete source delta. The only additional
|
||||||
|
compatibility change gives two taffy float literals explicit f32 types for
|
||||||
|
Rust 1.98's strict lint.
|
||||||
|
|
||||||
|
Follow-up artifacts live under target/background-review; reopen-before and
|
||||||
|
reopen-after contain lifecycle metrics. The silent-monitor experiment also
|
||||||
|
passed with the sink initially suspended. An initial muted-mic experiment used
|
||||||
|
an absent default input and correctly degraded; the committed fixture explicitly
|
||||||
|
selects a real monitor source before muting it.
|
||||||
|
|
||||||
|
|
||||||
|
Follow-up validation (all exit 0, 2026-10-13):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo fmt --all -- --check
|
||||||
|
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
|
env PATH=/usr/bin:/bin:$HOME/.cargo/bin cargo test --locked --workspace
|
||||||
|
env RUSTUP_HOME=$HOME/.cache/clipbin/rustup CARGO_HOME=$HOME/.cache/clipbin/cargo-home PATH=$HOME/.cache/clipbin/cargo-home/bin:$PATH CARGO_TARGET_DIR=target/windows-check cargo clippy --locked --target x86_64-pc-windows-msvc -p clipbin-core -p clipbin-obs -p clipbin-probe --all-targets -- -D warnings
|
||||||
|
env PATH=/usr/bin:/bin:$HOME/.cargo/bin TMPDIR=/home/harry/Dev/clipbin-desktop/target/rig-tmp scripts/validate/linux-background.sh /home/harry/Dev/clipbin-desktop/target/background-review/reopen-after
|
||||||
|
env PATH=/usr/bin:/bin:$HOME/.cargo/bin TMPDIR=/home/harry/Dev/clipbin-desktop/target/rig-tmp scripts/validate/linux-rig.sh /home/harry/Dev/clipbin-desktop/target/background-review/capture
|
||||||
|
env PATH=/usr/bin:/bin:$HOME/.cargo/bin TMPDIR=/home/harry/Dev/clipbin-desktop/target/rig-tmp bash -e -c 'for suite in linux-session linux-clips linux-playback; do scripts/validate/${suite}.sh /home/harry/Dev/clipbin-desktop/target/background-review/${suite}; done'
|
||||||
|
```
|
||||||
|
|
||||||
|
All 58 workspace tests passed (fixture-dependent media tests retain their
|
||||||
|
existing opt-in behavior). All twelve capture scenarios passed; silence and
|
||||||
|
muted-mic logs show the initially suspended sink and respectively 650 and
|
||||||
|
650/645 captured packets, with no degradation. Audio-server loss still degrades
|
||||||
|
while video continues. Lifecycle passed SNI registration/menu actions,
|
||||||
|
--background, autostart write/remove, close survival, second launch, all five
|
||||||
|
reopen cycles, audio warning, clean Quit, watcher loss and no-host fallback.
|
||||||
|
Session finalization/library reload, clips/bookmarks/protected cleanup, and
|
||||||
|
playback all passed after the persistent-loop change. Playback changed 6.4% of
|
||||||
|
preview pixels, audio RMS was 0.08044; decode median/p95 was 4.951/11.467 ms and
|
||||||
|
GPUI upload median/p95 was 0.186/0.334 ms. The simplify quick self-review checked
|
||||||
|
reuse, clarity and efficiency; it retained existing actions/clean finish, one
|
||||||
|
application/dispatcher, weak fault registration with explicit cleanup, and no
|
||||||
|
new callback locks or allocations. The Windows policy flag uses a separate
|
||||||
|
Cell to avoid adding native state borrows during close handling.
|
||||||
|
|||||||
@@ -116,9 +116,13 @@ if [[ "${mode}" == x11 ]]; then
|
|||||||
video_driver=x11
|
video_driver=x11
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# A moving 60 FPS picture with a 440 Hz tone, standing in for a game.
|
# A moving 60 FPS picture, optionally without any audio stream for idle tests.
|
||||||
|
pattern="testsrc2=size=${width}x${height}:rate=60,format=yuv420p[out0]"
|
||||||
|
if [[ "${CLIPBIN_RIG_NO_AUDIO:-0}" != 1 ]]; then
|
||||||
|
pattern+=';sine=frequency=440:sample_rate=48000[out1]'
|
||||||
|
fi
|
||||||
SDL_VIDEODRIVER="${video_driver}" ffplay -loglevel error -fs -window_title clipbin-test-game \
|
SDL_VIDEODRIVER="${video_driver}" ffplay -loglevel error -fs -window_title clipbin-test-game \
|
||||||
-f lavfi "testsrc2=size=${width}x${height}:rate=60,format=yuv420p[out0];sine=frequency=440:sample_rate=48000[out1]" \
|
-f lavfi "${pattern}" \
|
||||||
> "${rig_dir}/game.log" 2>&1 & pids+=($!)
|
> "${rig_dir}/game.log" 2>&1 & pids+=($!)
|
||||||
echo "${pids[-1]}" > "${rig_dir}/game.pid"
|
echo "${pids[-1]}" > "${rig_dir}/game.pid"
|
||||||
if [[ "${mode}" == x11 ]]; then
|
if [[ "${mode}" == x11 ]]; then
|
||||||
|
|||||||
@@ -81,6 +81,34 @@ swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
|
|||||||
wait_windows 0
|
wait_windows 0
|
||||||
menu 1
|
menu 1
|
||||||
wait_windows 1
|
wait_windows 1
|
||||||
|
# Repeated reopen must release each UI's threads and allocation state.
|
||||||
|
measure() {
|
||||||
|
python3 - "${app}" "$1" <<'PYMETRICS'
|
||||||
|
import pathlib,sys
|
||||||
|
status=dict(line.split(':',1) for line in pathlib.Path(f'/proc/{sys.argv[1]}/status').read_text().splitlines() if ':' in line)
|
||||||
|
print(f"{sys.argv[2]}\t{status['Threads'].strip()}\t{status['VmRSS'].split()[0]}")
|
||||||
|
PYMETRICS
|
||||||
|
}
|
||||||
|
measure 0 > "${out}/reopen-metrics.tsv"
|
||||||
|
for cycle in $(seq 5); do
|
||||||
|
swaymsg '[app_id="dev.clipbin.Clipbin"] kill' >/dev/null
|
||||||
|
wait_windows 0
|
||||||
|
sleep 1
|
||||||
|
menu 1
|
||||||
|
wait_windows 1
|
||||||
|
sleep 1
|
||||||
|
measure "${cycle}" >> "${out}/reopen-metrics.tsv"
|
||||||
|
done
|
||||||
|
cat "${out}/reopen-metrics.tsv"
|
||||||
|
python3 - "${out}/reopen-metrics.tsv" <<'PYBOUNDS'
|
||||||
|
import pathlib,sys
|
||||||
|
rows=[tuple(map(int,line.split())) for line in pathlib.Path(sys.argv[1]).read_text().splitlines()]
|
||||||
|
assert max(r[1] for r in rows[1:]) <= rows[0][1]+4, rows
|
||||||
|
# Allow the first reopen to warm caches; later windows must stay bounded.
|
||||||
|
rss=[r[2] for r in rows[1:]]
|
||||||
|
assert max(rss)-min(rss) <= 32*1024 and rows[-1][2] <= rows[0][2]+64*1024, rows
|
||||||
|
print('PASS five close/reopen cycles have bounded thread count and RSS')
|
||||||
|
PYBOUNDS
|
||||||
# Audio delivery loss must be visible in the application and real tray tooltip.
|
# Audio delivery loss must be visible in the application and real tray tooltip.
|
||||||
kill "$(cat "${CLIPBIN_RIG_DIR}/pulse.pid")"
|
kill "$(cat "${CLIPBIN_RIG_DIR}/pulse.pid")"
|
||||||
for _ in $(seq 15); do grep -q 'recorder_status=Degraded' "${out}/desktop.log" && break; sleep 1; done
|
for _ in $(seq 15); do grep -q 'recorder_status=Degraded' "${out}/desktop.log" && break; sleep 1; done
|
||||||
|
|||||||
@@ -128,6 +128,26 @@ else
|
|||||||
result "${name}" 1
|
result "${name}" 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 10. Silence is healthy: no playback streams, idle >10s before monitor capture.
|
||||||
|
for mode in silence muted-mic; do
|
||||||
|
name="audio-${mode}"
|
||||||
|
mic=""
|
||||||
|
setup=""
|
||||||
|
if [[ "${mode}" == muted-mic ]]; then
|
||||||
|
mic="--mic"
|
||||||
|
setup="pactl set-default-source clipbin-rig-sink.monitor; pactl set-source-mute clipbin-rig-sink.monitor 1;"
|
||||||
|
fi
|
||||||
|
CLIPBIN_RIG_NO_AUDIO=1 "${rig}" bash -c "sleep 12; test -z \"\$(pactl list short sink-inputs)\" || exit 1; pactl list short sinks | grep SUSPENDED || exit 1; ${setup} '${probe}' record ${mic} --seconds 16 --out '${out}/${name}'; status=\$?; pactl list short sinks; exit \$status" \
|
||||||
|
> "${out}/${name}.log" 2>&1
|
||||||
|
probe_exit=$?
|
||||||
|
if [[ "${probe_exit}" == 0 ]] && has "${out}/${name}.log" "status=Recording" \
|
||||||
|
&& ! has "${out}/${name}.log" "status=(degraded|failed)"; then
|
||||||
|
result "${name}" 0 "no playback for >10s, capture remains healthy"
|
||||||
|
else
|
||||||
|
result "${name}" 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if [[ "${failures}" == 0 ]]; then
|
if [[ "${failures}" == 0 ]]; then
|
||||||
echo "All scenarios passed. Logs in ${out}"
|
echo "All scenarios passed. Logs in ${out}"
|
||||||
|
|||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# Clipbin GPUI 0.2.2 patch
|
||||||
|
|
||||||
|
Baseline: crates.io gpui 0.2.2, checksum
|
||||||
|
`979b45cfa6ec723b6f42330915a1b3769b930d02b2d505f9697f8ca602bee707`.
|
||||||
|
Original Apache-2.0 license retained. This dependency is excluded from Clipbin's
|
||||||
|
workspace; its existing third-party unsafe code is unchanged.
|
||||||
|
|
||||||
|
`clipbin-background.patch` is the complete source delta from the published
|
||||||
|
crate (zero-context unified diff; apply with `patch -p1` or
|
||||||
|
`git apply --unidiff-zero`). It adds App::set_quit_on_last_window_close and an initially true backend
|
||||||
|
flag on Wayland, X11 and Windows. Clipbin sets it false so one Application/loop
|
||||||
|
lives until explicit Quit, while visible windows can be destroyed/recreated.
|
||||||
|
No timers, dispatchers or FFI calls are changed. Two taffy float literals
|
||||||
|
are explicitly typed f32 to satisfy Rust 1.98 strict clippy.
|
||||||
Vendored
+55
@@ -0,0 +1,55 @@
|
|||||||
|
--- a/src/app.rs
|
||||||
|
+++ b/src/app.rs
|
||||||
|
@@ -982,0 +983,5 @@
|
||||||
|
+ /// Keep the native loop alive when the last window closes (for tray apps).
|
||||||
|
+ pub fn set_quit_on_last_window_close(&self, quit: bool) {
|
||||||
|
+ self.platform.set_quit_on_last_window_close(quit);
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
--- a/src/platform/linux/platform.rs
|
||||||
|
+++ b/src/platform/linux/platform.rs
|
||||||
|
@@ -96,0 +97 @@
|
||||||
|
+ pub quit_on_last_window_close: bool,
|
||||||
|
@@ -122,0 +124 @@
|
||||||
|
+ quit_on_last_window_close: true,
|
||||||
|
@@ -232,0 +235,4 @@
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ fn set_quit_on_last_window_close(&self, quit: bool) {
|
||||||
|
+ self.with_common(|common| common.quit_on_last_window_close = quit);
|
||||||
|
--- a/src/platform/linux/wayland/client.rs
|
||||||
|
+++ b/src/platform/linux/wayland/client.rs
|
||||||
|
@@ -387 +387 @@
|
||||||
|
- if state.windows.is_empty() {
|
||||||
|
+ if state.windows.is_empty() && state.common.quit_on_last_window_close {
|
||||||
|
--- a/src/platform/linux/x11/client.rs
|
||||||
|
+++ b/src/platform/linux/x11/client.rs
|
||||||
|
@@ -250 +250 @@
|
||||||
|
- if state.windows.is_empty() {
|
||||||
|
+ if state.windows.is_empty() && state.common.quit_on_last_window_close {
|
||||||
|
--- a/src/platform/windows/platform.rs
|
||||||
|
+++ b/src/platform/windows/platform.rs
|
||||||
|
@@ -2 +2 @@
|
||||||
|
- cell::RefCell,
|
||||||
|
+ cell::{Cell, RefCell},
|
||||||
|
@@ -45,0 +46 @@
|
||||||
|
+ quit_on_last_window_close: Cell<bool>,
|
||||||
|
@@ -369,0 +371,4 @@
|
||||||
|
+ fn set_quit_on_last_window_close(&self, quit: bool) {
|
||||||
|
+ self.inner.quit_on_last_window_close.set(quit);
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
@@ -682,0 +688 @@
|
||||||
|
+ quit_on_last_window_close: Cell::new(true),
|
||||||
|
@@ -744 +750 @@
|
||||||
|
- lock.is_empty()
|
||||||
|
+ lock.is_empty() && self.quit_on_last_window_close.get()
|
||||||
|
--- a/src/platform.rs
|
||||||
|
+++ b/src/platform.rs
|
||||||
|
@@ -171,0 +172 @@
|
||||||
|
+ fn set_quit_on_last_window_close(&self, _quit: bool) {}
|
||||||
|
--- a/src/taffy.rs
|
||||||
|
+++ b/src/taffy.rs
|
||||||
|
@@ -312 +312 @@
|
||||||
|
- unit.map(|count| vec![repeat(count, vec![minmax(length(0.0), fr(1.0))])])
|
||||||
|
+ unit.map(|count| vec![repeat(count, vec![minmax(length(0.0_f32), fr(1.0_f32))])])
|
||||||
Vendored
+5
@@ -980,6 +980,11 @@ impl App {
|
|||||||
self.platform.activate(ignoring_other_apps);
|
self.platform.activate(ignoring_other_apps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep the native loop alive when the last window closes (for tray apps).
|
||||||
|
pub fn set_quit_on_last_window_close(&self, quit: bool) {
|
||||||
|
self.platform.set_quit_on_last_window_close(quit);
|
||||||
|
}
|
||||||
|
|
||||||
/// Hide the application at the platform level.
|
/// Hide the application at the platform level.
|
||||||
pub fn hide(&self) {
|
pub fn hide(&self) {
|
||||||
self.platform.hide();
|
self.platform.hide();
|
||||||
|
|||||||
Vendored
+1
@@ -169,6 +169,7 @@ pub(crate) trait Platform: 'static {
|
|||||||
fn quit(&self);
|
fn quit(&self);
|
||||||
fn restart(&self, binary_path: Option<PathBuf>);
|
fn restart(&self, binary_path: Option<PathBuf>);
|
||||||
fn activate(&self, ignoring_other_apps: bool);
|
fn activate(&self, ignoring_other_apps: bool);
|
||||||
|
fn set_quit_on_last_window_close(&self, _quit: bool) {}
|
||||||
fn hide(&self);
|
fn hide(&self);
|
||||||
fn hide_other_apps(&self);
|
fn hide_other_apps(&self);
|
||||||
fn unhide_other_apps(&self);
|
fn unhide_other_apps(&self);
|
||||||
|
|||||||
+6
@@ -94,6 +94,7 @@ pub(crate) struct PlatformHandlers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct LinuxCommon {
|
pub(crate) struct LinuxCommon {
|
||||||
|
pub quit_on_last_window_close: bool,
|
||||||
pub(crate) background_executor: BackgroundExecutor,
|
pub(crate) background_executor: BackgroundExecutor,
|
||||||
pub(crate) foreground_executor: ForegroundExecutor,
|
pub(crate) foreground_executor: ForegroundExecutor,
|
||||||
pub(crate) text_system: Arc<dyn PlatformTextSystem>,
|
pub(crate) text_system: Arc<dyn PlatformTextSystem>,
|
||||||
@@ -120,6 +121,7 @@ impl LinuxCommon {
|
|||||||
let background_executor = BackgroundExecutor::new(dispatcher.clone());
|
let background_executor = BackgroundExecutor::new(dispatcher.clone());
|
||||||
|
|
||||||
let common = LinuxCommon {
|
let common = LinuxCommon {
|
||||||
|
quit_on_last_window_close: true,
|
||||||
background_executor,
|
background_executor,
|
||||||
foreground_executor: ForegroundExecutor::new(dispatcher),
|
foreground_executor: ForegroundExecutor::new(dispatcher),
|
||||||
text_system,
|
text_system,
|
||||||
@@ -232,6 +234,10 @@ impl<P: LinuxClient + 'static> Platform for P {
|
|||||||
log::info!("activate is not implemented on Linux, ignoring the call")
|
log::info!("activate is not implemented on Linux, ignoring the call")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_quit_on_last_window_close(&self, quit: bool) {
|
||||||
|
self.with_common(|common| common.quit_on_last_window_close = quit);
|
||||||
|
}
|
||||||
|
|
||||||
fn hide(&self) {
|
fn hide(&self) {
|
||||||
log::info!("hide is not implemented on Linux, ignoring the call")
|
log::info!("hide is not implemented on Linux, ignoring the call")
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -384,7 +384,7 @@ impl WaylandClientStatePtr {
|
|||||||
{
|
{
|
||||||
state.keyboard_focused_window = Some(window);
|
state.keyboard_focused_window = Some(window);
|
||||||
}
|
}
|
||||||
if state.windows.is_empty() {
|
if state.windows.is_empty() && state.common.quit_on_last_window_close {
|
||||||
state.common.signal.stop();
|
state.common.signal.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -247,7 +247,7 @@ impl X11ClientStatePtr {
|
|||||||
}
|
}
|
||||||
state.cursor_styles.remove(&x_window);
|
state.cursor_styles.remove(&x_window);
|
||||||
|
|
||||||
if state.windows.is_empty() {
|
if state.windows.is_empty() && state.common.quit_on_last_window_close {
|
||||||
state.common.signal.stop();
|
state.common.signal.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -1,5 +1,5 @@
|
|||||||
use std::{
|
use std::{
|
||||||
cell::RefCell,
|
cell::{Cell, RefCell},
|
||||||
ffi::OsStr,
|
ffi::OsStr,
|
||||||
mem::ManuallyDrop,
|
mem::ManuallyDrop,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
@@ -43,6 +43,7 @@ pub(crate) struct WindowsPlatform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct WindowsPlatformInner {
|
struct WindowsPlatformInner {
|
||||||
|
quit_on_last_window_close: Cell<bool>,
|
||||||
state: RefCell<WindowsPlatformState>,
|
state: RefCell<WindowsPlatformState>,
|
||||||
raw_window_handles: std::sync::Weak<RwLock<SmallVec<[SafeHwnd; 4]>>>,
|
raw_window_handles: std::sync::Weak<RwLock<SmallVec<[SafeHwnd; 4]>>>,
|
||||||
// The below members will never change throughout the entire lifecycle of the app.
|
// The below members will never change throughout the entire lifecycle of the app.
|
||||||
@@ -367,6 +368,10 @@ impl Platform for WindowsPlatform {
|
|||||||
|
|
||||||
fn activate(&self, _ignoring_other_apps: bool) {}
|
fn activate(&self, _ignoring_other_apps: bool) {}
|
||||||
|
|
||||||
|
fn set_quit_on_last_window_close(&self, quit: bool) {
|
||||||
|
self.inner.quit_on_last_window_close.set(quit);
|
||||||
|
}
|
||||||
|
|
||||||
fn hide(&self) {}
|
fn hide(&self) {}
|
||||||
|
|
||||||
// todo(windows)
|
// todo(windows)
|
||||||
@@ -680,6 +685,7 @@ impl WindowsPlatformInner {
|
|||||||
context.directx_devices.take().unwrap(),
|
context.directx_devices.take().unwrap(),
|
||||||
));
|
));
|
||||||
Ok(Rc::new(Self {
|
Ok(Rc::new(Self {
|
||||||
|
quit_on_last_window_close: Cell::new(true),
|
||||||
state,
|
state,
|
||||||
raw_window_handles: context.raw_window_handles.clone(),
|
raw_window_handles: context.raw_window_handles.clone(),
|
||||||
validation_number: context.validation_number,
|
validation_number: context.validation_number,
|
||||||
@@ -741,7 +747,7 @@ impl WindowsPlatformInner {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
lock.remove(index);
|
lock.remove(index);
|
||||||
|
|
||||||
lock.is_empty()
|
lock.is_empty() && self.quit_on_last_window_close.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
|
|||||||
Vendored
+1
-1
@@ -309,7 +309,7 @@ impl ToTaffy<taffy::style::Style> for Style {
|
|||||||
unit: &Option<u16>,
|
unit: &Option<u16>,
|
||||||
) -> Vec<taffy::GridTemplateComponent<T>> {
|
) -> Vec<taffy::GridTemplateComponent<T>> {
|
||||||
// grid-template-columns: repeat(<number>, minmax(0, 1fr));
|
// grid-template-columns: repeat(<number>, minmax(0, 1fr));
|
||||||
unit.map(|count| vec![repeat(count, vec![minmax(length(0.0), fr(1.0))])])
|
unit.map(|count| vec![repeat(count, vec![minmax(length(0.0_f32), fr(1.0_f32))])])
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user