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(¶ms))?;
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(¶ms))?;
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}