Skip to main content

dre_protocol/
host.rs

1//! Core's side of the protocol: spawn a plugin, negotiate a version, send requests.
2//!
3//! Frames are read on a background thread so core can time out a silent plugin during the
4//! handshake and never blocks forever on a plugin that has died. stderr is drained on another
5//! thread, forwarded to a log sink and kept (last lines) for error messages. A plugin's `log` and
6//! `progress` messages (protocol 1) go to the same sink.
7
8use std::collections::VecDeque;
9use std::io::{BufRead, BufReader, BufWriter, Read, Write};
10use std::path::{Path, PathBuf};
11use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio};
12use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
13use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
14use std::sync::{Arc, Mutex, OnceLock};
15use std::time::Duration;
16
17use arrow::array::RecordBatch;
18use arrow::datatypes::SchemaRef;
19use serde_json::{Map, Value};
20
21use crate::frame::{self, Frame, FrameError};
22use crate::msg::{ConnectionField, DeliveryFile, Envelope, LogLevel, Request, Response, ResultSetMeta};
23use crate::options::OptionField;
24use crate::{
25    CORE_MIN_VERSION, Kind, MAX_VERSION, PluginId, parse_executable_name, parse_package_executable_name,
26};
27
28/// Receives each line a plugin logs: `(plugin, line)`. A `log` message arrives prefixed by its
29/// level (`info: `, `warning: ` for warn and error, `debug: ` for debug and trace), as does
30/// `progress` (`info: `); a stderr line arrives as written.
31pub type LogSink = Arc<dyn Fn(&str, &str) + Send + Sync>;
32
33/// A log sink that prefixes each plugin line with the plugin's file name, on core's stderr.
34pub fn stderr_log() -> LogSink {
35    Arc::new(|plugin, line| eprintln!("[{plugin}] {line}"))
36}
37
38/// Waits for a plugin's stderr to be logged. Lines reach the [`LogSink`] from a background thread,
39/// so a line the plugin wrote before answering a request can be logged after core has the answer.
40/// A host that logs a request's lines differently (hiding them while the request carries a secret)
41/// syncs before it changes how it logs.
42#[derive(Clone)]
43pub struct StderrSync {
44    progress: Arc<StderrProgress>,
45    pipe: RawPipe,
46}
47
48impl StderrSync {
49    /// Wait up to `timeout` until every complete line the plugin has written to stderr so far has
50    /// been logged. False when the plugin kept writing (or the pipe can't be inspected) until then.
51    pub fn wait(&self, timeout: Duration) -> bool {
52        let deadline = std::time::Instant::now() + timeout;
53        loop {
54            let p = &self.progress;
55            if p.done.load(Ordering::SeqCst) {
56                return true;
57            }
58            // Blocked on an empty pipe, and still blocked in the same read after looking: every
59            // line read before that read has been logged, and nothing more is waiting.
60            let reads = p.reads.load(Ordering::SeqCst);
61            if p.reading.load(Ordering::SeqCst)
62                && pending(self.pipe) == Some(0)
63                && p.reading.load(Ordering::SeqCst)
64                && p.reads.load(Ordering::SeqCst) == reads
65            {
66                return true;
67            }
68            if std::time::Instant::now() >= deadline {
69                return false;
70            }
71            std::thread::sleep(Duration::from_millis(1));
72        }
73    }
74}
75
76/// What the stderr forwarder is doing, for [`StderrSync`].
77#[derive(Default)]
78struct StderrProgress {
79    /// In a read of the pipe: every complete line read before it has been logged.
80    reading: AtomicBool,
81    /// Reads that returned.
82    reads: AtomicU64,
83    /// At end of stream: everything has been logged.
84    done: AtomicBool,
85}
86
87/// The pipe, read through [`StderrProgress`] so [`StderrSync`] knows where the forwarder is.
88struct TrackedPipe<R> {
89    pipe: R,
90    progress: Arc<StderrProgress>,
91}
92
93impl<R: Read> Read for TrackedPipe<R> {
94    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
95        self.progress.reading.store(true, Ordering::SeqCst);
96        let n = self.pipe.read(buf);
97        self.progress.reading.store(false, Ordering::SeqCst);
98        self.progress.reads.fetch_add(1, Ordering::SeqCst);
99        n
100    }
101}
102
103#[cfg(unix)]
104type RawPipe = std::os::fd::RawFd;
105#[cfg(windows)]
106type RawPipe = usize;
107#[cfg(not(any(unix, windows)))]
108type RawPipe = ();
109
110#[cfg(unix)]
111fn raw_pipe(pipe: &std::process::ChildStderr) -> RawPipe {
112    std::os::fd::AsRawFd::as_raw_fd(pipe)
113}
114#[cfg(windows)]
115fn raw_pipe(pipe: &std::process::ChildStderr) -> RawPipe {
116    std::os::windows::io::AsRawHandle::as_raw_handle(pipe) as usize
117}
118#[cfg(not(any(unix, windows)))]
119fn raw_pipe(_: &std::process::ChildStderr) -> RawPipe {}
120
121/// Bytes waiting in the pipe, unread.
122#[cfg(unix)]
123fn pending(fd: RawPipe) -> Option<usize> {
124    let mut n: libc::c_int = 0;
125    // SAFETY: FIONREAD writes one c_int; the forwarder keeps the descriptor open until it is done.
126    let r = unsafe { libc::ioctl(fd, libc::FIONREAD, &mut n) };
127    (r == 0).then_some(n.max(0) as usize)
128}
129#[cfg(windows)]
130fn pending(handle: RawPipe) -> Option<usize> {
131    let mut n: u32 = 0;
132    // SAFETY: peeks without reading; the forwarder keeps the handle open until it is done.
133    let ok = unsafe {
134        windows_sys::Win32::System::Pipes::PeekNamedPipe(
135            handle as windows_sys::Win32::Foundation::HANDLE,
136            std::ptr::null_mut(),
137            0,
138            std::ptr::null_mut(),
139            &mut n,
140            std::ptr::null_mut(),
141        )
142    };
143    (ok != 0).then_some(n as usize)
144}
145#[cfg(not(any(unix, windows)))]
146fn pending(_: RawPipe) -> Option<usize> {
147    None
148}
149
150pub const DEFAULT_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30);
151
152/// Set to `1` in a plugin's environment when a person is at core's terminal, else `0`.
153pub const INTERACTIVE_ENV: &str = "DRE_INTERACTIVE";
154const STDERR_TAIL: usize = 20;
155
156#[derive(Debug, Clone, PartialEq)]
157pub struct PluginInfo {
158    pub protocol_version: u32,
159    pub kind: Kind,
160    pub name: String,
161    pub version: String,
162    pub capabilities: Vec<String>,
163    /// Every plugin the executable serves (at least the one it's serving now).
164    pub provides: Vec<PluginId>,
165}
166
167/// A plugin's `describe` reply.
168#[derive(Debug, Clone, PartialEq, Default)]
169pub struct Description {
170    pub connection_fields: Vec<ConnectionField>,
171    pub option_fields: Vec<OptionField>,
172    /// A source's identifier quote character.
173    pub identifier_quote: Option<String>,
174    /// How a source wants string literals spelled (`chr` when absent; `backslash`).
175    pub string_literal: Option<String>,
176    /// A message destination's length limit, in characters.
177    pub message_limit: Option<u64>,
178}
179
180#[derive(Debug)]
181pub enum HostError {
182    Spawn {
183        plugin: String,
184        error: std::io::Error,
185    },
186    Crashed {
187        plugin: String,
188        status: Option<ExitStatus>,
189        stderr: Vec<String>,
190    },
191    Malformed {
192        plugin: String,
193        message: String,
194    },
195    Incompatible {
196        plugin: String,
197        core: (u32, u32),
198        plugin_range: (u32, u32),
199    },
200    Timeout {
201        plugin: String,
202        waiting_for: &'static str,
203    },
204    Unexpected {
205        plugin: String,
206        expected: &'static str,
207        got: String,
208    },
209    /// The plugin reported an error for a request, with its kind and code when it gave them.
210    Plugin {
211        plugin: String,
212        message: String,
213        kind: Option<String>,
214        code: Option<String>,
215    },
216    Arrow {
217        plugin: String,
218        message: String,
219    },
220}
221
222impl std::fmt::Display for HostError {
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        match self {
225            HostError::Spawn { plugin, error } => write!(f, "can't start plugin `{plugin}`: {error}"),
226            HostError::Crashed {
227                plugin,
228                status,
229                stderr,
230            } => {
231                match status {
232                    Some(s) => write!(f, "plugin `{plugin}` exited unexpectedly ({s})")?,
233                    None => write!(f, "plugin `{plugin}` closed its output unexpectedly")?,
234                }
235                if !stderr.is_empty() {
236                    write!(f, "; its last log lines:\n  {}", stderr.join("\n  "))?;
237                }
238                Ok(())
239            }
240            HostError::Malformed { plugin, message } => {
241                write!(f, "plugin `{plugin}` broke the protocol: {message}")
242            }
243            HostError::Incompatible {
244                plugin,
245                core,
246                plugin_range,
247            } => {
248                write!(
249                    f,
250                    "plugin `{plugin}` speaks protocol versions {}..={}, but this DRE core speaks {}..={}; ",
251                    plugin_range.0, plugin_range.1, core.0, core.1,
252                )?;
253                if plugin_range.1 >= core.0 {
254                    return f.write_str("update DRE");
255                }
256                let package = parse_package_executable_name(plugin)
257                    .or_else(|| parse_executable_name(plugin).map(|(_, n)| n));
258                match package {
259                    Some(p) => write!(f, "update the plugin with `dre plugin update {p}`"),
260                    None => f.write_str("update the plugin"),
261                }
262            }
263            HostError::Timeout { plugin, waiting_for } => {
264                write!(
265                    f,
266                    "plugin `{plugin}` didn't answer in time (waiting for {waiting_for})"
267                )
268            }
269            HostError::Unexpected {
270                plugin,
271                expected,
272                got,
273            } => {
274                write!(f, "plugin `{plugin}` sent {got} where {expected} was expected")
275            }
276            HostError::Plugin { message, .. } => f.write_str(message),
277            HostError::Arrow { plugin, message } => {
278                write!(f, "plugin `{plugin}` sent invalid Arrow data: {message}")
279            }
280        }
281    }
282}
283
284impl std::error::Error for HostError {}
285
286impl HostError {
287    fn plugin(plugin: &str, message: impl Into<String>) -> HostError {
288        HostError::Plugin {
289            plugin: plugin.to_string(),
290            message: message.into(),
291            kind: None,
292            code: None,
293        }
294    }
295}
296
297pub type Result<T> = std::result::Result<T, HostError>;
298
299/// Asks a running request to stop, from any thread (see [`PluginProcess::canceller`]), and
300/// can kill the plugin.
301#[derive(Clone)]
302pub struct Canceller {
303    stdin: Arc<Mutex<Option<BufWriter<ChildStdin>>>>,
304    current: Arc<AtomicU64>,
305    child: Arc<Mutex<Child>>,
306}
307
308impl Canceller {
309    /// Send `cancel` for the request running now, if any. Returns whether one was sent.
310    pub fn cancel(&self) -> bool {
311        let id = self.current.load(Ordering::SeqCst);
312        if id == 0 {
313            return false;
314        }
315        // A poisoned lock means a writer panicked mid-frame: nothing more can be sent.
316        self.stdin.lock().is_ok_and(|mut stdin| {
317            stdin
318                .as_mut()
319                .is_some_and(|w| frame::write_json(w, &Envelope::new(Some(id), Request::Cancel {})).is_ok())
320        })
321    }
322
323    /// Kill the plugin process.
324    pub fn kill(&self) {
325        if let Ok(mut c) = self.child.lock() {
326            let _ = c.kill();
327        }
328    }
329}
330
331/// Every plugin process this program has running, by a number of its own, so a cancelled run
332/// can reach all of them wherever they were started.
333static RUNNING: Mutex<Vec<(u64, Canceller)>> = Mutex::new(Vec::new());
334static NEXT_PROCESS: AtomicU64 = AtomicU64::new(1);
335
336/// Send `cancel` to every running plugin's current request (protocol 1). Returns how many had one.
337///
338/// # Panics
339///
340/// Panics if the list of running plugins is poisoned (a thread panicked while changing it).
341pub fn cancel_all() -> usize {
342    let all: Vec<Canceller> = RUNNING.lock().unwrap().iter().map(|(_, c)| c.clone()).collect();
343    all.iter().filter(|c| c.cancel()).count()
344}
345
346/// Kill every running plugin process.
347///
348/// # Panics
349///
350/// Panics if the list of running plugins is poisoned (a thread panicked while changing it).
351pub fn kill_all() {
352    let all: Vec<Canceller> = RUNNING.lock().unwrap().iter().map(|(_, c)| c.clone()).collect();
353    for c in all {
354        c.kill();
355    }
356}
357
358/// How many plugin processes are running.
359///
360/// # Panics
361///
362/// Panics if the list of running plugins is poisoned (a thread panicked while changing it).
363pub fn running() -> usize {
364    RUNNING.lock().unwrap().len()
365}
366/// A message from the plugin.
367pub enum Incoming {
368    Json(Response),
369    Arrow(Vec<u8>),
370}
371
372impl std::fmt::Debug for Incoming {
373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374        match self {
375            Incoming::Json(r) => write!(f, "Json({r:?})"),
376            Incoming::Arrow(b) => write!(f, "Arrow frame ({} bytes)", b.len()),
377        }
378    }
379}
380
381/// The outcome of a source `execute`.
382#[derive(Debug)]
383pub enum Execution {
384    NoResult { rows_affected: Option<u64> },
385    Result { schema: SchemaRef, rows: u64 },
386}
387
388pub struct PluginProcess {
389    label: String,
390    path: PathBuf,
391    /// Shared with [`Canceller`]s, which can kill it.
392    child: Arc<Mutex<Child>>,
393    /// This process's entry in [`RUNNING`].
394    registered: u64,
395    /// Shared with [`Canceller`]s; `None` once closed.
396    stdin: Arc<Mutex<Option<BufWriter<ChildStdin>>>>,
397    rx: Receiver<std::result::Result<Frame, FrameError>>,
398    log: LogSink,
399    /// The last request id given out.
400    last_id: u64,
401    /// The request running now (0: none).
402    current: Arc<AtomicU64>,
403    stderr: Arc<Mutex<VecDeque<String>>>,
404    stderr_sync: StderrSync,
405    /// Forwards stderr; joined on close/drop so no log line is lost when the plugin exits.
406    stderr_thread: Option<std::thread::JoinHandle<()>>,
407    info: Option<PluginInfo>,
408    /// A reply that arrived while core was still sending (a format failing mid-stream).
409    early: Option<std::result::Result<Frame, FrameError>>,
410    /// How many tries the last delivery took (1 without a retry).
411    last_attempts: u32,
412    /// The plugin asked for in the handshake. By default the one a `dre-<kind>-<name>` file name
413    /// names; none for a package executable, which then serves its first plugin.
414    serve: Option<PluginId>,
415}
416
417impl PluginProcess {
418    /// Spawn the plugin at `path` and complete the handshake.
419    pub fn start(path: &Path, log: LogSink) -> Result<PluginProcess> {
420        Self::start_in(path, log, None)
421    }
422
423    /// Like `start`, running the plugin in `cwd` (core uses the project directory).
424    pub fn start_in(path: &Path, log: LogSink, cwd: Option<&Path>) -> Result<PluginProcess> {
425        Self::start_for(path, None, log, cwd)
426    }
427
428    /// Like `start_in`, asking the executable for `plugin` (one of a package's plugins).
429    pub fn start_for(
430        path: &Path,
431        plugin: Option<&PluginId>,
432        log: LogSink,
433        cwd: Option<&Path>,
434    ) -> Result<PluginProcess> {
435        let timeout = std::env::var("DRE_PLUGIN_HANDSHAKE_TIMEOUT_MS")
436            .ok()
437            .and_then(|v| v.parse().ok())
438            .map(Duration::from_millis)
439            .unwrap_or(DEFAULT_HANDSHAKE_TIMEOUT);
440        let mut p = Self::spawn_in(path, log, &[], cwd)?;
441        if let Some(id) = plugin {
442            p.serve = Some(id.clone());
443        }
444        p.handshake((CORE_MIN_VERSION, MAX_VERSION), timeout)?;
445        Ok(p)
446    }
447
448    /// Spawn and handshake offering an explicit version range (the conformance suite uses this).
449    pub fn start_with(
450        path: &Path,
451        log: LogSink,
452        versions: (u32, u32),
453        timeout: Duration,
454    ) -> Result<PluginProcess> {
455        let mut p = Self::spawn(path, log)?;
456        p.handshake(versions, timeout)?;
457        Ok(p)
458    }
459
460    /// Spawn without a handshake (for tests that probe raw protocol behaviour).
461    pub fn spawn(path: &Path, log: LogSink) -> Result<PluginProcess> {
462        Self::spawn_env(path, log, &[])
463    }
464
465    /// Spawn with extra environment variables, without a handshake.
466    pub fn spawn_env(path: &Path, log: LogSink, env: &[(&str, &str)]) -> Result<PluginProcess> {
467        Self::spawn_in(path, log, env, None)
468    }
469
470    /// Spawn with extra environment variables and a working directory, without a handshake.
471    ///
472    /// # Panics
473    ///
474    /// Panics if the list of running plugins is poisoned (a thread panicked while changing it).
475    pub fn spawn_in(
476        path: &Path,
477        log: LogSink,
478        env: &[(&str, &str)],
479        cwd: Option<&Path>,
480    ) -> Result<PluginProcess> {
481        let label = path
482            .file_name()
483            .map(|f| f.to_string_lossy().to_string())
484            .unwrap_or_default();
485        let mut cmd = Command::new(path);
486        if let Some(dir) = cwd {
487            cmd.current_dir(dir);
488        }
489        // Plugins can't see the terminal (their stdio is piped), so tell them whether a person is
490        // there, e.g. to finish a browser sign-in. An existing DRE_INTERACTIVE is left alone.
491        if std::env::var_os(INTERACTIVE_ENV).is_none() {
492            use std::io::IsTerminal;
493            let there = std::io::stderr().is_terminal() && std::io::stdin().is_terminal();
494            cmd.env(INTERACTIVE_ENV, if there { "1" } else { "0" });
495        }
496        // Its own process group: Ctrl-C at the terminal reaches core, which asks the plugin to
497        // stop with `cancel`, instead of killing the plugin mid-request.
498        #[cfg(unix)]
499        {
500            use std::os::unix::process::CommandExt;
501            cmd.process_group(0);
502        }
503        // Files on a FUSE mount (a Databricks Volume as the target path) close before `execve`,
504        // or the plugin may never start (see `fuse_fds`).
505        #[cfg(target_os = "linux")]
506        crate::fuse_fds::close_in_child(&mut cmd);
507        #[cfg(windows)]
508        {
509            use std::os::windows::process::CommandExt;
510            const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
511            cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
512        }
513        let mut child = cmd
514            .envs(env.iter().copied())
515            .stdin(Stdio::piped())
516            .stdout(Stdio::piped())
517            .stderr(Stdio::piped())
518            .spawn()
519            .map_err(|error| HostError::Spawn {
520                plugin: label.clone(),
521                error,
522            })?;
523        let (Some(stdout), Some(stderr_pipe)) = (child.stdout.take(), child.stderr.take()) else {
524            let _ = child.kill();
525            return Err(HostError::Spawn {
526                plugin: label,
527                error: std::io::Error::other("the plugin's stdout or stderr isn't piped"),
528            });
529        };
530        let stdin = Arc::new(Mutex::new(child.stdin.take().map(BufWriter::new)));
531
532        let (tx, rx) = channel();
533        std::thread::spawn(move || {
534            let mut r = BufReader::new(stdout);
535            loop {
536                let f = frame::read_frame(&mut r);
537                let stop = f.is_err();
538                if tx.send(f).is_err() || stop {
539                    break;
540                }
541            }
542        });
543        let stderr = Arc::new(Mutex::new(VecDeque::new()));
544        let tail = stderr.clone();
545        let who = label.clone();
546        let sink = log.clone();
547        let stderr_sync = StderrSync {
548            progress: Arc::default(),
549            pipe: raw_pipe(&stderr_pipe),
550        };
551        let progress = stderr_sync.progress.clone();
552        let stderr_thread = std::thread::spawn(move || {
553            let mut reader = BufReader::new(TrackedPipe {
554                pipe: stderr_pipe,
555                progress: progress.clone(),
556            });
557            for line in reader.by_ref().lines() {
558                let Ok(line) = line else { break };
559                sink(&who, &line);
560                let mut t = tail.lock().unwrap();
561                if t.len() == STDERR_TAIL {
562                    t.pop_front();
563                }
564                t.push_back(line);
565            }
566            // Before the pipe closes, so a sync never inspects a closed descriptor.
567            progress.done.store(true, Ordering::SeqCst);
568            drop(reader);
569        });
570        let serve = parse_executable_name(&label).map(|(k, n)| PluginId::new(k, n));
571        let child = Arc::new(Mutex::new(child));
572        let current = Arc::new(AtomicU64::new(0));
573        let registered = NEXT_PROCESS.fetch_add(1, Ordering::SeqCst);
574        RUNNING.lock().unwrap().push((
575            registered,
576            Canceller {
577                stdin: stdin.clone(),
578                current: current.clone(),
579                child: child.clone(),
580            },
581        ));
582        Ok(PluginProcess {
583            label,
584            path: path.to_path_buf(),
585            child,
586            registered,
587            stdin,
588            rx,
589            log,
590            last_id: 0,
591            current,
592            stderr,
593            stderr_sync,
594            stderr_thread: Some(stderr_thread),
595            info: None,
596            early: None,
597            last_attempts: 1,
598            serve,
599        })
600    }
601
602    /// Name the plugin to ask for in the handshake (`None`: a package's first).
603    pub fn ask_for(&mut self, plugin: Option<PluginId>) {
604        self.serve = plugin;
605    }
606
607    /// Complete the handshake on a process from `spawn`/`spawn_env`.
608    pub fn handshake(&mut self, (min, max): (u32, u32), timeout: Duration) -> Result<()> {
609        self.send(&Request::Hello {
610            min_version: min,
611            max_version: max,
612            core_version: core_version(),
613            plugin: self.serve.clone(),
614        })?;
615        match self.recv(Some(timeout), "the hello reply")? {
616            Incoming::Json(Response::Hello {
617                protocol_version,
618                kind,
619                name,
620                version,
621                capabilities,
622                mut provides,
623            }) => {
624                if protocol_version < min || protocol_version > max {
625                    return Err(HostError::Incompatible {
626                        plugin: self.label.clone(),
627                        core: (min, max),
628                        plugin_range: (protocol_version, protocol_version),
629                    });
630                }
631                if let Some(want) = &self.serve
632                    && (want.kind != kind || want.name != name)
633                {
634                    return Err(HostError::plugin(
635                        &self.label,
636                        format!(
637                            "`{}` was asked for the {want} plugin but serves {kind}/{name}",
638                            self.label
639                        ),
640                    ));
641                }
642                if provides.is_empty() {
643                    provides.push(PluginId::new(kind, name.clone()));
644                }
645                self.info = Some(PluginInfo {
646                    protocol_version,
647                    kind,
648                    name,
649                    version,
650                    capabilities,
651                    provides,
652                });
653                Ok(())
654            }
655            Incoming::Json(Response::VersionMismatch {
656                min_version,
657                max_version,
658            }) => Err(HostError::Incompatible {
659                plugin: self.label.clone(),
660                core: (min, max),
661                plugin_range: (min_version, max_version),
662            }),
663            Incoming::Json(Response::Error { message, kind, code }) => Err(HostError::Plugin {
664                plugin: self.label.clone(),
665                message,
666                kind,
667                code,
668            }),
669            other => Err(self.unexpected("a hello reply", &other)),
670        }
671    }
672
673    pub fn label(&self) -> &str {
674        &self.label
675    }
676
677    pub fn path(&self) -> &Path {
678        &self.path
679    }
680
681    /// What the plugin said in its handshake.
682    ///
683    /// # Panics
684    ///
685    /// Panics if the process was spawned without a handshake ([`Self::spawn_env`],
686    /// [`Self::spawn_in`]).
687    pub fn info(&self) -> &PluginInfo {
688        self.info.as_ref().expect("handshake completed")
689    }
690
691    /// The process's exit status if it has exited (the lock is held only for the call).
692    fn try_wait(&self) -> std::io::Result<Option<ExitStatus>> {
693        self.child.lock().unwrap().try_wait()
694    }
695
696    /// A handle that can cancel the running request from another thread. Only a plugin on
697    /// protocol 1 or later understands `cancel`.
698    pub fn canceller(&self) -> Canceller {
699        Canceller {
700            stdin: self.stdin.clone(),
701            current: self.current.clone(),
702            child: self.child.clone(),
703        }
704    }
705
706    /// A handle that waits for the plugin's stderr to be logged; see [`StderrSync`].
707    pub fn stderr_sync(&self) -> StderrSync {
708        self.stderr_sync.clone()
709    }
710
711    /// The protocol version settled on in the handshake (0 before it).
712    fn version(&self) -> u32 {
713        self.info.as_ref().map_or(0, |i| i.protocol_version)
714    }
715
716    pub fn has(&self, capability: &str) -> bool {
717        self.info
718            .as_ref()
719            .is_some_and(|i| i.capabilities.iter().any(|c| c == capability))
720    }
721
722    fn unexpected(&self, expected: &'static str, got: &Incoming) -> HostError {
723        let got = match got {
724            Incoming::Json(r) => format!("{:?}", r)
725                .split([' ', '{'])
726                .next()
727                .unwrap_or("?")
728                .to_string(),
729            Incoming::Arrow(_) => "Arrow data".into(),
730        };
731        HostError::Unexpected {
732            plugin: self.label.clone(),
733            expected,
734            got: format!("`{got}`"),
735        }
736    }
737
738    fn crashed(&mut self) -> HostError {
739        // Give the process a moment to finish exiting so its status and last stderr are known.
740        let mut status = None;
741        for _ in 0..50 {
742            if let Ok(Some(s)) = self.try_wait() {
743                status = Some(s);
744                break;
745            }
746            std::thread::sleep(Duration::from_millis(20));
747        }
748        std::thread::sleep(Duration::from_millis(50));
749        let stderr = self.stderr.lock().unwrap().iter().cloned().collect();
750        HostError::Crashed {
751            plugin: self.label.clone(),
752            status,
753            stderr,
754        }
755    }
756
757    /// Send a request. From protocol 1 it carries an id: a new one, or for the messages that
758    /// continue a request (`result_set_end`, `finish`, `cancel`) the running request's.
759    pub fn send(&mut self, req: &Request) -> Result<()> {
760        let id = match req {
761            _ if self.version() == 0 => None,
762            Request::Hello { .. } => None,
763            Request::ResultSetEnd {} | Request::Finish {} | Request::Cancel {} => {
764                Some(self.current.load(Ordering::SeqCst)).filter(|id| *id != 0)
765            }
766            _ => {
767                self.last_id += 1;
768                self.current.store(self.last_id, Ordering::SeqCst);
769                Some(self.last_id)
770            }
771        };
772        // A poisoned lock means a writer panicked mid-frame: report a failed write.
773        let ok = self.stdin.lock().is_ok_and(|mut stdin| {
774            stdin
775                .as_mut()
776                .is_some_and(|w| frame::write_json(w, &Envelope::new(id, req)).is_ok())
777        });
778        if ok { Ok(()) } else { Err(self.write_failed()) }
779    }
780
781    pub fn send_batch(&mut self, batch: &RecordBatch) -> Result<()> {
782        // A plugin that fails part-way replies before reading the rest: stop sending and
783        // surface its error rather than streaming everything into a plugin that gave up.
784        if self.early.is_none()
785            && let Ok(f) = self.rx.try_recv()
786        {
787            self.early = Some(f);
788        }
789        if let Some(Ok(Frame::Json(v))) = &self.early
790            && let Ok(Response::Error { message, kind, code }) = serde_json::from_value::<Response>(v.clone())
791        {
792            return Err(HostError::Plugin {
793                plugin: self.label.clone(),
794                message,
795                kind,
796                code,
797            });
798        }
799        let ipc = frame::encode_batch(batch).map_err(|e| HostError::Arrow {
800            plugin: self.label.clone(),
801            message: e.to_string(),
802        })?;
803        let ok = self.stdin.lock().is_ok_and(|mut stdin| {
804            stdin
805                .as_mut()
806                .is_some_and(|w| frame::write_arrow(w, &ipc).is_ok())
807        });
808        if ok { Ok(()) } else { Err(self.write_failed()) }
809    }
810
811    /// Writing failed: the plugin has gone away. Prefer its own error message if it sent one.
812    fn write_failed(&mut self) -> HostError {
813        match self.rx.recv_timeout(Duration::from_secs(2)) {
814            Ok(Ok(Frame::Json(v))) => match serde_json::from_value::<Response>(v) {
815                Ok(Response::Error { message, kind, code }) => HostError::Plugin {
816                    plugin: self.label.clone(),
817                    message,
818                    kind,
819                    code,
820                },
821                _ => self.crashed(),
822            },
823            _ => self.crashed(),
824        }
825    }
826
827    /// Receive the next message. `timeout` of `None` waits as long as the plugin is alive. `log`
828    /// and `progress` messages go to the log sink on the way.
829    pub fn recv(&mut self, timeout: Option<Duration>, waiting_for: &'static str) -> Result<Incoming> {
830        loop {
831            match self.recv_any(timeout, waiting_for)? {
832                Incoming::Json(Response::Log {
833                    level,
834                    message,
835                    fields,
836                }) => {
837                    (self.log)(&self.label, &log_line(level, &message, &fields));
838                }
839                Incoming::Json(Response::Progress { message, done, total }) => {
840                    (self.log)(&self.label, &progress_line(message.as_deref(), done, total));
841                }
842                other => return Ok(other),
843            }
844        }
845    }
846
847    /// The next frame's JSON as sent, with nothing skipped or checked (conformance tests).
848    pub fn recv_raw(&mut self, timeout: Duration) -> Result<Value> {
849        match self.rx.recv_timeout(timeout) {
850            Ok(Ok(Frame::Json(v))) => Ok(v),
851            Ok(Ok(Frame::Arrow(_))) => Err(HostError::Malformed {
852                plugin: self.label.clone(),
853                message: "Arrow data where a JSON message was expected".into(),
854            }),
855            Ok(Err(FrameError::Malformed(m))) => Err(HostError::Malformed {
856                plugin: self.label.clone(),
857                message: m,
858            }),
859            Err(RecvTimeoutError::Timeout) => Err(HostError::Timeout {
860                plugin: self.label.clone(),
861                waiting_for: "a message",
862            }),
863            _ => Err(self.crashed()),
864        }
865    }
866
867    fn recv_any(&mut self, timeout: Option<Duration>, waiting_for: &'static str) -> Result<Incoming> {
868        let got = match timeout {
869            _ if self.early.is_some() => self.early.take(),
870            Some(t) => match self.rx.recv_timeout(t) {
871                Ok(f) => Some(f),
872                Err(RecvTimeoutError::Timeout) => {
873                    return Err(HostError::Timeout {
874                        plugin: self.label.clone(),
875                        waiting_for,
876                    });
877                }
878                Err(RecvTimeoutError::Disconnected) => None,
879            },
880            None => self.rx.recv().ok(),
881        };
882        match got {
883            Some(Ok(Frame::Json(v))) => match serde_json::from_value::<Envelope<Response>>(v.clone()) {
884                Ok(Envelope { id: Some(id), body })
885                    if !matches!(body, Response::Log { .. } | Response::Progress { .. }) =>
886                {
887                    let current = self.current.load(Ordering::SeqCst);
888                    if id != current {
889                        return Err(HostError::Malformed {
890                            plugin: self.label.clone(),
891                            message: format!(
892                                "a reply to request {id} arrived while request {current} was running"
893                            ),
894                        });
895                    }
896                    Ok(Incoming::Json(body))
897                }
898                Ok(e) => Ok(Incoming::Json(e.body)),
899                Err(e) => Err(HostError::Malformed {
900                    plugin: self.label.clone(),
901                    message: format!("unknown message {v}: {e}"),
902                }),
903            },
904            Some(Ok(Frame::Arrow(b))) => Ok(Incoming::Arrow(b)),
905            Some(Err(FrameError::Malformed(m))) => Err(HostError::Malformed {
906                plugin: self.label.clone(),
907                message: m,
908            }),
909            Some(Err(_)) | None => Err(self.crashed()),
910        }
911    }
912
913    /// Receive a JSON response, turning `error` into `HostError::Plugin`.
914    pub fn recv_json(&mut self, waiting_for: &'static str) -> Result<Response> {
915        match self.recv(None, waiting_for)? {
916            Incoming::Json(Response::Error { message, kind, code }) => Err(HostError::Plugin {
917                plugin: self.label.clone(),
918                message,
919                kind,
920                code,
921            }),
922            Incoming::Json(r) => Ok(r),
923            other => Err(self.unexpected(waiting_for, &other)),
924        }
925    }
926
927    /// Send a request and expect `ok`.
928    fn call_ok(&mut self, req: &Request, what: &'static str) -> Result<()> {
929        self.send(req)?;
930        match self.recv_json(what)? {
931            Response::Ok {} => Ok(()),
932            other => Err(self.unexpected(what, &Incoming::Json(other))),
933        }
934    }
935
936    pub fn describe(&mut self) -> Result<Vec<ConnectionField>> {
937        Ok(self.describe_all()?.0)
938    }
939
940    /// The connection fields and the options the plugin declares.
941    pub fn describe_all(&mut self) -> Result<(Vec<ConnectionField>, Vec<OptionField>)> {
942        let d = self.description()?;
943        Ok((d.connection_fields, d.option_fields))
944    }
945
946    /// The whole `describe` reply.
947    pub fn description(&mut self) -> Result<Description> {
948        self.send(&Request::Describe {})?;
949        match self.recv_json("a describe reply")? {
950            Response::Describe {
951                connection_fields,
952                option_fields,
953                identifier_quote,
954                string_literal,
955                message_limit,
956            } => Ok(Description {
957                connection_fields,
958                option_fields,
959                identifier_quote,
960                string_literal,
961                message_limit,
962            }),
963            other => Err(self.unexpected("a describe reply", &Incoming::Json(other))),
964        }
965    }
966
967    /// Check a profile entry's connection settings without connecting (protocol 1): the errors
968    /// and the warnings. `unresolved` keys (an unset `env_var()`) are sent as `null`.
969    pub fn validate_connection(
970        &mut self,
971        mut connection: Map<String, Value>,
972        unresolved: Vec<String>,
973    ) -> Result<(Vec<String>, Vec<String>)> {
974        for k in &unresolved {
975            connection.insert(k.clone(), Value::Null);
976        }
977        self.send(&Request::ValidateConnection {
978            connection,
979            unresolved,
980        })?;
981        match self.recv_json("a validated reply")? {
982            Response::Validated { errors, warnings } => Ok((errors, warnings)),
983            other => Err(self.unexpected("a validated reply", &Incoming::Json(other))),
984        }
985    }
986
987    /// Check a config block of options (needs `validate`); returns every problem found.
988    pub fn validate(&mut self, options: Map<String, Value>) -> Result<Vec<String>> {
989        Ok(self.validate_with(options, None)?.0)
990    }
991
992    /// Check a format's config block together with its `template` (as `write` would send it);
993    /// returns the errors, then the warnings.
994    pub fn validate_with(
995        &mut self,
996        options: Map<String, Value>,
997        template: Option<Value>,
998    ) -> Result<(Vec<String>, Vec<String>)> {
999        self.send(&Request::Validate { options, template })?;
1000        match self.recv_json("a validate reply")? {
1001            Response::Validated { errors, warnings } => Ok((errors, warnings)),
1002            other => Err(self.unexpected("a validate reply", &Incoming::Json(other))),
1003        }
1004    }
1005
1006    pub fn open(&mut self, connection: Map<String, Value>, read_only: bool) -> Result<()> {
1007        self.call_ok(
1008            &Request::Open {
1009                connection,
1010                read_only,
1011                sandbox: false,
1012            },
1013            "an open reply",
1014        )
1015    }
1016
1017    /// Open a read-only session that may not reach files, programs or the network beyond the
1018    /// database, where the plugin supports it (`dre query`).
1019    pub fn open_sandboxed(&mut self, connection: Map<String, Value>) -> Result<()> {
1020        self.call_ok(
1021            &Request::Open {
1022                connection,
1023                read_only: true,
1024                sandbox: true,
1025            },
1026            "an open reply",
1027        )
1028    }
1029
1030    pub fn check(&mut self, sql: &str) -> Result<()> {
1031        self.call_ok(&Request::Check { sql: sql.to_string() }, "a check reply")
1032    }
1033
1034    /// Run one statement, handing every batch to `on_batch` as it arrives.
1035    pub fn execute(
1036        &mut self,
1037        sql: &str,
1038        row_limit: Option<u64>,
1039        mut on_batch: impl FnMut(&SchemaRef, RecordBatch) -> std::result::Result<(), String>,
1040    ) -> Result<Execution> {
1041        self.send(&Request::Execute {
1042            sql: sql.to_string(),
1043            row_limit,
1044        })?;
1045        match self.recv_json("an execute reply")? {
1046            Response::NoResult { rows_affected } => return Ok(Execution::NoResult { rows_affected }),
1047            Response::Result { .. } => {}
1048            other => return Err(self.unexpected("an execute reply", &Incoming::Json(other))),
1049        }
1050        let mut schema: Option<SchemaRef> = None;
1051        let mut rows = 0u64;
1052        let mut sink_error = None;
1053        loop {
1054            match self.recv(None, "result data")? {
1055                Incoming::Arrow(ipc) => {
1056                    let (s, batches) = frame::decode_batches(&ipc).map_err(|e| HostError::Arrow {
1057                        plugin: self.label.clone(),
1058                        message: e.to_string(),
1059                    })?;
1060                    let schema = schema.get_or_insert(s);
1061                    for b in batches {
1062                        rows += b.num_rows() as u64;
1063                        if sink_error.is_none()
1064                            && let Err(e) = on_batch(schema, b)
1065                        {
1066                            // Keep draining so the session stays usable; report after.
1067                            sink_error = Some(e);
1068                        }
1069                    }
1070                }
1071                Incoming::Json(Response::ResultEnd { .. }) => break,
1072                Incoming::Json(Response::Error { message, kind, code }) => {
1073                    return Err(HostError::Plugin {
1074                        plugin: self.label.clone(),
1075                        message,
1076                        kind,
1077                        code,
1078                    });
1079                }
1080                other => return Err(self.unexpected("result data", &other)),
1081            }
1082        }
1083        if let Some(e) = sink_error {
1084            return Err(HostError::plugin(&self.label, e));
1085        }
1086        let schema = schema.ok_or_else(|| HostError::Malformed {
1087            plugin: self.label.clone(),
1088            message: "a result ended without any Arrow frame carrying its schema".into(),
1089        })?;
1090        Ok(Execution::Result { schema, rows })
1091    }
1092
1093    /// Start a format `write`; follow with `write_result_set` per result set, then `write_finish`.
1094    pub fn write_begin(
1095        &mut self,
1096        path: &str,
1097        format: &str,
1098        options: Map<String, Value>,
1099        result_sets: Vec<ResultSetMeta>,
1100        template: Option<Value>,
1101    ) -> Result<()> {
1102        self.send(&Request::Write {
1103            path: path.to_string(),
1104            format: format.to_string(),
1105            options,
1106            result_sets,
1107            template,
1108        })
1109    }
1110
1111    /// Stream one result set: at least one batch (carrying the schema), then the end marker.
1112    pub fn write_result_set(
1113        &mut self,
1114        schema: &SchemaRef,
1115        batches: impl IntoIterator<Item = RecordBatch>,
1116    ) -> Result<()> {
1117        let mut any = false;
1118        for b in batches {
1119            any = true;
1120            self.send_batch(&b)?;
1121        }
1122        if !any {
1123            self.send_batch(&RecordBatch::new_empty(schema.clone()))?;
1124        }
1125        self.send(&Request::ResultSetEnd {})
1126    }
1127
1128    /// Load rows into a temporary table on the source's session. Returns the relation to use in
1129    /// SQL, and the plugin's warning, if any. Only for plugins advertising `load`.
1130    pub fn load(
1131        &mut self,
1132        name: &str,
1133        schema: &SchemaRef,
1134        batches: impl IntoIterator<Item = RecordBatch>,
1135    ) -> Result<(String, Option<String>)> {
1136        self.send(&Request::Load {
1137            name: name.to_string(),
1138        })?;
1139        self.write_result_set(schema, batches)?;
1140        match self.recv_json("a loaded reply")? {
1141            Response::Loaded {
1142                relation, warning, ..
1143            } => Ok((relation, warning)),
1144            other => Err(self.unexpected("a loaded reply", &Incoming::Json(other))),
1145        }
1146    }
1147
1148    pub fn write_finish(&mut self) -> Result<Vec<String>> {
1149        Ok(self.write_finish_with_warnings()?.0)
1150    }
1151
1152    /// `write_finish`, also returning the format's warnings.
1153    pub fn write_finish_with_warnings(&mut self) -> Result<(Vec<String>, Vec<String>)> {
1154        self.send(&Request::Finish {})?;
1155        match self.recv_json("a written reply")? {
1156            Response::Written { files, warnings } => Ok((files, warnings)),
1157            other => Err(self.unexpected("a written reply", &Incoming::Json(other))),
1158        }
1159    }
1160
1161    /// Deliver one file with no plugin options.
1162    pub fn deliver(
1163        &mut self,
1164        local_path: &str,
1165        remote_path: Option<&str>,
1166        connection: Map<String, Value>,
1167    ) -> Result<String> {
1168        let file = DeliveryFile {
1169            local_path: local_path.to_string(),
1170            remote_path: remote_path.map(str::to_string),
1171        };
1172        self.deliver_files(&[file], connection, Map::new())
1173    }
1174
1175    /// Deliver `files` in one request with the destination entry's `options`. More than one file
1176    /// needs a plugin advertising `multi_file`; call once per file otherwise.
1177    pub fn deliver_files(
1178        &mut self,
1179        files: &[DeliveryFile],
1180        connection: Map<String, Value>,
1181        options: Map<String, Value>,
1182    ) -> Result<String> {
1183        let req = match files {
1184            [] => {
1185                return Err(HostError::Malformed {
1186                    plugin: self.label.clone(),
1187                    message: "nothing to deliver".into(),
1188                });
1189            }
1190            [one] => Request::Deliver {
1191                local_path: Some(one.local_path.clone()),
1192                remote_path: one.remote_path.clone(),
1193                files: Vec::new(),
1194                connection,
1195                options,
1196                message: None,
1197            },
1198            many => {
1199                if !self.has(crate::CAP_MULTI_FILE) {
1200                    return Err(HostError::plugin(
1201                        &self.label,
1202                        format!(
1203                            "{} files in one delivery, but the plugin doesn't advertise `multi_file`",
1204                            many.len()
1205                        ),
1206                    ));
1207                }
1208                Request::Deliver {
1209                    local_path: None,
1210                    remote_path: None,
1211                    files: many.to_vec(),
1212                    connection,
1213                    options,
1214                    message: None,
1215                }
1216            }
1217        };
1218        self.send(&req)?;
1219        match self.recv_json("a delivered reply")? {
1220            Response::Delivered { location, attempts } => {
1221                self.last_attempts = attempts.unwrap_or(1);
1222                Ok(location)
1223            }
1224            other => Err(self.unexpected("a delivered reply", &Incoming::Json(other))),
1225        }
1226    }
1227
1228    /// Deliver a message, with `attach` files, to a plugin advertising `message`.
1229    pub fn deliver_message(
1230        &mut self,
1231        message: &crate::msg::Message,
1232        attach: &[DeliveryFile],
1233        connection: Map<String, Value>,
1234        options: Map<String, Value>,
1235    ) -> Result<String> {
1236        if !self.has(crate::CAP_MESSAGE) {
1237            return Err(HostError::plugin(
1238                &self.label,
1239                "a message, but the plugin doesn't advertise `message`",
1240            ));
1241        }
1242        self.send(&Request::Deliver {
1243            local_path: None,
1244            remote_path: None,
1245            files: attach.to_vec(),
1246            connection,
1247            options,
1248            message: Some(message.clone()),
1249        })?;
1250        match self.recv_json("a delivered reply")? {
1251            Response::Delivered { location, attempts } => {
1252                self.last_attempts = attempts.unwrap_or(1);
1253                Ok(location)
1254            }
1255            other => Err(self.unexpected("a delivered reply", &Incoming::Json(other))),
1256        }
1257    }
1258
1259    /// How many tries the last delivery took: 1, or more when the plugin retried a temporary
1260    /// error.
1261    pub fn last_attempts(&self) -> u32 {
1262        self.last_attempts
1263    }
1264
1265    /// Ask the plugin to exit, and wait for it.
1266    pub fn close(mut self) -> Result<()> {
1267        let _ = self.send(&Request::Close {});
1268        let _ = self.recv(Some(Duration::from_secs(5)), "the close reply");
1269        if let Ok(mut stdin) = self.stdin.lock() {
1270            stdin.take();
1271        }
1272        for _ in 0..250 {
1273            if let Ok(Some(_)) = self.try_wait() {
1274                return Ok(());
1275            }
1276            std::thread::sleep(Duration::from_millis(20));
1277        }
1278        if let Ok(mut child) = self.child.lock() {
1279            let _ = child.kill();
1280        }
1281        Ok(())
1282    }
1283
1284    /// Raw access for protocol tests: write arbitrary bytes to the plugin.
1285    pub fn write_raw(&mut self, bytes: &[u8]) -> Result<()> {
1286        let ok = self.stdin.lock().is_ok_and(|mut stdin| {
1287            stdin
1288                .as_mut()
1289                .is_some_and(|w| w.write_all(bytes).and_then(|_| w.flush()).is_ok())
1290        });
1291        if ok { Ok(()) } else { Err(self.write_failed()) }
1292    }
1293
1294    /// Wait up to `timeout` for the process to exit.
1295    pub fn wait_exit(&mut self, timeout: Duration) -> Option<ExitStatus> {
1296        if let Ok(mut stdin) = self.stdin.lock() {
1297            stdin.take();
1298        }
1299        let deadline = std::time::Instant::now() + timeout;
1300        while std::time::Instant::now() < deadline {
1301            if let Ok(Some(s)) = self.try_wait() {
1302                return Some(s);
1303            }
1304            std::thread::sleep(Duration::from_millis(20));
1305        }
1306        None
1307    }
1308}
1309
1310impl PluginProcess {
1311    /// Wait (briefly) for the stderr forwarder to reach end of stream.
1312    fn drain_stderr(&mut self) {
1313        if let Some(t) = self.stderr_thread.take() {
1314            let deadline = std::time::Instant::now() + Duration::from_secs(2);
1315            while !t.is_finished() && std::time::Instant::now() < deadline {
1316                std::thread::sleep(Duration::from_millis(5));
1317            }
1318            if t.is_finished() {
1319                let _ = t.join();
1320            }
1321        }
1322    }
1323}
1324
1325impl Drop for PluginProcess {
1326    fn drop(&mut self) {
1327        if let Ok(mut r) = RUNNING.lock() {
1328            r.retain(|(id, _)| *id != self.registered);
1329        }
1330        if let Ok(mut stdin) = self.stdin.lock() {
1331            stdin.take();
1332        }
1333        if let Ok(None) = self.try_wait() {
1334            // Give it a moment to exit on its own (stdin is closed), then insist.
1335            for _ in 0..25 {
1336                if let Ok(Some(_)) = self.try_wait() {
1337                    break;
1338                }
1339                std::thread::sleep(Duration::from_millis(10));
1340            }
1341            let _ = self.child.lock().unwrap().kill();
1342            let _ = self.child.lock().unwrap().wait();
1343        }
1344        self.drain_stderr();
1345    }
1346}
1347
1348/// A `log` message as a log sink line: prefixed by its level, then its fields as `key=value`.
1349fn log_line(level: LogLevel, message: &str, fields: &Map<String, Value>) -> String {
1350    let prefix = match level {
1351        LogLevel::Error | LogLevel::Warn => "warning: ",
1352        LogLevel::Info => "info: ",
1353        LogLevel::Debug | LogLevel::Trace => "debug: ",
1354    };
1355    let mut line = format!("{prefix}{message}");
1356    for (k, v) in fields {
1357        match v {
1358            Value::String(s) => line.push_str(&format!(" {k}={s}")),
1359            v => line.push_str(&format!(" {k}={v}")),
1360        }
1361    }
1362    line
1363}
1364
1365/// A `progress` message as a log sink line.
1366fn progress_line(message: Option<&str>, done: Option<u64>, total: Option<u64>) -> String {
1367    let count = match (done, total) {
1368        (Some(d), Some(t)) => format!("{d}/{t}"),
1369        (Some(d), None) => d.to_string(),
1370        _ => String::new(),
1371    };
1372    match (message, count.is_empty()) {
1373        (Some(m), true) => format!("info: {m}"),
1374        (Some(m), false) => format!("info: {m} ({count})"),
1375        (None, _) => format!("info: {count}"),
1376    }
1377}
1378
1379static CORE_VERSION: OnceLock<String> = OnceLock::new();
1380
1381/// Set the DRE version sent to plugins in the handshake (`core_version`). This crate is versioned
1382/// on its own, so the host names the version of DRE it's part of; unset, it's "unreleased".
1383pub fn set_core_version(version: &str) {
1384    let _ = CORE_VERSION.set(version.to_string());
1385}
1386
1387fn core_version() -> String {
1388    CORE_VERSION.get().cloned().unwrap_or_else(|| "unreleased".into())
1389}