Skip to main content

vs_cli/
serve.rs

1//! `vs serve` — host the daemon in this process.
2//!
3//! Folded into the `vs` binary so the project ships exactly one CLI
4//! surface; the M0 `vibesurferd` binary is gone. Auto-spawn re-execs
5//! `vs serve` instead.
6//!
7//! # Threading model
8//!
9//! On **macOS**, `WKWebView` is hard-pinned to the Cocoa main thread.
10//! [`run`] therefore stays on the OS main thread, initializes
11//! `NSApplication`, constructs the `WkBackend` here, and spawns a
12//! worker thread that runs the tokio runtime + the daemon. Engine calls
13//! issued by the daemon (on tokio workers) flow through an mpsc
14//! channel back to main, where they're drained between NSRunLoop
15//! ticks. See [`vs_engine_webkit::runtime::MainThreadDispatcher`].
16//!
17//! On **Linux**, the same shape applies with a GLib main context and
18//! WebKitGTK 6. On **Windows**, with a Win32 message pump and
19//! WebView2.
20
21use std::sync::Arc;
22
23use anyhow::{Context as _, Result};
24use vs_daemon::{config::Paths as DaemonPaths, server, Daemon};
25
26/// Args specific to `vs serve`. `paths` is the resolved daemon home.
27pub struct ServeArgs {
28    pub paths: DaemonPaths,
29    /// If true, do not start a daemon — instead, read the PID file,
30    /// send SIGTERM, and wait for the socket to disappear.
31    pub stop: bool,
32}
33
34/// `vs serve --stop`. Reads the daemon PID file, sends SIGTERM (Unix)
35/// or TerminateProcess (Windows), and waits up to 5s for the socket
36/// file to disappear. Stale PID files for processes that already exit
37/// are cleaned up.
38pub fn run_stop(paths: &DaemonPaths) -> Result<()> {
39    let pid_file = paths.pid_file();
40    let pid_str = match std::fs::read_to_string(&pid_file) {
41        Ok(s) => s,
42        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
43            eprintln!("no daemon running (no PID file at {})", pid_file.display());
44            return Ok(());
45        }
46        Err(e) => return Err(e).context("read pid file"),
47    };
48    let pid: i32 = pid_str
49        .trim()
50        .parse()
51        .with_context(|| format!("malformed PID file: {pid_str:?}"))?;
52    #[cfg(unix)]
53    {
54        let r = unsafe { libc::kill(pid, libc::SIGTERM) };
55        if r != 0 {
56            let e = std::io::Error::last_os_error();
57            if e.raw_os_error() == Some(libc::ESRCH) {
58                let _ = std::fs::remove_file(&pid_file);
59                eprintln!("no daemon running (cleaned stale PID file for pid {pid})");
60                return Ok(());
61            }
62            return Err(anyhow::anyhow!("kill({pid}, SIGTERM) failed: {e}"));
63        }
64    }
65    #[cfg(windows)]
66    {
67        use windows::Win32::Foundation::CloseHandle;
68        use windows::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
69        unsafe {
70            let h = OpenProcess(PROCESS_TERMINATE, false, u32::try_from(pid).unwrap_or(0))
71                .map_err(|e| anyhow::anyhow!("OpenProcess({pid}): {e}"))?;
72            let r = TerminateProcess(h, 0);
73            let _ = CloseHandle(h);
74            r.map_err(|e| anyhow::anyhow!("TerminateProcess({pid}): {e}"))?;
75        }
76    }
77    let socket = paths.socket();
78    for _ in 0..50 {
79        if !socket.exists() {
80            eprintln!("daemon stopped (pid {pid})");
81            return Ok(());
82        }
83        std::thread::sleep(std::time::Duration::from_millis(100));
84    }
85    Err(anyhow::anyhow!(
86        "daemon (pid {pid}) did not exit within 5s; socket still present at {}",
87        socket.display()
88    ))
89}
90
91/// Future that resolves on SIGTERM (Unix) or never (other platforms).
92/// Lets the server loop treat SIGTERM equivalently to ctrl-c.
93#[cfg(unix)]
94pub async fn wait_terminate() {
95    if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
96        s.recv().await;
97    } else {
98        std::future::pending::<()>().await;
99    }
100}
101
102#[cfg(not(unix))]
103pub async fn wait_terminate() {
104    std::future::pending::<()>().await;
105}
106
107// =============================================================================
108// macOS: NSApp on main, tokio on worker, real WKWebView backend.
109// =============================================================================
110
111#[cfg(target_os = "macos")]
112#[allow(clippy::too_many_lines)]
113pub fn run(args: &ServeArgs) -> Result<()> {
114    use objc2::MainThreadMarker;
115    use objc2_app_kit::NSApplication;
116    use objc2_foundation::{NSDate, NSDefaultRunLoopMode, NSRunLoop};
117    use vs_engine_webkit::{backend::webkit::WkBackend, Engine, EngineRuntime};
118
119    if args.stop {
120        return run_stop(&args.paths);
121    }
122
123    init_tracing();
124    install_panic_hook();
125    install_seh_handler();
126    args.paths.ensure_root().context("ensure ~/.vibesurfer")?;
127
128    let mtm = MainThreadMarker::new()
129        .ok_or_else(|| anyhow::anyhow!("vs serve must be invoked from the OS main thread"))?;
130    // Initialize NSApp; required for WKWebView even though we don't run
131    // the AppKit event loop directly.
132    let _app = NSApplication::sharedApplication(mtm);
133
134    let store = vs_store::Store::open(args.paths.db()).context("open state.db")?;
135    let captures_dir = args.paths.captures();
136    let skills_dir = args.paths.root.join("skills");
137
138    // Engine lives on this thread (the Cocoa main thread). Construct
139    // the WkBackend here and hand it to `EngineRuntime::dispatcher`,
140    // which gives us back a runtime handle (for the daemon) and a
141    // dispatcher we drive in this thread's run loop.
142    let backend = WkBackend::new(mtm).with_capture_dir(captures_dir.clone());
143    let engine_box: Box<dyn Engine> = Box::new(backend);
144    let (engine_runtime, mut dispatcher) = EngineRuntime::dispatcher(engine_box);
145    let engine_runtime = Arc::new(engine_runtime);
146
147    let mut daemon = Daemon::new(store, engine_runtime.clone())
148        .with_captures_dir(captures_dir)
149        .with_skills_dir(skills_dir);
150
151    if let Ok(k) = vs_store::MasterKey::resolve(args.paths.key_file()) {
152        daemon = daemon.with_master_key(k);
153    } else {
154        tracing::warn!(
155            "no master key (keyring entry missing and {} not present); vs_auth save|load will fail",
156            args.paths.key_file().display()
157        );
158    }
159
160    let socket = args.paths.socket();
161    let pid_path = args.paths.pid_file();
162
163    // Spawn the tokio runtime on a worker. It owns the daemon and the
164    // socket server; ctrl-c on the worker triggers a graceful shutdown
165    // by closing `shutdown_rx` and dropping the runtime, which closes
166    // our engine channel and pops us out of the run-loop below.
167    let server_thread = std::thread::Builder::new()
168        .name("vs-daemon-tokio".into())
169        .spawn(move || -> Result<()> {
170            let rt = tokio::runtime::Builder::new_multi_thread()
171                .worker_threads(4)
172                .enable_all()
173                .build()
174                .context("build tokio runtime")?;
175            if let Err(e) = std::fs::write(&pid_path, std::process::id().to_string()) {
176                tracing::warn!(?pid_path, error = %e, "write pid file");
177            }
178            rt.block_on(async move {
179                let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
180                let mut server =
181                    tokio::spawn(async move { server::serve(daemon, socket, shutdown_rx).await });
182                tokio::select! {
183                    _ = tokio::signal::ctrl_c() => {
184                        tracing::info!("ctrl-c received, shutting down");
185                        let _ = shutdown_tx.send(());
186                        if let Ok(Err(e)) = server.await {
187                            tracing::error!(error = %e, "server task ended with error");
188                        }
189                    }
190                    () = wait_terminate() => {
191                        tracing::info!("SIGTERM received, shutting down");
192                        let _ = shutdown_tx.send(());
193                        if let Ok(Err(e)) = server.await {
194                            tracing::error!(error = %e, "server task ended with error");
195                        }
196                    }
197                    res = &mut server => {
198                        // server::serve returned without an external
199                        // signal — typically a bind failure. Surface it
200                        // before the run loop exits.
201                        match res {
202                            Ok(Err(e)) => tracing::error!(
203                                error = %e,
204                                "server task failed before shutdown signal"
205                            ),
206                            Err(e) => tracing::error!(
207                                error = %e,
208                                "server task panicked"
209                            ),
210                            Ok(Ok(())) => {}
211                        }
212                    }
213                }
214            });
215            let _ = std::fs::remove_file(&pid_path);
216            // Dropping `rt` and the moved `engine_runtime` (held by the
217            // daemon) closes the engine channel, which signals the main
218            // loop to exit.
219            drop(rt);
220            Ok(())
221        })
222        .context("spawn vs-daemon-tokio thread")?;
223
224    // Main run-loop: drain engine jobs, then pump NSRunLoop briefly.
225    // Exit when the channel closes (the worker dropped the daemon).
226    let runloop = NSRunLoop::currentRunLoop();
227    'main: loop {
228        // Drain all queued jobs.
229        loop {
230            match dispatcher.tick() {
231                Ok(true) => {}
232                Ok(false) => break,
233                Err(()) => break 'main,
234            }
235        }
236        // Pump the runloop briefly so WKWebView delegates / JS
237        // completion handlers fire on this thread.
238        let slice = NSDate::dateWithTimeIntervalSinceNow(0.05);
239        unsafe { runloop.runMode_beforeDate(NSDefaultRunLoopMode, &slice) };
240    }
241
242    let _ = server_thread.join();
243    drop(engine_runtime); // explicit, even though it's already dead
244    Ok(())
245}
246
247// =============================================================================
248// Linux: GTK on main, tokio on worker, real WebKitGTK 6 backend.
249// =============================================================================
250
251#[cfg(target_os = "linux")]
252#[allow(clippy::too_many_lines)]
253pub fn run(args: &ServeArgs) -> Result<()> {
254    use vs_engine_webkit::{backend::wpe::WpeBackend, Engine, EngineRuntime};
255
256    init_tracing();
257    install_panic_hook();
258    install_seh_handler();
259    args.paths.ensure_root().context("ensure ~/.vibesurfer")?;
260    if args.stop {
261        return run_stop(&args.paths);
262    }
263
264    // GTK init must happen on the OS main thread, before any WebView.
265    gtk4::init().context("gtk4 init")?;
266
267    let store = vs_store::Store::open(args.paths.db()).context("open state.db")?;
268    let captures_dir = args.paths.captures();
269    let skills_dir = args.paths.root.join("skills");
270
271    let backend = WpeBackend::new().with_capture_dir(captures_dir.clone());
272    let engine_box: Box<dyn Engine> = Box::new(backend);
273    let (engine_runtime, mut dispatcher) = EngineRuntime::dispatcher(engine_box);
274    let engine_runtime = Arc::new(engine_runtime);
275
276    let mut daemon = Daemon::new(store, engine_runtime.clone())
277        .with_captures_dir(captures_dir)
278        .with_skills_dir(skills_dir);
279
280    if let Ok(k) = vs_store::MasterKey::resolve(args.paths.key_file()) {
281        daemon = daemon.with_master_key(k);
282    } else {
283        tracing::warn!(
284            "no master key (keyring entry missing and {} not present); vs_auth save|load will fail",
285            args.paths.key_file().display()
286        );
287    }
288
289    let socket = args.paths.socket();
290    let pid_path = args.paths.pid_file();
291
292    let server_thread = std::thread::Builder::new()
293        .name("vs-daemon-tokio".into())
294        .spawn(move || -> Result<()> {
295            let rt = tokio::runtime::Builder::new_multi_thread()
296                .worker_threads(4)
297                .enable_all()
298                .build()
299                .context("build tokio runtime")?;
300            if let Err(e) = std::fs::write(&pid_path, std::process::id().to_string()) {
301                tracing::warn!(?pid_path, error = %e, "write pid file");
302            }
303            rt.block_on(async move {
304                let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
305                let mut server =
306                    tokio::spawn(async move { server::serve(daemon, socket, shutdown_rx).await });
307                tokio::select! {
308                    _ = tokio::signal::ctrl_c() => {
309                        tracing::info!("ctrl-c received, shutting down");
310                        let _ = shutdown_tx.send(());
311                        if let Ok(Err(e)) = server.await {
312                            tracing::error!(error = %e, "server task ended with error");
313                        }
314                    }
315                    () = wait_terminate() => {
316                        tracing::info!("SIGTERM received, shutting down");
317                        let _ = shutdown_tx.send(());
318                        if let Ok(Err(e)) = server.await {
319                            tracing::error!(error = %e, "server task ended with error");
320                        }
321                    }
322                    res = &mut server => {
323                        match res {
324                            Ok(Err(e)) => tracing::error!(
325                                error = %e,
326                                "server task failed before shutdown signal"
327                            ),
328                            Err(e) => tracing::error!(
329                                error = %e,
330                                "server task panicked"
331                            ),
332                            Ok(Ok(())) => {}
333                        }
334                    }
335                }
336            });
337            let _ = std::fs::remove_file(&pid_path);
338            drop(rt);
339            Ok(())
340        })
341        .context("spawn vs-daemon-tokio thread")?;
342
343    // Pump the GLib main context on the main thread, draining engine
344    // jobs between iterations. Exit when the channel closes.
345    let main_ctx = glib::MainContext::default();
346    'main: loop {
347        loop {
348            match dispatcher.tick() {
349                Ok(true) => {}
350                Ok(false) => break,
351                Err(()) => break 'main,
352            }
353        }
354        // Iterate non-blocking — if the GLib loop has nothing to do,
355        // sleep briefly so we don't burn CPU.
356        if !main_ctx.iteration(false) {
357            std::thread::sleep(std::time::Duration::from_millis(10));
358        }
359    }
360
361    let _ = server_thread.join();
362    drop(engine_runtime);
363    Ok(())
364}
365
366// =============================================================================
367// Windows: WebView2 + Win32 message pump on main, tokio on worker.
368// =============================================================================
369
370#[cfg(target_os = "windows")]
371#[allow(clippy::too_many_lines)]
372pub fn run(args: &ServeArgs) -> Result<()> {
373    use vs_engine_webkit::{backend::webview2::Webview2Backend, Engine, EngineRuntime};
374    use windows::Win32::System::Com::{CoInitializeEx, COINIT_APARTMENTTHREADED};
375    use windows::Win32::UI::WindowsAndMessaging::{
376        DispatchMessageW, PeekMessageW, TranslateMessage, MSG, PM_REMOVE,
377    };
378
379    init_tracing();
380    install_panic_hook();
381    install_seh_handler();
382    args.paths.ensure_root().context("ensure ~/.vibesurfer")?;
383    if args.stop {
384        return run_stop(&args.paths);
385    }
386
387    // SAFETY: required first call on this thread before any
388    // WebView2 COM API. RPC_E_CHANGED_MODE on second call is fine.
389    unsafe {
390        let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
391    }
392
393    let store = vs_store::Store::open(args.paths.db()).context("open state.db")?;
394    let captures_dir = args.paths.captures();
395    let skills_dir = args.paths.root.join("skills");
396
397    let backend = Webview2Backend::new().with_capture_dir(captures_dir.clone());
398    let engine_box: Box<dyn Engine> = Box::new(backend);
399    let (engine_runtime, mut dispatcher) = EngineRuntime::dispatcher(engine_box);
400    let engine_runtime = Arc::new(engine_runtime);
401
402    let mut daemon = Daemon::new(store, engine_runtime.clone())
403        .with_captures_dir(captures_dir)
404        .with_skills_dir(skills_dir);
405
406    if let Ok(k) = vs_store::MasterKey::resolve(args.paths.key_file()) {
407        daemon = daemon.with_master_key(k);
408    } else {
409        tracing::warn!(
410            "no master key (keyring entry missing and {} not present); vs_auth save|load will fail",
411            args.paths.key_file().display()
412        );
413    }
414
415    let socket = args.paths.socket();
416    let pid_path = args.paths.pid_file();
417
418    let server_thread = std::thread::Builder::new()
419        .name("vs-daemon-tokio".into())
420        .spawn(move || -> Result<()> {
421            let rt = tokio::runtime::Builder::new_multi_thread()
422                .worker_threads(4)
423                .enable_all()
424                .build()
425                .context("build tokio runtime")?;
426            if let Err(e) = std::fs::write(&pid_path, std::process::id().to_string()) {
427                tracing::warn!(?pid_path, error = %e, "write pid file");
428            }
429            rt.block_on(async move {
430                let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
431                let mut server =
432                    tokio::spawn(async move { server::serve(daemon, socket, shutdown_rx).await });
433                tokio::select! {
434                    _ = tokio::signal::ctrl_c() => {
435                        tracing::info!("ctrl-c received, shutting down");
436                        let _ = shutdown_tx.send(());
437                        if let Ok(Err(e)) = server.await {
438                            tracing::error!(error = %e, "server task ended with error");
439                        }
440                    }
441                    () = wait_terminate() => {
442                        tracing::info!("SIGTERM received, shutting down");
443                        let _ = shutdown_tx.send(());
444                        if let Ok(Err(e)) = server.await {
445                            tracing::error!(error = %e, "server task ended with error");
446                        }
447                    }
448                    res = &mut server => {
449                        match res {
450                            Ok(Err(e)) => tracing::error!(
451                                error = %e,
452                                "server task failed before shutdown signal"
453                            ),
454                            Err(e) => tracing::error!(
455                                error = %e,
456                                "server task panicked"
457                            ),
458                            Ok(Ok(())) => {}
459                        }
460                    }
461                }
462            });
463            let _ = std::fs::remove_file(&pid_path);
464            drop(rt);
465            Ok(())
466        })
467        .context("spawn vs-daemon-tokio thread")?;
468
469    // Pump Win32 messages on the main thread, draining engine jobs
470    // between iterations. Exit when the channel closes.
471    let mut shutdown = false;
472    while !shutdown {
473        loop {
474            match dispatcher.tick() {
475                Ok(true) => {}
476                Ok(false) => break,
477                Err(()) => {
478                    shutdown = true;
479                    break;
480                }
481            }
482        }
483        // Non-blocking PeekMessage. If a message exists, dispatch
484        // (WebView2 callback completions arrive this way).
485        let mut msg = MSG::default();
486        unsafe {
487            while PeekMessageW(&raw mut msg, None, 0, 0, PM_REMOVE).as_bool() {
488                let _ = TranslateMessage(&raw const msg);
489                DispatchMessageW(&raw const msg);
490            }
491        }
492        std::thread::sleep(std::time::Duration::from_millis(10));
493    }
494
495    let _ = server_thread.join();
496    drop(engine_runtime);
497    Ok(())
498}
499
500fn init_tracing() {
501    if tracing::dispatcher::has_been_set() {
502        return;
503    }
504    let _ = tracing_subscriber::fmt()
505        .with_env_filter(
506            tracing_subscriber::EnvFilter::try_from_default_env()
507                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("vs_daemon=info,info")),
508        )
509        .with_writer(std::io::stderr)
510        .try_init();
511}
512
513/// Install a panic hook that logs the panic via tracing::error
514/// before the default hook prints to stderr. The default hook
515/// already writes to stderr, but on Windows a panic on a non-main
516/// thread can sometimes terminate the process before stderr is
517/// flushed; routing through tracing first guarantees the panic
518/// reaches the daemon log file the test harness captures.
519fn install_panic_hook() {
520    let prev = std::panic::take_hook();
521    std::panic::set_hook(Box::new(move |info| {
522        let location = info
523            .location()
524            .map_or_else(|| "<unknown>".to_string(), ToString::to_string);
525        let msg = info
526            .payload()
527            .downcast_ref::<&str>()
528            .copied()
529            .or_else(|| info.payload().downcast_ref::<String>().map(String::as_str))
530            .unwrap_or("<no message>");
531        tracing::error!(at = %location, "PANIC: {msg}");
532        prev(info);
533    }));
534}
535
536/// Install a Win32 vectored exception handler that logs structured
537/// exceptions (access violation, stack overflow, etc.) before the
538/// process dies. Rust's panic machinery does not catch SEH on
539/// Windows by default — when a COM call inside `Webview2Backend`
540/// dereferences bad memory, the daemon vanishes with no message,
541/// no panic hook firing, no trace.
542///
543/// This hook writes a single line to stderr containing the
544/// exception code + faulting address, then returns
545/// `EXCEPTION_CONTINUE_SEARCH` so the OS's default handler runs
546/// (process death). Stderr is captured to the daemon log by the
547/// test harness, so the line lands in CI output.
548///
549/// Direct stderr write — not tracing — because the allocator may
550/// be in a bad state during an SEH; tracing's formatting could
551/// deadlock. `writeln!` to a locked stderr handle is the cheapest
552/// thing that can work here.
553#[cfg(target_os = "windows")]
554fn install_seh_handler() {
555    use std::io::Write;
556    use windows::Win32::System::Diagnostics::Debug::{
557        AddVectoredExceptionHandler, EXCEPTION_POINTERS,
558    };
559
560    unsafe extern "system" fn handler(info: *mut EXCEPTION_POINTERS) -> i32 {
561        if info.is_null() {
562            return 0; // EXCEPTION_CONTINUE_SEARCH
563        }
564        let info = unsafe { &*info };
565        if info.ExceptionRecord.is_null() {
566            return 0;
567        }
568        let rec = unsafe { &*info.ExceptionRecord };
569        // Filter to fatal-class exceptions only. Status codes in the
570        // 0xC0... range are NTSTATUS errors; lower codes are e.g.
571        // C++ EH (0xE06D7363), DLL not found, debug breakpoints —
572        // not interesting and not always fatal.
573        // NTSTATUS is i32 internally — bit-reinterpret to u32 for
574        // hex display + range check. `as u32` would clippy-deny on
575        // `cast_sign_loss`; the to_ne_bytes round-trip is bit-exact.
576        let code = u32::from_ne_bytes(rec.ExceptionCode.0.to_ne_bytes());
577        if code & 0xF000_0000 != 0xC000_0000 {
578            return 0;
579        }
580        let mut err = std::io::stderr().lock();
581        let _ = writeln!(
582            err,
583            "VIBESURFER_SEH code=0x{:08x} ip={:p} flags=0x{:x} params={}",
584            code, rec.ExceptionAddress, rec.ExceptionFlags, rec.NumberParameters,
585        );
586        // For STATUS_ACCESS_VIOLATION, ExceptionInformation carries
587        // [access_kind, faulting_va] — log both. access_kind: 0=read,
588        // 1=write, 8=DEP/execute. faulting_va is the address that was
589        // being read / written / executed at the time of the fault.
590        if code == 0xC000_0005 && rec.NumberParameters >= 2 {
591            let kind = rec.ExceptionInformation[0];
592            let va = rec.ExceptionInformation[1];
593            let kind_str = match kind {
594                0 => "read",
595                1 => "write",
596                8 => "execute",
597                _ => "?",
598            };
599            let _ = writeln!(
600                err,
601                "VIBESURFER_SEH access={kind_str} (kind={kind}) faulting_va=0x{va:x}",
602            );
603        }
604        // Also dump RIP / RSP / a few callee-saved registers from the
605        // ContextRecord. RIP confirms the IP from ExceptionAddress;
606        // RSP + return-address-on-stack often points at the caller
607        // even when ExceptionAddress is 0x0.
608        if !info.ContextRecord.is_null() {
609            let ctx = unsafe { &*info.ContextRecord };
610            #[cfg(target_arch = "x86_64")]
611            {
612                let _ = writeln!(
613                    err,
614                    "VIBESURFER_SEH rip=0x{:x} rsp=0x{:x} rbp=0x{:x} rcx=0x{:x} rdx=0x{:x}",
615                    ctx.Rip, ctx.Rsp, ctx.Rbp, ctx.Rcx, ctx.Rdx,
616                );
617            }
618            #[cfg(target_arch = "aarch64")]
619            {
620                let _ = writeln!(
621                    err,
622                    "VIBESURFER_SEH pc=0x{:x} sp=0x{:x} fp=0x{:x}",
623                    ctx.Pc, ctx.Sp, ctx.Fp,
624                );
625            }
626        }
627        let _ = err.flush();
628        0
629    }
630
631    unsafe {
632        AddVectoredExceptionHandler(1 /* CALL_FIRST */, Some(handler));
633    }
634}
635
636/// No-op on non-Windows. The Unix kernels we target deliver crashes
637/// as signals (SIGSEGV etc.); Rust's panic + signal handling already
638/// covers those.
639#[cfg(not(target_os = "windows"))]
640fn install_seh_handler() {}