Skip to main content

hyperdb_api/
process.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Hyper server process management.
5//!
6//! This module provides [`HyperProcess`] for spawning and managing local hyperd server instances.
7//!
8//! # Callback Connection Architecture
9//!
10//! The `HyperProcess` uses a **callback connection** mechanism for reliable process lifecycle
11//! management. This works as follows:
12//!
13//! 1. **Startup**: The client creates a TCP listener on an ephemeral port (the "callback proxy")
14//!    and passes this address to hyperd via `--callback-connection`. Hyper connects back to this
15//!    listener and sends its actual listen endpoint over this connection.
16//!
17//! 2. **Runtime**: The callback connection remains open for the lifetime of the `HyperProcess`.
18//!    This connection acts as a "dead man's switch" - Hyper monitors it continuously.
19//!
20//! 3. **Graceful Shutdown**: When `HyperProcess` is dropped or explicitly shut down, the callback
21//!    connection is closed. Hyper detects this and initiates graceful shutdown automatically.
22//!
23//! 4. **Crash Safety**: If the client process crashes or is killed, the OS automatically closes
24//!    the TCP connection. Hyper detects this and shuts down gracefully, preventing orphan
25//!    processes. This is the key advantage over signal-based shutdown.
26//!
27//! # Protocol Details
28//!
29//! The callback connection protocol is simple:
30//! - Client listens on `127.0.0.1:<ephemeral_port>`
31//! - Client starts hyperd with `--callback-connection=tab.tcp://127.0.0.1:<port>`
32//! - Hyper connects to this address and sends: `[1 byte length][N bytes descriptor]`
33//! - The descriptor is the actual listen endpoint (e.g., `tab.tcp://localhost:54321`)
34//! - Connection stays open until shutdown is desired
35//!
36//! # Listen Modes
37//!
38//! `HyperProcess` supports different listen modes via [`ListenMode`]:
39//!
40//! - **`LibPq`** (default): `PostgreSQL` wire protocol for full read/write access
41//! - **Grpc**: gRPC protocol for query-only Arrow-based access
42//! - **Both**: Both protocols enabled (libpq for read/write, gRPC for Arrow queries)
43//!
44//! ```no_run
45//! use hyperdb_api::{HyperProcess, ListenMode, Parameters, Result};
46//!
47//! fn main() -> Result<()> {
48//!     // gRPC only
49//!     let mut params = Parameters::new();
50//!     params.set_listen_mode(ListenMode::Grpc { port: 0 });
51//!     let hyper = HyperProcess::new(None, Some(&params))?;
52//!     println!("gRPC endpoint: {}", hyper.grpc_endpoint().unwrap());
53//!
54//!     // Both libpq and gRPC
55//!     let mut params = Parameters::new();
56//!     params.set_listen_mode(ListenMode::Both { grpc_port: 7484 });
57//!     let hyper = HyperProcess::new(None, Some(&params))?;
58//!     println!("libpq endpoint: {}", hyper.endpoint().unwrap());
59//!     println!("gRPC endpoint: {}", hyper.grpc_endpoint().unwrap());
60//!     Ok(())
61//! }
62//! ```
63
64use std::io::Read;
65use std::net::{Shutdown, TcpListener, TcpStream};
66use std::path::{Path, PathBuf};
67use std::process::{Child, Command, Stdio};
68use std::sync::Arc;
69use std::sync::atomic::{AtomicBool, Ordering};
70use std::thread;
71use std::time::Duration;
72
73#[cfg(unix)]
74use std::os::unix::process::CommandExt;
75
76#[cfg(any(unix, windows))]
77use hyperdb_api_core::client::ConnectionEndpoint;
78
79use tracing::info;
80
81use crate::error::{Error, Result};
82
83/// Monotonic disambiguator for per-process IPC endpoint names.
84///
85/// A single process can spawn several `HyperProcess` instances — the MCP daemon
86/// restarting `hyperd`, or a test harness driving several daemons in turn — and
87/// each IPC instance must claim a *distinct* endpoint. Keying the name on
88/// `std::process::id()` alone repeats within a process, so an endpoint not yet
89/// released by a prior instance would make the next `bind` fail; this counter
90/// gives every instance in the process a unique suffix, while the pid keeps
91/// names distinct across processes.
92///
93/// This applies symmetrically to both transports: the Windows named-pipe name
94/// (`hyper-<pid>-<seq>`) and the default Unix domain-socket *directory*
95/// (`hyper-<pid>-<seq>`). Both previously used the bare `hyper-<pid>` shape,
96/// which collided for two concurrently-live IPC instances in one process (the
97/// sequential case only worked because `Drop` removed the per-pid artifact
98/// first).
99#[cfg(any(unix, windows))]
100static IPC_INSTANCE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
101
102/// Specifies which protocols `HyperProcess` should listen on.
103///
104/// # Examples
105///
106/// ```
107/// use hyperdb_api::{ListenMode, Parameters};
108///
109/// // LibPq only (default) - for full read/write access
110/// let mut params = Parameters::new();
111/// params.set_listen_mode(ListenMode::LibPq);
112///
113/// // gRPC only - for Arrow-based query access
114/// let mut params = Parameters::new();
115/// params.set_listen_mode(ListenMode::Grpc { port: 0 }); // auto-assign port
116///
117/// // Both protocols - libpq for writes, gRPC for Arrow queries
118/// let mut params = Parameters::new();
119/// params.set_listen_mode(ListenMode::Both { grpc_port: 7484 });
120/// ```
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
122pub enum ListenMode {
123    /// `PostgreSQL` wire protocol only (default).
124    ///
125    /// This is the traditional connection mode that supports all Hyper features
126    /// including read and write operations.
127    #[default]
128    LibPq,
129
130    /// gRPC protocol only.
131    ///
132    /// This mode is optimized for query-only workloads and returns results in
133    /// Arrow IPC format. Note that gRPC mode does not support write operations.
134    ///
135    /// Set `port` to 0 to auto-assign an available port.
136    Grpc {
137        /// The port to listen on (0 for auto-assign).
138        port: u16,
139    },
140
141    /// Both libpq and gRPC protocols.
142    ///
143    /// This mode enables full read/write access via libpq while also providing
144    /// gRPC access for Arrow-based queries. The libpq port is auto-assigned,
145    /// while the gRPC port is specified.
146    ///
147    /// Note: When using `Both` mode, the callback connection returns the libpq
148    /// endpoint. Use `HyperProcess::grpc_endpoint()` to get the gRPC endpoint.
149    Both {
150        /// The gRPC port to listen on (cannot be 0 - must be a specific port).
151        grpc_port: u16,
152    },
153}
154
155/// A running Hyper server instance.
156///
157/// This struct manages the lifecycle of a local Hyper server process using a callback
158/// connection for reliable shutdown. The server is automatically shut down when this
159/// object is dropped.
160///
161/// # Callback Connection (Dead Man's Switch)
162///
163/// Unlike traditional process management that relies on signals, `HyperProcess` maintains
164/// a TCP connection to the Hyper server. When this connection is closed (either explicitly
165/// or because the client process exits), Hyper automatically shuts down gracefully.
166///
167/// This provides several benefits:
168/// - **No orphan processes**: If your application crashes, Hyper shuts down automatically
169/// - **Graceful shutdown**: Hyper can flush data and clean up properly
170/// - **Cross-platform**: Works reliably on macOS, Linux, and Windows
171///
172/// # Example
173///
174/// ```no_run
175/// use hyperdb_api::{HyperProcess, Result};
176///
177/// fn main() -> Result<()> {
178///     // Start a Hyper server (auto-detect hyperd location)
179///     let hyper = HyperProcess::new(None, None)?;
180///
181///     println!("Hyper server running at: {}", hyper.endpoint().unwrap());
182///
183///     // Server automatically shuts down when `hyper` goes out of scope
184///     // via the callback connection mechanism
185///     Ok(())
186/// }
187/// ```
188#[must_use = "HyperProcess will shut down when dropped; store it to keep the server running"]
189#[derive(Debug)]
190pub struct HyperProcess {
191    /// The child process handle.
192    child: Option<Child>,
193    /// The libpq endpoint descriptor (host:port or socket path), if libpq is enabled.
194    endpoint: Option<String>,
195    /// The parsed connection endpoint for libpq.
196    connection_endpoint: Option<ConnectionEndpoint>,
197    /// The gRPC endpoint (host:port), if gRPC is enabled.
198    grpc_endpoint: Option<String>,
199    /// Path to the hyperd executable.
200    #[expect(
201        dead_code,
202        reason = "retained for diagnostics and future restart/respawn paths"
203    )]
204    hyperd_path: PathBuf,
205    /// Whether shutdown has been initiated.
206    shutdown_initiated: Arc<AtomicBool>,
207    /// The callback connection to hyperd.
208    /// Keeping this open maintains the "dead man's switch" - when dropped, Hyper shuts down.
209    callback_connection: Option<TcpStream>,
210    /// The listen mode this process was started with.
211    listen_mode: ListenMode,
212    /// The transport mode this process was started with.
213    transport_mode: TransportMode,
214    /// The socket directory for UDS connections (Unix only).
215    /// This directory is automatically cleaned up on drop.
216    #[cfg(unix)]
217    socket_directory: Option<PathBuf>,
218    /// The pipe name for Named Pipe connections (Windows only).
219    #[cfg(windows)]
220    pipe_name: Option<String>,
221    /// The log directory where hyperd writes its log files.
222    log_dir: Option<PathBuf>,
223}
224
225impl HyperProcess {
226    /// Starts a new Hyper server instance.
227    ///
228    /// This method:
229    /// 1. Creates a callback listener on an ephemeral port
230    /// 2. Starts the hyperd process with the callback connection address
231    /// 3. Waits for Hyper to connect back and provide its listen endpoint
232    ///
233    /// # Arguments
234    ///
235    /// * `hyper_path` - Optional path to the hyperd executable. If `None`, searches
236    ///   in common locations (`HYPERD_PATH` env var, then known build output paths).
237    /// * `parameters` - Optional parameters for the server.
238    ///
239    /// # Errors
240    ///
241    /// Returns an error if:
242    /// - The hyperd executable cannot be found
243    /// - The callback listener cannot be created
244    /// - The server fails to start
245    /// - Hyper doesn't connect back within the timeout (30 seconds)
246    ///
247    /// # Example
248    ///
249    /// ```no_run
250    /// use hyperdb_api::{HyperProcess, Result};
251    /// use std::path::Path;
252    ///
253    /// fn main() -> Result<()> {
254    ///     // Auto-detect hyperd location
255    ///     let hyper = HyperProcess::new(None, None)?;
256    ///
257    ///     // Or specify explicit path
258    ///     let hyper2 = HyperProcess::new(
259    ///         Some(Path::new("/path/to/hyperd")),
260    ///         None,
261    ///     )?;
262    ///     Ok(())
263    /// }
264    /// ```
265    pub fn new(hyper_path: Option<&Path>, parameters: Option<&Parameters>) -> Result<Self> {
266        let hyperd_path = match hyper_path {
267            Some(path) => path.to_path_buf(),
268            None => Self::find_hyperd()?,
269        };
270        Self::start_server(&hyperd_path, parameters)
271    }
272
273    /// Builds the default Unix domain-socket *directory* for an IPC instance
274    /// when the caller has not supplied one.
275    ///
276    /// The basename is `hyper-<pid>-<seq>`, where `<seq>` comes from the
277    /// process-wide [`IPC_INSTANCE_SEQ`] counter. The suffix is load-bearing:
278    /// two concurrently-live IPC `HyperProcess` instances in one process must
279    /// not share a socket path, or the second bind fails and surfaces as a
280    /// 60 s callback timeout. The `hyper-` prefix is also load-bearing — `Drop`
281    /// only cleans up directories whose basename `starts_with("hyper-")`.
282    #[cfg(unix)]
283    fn default_socket_dir() -> PathBuf {
284        let seq = IPC_INSTANCE_SEQ.fetch_add(1, Ordering::Relaxed);
285        std::env::temp_dir().join(format!("hyper-{}-{}", std::process::id(), seq))
286    }
287
288    /// Resolves the hyperd executable from the `HYPERD_PATH` environment
289    /// variable. The value can point at the executable directly, or at a
290    /// directory containing it.
291    ///
292    /// If `HYPERD_PATH` is unset, returns an error instructing the caller
293    /// to either set it or run the `hyperdb-bootstrap` downloader to
294    /// install a pinned release at `.hyperd/current/hyperd`.
295    fn find_hyperd() -> Result<PathBuf> {
296        #[cfg(windows)]
297        const HYPERD_EXE: &str = "hyperd.exe";
298        #[cfg(not(windows))]
299        const HYPERD_EXE: &str = "hyperd";
300
301        let Ok(path_str) = std::env::var("HYPERD_PATH") else {
302            // Walk up from CWD looking for .hyperd/current/<exe> written by
303            // `hyperdb-bootstrap download`. This lets `node examples/foo.mjs`
304            // run from any subdirectory of the repo without exporting HYPERD_PATH.
305            if let Ok(cwd) = std::env::current_dir() {
306                let mut dir = cwd.as_path();
307                loop {
308                    let candidate = dir.join(".hyperd").join("current").join(HYPERD_EXE);
309                    if candidate.exists() {
310                        return Ok(candidate);
311                    }
312                    match dir.parent() {
313                        Some(parent) => dir = parent,
314                        None => break,
315                    }
316                }
317            }
318            return Err(Error::config(
319                "HYPERD_PATH is not set. Point it at a hyperd executable, \
320                or run `make download-hyperd` (or `cargo run -p hyperdb-bootstrap -- download`) \
321                to install a pinned release at `.hyperd/current/hyperd`.",
322            ));
323        };
324
325        let path = PathBuf::from(&path_str);
326        if path.is_dir() {
327            let with_exe = path.join(HYPERD_EXE);
328            if with_exe.exists() {
329                return Ok(with_exe);
330            }
331            #[cfg(windows)]
332            {
333                let without_exe = path.join("hyperd");
334                if without_exe.exists() {
335                    return Ok(without_exe);
336                }
337            }
338            return Err(Error::config(format!(
339                "HYPERD_PATH set to '{path_str}' but {HYPERD_EXE} not found in that directory"
340            )));
341        }
342        if path.exists() {
343            return Ok(path);
344        }
345        #[cfg(windows)]
346        {
347            let with_ext = PathBuf::from(format!("{path_str}.exe"));
348            if with_ext.exists() {
349                return Ok(with_ext);
350            }
351        }
352        Err(Error::config(format!(
353            "HYPERD_PATH set to '{}' but hyperd executable not found (checked: {})",
354            path_str,
355            path.display()
356        )))
357    }
358
359    /// Starts the hyperd server process with callback connection.
360    fn start_server(hyperd_path: &Path, parameters: Option<&Parameters>) -> Result<Self> {
361        // Verify hyperd exists
362        if !hyperd_path.exists() {
363            return Err(Error::config(format!(
364                "Hyper executable not found at: {}",
365                hyperd_path.display()
366            )));
367        }
368
369        info!(
370            target: "hyperdb_api",
371            path = %hyperd_path.display(),
372            "hyperd-starting"
373        );
374
375        // Create callback listener on ephemeral port
376        // This is the "dead man's switch" - when this connection is closed, Hyper shuts down
377        let callback_listener = TcpListener::bind("127.0.0.1:0")
378            .map_err(|e| Error::connection_with_io("Failed to create callback listener", e))?;
379
380        let callback_port = callback_listener
381            .local_addr()
382            .map_err(|e| Error::connection_with_io("Failed to get callback port", e))?
383            .port();
384
385        // Set a timeout for accepting the callback connection
386        callback_listener.set_nonblocking(false).map_err(|e| {
387            Error::connection_with_io("Failed to set callback listener to blocking", e)
388        })?;
389
390        // Check if user wants to disable default parameters
391        let use_defaults = parameters.is_none_or(|p| !p.contains_key(NO_DEFAULT_PARAMETERS));
392
393        // Get the listen mode
394        let listen_mode = parameters.and_then(|p| p.listen_mode).unwrap_or_default();
395
396        // Get transport mode. The default is TCP on every platform, and that
397        // is now a measured choice rather than a pending one: IPC wins latency
398        // but loses bulk streaming. On macOS/UDS, connect is ~30% faster and a
399        // small round-trip ~19% faster, while streamed reads past ~10k rows
400        // cost up to +62%; Windows Named Pipes show the same shape (+34-41% on
401        // single-connection inserts, -76% on async full scans). TCP therefore
402        // remains the right default for mixed workloads, and is retained here
403        // pending the separate transport change.
404        //
405        // Figures and methodology: hyperdb-api-core/docs/IPC_IMPLEMENTATION.md
406        // and docs/BENCHMARK_GUIDE.md.
407        #[cfg(unix)]
408        let transport_mode = parameters
409            .and_then(|p| p.transport_mode)
410            .unwrap_or(TransportMode::Tcp);
411        #[cfg(windows)]
412        let transport_mode = parameters
413            .and_then(|p| p.transport_mode)
414            .unwrap_or(TransportMode::Tcp);
415        #[cfg(not(any(unix, windows)))]
416        let transport_mode = TransportMode::Tcp;
417
418        // Create socket directory for UDS if needed (Unix only)
419        #[cfg(unix)]
420        let socket_directory: Option<PathBuf> = if transport_mode == TransportMode::Ipc {
421            // Use custom directory if provided, otherwise create temp directory
422            let dir = if let Some(custom_dir) =
423                parameters.and_then(|p| p.domain_socket_directory.as_ref())
424            {
425                custom_dir.clone()
426            } else {
427                // Create a temp directory for the socket. The basename carries a
428                // per-process monotonic suffix (`hyper-<pid>-<seq>`) so two
429                // concurrently-live IPC instances in one process never share a
430                // socket path — see `Self::default_socket_dir`.
431                let temp_dir = Self::default_socket_dir();
432                std::fs::create_dir_all(&temp_dir).map_err(|e| {
433                    Error::connection_with_io("Failed to create socket directory", e)
434                })?;
435                temp_dir
436            };
437            Some(dir)
438        } else {
439            None
440        };
441
442        // On non-Unix platforms there is no UDS socket directory; the variable
443        // is only referenced inside `#[cfg(unix)]` blocks so we do not need a
444        // placeholder binding here.
445
446        // Create pipe name for Named Pipes if needed (Windows only)
447        #[cfg(windows)]
448        let pipe_name: Option<String> = if transport_mode == TransportMode::Ipc {
449            let seq = IPC_INSTANCE_SEQ.fetch_add(1, Ordering::Relaxed);
450            Some(format!("hyper-{}-{}", std::process::id(), seq))
451        } else {
452            None
453        };
454
455        // Build command arguments
456        let mut cmd = Command::new(hyperd_path);
457
458        // The "run" subcommand starts the server
459        cmd.arg("run");
460
461        // Callback connection - Hyper will connect to this and send its endpoint
462        // When this connection is closed, Hyper will shut down gracefully
463        cmd.arg("--callback-connection")
464            .arg(format!("tab.tcp://127.0.0.1:{callback_port}"));
465
466        // Configure listen connection based on mode and transport
467        // Connection string formats:
468        // - tab.tcp://host:port - libpq over TCP
469        // - tab.domain://<dir>/domain/<name> - libpq over Unix Domain Socket
470        // - tcp.grpc://host:port - gRPC
471        #[cfg(unix)]
472        let listen_connection = if transport_mode == TransportMode::Ipc {
473            let socket_dir = socket_directory.as_ref().unwrap();
474            match listen_mode {
475                ListenMode::LibPq => format!("tab.domain://{}/domain/hyper", socket_dir.display()),
476                ListenMode::Grpc { port } => format!("tcp.grpc://127.0.0.1:{port}"),
477                ListenMode::Both { grpc_port } => {
478                    format!(
479                        "tab.domain://{}/domain/hyper,tcp.grpc://127.0.0.1:{}",
480                        socket_dir.display(),
481                        grpc_port
482                    )
483                }
484            }
485        } else {
486            match listen_mode {
487                ListenMode::LibPq => "tab.tcp://localhost:0".to_string(),
488                ListenMode::Grpc { port } => format!("tcp.grpc://127.0.0.1:{port}"),
489                ListenMode::Both { grpc_port } => {
490                    format!("tab.tcp://localhost:0,tcp.grpc://127.0.0.1:{grpc_port}")
491                }
492            }
493        };
494
495        #[cfg(windows)]
496        let listen_connection = if transport_mode == TransportMode::Ipc {
497            let pname = pipe_name.as_ref().unwrap();
498            match listen_mode {
499                ListenMode::LibPq => format!("tab.pipe://./pipe/{pname}"),
500                ListenMode::Grpc { port } => format!("tcp.grpc://127.0.0.1:{port}"),
501                ListenMode::Both { grpc_port } => {
502                    format!("tab.pipe://./pipe/{pname},tcp.grpc://127.0.0.1:{grpc_port}")
503                }
504            }
505        } else {
506            match listen_mode {
507                ListenMode::LibPq => "tab.tcp://localhost:0".to_string(),
508                ListenMode::Grpc { port } => format!("tcp.grpc://127.0.0.1:{port}"),
509                ListenMode::Both { grpc_port } => {
510                    format!("tab.tcp://localhost:0,tcp.grpc://127.0.0.1:{grpc_port}")
511                }
512            }
513        };
514
515        #[cfg(not(any(unix, windows)))]
516        let listen_connection = match listen_mode {
517            ListenMode::LibPq => "tab.tcp://localhost:0".to_string(),
518            ListenMode::Grpc { port } => format!("tcp.grpc://127.0.0.1:{}", port),
519            ListenMode::Both { grpc_port } => {
520                format!("tab.tcp://localhost:0,tcp.grpc://127.0.0.1:{}", grpc_port)
521            }
522        };
523
524        cmd.arg("--listen-connection").arg(&listen_connection);
525
526        // Helper to check if a parameter is already set by the user
527        let user_has_param =
528            |key: &str| -> bool { parameters.is_some_and(|p| p.contains_key(key)) };
529
530        // Apply default instance parameters (matching C++ HyperProcess behavior)
531        // These can be overridden by user parameters or disabled entirely with NO_DEFAULT_PARAMETERS
532        if use_defaults {
533            // Initial user for the Hyper instance
534            if !user_has_param("init_user") {
535                cmd.arg("--init-user=tableau_internal_user");
536            }
537
538            // Enable gRPC threads if gRPC mode is enabled
539            // Required for gRPC to function - without this, Hyper will fail to start with:
540            // "gRPC threads are required for running gRPC services"
541            // Using 4 threads as a reasonable default (can be overridden by user)
542            if matches!(
543                listen_mode,
544                ListenMode::Grpc { .. } | ListenMode::Both { .. }
545            ) && !user_has_param("grpc_threads")
546            {
547                cmd.arg("--grpc-threads=4");
548            }
549
550            // Enable gRPC result persistence if gRPC mode is enabled
551            // This is required for ADAPTIVE and ASYNC transfer modes
552            if matches!(
553                listen_mode,
554                ListenMode::Grpc { .. } | ListenMode::Both { .. }
555            ) && !user_has_param("grpc_persist_results")
556            {
557                cmd.arg("--grpc-persist-results=true");
558            }
559
560            // Default language setting
561            if !user_has_param("language") {
562                cmd.arg("--language=en_US");
563            }
564
565            // Log configuration: file-based JSON logging
566            if !user_has_param("log_config") {
567                cmd.arg(format!("--log-config={DEFAULT_LOG_CONFIG}"));
568            }
569
570            // Date style for date parsing (Month-Day-Year)
571            if !user_has_param("date_style") {
572                cmd.arg("--date-style=MDY");
573            }
574
575            // Enforce strict date_style (day/month/year ordering must match exactly)
576            if !user_has_param("date_style_lenient") {
577                cmd.arg("--date-style-lenient=false");
578            }
579
580            // Set default log directory to current directory
581            if !user_has_param("log_dir")
582                && let Ok(cwd) = std::env::current_dir()
583            {
584                cmd.arg(format!("--log-dir={}", cwd.display()));
585            }
586
587            // Disable password requirement for local development
588            if !user_has_param("no_password") {
589                cmd.arg("--no-password");
590            }
591
592            // Skip license check for local development
593            if !user_has_param("skip_license") {
594                cmd.arg("--skip-license");
595            }
596
597            // Default new .hyper databases to file format version 3, which
598            // adds support for 128-bit NUMERICs (required to ingest parquet
599            // files whose decimal columns are stored as DECIMAL128).
600            // File format 3 has shipped since Hyper 2022.4.0.
601            if !user_has_param("default_database_version") {
602                cmd.arg("--default-database-version=3");
603            }
604        }
605
606        // Add custom parameters from user
607        if let Some(params) = parameters {
608            for (key, value) in params.iter() {
609                // Skip internal/special parameters
610                if key == "callback_connection"
611                    || key == "listen_connection"
612                    || key == NO_DEFAULT_PARAMETERS
613                {
614                    continue;
615                }
616
617                // Convert underscores to dashes for command-line arguments
618                let cli_key = key.replace('_', "-");
619
620                if value.is_empty() {
621                    cmd.arg(format!("--{cli_key}"));
622                } else {
623                    cmd.arg(format!("--{cli_key}={value}"));
624                }
625            }
626        }
627
628        // Resolve the effective log directory for later access via log_dir()
629        let resolved_log_dir =
630            if let Some(user_dir) = parameters.and_then(|p| p.get("log_dir")).map(PathBuf::from) {
631                Some(user_dir)
632            } else if use_defaults {
633                std::env::current_dir().ok()
634            } else {
635                None
636            };
637
638        // Redirect stdout/stderr to null - we get the endpoint via callback connection
639        cmd.stdout(Stdio::null());
640        cmd.stderr(Stdio::null());
641
642        // On Unix, start hyperd in its own process group so it doesn't receive
643        // Ctrl-C (SIGINT) signals meant for the parent process. This allows the
644        // parent to handle Ctrl-C gracefully and properly shut down hyperd via
645        // the callback connection mechanism.
646        #[cfg(unix)]
647        cmd.process_group(0);
648
649        // On Windows, prevent a console window from flashing when spawning hyperd.
650        // CREATE_NO_WINDOW (0x08000000) suppresses the creation of a visible console.
651        #[cfg(windows)]
652        {
653            use std::os::windows::process::CommandExt;
654            const CREATE_NO_WINDOW: u32 = 0x08000000;
655            cmd.creation_flags(CREATE_NO_WINDOW);
656        }
657
658        // Start the process
659        let child = cmd.spawn().map_err(|e| {
660            Error::connection_with_io(
661                format!("Failed to start Hyper server at {}", hyperd_path.display()),
662                e,
663            )
664        })?;
665
666        // Wait for Hyper to connect back to our callback listener
667        let (callback_connection, callback_endpoint) = Self::wait_for_callback(&callback_listener)?;
668
669        // Determine the endpoints based on listen mode and callback response
670        let (endpoint, grpc_endpoint) = match listen_mode {
671            ListenMode::LibPq => {
672                // Callback returns libpq endpoint
673                (Some(callback_endpoint), None)
674            }
675            ListenMode::Grpc { port } => {
676                // Callback returns gRPC endpoint (with resolved port if auto-assigned)
677                let grpc_ep = if port == 0 {
678                    // The callback returns the actual gRPC endpoint with resolved port
679                    callback_endpoint
680                } else {
681                    format!("127.0.0.1:{port}")
682                };
683                (None, Some(grpc_ep))
684            }
685            ListenMode::Both { grpc_port } => {
686                // Callback returns libpq endpoint, gRPC uses specified port
687                (
688                    Some(callback_endpoint),
689                    Some(format!("127.0.0.1:{grpc_port}")),
690                )
691            }
692        };
693
694        // Parse connection endpoint if we have a libpq endpoint
695        let connection_endpoint = endpoint.as_ref().map(|ep| {
696            #[cfg(unix)]
697            {
698                // Check if it's a UDS path (contains path separator but no colon with port)
699                if ep.starts_with('/') || socket_directory.is_some() {
700                    // UDS endpoint - construct from socket directory
701                    if let Some(ref dir) = socket_directory {
702                        return ConnectionEndpoint::domain_socket(dir, "hyper");
703                    }
704                    // Parse as path
705                    let path = std::path::Path::new(ep);
706                    let dir = path.parent().unwrap_or(std::path::Path::new("/"));
707                    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("hyper");
708                    return ConnectionEndpoint::domain_socket(dir, name);
709                }
710            }
711            #[cfg(windows)]
712            {
713                // Check if it's a Named Pipe endpoint
714                if let Some(ref pname) = pipe_name {
715                    return ConnectionEndpoint::named_pipe(".", pname);
716                }
717            }
718            // TCP endpoint (host:port format)
719            let parts: Vec<&str> = ep.split(':').collect();
720            if parts.len() == 2
721                && let Ok(port) = parts[1].parse::<u16>()
722            {
723                return ConnectionEndpoint::tcp(parts[0], port);
724            }
725            ConnectionEndpoint::tcp("localhost", 7483) // fallback
726        });
727
728        Ok(HyperProcess {
729            child: Some(child),
730            endpoint,
731            connection_endpoint,
732            grpc_endpoint,
733            hyperd_path: hyperd_path.to_path_buf(),
734            shutdown_initiated: Arc::new(AtomicBool::new(false)),
735            callback_connection: Some(callback_connection),
736            listen_mode,
737            transport_mode,
738            log_dir: resolved_log_dir,
739            #[cfg(unix)]
740            socket_directory,
741            #[cfg(windows)]
742            pipe_name,
743        })
744    }
745
746    /// Waits for Hyper to connect to our callback listener and send its endpoint.
747    ///
748    /// Protocol:
749    /// - Hyper connects to the callback listener
750    /// - Hyper sends: [1 byte length][N bytes connection descriptor string]
751    /// - Connection descriptor format: "tab.tcp://host:port"
752    fn wait_for_callback(listener: &TcpListener) -> Result<(TcpStream, String)> {
753        // Set a timeout for accepting connections
754        listener.set_nonblocking(true).ok();
755
756        let timeout = Duration::from_secs(60);
757        let start = std::time::Instant::now();
758
759        // Poll for incoming connection with timeout
760        let mut stream = loop {
761            if start.elapsed() > timeout {
762                return Err(Error::internal(
763                    "Timeout waiting for Hyper to connect to callback listener. \
764                    Hyper may have failed to start - check hyperd logs for details.",
765                ));
766            }
767
768            match listener.accept() {
769                Ok((stream, _addr)) => break stream,
770                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
771                    thread::sleep(Duration::from_millis(50));
772                }
773                Err(e) => {
774                    return Err(Error::connection_with_io(
775                        "Failed to accept callback connection",
776                        e,
777                    ));
778                }
779            }
780        };
781
782        // Set stream back to blocking for reading
783        stream.set_nonblocking(false).map_err(|e| {
784            Error::connection_with_io("Failed to set callback stream to blocking", e)
785        })?;
786
787        // Set read timeout
788        stream.set_read_timeout(Some(Duration::from_secs(10))).ok();
789
790        // Read the endpoint descriptor from Hyper
791        // Protocol: [1 byte length][N bytes descriptor string]
792        let mut len_buf = [0u8; 1];
793        stream.read_exact(&mut len_buf).map_err(|e| {
794            Error::connection_with_io("Failed to read endpoint length from Hyper", e)
795        })?;
796
797        let len = len_buf[0] as usize;
798        if len == 0 {
799            return Err(Error::internal("Hyper sent empty endpoint descriptor"));
800        }
801
802        let mut descriptor_buf = vec![0u8; len];
803        stream.read_exact(&mut descriptor_buf).map_err(|e| {
804            Error::connection_with_io("Failed to read endpoint descriptor from Hyper", e)
805        })?;
806
807        let descriptor = String::from_utf8(descriptor_buf)
808            .map_err(|e| Error::internal(format!("Invalid UTF-8 in endpoint descriptor: {e}")))?;
809
810        // Trim null bytes and whitespace that Hyper may include
811        let descriptor = descriptor.trim_matches(|c: char| c == '\0' || c.is_whitespace());
812
813        // Parse the connection descriptor (format: "tab.tcp://host:port")
814        let endpoint = Self::parse_connection_descriptor(descriptor)?;
815
816        // Clear read timeout for the connection we'll keep open
817        stream.set_read_timeout(None).ok();
818
819        info!(
820            target: "hyperdb_api",
821            %endpoint,
822            "hyperd-started"
823        );
824
825        Ok((stream, endpoint))
826    }
827
828    /// Parses a connection descriptor to extract host:port, socket path, or pipe path.
829    ///
830    /// Input formats:
831    /// - "tab.tcp://host:port" → "host:port"
832    /// - "tab.domain://<dir>/domain/<name>" → "<dir>/domain/<name>" (socket path)
833    /// - "tab.pipe://<host>/pipe/<name>" → "<host>/pipe/<name>" (named pipe)
834    /// - "tcp.grpc://host:port" → "host:port"
835    fn parse_connection_descriptor(descriptor: &str) -> Result<String> {
836        // Handle domain socket format
837        if let Some(rest) = descriptor.strip_prefix("tab.domain://") {
838            // Return the full path for UDS
839            if let Some(idx) = rest.find("/domain/") {
840                let dir = &rest[..idx];
841                let name = &rest[idx + 8..]; // "/domain/".len() == 8
842                let socket_path = format!("{dir}/domain/{name}");
843                return Ok(socket_path);
844            }
845            return Ok(rest.to_string());
846        }
847
848        // Handle named pipe format
849        if let Some(rest) = descriptor.strip_prefix("tab.pipe://") {
850            // Format: tab.pipe://<host>/pipe/<name>
851            // Return as pipe path: \\<host>\pipe\<name>
852            if let Some(idx) = rest.find("/pipe/") {
853                let host = &rest[..idx];
854                let name = &rest[idx + 6..]; // "/pipe/".len() == 6
855                let pipe_path = format!(r"\\{host}\pipe\{name}");
856                return Ok(pipe_path);
857            }
858            return Ok(rest.to_string());
859        }
860
861        // Handle TCP prefixes
862        let without_prefix = descriptor
863            .strip_prefix("tab.tcp://")
864            .or_else(|| descriptor.strip_prefix("tcp.grpc://"))
865            .or_else(|| descriptor.strip_prefix("tcp.grpctls://"))
866            .or_else(|| descriptor.strip_prefix("tcp://"))
867            .unwrap_or(descriptor);
868
869        // Validate it looks like host:port
870        if without_prefix.contains(':') && !without_prefix.is_empty() {
871            Ok(without_prefix.to_string())
872        } else {
873            Err(Error::internal(format!(
874                "Invalid connection descriptor format: '{descriptor}'. Expected '<scheme>://host:port' or 'tab.domain://<dir>/domain/<name>'"
875            )))
876        }
877    }
878
879    /// Returns the libpq endpoint for connecting to this instance.
880    ///
881    /// The endpoint is in the format "host:port" (e.g., "localhost:54321").
882    ///
883    /// Returns `None` if the process was started in gRPC-only mode.
884    /// Use [`grpc_endpoint`](Self::grpc_endpoint) for gRPC connections.
885    #[must_use]
886    pub fn endpoint(&self) -> Option<&str> {
887        self.endpoint.as_deref()
888    }
889
890    /// Returns the libpq endpoint, or an error if not available.
891    ///
892    /// This is a convenience method to avoid `unwrap()` calls when you need
893    /// the endpoint and want proper error handling.
894    ///
895    /// # Errors
896    ///
897    /// Returns an error if this process was started in gRPC-only mode.
898    ///
899    /// # Example
900    ///
901    /// ```no_run
902    /// use hyperdb_api::{HyperProcess, Result};
903    ///
904    /// fn main() -> Result<()> {
905    ///     let hyper = HyperProcess::new(None, None)?;
906    ///     let endpoint = hyper.require_endpoint()?; // No unwrap() needed!
907    ///     println!("Server running at: {}", endpoint);
908    ///     Ok(())
909    /// }
910    /// ```
911    pub fn require_endpoint(&self) -> crate::error::Result<&str> {
912        self.endpoint().ok_or_else(|| {
913            crate::error::Error::internal(
914                "HyperProcess does not have a libpq endpoint (gRPC-only mode). \
915                 Use grpc_endpoint() instead or start with LibPq or Both listen mode.",
916            )
917        })
918    }
919
920    /// Returns the gRPC endpoint for connecting to this instance.
921    ///
922    /// The endpoint is in the format "host:port" (e.g., "127.0.0.1:7484").
923    ///
924    /// Returns `None` if the process was started in libpq-only mode.
925    #[must_use]
926    pub fn grpc_endpoint(&self) -> Option<&str> {
927        self.grpc_endpoint.as_deref()
928    }
929
930    /// Returns the gRPC endpoint, or an error if not available.
931    ///
932    /// This is a convenience method to avoid `unwrap()` calls when you need
933    /// the gRPC endpoint and want proper error handling.
934    ///
935    /// # Errors
936    ///
937    /// Returns an error if this process was started in libpq-only mode.
938    pub fn require_grpc_endpoint(&self) -> crate::error::Result<&str> {
939        self.grpc_endpoint().ok_or_else(|| {
940            crate::error::Error::internal(
941                "HyperProcess does not have a gRPC endpoint (libpq-only mode). \
942                 Use endpoint() instead or start with Grpc or Both listen mode.",
943            )
944        })
945    }
946
947    /// Returns the gRPC endpoint as a full URL suitable for gRPC clients.
948    ///
949    /// Returns the endpoint prefixed with "http://" (e.g., "<http://127.0.0.1:7484>").
950    /// Returns `None` if the process was started in libpq-only mode.
951    #[must_use]
952    pub fn grpc_url(&self) -> Option<String> {
953        self.grpc_endpoint.as_ref().map(|ep| format!("http://{ep}"))
954    }
955
956    /// Returns the gRPC URL, or an error if not available.
957    ///
958    /// This is a convenience method to avoid `unwrap()` calls when you need
959    /// the gRPC URL and want proper error handling.
960    ///
961    /// # Errors
962    ///
963    /// Returns an error if this process was started in libpq-only mode.
964    pub fn require_grpc_url(&self) -> crate::error::Result<String> {
965        Ok(format!("http://{}", self.require_grpc_endpoint()?))
966    }
967
968    /// Returns the listen mode this process was started with.
969    #[must_use]
970    pub fn listen_mode(&self) -> ListenMode {
971        self.listen_mode
972    }
973
974    /// Returns the transport mode this process was started with.
975    #[must_use]
976    pub fn transport_mode(&self) -> TransportMode {
977        self.transport_mode
978    }
979
980    /// Returns the connection endpoint for this process.
981    ///
982    /// This returns a [`ConnectionEndpoint`] that can be used to connect
983    /// to this Hyper instance via TCP, Unix Domain Socket, or Named Pipe.
984    #[must_use]
985    pub fn connection_endpoint(&self) -> Option<&ConnectionEndpoint> {
986        self.connection_endpoint.as_ref()
987    }
988
989    /// Returns the log directory where hyperd writes its log files.
990    ///
991    /// The log file is typically `hyperd.log` within this directory.
992    /// Returns `None` if the log directory could not be determined (e.g.,
993    /// default parameters were disabled and no `log_dir` was specified).
994    ///
995    /// This is useful for setting up [`QueryStatsProvider`](crate::QueryStatsProvider)
996    /// implementations that parse the Hyper log file.
997    #[must_use]
998    pub fn log_dir(&self) -> Option<&Path> {
999        self.log_dir.as_deref()
1000    }
1001
1002    /// Returns the socket directory used for UDS connections (Unix only).
1003    ///
1004    /// Returns `None` if the process is using TCP or if no socket directory was created.
1005    #[cfg(unix)]
1006    #[must_use]
1007    pub fn socket_directory(&self) -> Option<&Path> {
1008        self.socket_directory.as_deref()
1009    }
1010
1011    /// Returns the pipe name used for Named Pipe connections (Windows only).
1012    ///
1013    /// Returns `None` if the process is using TCP.
1014    #[cfg(windows)]
1015    #[must_use]
1016    pub fn pipe_name(&self) -> Option<&str> {
1017        self.pipe_name.as_deref()
1018    }
1019
1020    /// Returns the process ID of the Hyper server.
1021    #[must_use]
1022    pub fn pid(&self) -> Option<u32> {
1023        self.child.as_ref().map(std::process::Child::id)
1024    }
1025
1026    /// Returns whether the Hyper server process is still running.
1027    #[must_use]
1028    pub fn is_running(&self) -> bool {
1029        if let Some(ref child) = self.child {
1030            // Try to check if process is alive without waiting
1031            #[cfg(unix)]
1032            {
1033                match Command::new("kill")
1034                    .args(["-0", &child.id().to_string()])
1035                    .output()
1036                {
1037                    Ok(output) => output.status.success(),
1038                    Err(_) => false,
1039                }
1040            }
1041            #[cfg(not(unix))]
1042            {
1043                // On Windows, we can't easily check without waiting
1044                // Assume it's running if we have a handle
1045                let _ = child; // Silence unused variable warning
1046                true
1047            }
1048        } else {
1049            false
1050        }
1051    }
1052
1053    /// Returns true if the hyperd child process has exited (or no child exists).
1054    ///
1055    /// Uses [`std::process::Child::try_wait`] under the hood, which is correct
1056    /// on both Unix and Windows. On Unix this also reaps any zombie state as a
1057    /// side effect — a hyperd that has been SIGKILLed but not yet `wait()`ed
1058    /// on by the parent will be observed as exited and cleaned up here.
1059    ///
1060    /// Prefer this over [`Self::is_running`] when the caller owns the
1061    /// `HyperProcess` mutably and needs an authoritative liveness signal.
1062    /// `is_running` uses `kill -0` on Unix (which incorrectly reports zombies
1063    /// as alive) and is a no-op on Windows.
1064    pub fn has_exited(&mut self) -> bool {
1065        match self.child.as_mut() {
1066            Some(child) => match child.try_wait() {
1067                Ok(Some(_status)) => true,
1068                Ok(None) => false,
1069                Err(_) => true,
1070            },
1071            None => true,
1072        }
1073    }
1074
1075    /// Shuts down the Hyper server gracefully with a timeout.
1076    ///
1077    /// This closes the callback connection, which signals Hyper to shut down gracefully.
1078    /// If Hyper doesn't exit within the timeout, it will be forcefully terminated.
1079    ///
1080    /// # Arguments
1081    ///
1082    /// * `timeout` - Maximum time to wait for graceful shutdown before force-killing.
1083    ///
1084    /// # Errors
1085    ///
1086    /// Returns an error if the shutdown fails.
1087    pub fn shutdown_timeout(mut self, timeout: Duration) -> Result<()> {
1088        self.shutdown_initiated.store(true, Ordering::SeqCst);
1089        self.do_shutdown(Some(timeout))
1090    }
1091
1092    /// Shuts down the Hyper server gracefully, waiting indefinitely.
1093    ///
1094    /// This closes the callback connection and waits for Hyper to exit.
1095    /// Use [`shutdown_timeout`](Self::shutdown_timeout) if you need a timeout.
1096    ///
1097    /// # Errors
1098    ///
1099    /// Returns an error if the shutdown fails.
1100    pub fn shutdown_graceful(mut self) -> Result<()> {
1101        self.shutdown_initiated.store(true, Ordering::SeqCst);
1102        self.do_shutdown(None)
1103    }
1104
1105    /// Closes the callback connection to signal Hyper to shut down.
1106    ///
1107    /// This is the graceful shutdown mechanism - Hyper monitors the callback connection
1108    /// and will initiate shutdown when it's closed.
1109    fn close_callback_connection(&mut self) {
1110        if let Some(conn) = self.callback_connection.take() {
1111            // Gracefully shutdown both directions
1112            let _ = conn.shutdown(Shutdown::Both);
1113            // Connection is dropped here, closing the socket
1114        }
1115    }
1116
1117    /// Internal shutdown implementation.
1118    fn do_shutdown(&mut self, timeout: Option<Duration>) -> Result<()> {
1119        info!(target: "hyperdb_api", "hyperd-shutdown");
1120
1121        // Step 1: Close the callback connection to signal graceful shutdown
1122        // Hyper will detect this and begin shutting down
1123        self.close_callback_connection();
1124
1125        if let Some(mut child) = self.child.take() {
1126            // Step 2: Wait for the process to exit
1127            let wait_result = if let Some(timeout) = timeout {
1128                // Wait with timeout
1129                let start = std::time::Instant::now();
1130                loop {
1131                    match child.try_wait() {
1132                        Ok(Some(status)) => break Ok(status),
1133                        Ok(None) => {
1134                            if start.elapsed() > timeout {
1135                                // Step 3: Force kill ONLY after timeout
1136                                // This should rarely happen if Hyper is healthy
1137                                #[cfg(unix)]
1138                                {
1139                                    // Try SIGTERM first
1140                                    let _ = Command::new("kill")
1141                                        .args(["-TERM", &child.id().to_string()])
1142                                        .output();
1143                                    thread::sleep(Duration::from_millis(100));
1144                                }
1145                                // Then force kill
1146                                let _ = child.kill();
1147                                break child.wait().map_err(|e| {
1148                                    Error::connection_with_io("Failed to wait for hyperd", e)
1149                                });
1150                            }
1151                            thread::sleep(Duration::from_millis(100));
1152                        }
1153                        Err(e) => {
1154                            break Err(Error::connection_with_io("Failed to wait for hyperd", e));
1155                        }
1156                    }
1157                }
1158            } else {
1159                // Wait indefinitely
1160                child
1161                    .wait()
1162                    .map_err(|e| Error::connection_with_io("Failed to wait for hyperd", e))
1163            };
1164
1165            wait_result?;
1166        }
1167
1168        Ok(())
1169    }
1170}
1171
1172impl Drop for HyperProcess {
1173    fn drop(&mut self) {
1174        if !self.shutdown_initiated.load(Ordering::SeqCst) {
1175            // Try to gracefully shutdown with a short timeout
1176            let _ = self.do_shutdown(Some(Duration::from_secs(5)));
1177        }
1178
1179        // Clean up socket directory if we created one
1180        #[cfg(unix)]
1181        if let Some(ref dir) = self.socket_directory {
1182            // Only clean up if it's a temp directory we created (contains our PID)
1183            let dir_name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
1184            if dir_name.starts_with("hyper-") {
1185                let _ = std::fs::remove_dir_all(dir);
1186            }
1187        }
1188    }
1189}
1190
1191// SAFETY: `HyperProcess` owns its `std::process::Child` handle and (optionally)
1192// a TCP connection. Both are themselves `Send`, and no field holds thread-local
1193// state or a non-`Send` raw pointer. Ownership of a `HyperProcess` therefore
1194// transfers cleanly across thread boundaries.
1195unsafe impl Send for HyperProcess {}
1196
1197/// Special parameter key that disables default instance parameters when present.
1198///
1199/// By default, [`HyperProcess`] starts hyperd with a set of sensible default parameters
1200/// (matching the C++ `HyperProcess` behavior). If you need full control over all parameters,
1201/// include this key in your [`Parameters`] to disable all defaults.
1202pub(crate) const NO_DEFAULT_PARAMETERS: &str = "no_default_parameters";
1203
1204/// Default log configuration for hyperd: file-based JSON logging.
1205const DEFAULT_LOG_CONFIG: &str = "file,json,all,hyperd,0";
1206
1207/// Parameters for configuring the Hyper server.
1208///
1209/// When starting a [`HyperProcess`], a set of default parameters are automatically applied
1210/// (matching the C++ `HyperProcess` behavior):
1211///
1212/// | Parameter | Default Value | Description |
1213/// |-----------|---------------|-------------|
1214/// | `init_user` | `tableau_internal_user` | Initial user for the Hyper instance |
1215/// | `language` | `en_US` | Default language setting |
1216/// | `log_config` | `file,json,all,hyperd,0` | Log configuration |
1217/// | `date_style` | `MDY` | Date format (Month-Day-Year) |
1218/// | `date_style_lenient` | `false` | Strict date parsing |
1219/// | `log_dir` | Current directory | Log file directory |
1220/// | `no_password` | (flag) | Disable password requirement |
1221/// | `skip_license` | (flag) | Skip license check |
1222/// | `default_database_version` | `3` | File format version for newly created `.hyper` databases (v3 adds 128-bit NUMERIC support, required for DECIMAL128 parquet columns) |
1223///
1224/// To disable these defaults, add the `no_default_parameters` key (for example
1225/// `params.set("no_default_parameters", "")` via [`Parameters::set`].
1226///
1227/// # Listen Modes
1228///
1229/// Use [`set_listen_mode`](Parameters::set_listen_mode) to configure which protocols Hyper listens on:
1230///
1231/// ```
1232/// use hyperdb_api::{ListenMode, Parameters};
1233///
1234/// // gRPC only (for Arrow-based queries)
1235/// let mut params = Parameters::new();
1236/// params.set_listen_mode(ListenMode::Grpc { port: 0 });
1237///
1238/// // Both libpq and gRPC
1239/// let mut params = Parameters::new();
1240/// params.set_listen_mode(ListenMode::Both { grpc_port: 7484 });
1241/// ```
1242///
1243/// # Example
1244///
1245/// ```
1246/// use hyperdb_api::Parameters;
1247///
1248/// let mut params = Parameters::new();
1249/// params.set("log_file_size_limit", "100k");
1250/// params.set("log_file_max_count", "7");
1251/// ```
1252///
1253/// # Transport Modes
1254///
1255/// Use [`set_transport_mode`](Parameters::set_transport_mode) to control whether Hyper uses
1256/// TCP or IPC (Unix Domain Sockets):
1257///
1258/// ```
1259/// use hyperdb_api::{Parameters, TransportMode};
1260///
1261/// let mut params = Parameters::new();
1262/// params.set_transport_mode(TransportMode::Tcp); // Force TCP instead of IPC
1263/// ```
1264///
1265/// Transport mode for `HyperProcess` connections.
1266///
1267/// Controls whether the server uses TCP or Unix Domain Sockets (IPC) for connections.
1268/// On Unix systems, IPC is the default for better local performance.
1269/// On Windows, TCP is always used.
1270#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1271pub enum TransportMode {
1272    /// Use IPC (Unix Domain Sockets on Unix, Named Pipes on Windows).
1273    /// This is the default mode and provides better performance for local connections.
1274    #[default]
1275    Ipc,
1276
1277    /// Use TCP/IP connections.
1278    /// Required when connecting from remote clients or when IPC is not available.
1279    Tcp,
1280}
1281
1282/// Parameters for configuring the Hyper server.
1283///
1284/// When starting a [`HyperProcess`], a set of default parameters are automatically applied
1285/// (matching the C++ `HyperProcess` behavior). You can override these defaults or disable
1286/// them entirely by adding the `no_default_parameters` key (for example
1287/// `params.set("no_default_parameters", "")` via [`Parameters::set`].
1288///
1289/// # Transport Modes
1290///
1291/// Use [`set_transport_mode`](Self::set_transport_mode) to control whether Hyper uses
1292/// TCP or IPC (Unix Domain Sockets on Unix systems).
1293///
1294/// # Example
1295///
1296/// ```
1297/// use hyperdb_api::{Parameters, TransportMode};
1298///
1299/// let mut params = Parameters::new();
1300/// params.set("log_file_size_limit", "100k");
1301/// params.set_transport_mode(TransportMode::Tcp); // Force TCP instead of IPC
1302/// ```
1303#[derive(Debug, Clone, Default)]
1304pub struct Parameters {
1305    values: Vec<(String, String)>,
1306    /// The listen mode for the Hyper server.
1307    pub(crate) listen_mode: Option<ListenMode>,
1308    /// The transport mode (TCP or IPC/UDS).
1309    pub(crate) transport_mode: Option<TransportMode>,
1310    /// Custom domain socket directory (Unix only).
1311    #[cfg(unix)]
1312    pub(crate) domain_socket_directory: Option<PathBuf>,
1313}
1314
1315impl Parameters {
1316    /// Creates a new empty Parameters instance.
1317    #[must_use]
1318    pub fn new() -> Self {
1319        Parameters {
1320            values: Vec::new(),
1321            listen_mode: None,
1322            transport_mode: None,
1323            #[cfg(unix)]
1324            domain_socket_directory: None,
1325        }
1326    }
1327
1328    /// Sets the transport mode (TCP or IPC/UDS).
1329    ///
1330    /// By default, `HyperProcess` uses IPC (Unix Domain Sockets on Unix) for better
1331    /// performance. Use `TransportMode::Tcp` if you need TCP connections.
1332    ///
1333    /// # Example
1334    ///
1335    /// ```
1336    /// use hyperdb_api::{Parameters, TransportMode};
1337    ///
1338    /// let mut params = Parameters::new();
1339    /// params.set_transport_mode(TransportMode::Tcp); // Use TCP instead of IPC
1340    /// ```
1341    pub fn set_transport_mode(&mut self, mode: TransportMode) -> &mut Self {
1342        self.transport_mode = Some(mode);
1343        self
1344    }
1345
1346    /// Returns the configured transport mode.
1347    #[must_use]
1348    pub fn transport_mode(&self) -> Option<TransportMode> {
1349        self.transport_mode
1350    }
1351
1352    /// Sets a custom domain socket directory (Unix only).
1353    ///
1354    /// By default, `HyperProcess` creates sockets in a temporary directory.
1355    /// Use this to specify a custom location.
1356    #[cfg(unix)]
1357    pub fn set_domain_socket_directory(&mut self, dir: impl Into<PathBuf>) -> &mut Self {
1358        self.domain_socket_directory = Some(dir.into());
1359        self
1360    }
1361
1362    /// Returns the configured domain socket directory (Unix only).
1363    #[cfg(unix)]
1364    #[must_use]
1365    pub fn domain_socket_directory(&self) -> Option<&Path> {
1366        self.domain_socket_directory.as_deref()
1367    }
1368
1369    /// Sets a parameter value.
1370    ///
1371    /// # Arguments
1372    ///
1373    /// * `key` - The parameter name.
1374    /// * `value` - The parameter value (empty string for flags).
1375    pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
1376        let key = key.into();
1377        let value = value.into();
1378
1379        // Update existing or add new
1380        if let Some(entry) = self.values.iter_mut().find(|(k, _)| k == &key) {
1381            entry.1 = value;
1382        } else {
1383            self.values.push((key, value));
1384        }
1385
1386        self
1387    }
1388
1389    /// Sets the listen mode for the Hyper server.
1390    ///
1391    /// This controls which protocols the server listens on:
1392    /// - [`ListenMode::LibPq`]: `PostgreSQL` wire protocol only (default)
1393    /// - [`ListenMode::Grpc`]: gRPC protocol only (query-only, Arrow results)
1394    /// - [`ListenMode::Both`]: Both protocols enabled
1395    ///
1396    /// # Example
1397    ///
1398    /// ```
1399    /// use hyperdb_api::{ListenMode, Parameters};
1400    ///
1401    /// let mut params = Parameters::new();
1402    /// params.set_listen_mode(ListenMode::Grpc { port: 0 }); // Auto-assign port
1403    /// ```
1404    pub fn set_listen_mode(&mut self, mode: ListenMode) -> &mut Self {
1405        self.listen_mode = Some(mode);
1406        self
1407    }
1408
1409    /// Returns the configured listen mode, if any.
1410    #[must_use]
1411    pub fn listen_mode(&self) -> Option<ListenMode> {
1412        self.listen_mode
1413    }
1414
1415    /// Gets a parameter value.
1416    #[must_use]
1417    pub fn get(&self, key: &str) -> Option<&str> {
1418        self.values
1419            .iter()
1420            .find(|(k, _)| k == key)
1421            .map(|(_, v)| v.as_str())
1422    }
1423
1424    /// Returns whether the parameters contain the given key.
1425    #[must_use]
1426    pub fn contains_key(&self, key: &str) -> bool {
1427        self.values.iter().any(|(k, _)| k == key)
1428    }
1429
1430    /// Returns an iterator over the parameters.
1431    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
1432        self.values.iter().map(|(k, v)| (k.as_str(), v.as_str()))
1433    }
1434
1435    /// Returns whether the parameters are empty.
1436    #[must_use]
1437    pub fn is_empty(&self) -> bool {
1438        self.values.is_empty()
1439    }
1440
1441    /// Returns the number of parameters.
1442    #[must_use]
1443    pub fn len(&self) -> usize {
1444        self.values.len()
1445    }
1446}
1447
1448#[cfg(test)]
1449mod tests {
1450    use super::*;
1451
1452    #[test]
1453    fn test_parameters() {
1454        let mut params = Parameters::new();
1455        params.set("key1", "value1");
1456        params.set("key2", "value2");
1457
1458        assert_eq!(params.get("key1"), Some("value1"));
1459        assert_eq!(params.get("key2"), Some("value2"));
1460        assert_eq!(params.get("key3"), None);
1461        assert_eq!(params.len(), 2);
1462    }
1463
1464    #[test]
1465    fn test_parameters_update() {
1466        let mut params = Parameters::new();
1467        params.set("key", "value1");
1468        params.set("key", "value2");
1469
1470        assert_eq!(params.get("key"), Some("value2"));
1471        assert_eq!(params.len(), 1);
1472    }
1473
1474    #[test]
1475    fn test_parse_connection_descriptor() {
1476        assert_eq!(
1477            HyperProcess::parse_connection_descriptor("tab.tcp://localhost:12345").unwrap(),
1478            "localhost:12345"
1479        );
1480        assert_eq!(
1481            HyperProcess::parse_connection_descriptor("tab.tcp://127.0.0.1:7483").unwrap(),
1482            "127.0.0.1:7483"
1483        );
1484        assert_eq!(
1485            HyperProcess::parse_connection_descriptor("tcp://localhost:8080").unwrap(),
1486            "localhost:8080"
1487        );
1488        // Already in host:port format
1489        assert_eq!(
1490            HyperProcess::parse_connection_descriptor("localhost:9999").unwrap(),
1491            "localhost:9999"
1492        );
1493    }
1494
1495    #[test]
1496    fn test_parse_connection_descriptor_named_pipe() {
1497        assert_eq!(
1498            HyperProcess::parse_connection_descriptor("tab.pipe://./pipe/hyper-12345").unwrap(),
1499            r"\\.\pipe\hyper-12345"
1500        );
1501        assert_eq!(
1502            HyperProcess::parse_connection_descriptor("tab.pipe://server1/pipe/mydb").unwrap(),
1503            r"\\server1\pipe\mydb"
1504        );
1505    }
1506
1507    #[test]
1508    fn test_parse_connection_descriptor_invalid() {
1509        assert!(HyperProcess::parse_connection_descriptor("").is_err());
1510        assert!(HyperProcess::parse_connection_descriptor("invalid").is_err());
1511    }
1512
1513    #[test]
1514    fn test_parameters_contains_key() {
1515        let mut params = Parameters::new();
1516        params.set("key1", "value1");
1517
1518        assert!(params.contains_key("key1"));
1519        assert!(!params.contains_key("key2"));
1520    }
1521
1522    #[test]
1523    fn test_no_default_parameters_constant() {
1524        // Verify the constant matches what C++ uses
1525        assert_eq!(NO_DEFAULT_PARAMETERS, "no_default_parameters");
1526    }
1527
1528    /// Two IPC instances in one process must derive *distinct* default socket
1529    /// directories, or their sockets collide and the second bind 60 s-timeouts.
1530    /// Also guards the `hyper-` prefix that `Drop`'s cleanup keys on.
1531    #[cfg(unix)]
1532    #[test]
1533    fn default_socket_dir_names_are_unique_and_prefixed() {
1534        let a = HyperProcess::default_socket_dir();
1535        let b = HyperProcess::default_socket_dir();
1536        assert_ne!(a, b, "concurrent IPC instances must not share a socket dir");
1537        for dir in [&a, &b] {
1538            let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
1539            assert!(
1540                name.starts_with("hyper-"),
1541                "socket dir basename must keep the `hyper-` prefix so Drop cleans it up: {name}"
1542            );
1543        }
1544    }
1545
1546    #[test]
1547    fn test_parameters_with_no_defaults() {
1548        let mut params = Parameters::new();
1549        params.set(NO_DEFAULT_PARAMETERS, "");
1550        params.set("init_user", "custom_user");
1551
1552        assert!(params.contains_key(NO_DEFAULT_PARAMETERS));
1553        assert_eq!(params.get("init_user"), Some("custom_user"));
1554    }
1555}