Skip to main content

mitosis/pg/
cluster.rs

1use crate::error::{ErrorCode, MitosisError, Result};
2use crate::pg::binaries::PgInstall;
3use crate::proc_util::{has_exited, is_alive, process_cwd, process_start_time};
4use nix::errno::Errno;
5use nix::sys::signal::{kill, killpg, Signal};
6use nix::unistd::Pid;
7use std::io::{Read, Seek, SeekFrom, Write};
8use std::os::unix::fs::MetadataExt;
9use std::os::unix::process::CommandExt;
10use std::path::{Path, PathBuf};
11use std::process::{Command, Stdio};
12use std::time::{Duration, Instant};
13
14#[derive(Clone, Debug)]
15pub struct StartOptions {
16    pub port: u16,
17    pub socket_dir: PathBuf,
18    pub listen_tcp: bool,
19    pub durable: bool,
20    pub log_file: PathBuf,
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct RunningPg {
25    pub pid: i32,
26    pub start_time: u64,
27}
28
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct PostmasterInfo {
31    pub pid: i32,
32    pub data_dir: PathBuf,
33    pub port: u16,
34    /// Postmaster start time (line 3), unix seconds.
35    pub start_time: u64,
36}
37
38fn internal(msg: impl Into<String>) -> MitosisError {
39    MitosisError::new(ErrorCode::Internal, msg)
40}
41
42/// The auth methods `initdb` is told to use; `make_standalone` writes the same `pg_hba.conf`.
43const AUTH_LOCAL: &str = "trust";
44const AUTH_HOST: &str = "scram-sha-256";
45
46pub fn initdb(install: &PgInstall, data_dir: &Path) -> Result<()> {
47    let out = Command::new(install.bin("initdb"))
48        .arg("-D")
49        .arg(data_dir)
50        .args(["-U", "postgres", "-E", "UTF8"])
51        .arg(format!("--auth-local={AUTH_LOCAL}"))
52        .arg(format!("--auth-host={AUTH_HOST}"))
53        .output()?;
54    if !out.status.success() {
55        return Err(internal(format!(
56            "initdb failed: {}",
57            String::from_utf8_lossy(&out.stderr).trim()
58        )));
59    }
60    Ok(())
61}
62
63/// The `pg_hba.conf` that `initdb` writes with `AUTH_LOCAL` and `AUTH_HOST` (checked against a real
64/// `initdb` in the tests): the local socket trusts, TCP needs a password; `replication` lines too.
65fn hba_text() -> String {
66    let mut text = String::new();
67    for db in ["all", "replication"] {
68        text.push_str(&format!(
69            "{:<8}{db:<16}{:<40}{AUTH_LOCAL}\n",
70            "local", "all"
71        ));
72        for addr in ["127.0.0.1/32", "::1/128"] {
73            text.push_str(&format!(
74                "{:<8}{db:<16}{:<16}{addr:<24}{AUTH_HOST}\n",
75                "host", "all"
76            ));
77        }
78    }
79    text
80}
81
82/// Comments out (with a `#mitosis: ` prefix) every active line of `text` for which `hit` is true;
83/// returns the new text and the commented lines.
84fn comment_out(text: &str, hit: impl Fn(&str) -> bool) -> (String, Vec<String>) {
85    let mut out = String::with_capacity(text.len());
86    let mut hits = Vec::new();
87    for line in text.split_inclusive('\n') {
88        if hit(line.trim_start()) {
89            hits.push(line.trim().to_string());
90            out.push_str("#mitosis: ");
91        }
92        out.push_str(line);
93    }
94    (out, hits)
95}
96
97/// The setting name of an active `postgresql.conf` line (`name = value`, `name value`), if any,
98/// in lower case: Postgres reads names case-insensitively.
99fn setting_name(line: &str) -> Option<String> {
100    line.split(|c: char| c == '=' || c.is_whitespace())
101        .next()
102        .filter(|n| !n.is_empty() && !n.starts_with('#'))
103        .map(str::to_ascii_lowercase)
104}
105
106/// True when the `include*` line `line` points outside `data_dir`: an absolute path elsewhere, or
107/// a relative one that climbs out with `..`. A relative path is read relative to the including file.
108fn include_leaves_dir(line: &str, data_dir: &Path) -> bool {
109    let Some((_, rest)) = line.split_once(|c: char| c == '=' || c.is_whitespace()) else {
110        return true;
111    };
112    let rest = rest.trim_start_matches(|c: char| c == '=' || c.is_whitespace());
113    let path = rest
114        .strip_prefix('\'')
115        .and_then(|r| r.split('\'').next())
116        .unwrap_or_else(|| rest.split('#').next().unwrap_or("").trim());
117    let path = Path::new(path);
118    if path.is_absolute() {
119        !path.starts_with(data_dir)
120    } else {
121        path.components()
122            .any(|c| matches!(c, std::path::Component::ParentDir))
123    }
124}
125
126/// Settings a copy must not inherit: the value it gets instead (appended to `postgresql.conf`),
127/// or `None` to just comment the source's line out. A copy must not run the source's
128/// `archive_command` against its WAL archive, load its certificates, follow a primary, or read
129/// its `hba_file`, `ident_file` or data folder.
130const NEUTRALISED: [(&str, Option<&str>); 11] = [
131    ("archive_mode", Some("off")),
132    ("archive_command", Some("''")),
133    ("ssl", Some("off")),
134    ("restore_command", Some("''")),
135    ("primary_conninfo", Some("''")),
136    ("primary_slot_name", Some("''")),
137    // A source with synchronous replication would make every commit wait for a standby.
138    ("synchronous_standby_names", Some("''")),
139    ("data_directory", None),
140    ("hba_file", None),
141    ("ident_file", None),
142    ("external_pid_file", None),
143];
144
145/// Makes a folder copied by `pg_basebackup` a normal standalone cluster, before its first start:
146/// removes the standby, recovery and pid files, writes the `pg_hba.conf` `initdb` writes, empties
147/// `postgresql.auto.conf`, comments out `include` lines that point outside the folder and the
148/// `NEUTRALISED` settings (the copy gets safe values appended). A `postgresql.conf` or
149/// `pg_ident.conf` the source keeps outside its data folder (Debian layout) is not copied, so
150/// an empty one stands in. Returns warnings for the build log.
151pub fn make_standalone(data_dir: &Path) -> Result<Vec<String>> {
152    for name in [
153        "standby.signal",
154        "recovery.signal",
155        "postmaster.pid",
156        "postmaster.opts",
157    ] {
158        match std::fs::remove_file(data_dir.join(name)) {
159            Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
160                return Err(internal(format!("remove {name}: {e}")))
161            }
162            _ => {}
163        }
164    }
165    let write = |name: &str, text: &str| {
166        std::fs::write(data_dir.join(name), text)
167            .map_err(|e| internal(format!("write {name}: {e}")))
168    };
169    let mut warnings = Vec::new();
170    write("pg_hba.conf", &hba_text())?;
171    write("postgresql.auto.conf", "")?;
172    if !data_dir.join("pg_ident.conf").exists() {
173        warnings.push("pg_ident.conf was not in the copied folder; wrote an empty one".into());
174        write("pg_ident.conf", "# Mitosis: no user name maps\n")?;
175    }
176
177    let conf = data_dir.join("postgresql.conf");
178    let text = match std::fs::read_to_string(&conf) {
179        Ok(t) => t,
180        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
181            warnings.push(
182                "postgresql.conf was not in the copied folder (the source keeps it elsewhere); \
183                 using Postgres defaults"
184                    .into(),
185            );
186            String::new()
187        }
188        Err(e) => return Err(internal(format!("read {}: {e}", conf.display()))),
189    };
190    let (mut text, hits) = comment_out(&text, |line| match setting_name(line).as_deref() {
191        Some("include" | "include_dir" | "include_if_exists") => include_leaves_dir(line, data_dir),
192        Some(name) => NEUTRALISED.iter().any(|(n, _)| *n == name),
193        None => false,
194    });
195    // Names only: a value can hold a password (`primary_conninfo`) or keys (`archive_command`).
196    warnings.extend(
197        hits.iter()
198            .filter_map(|l| setting_name(l))
199            .map(|name| format!("commented out the source's {name} setting in postgresql.conf")),
200    );
201    // Later lines win, so these also beat a setting an included file inside the folder sets.
202    if !text.is_empty() && !text.ends_with('\n') {
203        text.push('\n');
204    }
205    text.push_str("# Mitosis: a copy must not inherit these\n");
206    for (name, value) in NEUTRALISED {
207        if let Some(v) = value {
208            text.push_str(&format!("{name} = {v}\n"));
209        }
210    }
211    write("postgresql.conf", &text)?;
212    Ok(warnings)
213}
214
215/// Comments out the active `shared_preload_libraries` line(s) of `postgresql.conf`; returns them
216/// (empty when there was none, or no file). For a copy whose libraries this machine does not have.
217pub fn disable_preload_libraries(data_dir: &Path) -> Result<Vec<String>> {
218    let conf = data_dir.join("postgresql.conf");
219    let text = match std::fs::read_to_string(&conf) {
220        Ok(t) => t,
221        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
222        Err(e) => return Err(internal(format!("read {}: {e}", conf.display()))),
223    };
224    let (text, hits) = comment_out(&text, |line| {
225        setting_name(line).as_deref() == Some("shared_preload_libraries")
226    });
227    if !hits.is_empty() {
228        std::fs::write(&conf, text)
229            .map_err(|e| internal(format!("write {}: {e}", conf.display())))?;
230    }
231    Ok(hits)
232}
233
234/// True when a start failure message says a library could not be loaded.
235pub fn is_missing_library(message: &str) -> bool {
236    message.contains("could not access file")
237        || message.contains("could not load library")
238        || message.contains("shared_preload_libraries")
239}
240
241/// Makes sure the folder's cluster has a `postgres` role that is a superuser, in single-user
242/// mode: it needs no socket, no password and no knowing the name of the bootstrap superuser
243/// (a copy of a Homebrew-made source has the OS user's name there, not `postgres`).
244/// The server must not be running. Also finishes the backup recovery of a fresh copy.
245pub fn ensure_postgres_superuser(install: &PgInstall, data_dir: &Path) -> Result<()> {
246    // One line: in single-user mode a newline ends the statement.
247    let sql = "DO $$BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'postgres') \
248        THEN CREATE ROLE postgres SUPERUSER LOGIN; ELSE ALTER ROLE postgres SUPERUSER LOGIN; \
249        END IF; END$$;\n";
250    let mut child = Command::new(install.bin("postgres"))
251        .env("LC_ALL", "C")
252        .arg("--single")
253        .arg("-D")
254        .arg(data_dir)
255        // The lean memory settings `start` uses, not the source's.
256        .args(["-c", "shared_buffers=32MB", "-c", "max_connections=20"])
257        .arg("template1")
258        .stdin(Stdio::piped())
259        .stdout(Stdio::piped())
260        .stderr(Stdio::piped())
261        .spawn()
262        .map_err(|e| internal(format!("could not run postgres --single: {e}")))?;
263    let mut stdin = child.stdin.take().expect("stdin is piped");
264    let writer = std::thread::spawn(move || stdin.write_all(sql.as_bytes()));
265    let out = child.wait_with_output()?;
266    let _ = writer.join();
267    // Single-user mode exits 0 even when a statement failed: its errors are on stderr.
268    let stderr = String::from_utf8_lossy(&out.stderr);
269    if !out.status.success() || stderr.contains("ERROR:") || stderr.contains("FATAL:") {
270        return Err(MitosisError::new(
271            ErrorCode::PostgresStartFailed,
272            format!("preparing the copied cluster failed:\n{}", stderr.trim()),
273        ));
274    }
275    Ok(())
276}
277
278/// macOS limits a Unix socket path (`sun_path`) to 104 bytes including the trailing NUL.
279const MAX_SOCKET_PATH: usize = 103;
280
281/// Fails early when postgres could not create its socket file in `socket_dir`.
282pub fn check_socket_path(socket_dir: &Path, port: u16) -> Result<()> {
283    let path = socket_dir.join(format!(".s.PGSQL.{port}"));
284    let len = path.as_os_str().len();
285    if len > MAX_SOCKET_PATH {
286        return Err(MitosisError::new(
287            ErrorCode::PostgresStartFailed,
288            format!(
289                "socket path too long ({len} bytes, max {MAX_SOCKET_PATH}): {}",
290                path.display()
291            ),
292        )
293        .with_hint("set MITOSIS_HOME to a shorter path"));
294    }
295    Ok(())
296}
297
298/// Spawns postgres in its own process group and returns without waiting for it to be ready.
299pub fn start(install: &PgInstall, data_dir: &Path, opts: &StartOptions) -> Result<RunningPg> {
300    check_socket_path(&opts.socket_dir, opts.port)?;
301    if let Some(parent) = opts.log_file.parent() {
302        std::fs::create_dir_all(parent)?;
303    }
304    std::fs::create_dir_all(&opts.socket_dir)?;
305    let log = std::fs::OpenOptions::new()
306        .create(true)
307        .append(true)
308        .open(&opts.log_file)?;
309    // The log is appended across tries; wait_ready classifies only what follows the last marker.
310    let tries = std::fs::read_to_string(&opts.log_file)
311        .map(|t| t.matches(TRY_MARKER).count())
312        .unwrap_or(0);
313    writeln!(&log, "{TRY_MARKER} {}", tries + 1)?;
314    let log_err = log.try_clone()?;
315
316    let listen = if opts.listen_tcp {
317        "listen_addresses=127.0.0.1"
318    } else {
319        "listen_addresses="
320    };
321    let mut cmd = Command::new(install.bin("postgres"));
322    // macOS postgres aborts ("postmaster became multithreaded") when no locale is set in the
323    // environment; database collation comes from initdb, so C here changes nothing else.
324    cmd.env("LC_ALL", "C");
325    cmd.arg("-D")
326        .arg(data_dir)
327        .args(["-p", &opts.port.to_string()])
328        .arg("-k")
329        .arg(&opts.socket_dir)
330        .args([
331            "-c",
332            listen,
333            "-c",
334            "shared_buffers=32MB",
335            "-c",
336            "max_connections=20",
337        ]);
338    if !opts.durable {
339        cmd.args([
340            "-c",
341            "fsync=off",
342            "-c",
343            "synchronous_commit=off",
344            "-c",
345            "full_page_writes=off",
346        ]);
347    }
348    let child = cmd
349        .process_group(0)
350        .stdin(Stdio::null())
351        .stdout(log)
352        .stderr(log_err)
353        .spawn()
354        .map_err(|e| {
355            MitosisError::new(
356                ErrorCode::PostgresStartFailed,
357                format!("could not spawn postgres: {e}"),
358            )
359        })?;
360    let pid = child.id() as i32;
361    drop(child); // dropping a std Child neither kills nor waits; has_exited reaps it later
362                 // 0 only if the process died before we could look; wait_ready reports that failure.
363    Ok(RunningPg {
364        pid,
365        start_time: process_start_time(pid).unwrap_or(0),
366    })
367}
368
369const TRY_MARKER: &str = "mitosis: start try";
370
371/// Like `log_tail`, but only the lines after the last start-try marker (the current try).
372fn current_try_tail(path: &Path, n: usize) -> String {
373    let tail = log_tail(path, usize::MAX);
374    let after = match tail.rfind(TRY_MARKER) {
375        Some(i) => tail[i..].split_once('\n').map_or("", |(_, rest)| rest),
376        None => &tail,
377    };
378    let lines: Vec<&str> = after.lines().collect();
379    lines[lines.len().saturating_sub(n)..].join("\n")
380}
381
382/// Last `n` lines of the last 64 KiB of the file; empty if unreadable.
383pub(crate) fn log_tail(path: &Path, n: usize) -> String {
384    let read = || -> std::io::Result<String> {
385        let mut f = std::fs::File::open(path)?;
386        let len = f.metadata()?.len();
387        f.seek(SeekFrom::Start(len.saturating_sub(64 * 1024)))?;
388        let mut buf = Vec::new();
389        f.read_to_end(&mut buf)?;
390        Ok(String::from_utf8_lossy(&buf).into_owned())
391    };
392    let text = read().unwrap_or_default();
393    let lines: Vec<&str> = text.lines().collect();
394    lines[lines.len().saturating_sub(n)..].join("\n")
395}
396
397fn start_failure(log_tail: &str, port: u16) -> MitosisError {
398    if log_tail.contains("could not bind") || log_tail.contains("Address already in use") {
399        MitosisError::new(
400            ErrorCode::PortInUse,
401            format!("port {port} is already in use"),
402        )
403    } else {
404        MitosisError::new(
405            ErrorCode::PostgresStartFailed,
406            format!("postgres exited during startup:\n{log_tail}"),
407        )
408    }
409}
410
411/// On timeout the process is left running; the caller must `stop` it.
412pub async fn wait_ready(
413    install: &PgInstall,
414    pg: &RunningPg,
415    opts: &StartOptions,
416    timeout: Duration,
417) -> Result<()> {
418    let deadline = Instant::now() + timeout;
419    loop {
420        if has_exited(pg.pid) {
421            return Err(start_failure(
422                &current_try_tail(&opts.log_file, 20),
423                opts.port,
424            ));
425        }
426        let ready = tokio::process::Command::new(install.bin("pg_isready"))
427            .arg("-h")
428            .arg(&opts.socket_dir)
429            .args(["-p", &opts.port.to_string()])
430            .stdout(Stdio::null())
431            .stderr(Stdio::null())
432            .status()
433            .await?;
434        if ready.success() {
435            return Ok(());
436        }
437        if Instant::now() >= deadline {
438            return Err(MitosisError::new(
439                ErrorCode::PostgresStartFailed,
440                format!(
441                    "postgres not ready after {}s:\n{}",
442                    timeout.as_secs(),
443                    current_try_tail(&opts.log_file, 20)
444                ),
445            ));
446        }
447        tokio::time::sleep(Duration::from_millis(50)).await;
448    }
449}
450
451/// Runs `sql` with psql over the socket. The SQL goes on stdin, never in argv, so `ps` cannot show
452/// secrets in it (e.g. `ALTER ROLE ... PASSWORD`).
453pub fn psql(
454    install: &PgInstall,
455    socket_dir: &Path,
456    port: u16,
457    db: &str,
458    sql: &str,
459) -> Result<String> {
460    let mut child = Command::new(install.bin("psql"))
461        .arg("-h")
462        .arg(socket_dir)
463        .args(["-p", &port.to_string(), "-U", "postgres", "-d", db])
464        .args(["-v", "ON_ERROR_STOP=1", "-X", "-A", "-t", "-q", "-f", "-"])
465        .env("PGCONNECT_TIMEOUT", "5")
466        .stdin(Stdio::piped())
467        .stdout(Stdio::piped())
468        .stderr(Stdio::piped())
469        .spawn()?;
470    let mut stdin = child.stdin.take().expect("psql stdin is piped");
471    let input = sql.to_owned();
472    // Written from a thread so a large output cannot deadlock against a large input.
473    let writer = std::thread::spawn(move || stdin.write_all(input.as_bytes())); // stdin closes on drop
474    let out = child.wait_with_output()?;
475    let written = writer
476        .join()
477        .unwrap_or_else(|_| Err(std::io::Error::other("stdin writer panicked")));
478    if !out.status.success() {
479        return Err(internal(format!(
480            "psql failed: {}",
481            String::from_utf8_lossy(&out.stderr).trim()
482        )));
483    }
484    written.map_err(|e| internal(format!("could not send SQL to psql: {e}")))?;
485    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
486}
487
488/// Polls `has_exited` (which also reaps our own child) for up to `wait`; true once gone.
489fn wait_exited(pid: i32, wait: Duration) -> bool {
490    let deadline = Instant::now() + wait;
491    while !has_exited(pid) {
492        if Instant::now() >= deadline {
493            return false;
494        }
495        std::thread::sleep(Duration::from_millis(20));
496    }
497    true
498}
499
500/// True while `pg` still runs as the same process: same pid and exactly the same kernel start
501/// time. A dead child (zombie) is reaped and counts as gone.
502fn is_same_process(pg: RunningPg) -> bool {
503    pg.start_time != 0 && !has_exited(pg.pid) && process_start_time(pg.pid) == Some(pg.start_time)
504}
505
506/// Whether `pid` serves `data_dir`. Postgres chdirs into its data dir, so its cwd names it exactly.
507/// `Some(false)` when it provably does not (gone, another user's process, another folder);
508/// `None` when that cannot be told.
509fn serves_dir(pid: i32, data_dir: &Path) -> Option<bool> {
510    match process_cwd(pid) {
511        Ok((dev, ino, path)) => {
512            let same_inode =
513                std::fs::metadata(data_dir).is_ok_and(|m| (m.dev(), m.ino()) == (dev, ino));
514            let same_path = data_dir.canonicalize().is_ok_and(|d| d == path);
515            Some(same_inode || same_path)
516        }
517        Err(e) if matches!(e.raw_os_error(), Some(libc::ESRCH) | Some(libc::EPERM)) => Some(false),
518        Err(_) => None,
519    }
520}
521
522/// The postmaster now serving `data_dir`, identified exactly: `expected` (the server we started,
523/// as recorded) while it still runs, else the pid in `postmaster.pid` when that process runs in
524/// `data_dir`. `None` when no server of this folder runs; an error when a live pid cannot be told apart.
525pub fn running_server(data_dir: &Path, expected: Option<RunningPg>) -> Result<Option<RunningPg>> {
526    if let Some(pg) = expected.filter(|pg| is_same_process(*pg)) {
527        return Ok(Some(pg));
528    }
529    let Some(info) = read_postmaster_pid(data_dir)? else {
530        return Ok(None);
531    };
532    if has_exited(info.pid) {
533        return Ok(None);
534    }
535    // Start time read on both sides of the cwd check, so a pid reused in between is caught.
536    let before = process_start_time(info.pid);
537    match serves_dir(info.pid, data_dir) {
538        Some(true) => {}
539        Some(false) => return Ok(None),
540        None => {
541            return Err(internal(format!(
542                "cannot tell whether pid {} (from postmaster.pid) serves {}",
543                info.pid,
544                data_dir.display()
545            )))
546        }
547    }
548    match (before, process_start_time(info.pid)) {
549        (Some(a), Some(b)) if a == b => Ok(Some(RunningPg {
550            pid: info.pid,
551            start_time: a,
552        })),
553        _ => Ok(None), // exited (or replaced) during the check
554    }
555}
556
557/// Removes `postmaster.pid` when the pid it names is no longer alive (a crash or reboot).
558fn remove_dead_pid_file(data_dir: &Path) -> Result<()> {
559    let Some(info) = read_postmaster_pid(data_dir).ok().flatten() else {
560        return Ok(());
561    };
562    if is_alive(info.pid) {
563        return Ok(()); // a live process that is not this cluster's server: leave its file alone
564    }
565    match std::fs::remove_file(data_dir.join("postmaster.pid")) {
566        Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
567        _ => Ok(()),
568    }
569}
570
571/// Stops the cluster's server, identified by `running_server` (pass the recorded identity when
572/// there is one). Nothing running is success; a pid that is not this cluster's server is never
573/// signalled. Blocks (up to the timeout plus a few seconds): async callers use `spawn_blocking`.
574pub fn stop(
575    install: &PgInstall,
576    data_dir: &Path,
577    expected: Option<RunningPg>,
578    timeout: Duration,
579) -> Result<()> {
580    let Some(pg) = running_server(data_dir, expected)? else {
581        return remove_dead_pid_file(data_dir);
582    };
583    // pg_ctl signals whatever pid the file names, so it is used only when that is our server.
584    let file_pid = read_postmaster_pid(data_dir).ok().flatten().map(|i| i.pid);
585    if file_pid == Some(pg.pid) {
586        let secs = timeout.as_secs().max(1);
587        let out = Command::new(install.bin("pg_ctl"))
588            .arg("stop")
589            .arg("-D")
590            .arg(data_dir)
591            .args(["-m", "fast", "-t", &secs.to_string()])
592            .output()?;
593        if out.status.success() {
594            wait_exited(pg.pid, Duration::from_secs(5)); // reap our child; pg_ctl already confirmed shutdown
595            return Ok(());
596        }
597    }
598    // Re-checked right before the kill: the pid may have exited and been reused meanwhile.
599    if !is_same_process(pg) {
600        return remove_dead_pid_file(data_dir);
601    }
602    let pid = Pid::from_raw(pg.pid);
603    // The postmaster leads its own group when we started it; otherwise hit just the pid.
604    let mut res = killpg(pid, Signal::SIGKILL);
605    if res == Err(Errno::ESRCH) {
606        res = kill(pid, Signal::SIGKILL);
607    }
608    match res {
609        Ok(()) | Err(Errno::ESRCH) => {}
610        Err(e) => {
611            return Err(internal(format!(
612                "could not kill postgres pid {}: {e}",
613                pg.pid
614            )))
615        }
616    }
617    if !wait_exited(pg.pid, Duration::from_secs(5)) {
618        return Err(internal(format!(
619            "postgres pid {} for {} is still alive after SIGKILL",
620            pg.pid,
621            data_dir.display()
622        )));
623    }
624    remove_dead_pid_file(data_dir)
625}
626
627/// `Database cluster state` from `pg_control`, as `pg_controldata` reports it.
628#[derive(Clone, Debug, PartialEq, Eq)]
629pub enum ControlState {
630    /// Shut down cleanly (`shut down` or `shut down in recovery`): every write reached disk.
631    ShutDown,
632    InProduction,
633    Other(String),
634}
635
636/// The cluster state from `pg_controldata` output; `None` when the line is missing.
637pub fn parse_control_state(text: &str) -> Option<ControlState> {
638    let state = text
639        .lines()
640        .find_map(|l| l.strip_prefix("Database cluster state:"))?
641        .trim();
642    Some(match state {
643        "shut down" | "shut down in recovery" => ControlState::ShutDown,
644        "in production" => ControlState::InProduction,
645        other => ControlState::Other(other.to_string()),
646    })
647}
648
649/// Runs `pg_controldata` on a stopped (or crashed) cluster and reads its state.
650pub fn control_state(install: &PgInstall, data_dir: &Path) -> Result<ControlState> {
651    let out = Command::new(install.bin("pg_controldata"))
652        .arg("-D")
653        .arg(data_dir)
654        .env("LC_ALL", "C") // the labels are translated otherwise
655        .output()?;
656    let text = String::from_utf8_lossy(&out.stdout);
657    match parse_control_state(&text) {
658        Some(s) if out.status.success() => Ok(s),
659        _ => Err(internal(format!(
660            "pg_controldata failed for {}: {}",
661            data_dir.display(),
662            String::from_utf8_lossy(&out.stderr).trim()
663        ))),
664    }
665}
666
667/// Reads lines 1-4 of `postmaster.pid` (pid, data dir, start time, port);
668/// `None` if the file is absent.
669pub fn read_postmaster_pid(data_dir: &Path) -> Result<Option<PostmasterInfo>> {
670    let text = match std::fs::read_to_string(data_dir.join("postmaster.pid")) {
671        Ok(t) => t,
672        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
673        Err(e) => return Err(e.into()),
674    };
675    let lines: Vec<&str> = text.lines().collect();
676    let bad = || {
677        internal(format!(
678            "malformed postmaster.pid in {}",
679            data_dir.display()
680        ))
681    };
682    let pid: i32 = lines
683        .first()
684        .and_then(|l| l.trim().parse().ok())
685        .filter(|p| *p > 1)
686        .ok_or_else(bad)?;
687    let dir = lines
688        .get(1)
689        .map(|l| l.trim())
690        .filter(|l| !l.is_empty())
691        .ok_or_else(bad)?;
692    let start_time = lines
693        .get(2)
694        .and_then(|l| l.trim().parse().ok())
695        .ok_or_else(bad)?;
696    let port = lines
697        .get(3)
698        .and_then(|l| l.trim().parse().ok())
699        .ok_or_else(bad)?;
700    Ok(Some(PostmasterInfo {
701        pid,
702        data_dir: PathBuf::from(dir),
703        port,
704        start_time,
705    }))
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    #[test]
713    fn parses_postmaster_pid() {
714        let dir = tempfile::tempdir().unwrap();
715        std::fs::write(
716            dir.path().join("postmaster.pid"),
717            "4242\n/tmp/data\n1700000000\n54321\n/tmp/sock\nlocalhost\n  1234 5678\nready   \n",
718        )
719        .unwrap();
720        let info = read_postmaster_pid(dir.path()).unwrap().unwrap();
721        assert_eq!(
722            info,
723            PostmasterInfo {
724                pid: 4242,
725                data_dir: PathBuf::from("/tmp/data"),
726                port: 54321,
727                start_time: 1_700_000_000
728            }
729        );
730    }
731
732    #[test]
733    fn stop_never_runs_pg_ctl_for_a_foreign_or_dead_pid() {
734        // A bin dir with no pg_ctl: reaching pg_ctl would fail with an io error instead.
735        let install = PgInstall {
736            bin_dir: PathBuf::from("/nonexistent"),
737            major: 16,
738            version: "16.0".into(),
739        };
740        let dir = tempfile::tempdir().unwrap();
741        let pid_file = dir.path().join("postmaster.pid");
742
743        // A live process that does not run in the data dir (this test) is not its server:
744        // nothing is signalled and its pid file stays.
745        let me = std::process::id();
746        std::fs::write(&pid_file, format!("{me}\n/tmp/data\n1000\n54321\n")).unwrap();
747        assert_eq!(running_server(dir.path(), None).unwrap(), None);
748        stop(&install, dir.path(), None, Duration::from_secs(1)).unwrap();
749        assert!(pid_file.exists() && is_alive(me as i32));
750
751        // A recorded identity with the wrong start time is not trusted either.
752        let wrong = RunningPg {
753            pid: me as i32,
754            start_time: process_start_time(me as i32).unwrap() - 100,
755        };
756        stop(&install, dir.path(), Some(wrong), Duration::from_secs(1)).unwrap();
757        assert!(is_alive(me as i32));
758
759        let mut child = Command::new("true").spawn().unwrap();
760        let dead = child.id();
761        child.wait().unwrap();
762        std::fs::write(&pid_file, format!("{dead}\n/tmp/data\n1000\n54321\n")).unwrap();
763        stop(&install, dir.path(), None, Duration::from_secs(1)).unwrap();
764        assert!(
765            !pid_file.exists(),
766            "a pid file whose pid is dead is removed"
767        );
768    }
769
770    #[test]
771    fn a_process_running_in_the_data_dir_is_its_server() {
772        let dir = tempfile::tempdir().unwrap();
773        let mut child = Command::new("sleep")
774            .arg("30")
775            .current_dir(dir.path())
776            .spawn()
777            .unwrap();
778        let pid = child.id() as i32;
779        std::fs::write(
780            dir.path().join("postmaster.pid"),
781            format!("{pid}\n/tmp/data\n1\n54321\n"),
782        )
783        .unwrap();
784        let found = running_server(dir.path(), None).unwrap();
785        let start = process_start_time(pid);
786        let other = tempfile::tempdir().unwrap();
787        std::fs::copy(
788            dir.path().join("postmaster.pid"),
789            other.path().join("postmaster.pid"),
790        )
791        .unwrap();
792        let elsewhere = running_server(other.path(), None).unwrap();
793        child.kill().unwrap();
794        child.wait().unwrap();
795        assert_eq!(
796            found,
797            Some(RunningPg {
798                pid,
799                start_time: start.unwrap()
800            }),
801            "the recorded start time is the kernel's, not line 3 of the pid file"
802        );
803        assert_eq!(
804            elsewhere, None,
805            "same pid file, other folder: not its server"
806        );
807        assert_eq!(running_server(dir.path(), None).unwrap(), None, "exited");
808    }
809
810    #[test]
811    fn missing_or_malformed_postmaster_pid() {
812        let dir = tempfile::tempdir().unwrap();
813        assert!(read_postmaster_pid(dir.path()).unwrap().is_none());
814        std::fs::write(dir.path().join("postmaster.pid"), "oops\n").unwrap();
815        assert!(read_postmaster_pid(dir.path()).is_err());
816        for pid in ["0", "1", "-1"] {
817            std::fs::write(
818                dir.path().join("postmaster.pid"),
819                format!("{pid}\n/tmp/data\n1700000000\n54321\n"),
820            )
821            .unwrap();
822            assert!(
823                read_postmaster_pid(dir.path()).is_err(),
824                "pid {pid} must be rejected"
825            );
826        }
827        std::fs::write(
828            dir.path().join("postmaster.pid"),
829            "4242\n/tmp/data\nnotatime\n54321\n",
830        )
831        .unwrap();
832        assert!(read_postmaster_pid(dir.path()).is_err());
833    }
834
835    #[test]
836    fn log_tail_keeps_last_lines() {
837        let dir = tempfile::tempdir().unwrap();
838        let p = dir.path().join("pg.log");
839        let text: String = (1..=30).map(|i| format!("line {i}\n")).collect();
840        std::fs::write(&p, text).unwrap();
841        let tail = log_tail(&p, 20);
842        assert_eq!(tail.lines().count(), 20);
843        assert!(tail.starts_with("line 11") && tail.ends_with("line 30"));
844        assert_eq!(log_tail(&dir.path().join("none.log"), 20), "");
845    }
846
847    #[test]
848    fn socket_path_length_is_checked() {
849        // "/s/" + 82 chars + "/.s.PGSQL.54321" = 3 + 82 + 15 = 100 bytes
850        let ok = PathBuf::from(format!("/s/{}", "a".repeat(82)));
851        assert!(check_socket_path(&ok, 54321).is_ok());
852        let edge = PathBuf::from(format!("/s/{}", "a".repeat(85))); // exactly 103
853        assert!(check_socket_path(&edge, 54321).is_ok());
854        let long = PathBuf::from(format!("/s/{}", "a".repeat(86))); // 104
855        let e = check_socket_path(&long, 54321).unwrap_err();
856        assert_eq!(e.code, ErrorCode::PostgresStartFailed);
857        assert!(
858            e.message
859                .starts_with("socket path too long (104 bytes, max 103): /s/"),
860            "{}",
861            e.message
862        );
863        assert_eq!(
864            e.hint.as_deref(),
865            Some("set MITOSIS_HOME to a shorter path")
866        );
867    }
868
869    #[test]
870    fn start_failure_classifies_port_conflicts() {
871        let e = start_failure(
872            "FATAL:  could not bind IPv4 address \"127.0.0.1\": Address already in use",
873            54321,
874        );
875        assert_eq!(e.code, ErrorCode::PortInUse);
876        let e = start_failure("FATAL:  data directory has wrong ownership", 54321);
877        assert_eq!(e.code, ErrorCode::PostgresStartFailed);
878        assert!(e.message.contains("wrong ownership"));
879    }
880
881    #[test]
882    fn current_try_tail_ignores_earlier_tries() {
883        let dir = tempfile::tempdir().unwrap();
884        let p = dir.path().join("pg.log");
885        std::fs::write(
886            &p,
887            "mitosis: start try 1\nFATAL: Address already in use\nmitosis: start try 2\nFATAL: other\n",
888        )
889        .unwrap();
890        assert_eq!(current_try_tail(&p, 20), "FATAL: other");
891        std::fs::write(&p, "no marker\n").unwrap();
892        assert_eq!(current_try_tail(&p, 20), "no marker");
893    }
894
895    #[test]
896    fn parse_control_state_cases() {
897        let sample = |state: &str| {
898            format!(
899                "pg_control version number:            1300\n\
900                 Catalog version number:               202307071\n\
901                 Database system identifier:           7300000000000000000\n\
902                 Database cluster state:               {state}\n\
903                 pg_control last modified:             Mon Oct 19 10:00:00 2026\n"
904            )
905        };
906        assert_eq!(
907            parse_control_state(&sample("shut down")),
908            Some(ControlState::ShutDown)
909        );
910        assert_eq!(
911            parse_control_state(&sample("shut down in recovery")),
912            Some(ControlState::ShutDown)
913        );
914        assert_eq!(
915            parse_control_state(&sample("in production")),
916            Some(ControlState::InProduction)
917        );
918        assert_eq!(
919            parse_control_state(&sample("in crash recovery")),
920            Some(ControlState::Other("in crash recovery".into()))
921        );
922        assert_eq!(
923            parse_control_state("pg_control version number: 1300\n"),
924            None
925        );
926    }
927
928    #[test]
929    fn make_standalone_comments_outside_includes() {
930        let tmp = tempfile::tempdir().unwrap();
931        let d = tmp.path();
932        std::fs::write(
933            d.join("postgresql.conf"),
934            format!(
935                "shared_buffers = 1GB\n#include 'commented.conf'\ninclude '/etc/pg/extra.conf'\n\
936                 include_dir = '../conf.d'  # up\ninclude_if_exists='/etc/pg/maybe.conf'\n\
937                 include 'local.conf'\ninclude_dir '{}/conf.d'\nshared_preload_libraries = 'x'\n\
938                 INCLUDE '/etc/pg/upper.conf'\n",
939                d.display()
940            ),
941        )
942        .unwrap();
943        std::fs::write(d.join("pg_ident.conf"), "").unwrap();
944
945        let warnings = make_standalone(d).unwrap();
946
947        assert_eq!(warnings.len(), 4, "{warnings:?}");
948        assert!(std::fs::read_to_string(d.join("postgresql.conf"))
949            .unwrap()
950            .contains("#mitosis: INCLUDE '/etc/pg/upper.conf'"));
951        let conf = std::fs::read_to_string(d.join("postgresql.conf")).unwrap();
952        assert!(conf.contains("#mitosis: include '/etc/pg/extra.conf'"));
953        assert!(conf.contains("#mitosis: include_dir = '../conf.d'"));
954        assert!(conf.contains("#mitosis: include_if_exists='/etc/pg/maybe.conf'"));
955        // Inside the folder, and settings that are not includes, stay.
956        assert!(conf.contains("\ninclude 'local.conf'\n"));
957        assert!(conf.contains(&format!("\ninclude_dir '{}/conf.d'\n", d.display())));
958        assert!(conf.lines().any(|l| l == "shared_buffers = 1GB"));
959        assert!(conf.contains("\nshared_preload_libraries = 'x'\n"));
960
961        let off = disable_preload_libraries(d).unwrap();
962        assert_eq!(off, vec!["shared_preload_libraries = 'x'".to_string()]);
963        let conf = std::fs::read_to_string(d.join("postgresql.conf")).unwrap();
964        assert!(conf.contains("#mitosis: shared_preload_libraries = 'x'"));
965        assert!(disable_preload_libraries(d).unwrap().is_empty());
966    }
967
968    #[test]
969    fn missing_library_messages() {
970        assert!(is_missing_library(
971            "postgres exited during startup:\nFATAL:  could not access file \"timescaledb\": No such file"
972        ));
973        assert!(is_missing_library("could not load library \"$libdir/x\""));
974        assert!(!is_missing_library("port 5432 is already in use"));
975    }
976}