Skip to main content

hel/
hel_git_proxy.rs

1//! Authenticated, path-confined Git smart-protocol bridge for local bundles.
2
3#[cfg(feature = "controller")]
4use std::collections::BTreeMap;
5#[cfg(feature = "controller")]
6use std::fs::{File, OpenOptions, TryLockError};
7use std::future::Future;
8#[cfg(feature = "controller")]
9use std::io::Write as _;
10use std::path::Path;
11#[cfg(feature = "controller")]
12use std::path::PathBuf;
13#[cfg(feature = "controller")]
14use std::process::Stdio;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::time::{Duration, Instant};
17
18use anyhow::{Context, Result, anyhow, bail, ensure};
19use serde::{Deserialize, Serialize};
20use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
21#[cfg(feature = "controller")]
22use tokio::process::Command;
23use tokio_util::sync::CancellationToken;
24
25#[cfg(feature = "controller")]
26use crate::hel_local_git::canonical_repository;
27#[cfg(feature = "controller")]
28use crate::hel_targets::CommandSpec;
29
30const BRIDGE_MAGIC: &[u8] = b"HEL-GIT-BRIDGE-2\n";
31const MAX_FRAME: usize = 1024 * 1024;
32const MAX_OPEN: usize = 16 * 1024;
33/// How long a broker waits for its target bridge to exit once the frame
34/// stream between them has closed.
35#[cfg(feature = "controller")]
36const BRIDGE_EXIT_GRACE: Duration = Duration::from_secs(5);
37/// How long a client may take to name its repository and service.
38///
39/// The proxy writes its open line as soon as it connects, so a connection that
40/// is still silent this much later is one that will never speak. Exchanges are
41/// served one at a time, so without this deadline such a client holds the
42/// session's whole bridge.
43#[cfg(unix)]
44const HANDSHAKE_DEADLINE: Duration = Duration::from_secs(30);
45/// How long one exchange may move nothing in either direction before the
46/// bridge gives up on it.
47///
48/// The deadline is idle rather than total because a legitimate transfer can
49/// run for a very long time while still making progress — a huge clone is slow
50/// but never silent. Only the longest legitimately quiet phase of a Git
51/// service has to fit inside the window: counting and compressing objects
52/// before the first pack byte, or indexing a pushed pack before the report.
53/// Five minutes covers that for very large repositories while still turning a
54/// wedged client into a reported failure in minutes instead of never.
55const EXCHANGE_IDLE_DEADLINE: Duration = Duration::from_secs(300);
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59#[cfg(feature = "controller")]
60pub struct GitBrokerSpec {
61    pub session_id: String,
62    pub bridge: CommandSpec,
63    pub repositories: BTreeMap<String, PathBuf>,
64    pub ready_path: PathBuf,
65    pub pid_path: PathBuf,
66}
67
68#[derive(Debug, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70struct GitOpen {
71    repository: String,
72    service: String,
73}
74
75#[cfg(feature = "controller")]
76impl GitBrokerSpec {
77    pub fn write(&self, path: &Path) -> Result<()> {
78        let parent = path.parent().unwrap_or_else(|| Path::new("."));
79        std::fs::create_dir_all(parent)?;
80        crate::hel_config::atomic_write(path, &serde_json::to_vec_pretty(self)?)
81    }
82
83    pub fn read(path: &Path) -> Result<Self> {
84        let bytes = std::fs::read(path)
85            .with_context(|| format!("read Git broker spec {}", path.display()))?;
86        serde_json::from_slice(&bytes)
87            .with_context(|| format!("parse Git broker spec {}", path.display()))
88    }
89}
90
91/// What a PID file says about the broker that wrote it.
92#[cfg(feature = "controller")]
93enum BrokerLock {
94    /// A broker holds the lock; the file names its process when it is
95    /// readable, which it is for every broker past its own startup.
96    Held(Option<i32>),
97    /// Nobody holds the lock, so no broker is serving this session.
98    Free,
99}
100
101/// Read a broker's PID file through the lock its owner holds.
102///
103/// A live broker holds an exclusive advisory lock on its PID file for as long
104/// as it runs, so liveness is the lock rather than the number written in the
105/// file: a PID file left behind by a killed broker, or one whose PID the
106/// system has since handed to an unrelated process, reads as dead and is
107/// restarted instead of being trusted, blocking the session forever, or —
108/// worse — being signalled.
109#[cfg(feature = "controller")]
110fn broker_lock(pid_path: &Path) -> BrokerLock {
111    let Ok(file) = OpenOptions::new().read(true).write(true).open(pid_path) else {
112        return BrokerLock::Free;
113    };
114    match file.try_lock() {
115        // Nobody holds the lock, so the broker that wrote this file is gone.
116        Ok(()) => {
117            let _ = file.unlock();
118            BrokerLock::Free
119        }
120        Err(TryLockError::WouldBlock) => BrokerLock::Held(
121            std::fs::read_to_string(pid_path)
122                .ok()
123                .and_then(|pid| pid.trim().parse().ok()),
124        ),
125        Err(TryLockError::Error(error)) => {
126            tracing::warn!(
127                path = %pid_path.display(),
128                error = %error,
129                "could not test the Git broker lock; treating the broker as gone"
130            );
131            BrokerLock::Free
132        }
133    }
134}
135
136/// Whether a broker process still owns this session's bridge.
137#[cfg(feature = "controller")]
138pub fn broker_is_alive(pid_path: &Path) -> bool {
139    matches!(broker_lock(pid_path), BrokerLock::Held(_))
140}
141
142/// The process ID of the broker serving this session, when one is running.
143///
144/// Only a broker that still holds its lock is named, so a caller that stops a
145/// broker can never signal a PID the system has reassigned.
146#[cfg(feature = "controller")]
147pub fn running_broker_pid(pid_path: &Path) -> Option<i32> {
148    match broker_lock(pid_path) {
149        BrokerLock::Held(pid) => pid,
150        BrokerLock::Free => None,
151    }
152}
153
154/// Take ownership of this session's broker slot for the life of the process.
155///
156/// Visible to the crate so tests can stand in for a broker exactly as one
157/// behaves: holding this lock is what makes a process the session's broker.
158#[doc(hidden)]
159#[cfg(feature = "controller")]
160pub fn claim_broker_pid_file(pid_path: &Path) -> Result<File> {
161    let mut options = OpenOptions::new();
162    options.create(true).read(true).write(true);
163    #[cfg(unix)]
164    {
165        use std::os::unix::fs::OpenOptionsExt;
166        options.mode(0o600);
167    }
168    let mut file = options
169        .open(pid_path)
170        .with_context(|| format!("open Git broker lock {}", pid_path.display()))?;
171    match file.try_lock() {
172        Ok(()) => {}
173        Err(TryLockError::WouldBlock) => bail!(
174            "another local Git broker already owns {}",
175            pid_path.display()
176        ),
177        Err(TryLockError::Error(error)) => {
178            return Err(error)
179                .with_context(|| format!("lock Git broker file {}", pid_path.display()));
180        }
181    }
182    file.set_len(0)?;
183    file.write_all(std::process::id().to_string().as_bytes())?;
184    file.flush()?;
185    Ok(file)
186}
187
188#[cfg(feature = "controller")]
189pub async fn run_broker(spec_path: &Path) -> Result<()> {
190    let spec = GitBrokerSpec::read(spec_path)?;
191    let repositories = spec
192        .repositories
193        .iter()
194        .map(|(id, path)| Ok((id.clone(), canonical_repository(path)?)))
195        .collect::<Result<BTreeMap<_, _>>>()?;
196    if let Some(parent) = spec.ready_path.parent() {
197        std::fs::create_dir_all(parent)?;
198    }
199    // Held until this process exits: it is what `broker_is_alive` observes.
200    let pid_file = claim_broker_pid_file(&spec.pid_path)?;
201    let result = run_bridge_process(&spec, repositories).await;
202    for path in [&spec.ready_path, &spec.pid_path] {
203        if let Err(error) = std::fs::remove_file(path)
204            && error.kind() != std::io::ErrorKind::NotFound
205        {
206            tracing::warn!(
207                path = %path.display(),
208                %error,
209                "could not remove Git broker lifecycle marker"
210            );
211        }
212    }
213    drop(pid_file);
214    result
215}
216
217#[cfg(feature = "controller")]
218async fn run_bridge_process(
219    spec: &GitBrokerSpec,
220    repositories: BTreeMap<String, PathBuf>,
221) -> Result<()> {
222    let mut child = Command::new(&spec.bridge.program)
223        .args(&spec.bridge.args)
224        .envs(&spec.bridge.env)
225        .stdin(Stdio::piped())
226        .stdout(Stdio::piped())
227        .stderr(Stdio::inherit())
228        .spawn()
229        .with_context(|| format!("start {}", spec.bridge.purpose))?;
230    let mut input = child.stdin.take().context("Git bridge stdin is missing")?;
231    let mut output = child
232        .stdout
233        .take()
234        .context("Git bridge stdout is missing")?;
235    let mut magic = vec![0; BRIDGE_MAGIC.len()];
236    output
237        .read_exact(&mut magic)
238        .await
239        .context("read Git bridge greeting")?;
240    ensure!(magic == BRIDGE_MAGIC, "target Git bridge version mismatch");
241    crate::hel_config::atomic_write(&spec.ready_path, b"ready\n")?;
242
243    let outcome = serve_bridge(
244        &mut input,
245        &mut output,
246        &repositories,
247        &spec.session_id,
248        EXCHANGE_IDLE_DEADLINE,
249    )
250    .await;
251    // Closing the frame stream is how an idle target bridge learns that its
252    // broker is finished with it.
253    drop(input);
254    drop(output);
255    let status = match tokio::time::timeout(BRIDGE_EXIT_GRACE, child.wait()).await {
256        Ok(status) => Some(status.context("wait for target Git bridge")?),
257        Err(_) => {
258            if let Err(error) = child.kill().await
259                && error.kind() != std::io::ErrorKind::NotFound
260            {
261                tracing::warn!(
262                    session_id = %spec.session_id,
263                    %error,
264                    "could not terminate a timed-out target Git bridge"
265                );
266            }
267            None
268        }
269    };
270    outcome?;
271    let status = status.context("target Git bridge did not exit after its stream closed")?;
272    ensure!(status.success(), "target Git bridge exited with {status}");
273    Ok(())
274}
275
276/// Outcome of one bridged Git exchange whose frame stream is still in sync.
277///
278/// A failed exchange costs its own connection and nothing else, so both loops
279/// report it and keep serving. A frame stream that can no longer be
280/// interpreted is returned as `Err` instead, because no later exchange could
281/// be framed correctly after it.
282#[must_use]
283enum Exchange {
284    Completed,
285    Failed(anyhow::Error),
286}
287
288/// Idle watchdog for one exchange, shared by both halves of its transfer.
289///
290/// Every frame and every byte moved in either direction marks progress. When
291/// nothing moves for a whole idle window the exchange is aborted, which is how
292/// a client that wedges mid-transfer becomes its own connection's failure
293/// instead of a bridge that serves nobody again.
294struct ExchangeWatch {
295    idle: Duration,
296    start: Instant,
297    /// Milliseconds after `start` at which progress was last marked.
298    progress: AtomicU64,
299    abort: CancellationToken,
300}
301
302impl ExchangeWatch {
303    fn new(idle: Duration) -> Self {
304        Self {
305            idle,
306            start: Instant::now(),
307            progress: AtomicU64::new(0),
308            abort: CancellationToken::new(),
309        }
310    }
311
312    /// Record that this exchange moved something, in either direction.
313    fn mark(&self) {
314        let elapsed = self.start.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
315        self.progress.store(elapsed, Ordering::Relaxed);
316    }
317
318    /// Resolve once no progress has been marked for a whole idle window.
319    async fn stalled(&self) {
320        loop {
321            let progress = self.progress.load(Ordering::Relaxed);
322            let deadline = self.start + Duration::from_millis(progress) + self.idle;
323            tokio::time::sleep_until(deadline.into()).await;
324            if self.progress.load(Ordering::Relaxed) == progress {
325                return;
326            }
327        }
328    }
329
330    /// Give up on this exchange: both halves stop touching their own endpoint
331    /// and finish their framing. The idle window restarts, so the peer still
332    /// gets a full one to end its half of the exchange.
333    fn abort(&self) {
334        self.mark();
335        self.abort.cancel();
336    }
337
338    fn is_aborted(&self) -> bool {
339        self.abort.is_cancelled()
340    }
341
342    /// Resolve once this exchange has been given up on.
343    async fn aborted(&self) {
344        self.abort.cancelled().await;
345    }
346
347    fn stall(&self) -> anyhow::Error {
348        anyhow!(
349            "a bridged Git exchange moved nothing for {} seconds",
350            self.idle.as_secs_f64()
351        )
352    }
353}
354
355/// Run one exchange's transfer under its idle watchdog.
356///
357/// The watchdog never drops the transfer: a half dropped mid-frame would leave
358/// a partial frame in the stream and desynchronise every exchange after it.
359/// The first idle window aborts the transfer instead, which makes both halves
360/// let go of their own endpoint and still write the framing the peer is
361/// reading for. Only a second idle window is fatal, because a peer that never
362/// ends its half of an aborted exchange leaves a frame stream that can no
363/// longer be interpreted.
364///
365/// Returns the transfer's own result together with the stall that aborted it,
366/// if one did.
367async fn watch_exchange<T>(
368    watch: &ExchangeWatch,
369    transfer: impl Future<Output = Result<T>>,
370) -> Result<(T, Option<anyhow::Error>)> {
371    tokio::pin!(transfer);
372    tokio::select! {
373        result = &mut transfer => Ok((result?, None)),
374        () = watch.stalled() => {
375            watch.abort();
376            tokio::select! {
377                result = &mut transfer => Ok((result?, Some(watch.stall()))),
378                () = watch.stalled() => Err(watch
379                    .stall()
380                    .context("a stalled Git bridge exchange was never ended by its peer")),
381            }
382        }
383    }
384}
385
386/// Serve bridged Git exchanges until the target bridge closes its stream.
387#[cfg(feature = "controller")]
388async fn serve_bridge(
389    input: &mut (impl AsyncWrite + Unpin),
390    output: &mut (impl AsyncRead + Unpin),
391    repositories: &BTreeMap<String, PathBuf>,
392    session_id: &str,
393    idle: Duration,
394) -> Result<()> {
395    loop {
396        let open = match read_frame(output, MAX_OPEN).await? {
397            Frame::Data(open) => open,
398            // The target bridge is gone: no further exchange is possible.
399            Frame::Closed => return Ok(()),
400            Frame::End => {
401                tracing::warn!(
402                    session_id,
403                    "target Git bridge ended an exchange that was not open"
404                );
405                continue;
406            }
407        };
408        match serve_exchange(input, output, repositories, &open, idle).await? {
409            Exchange::Completed => {}
410            Exchange::Failed(error) => tracing::warn!(
411                session_id,
412                error = format!("{error:#}"),
413                "bridged Git exchange failed"
414            ),
415        }
416    }
417}
418
419#[cfg(feature = "controller")]
420async fn serve_exchange(
421    input: &mut (impl AsyncWrite + Unpin),
422    output: &mut (impl AsyncRead + Unpin),
423    repositories: &BTreeMap<String, PathBuf>,
424    open: &[u8],
425    idle: Duration,
426) -> Result<Exchange> {
427    let request: GitOpen = match serde_json::from_slice(open) {
428        Ok(request) => request,
429        Err(error) => {
430            let error = anyhow!(error).context("decode Git bridge request");
431            return refuse_exchange(input, output, error).await;
432        }
433    };
434    let Some(repository) = repositories.get(&request.repository) else {
435        let error = anyhow!(
436            "Git bridge requested unknown repository {:?}",
437            request.repository
438        );
439        return refuse_exchange(input, output, error).await;
440    };
441    let command = match git_service(&request.service) {
442        Ok(command) => command,
443        Err(error) => return refuse_exchange(input, output, error).await,
444    };
445    serve_git(input, output, repository, command, idle).await
446}
447
448#[cfg(feature = "controller")]
449fn git_service(service: &str) -> Result<&'static str> {
450    match service {
451        "git-upload-pack" => Ok("upload-pack"),
452        "git-receive-pack" => Ok("receive-pack"),
453        _ => bail!("unsupported Git service {service:?}"),
454    }
455}
456
457/// Refuse one exchange without losing the frame stream: end this side of it,
458/// then read the target's side through to its end frame.
459#[cfg(feature = "controller")]
460async fn refuse_exchange(
461    input: &mut (impl AsyncWrite + Unpin),
462    output: &mut (impl AsyncRead + Unpin),
463    error: anyhow::Error,
464) -> Result<Exchange> {
465    write_frame(input, &[]).await?;
466    drain_exchange(output).await?;
467    Ok(Exchange::Failed(error))
468}
469
470/// Read the peer's remaining frames for the exchange in progress.
471#[cfg(feature = "controller")]
472async fn drain_exchange(output: &mut (impl AsyncRead + Unpin)) -> Result<()> {
473    loop {
474        match read_frame(output, MAX_FRAME).await? {
475            Frame::Data(_) => {}
476            Frame::End => return Ok(()),
477            Frame::Closed => bail!("the target Git bridge closed during an exchange"),
478        }
479    }
480}
481
482#[cfg(feature = "controller")]
483async fn serve_git<W, R>(
484    bridge_input: &mut W,
485    bridge_output: &mut R,
486    repository: &Path,
487    command: &str,
488    idle: Duration,
489) -> Result<Exchange>
490where
491    W: AsyncWrite + Unpin,
492    R: AsyncRead + Unpin,
493{
494    let mut git = Command::new("git");
495    git.args([
496        "-c",
497        "core.hooksPath=/dev/null",
498        "-c",
499        "receive.denyCurrentBranch=updateInstead",
500        "-c",
501        "receive.denyNonFastForwards=true",
502        "-c",
503        "receive.denyDeletes=true",
504        command,
505    ]);
506    git.arg(repository)
507        .stdin(Stdio::piped())
508        .stdout(Stdio::piped())
509        .stderr(Stdio::inherit());
510    let mut git = match git.spawn().with_context(|| format!("start git {command}")) {
511        Ok(git) => git,
512        Err(error) => return refuse_exchange(bridge_input, bridge_output, error).await,
513    };
514    let (Some(mut git_input), Some(mut git_output)) = (git.stdin.take(), git.stdout.take()) else {
515        if let Err(kill_error) = git.kill().await {
516            tracing::warn!(%kill_error, "could not stop Git service with missing pipes");
517        }
518        let error = anyhow!("git {command} was started without both pipes");
519        return refuse_exchange(bridge_input, bridge_output, error).await;
520    };
521
522    let watch = ExchangeWatch::new(idle);
523    let watch = &watch;
524    let to_target = async {
525        let mut buffer = vec![0; 64 * 1024];
526        let mut failure = None;
527        loop {
528            let count = tokio::select! {
529                biased;
530                // The watchdog gave up on this exchange: let go of a service
531                // that has stopped moving and end our half of the stream.
532                () = watch.aborted() => break,
533                // `read` is cancel-safe, and every frame is written outside
534                // the select, so no partial frame can reach the target.
535                result = git_output.read(&mut buffer) => match result {
536                    Ok(0) => break,
537                    Ok(count) => count,
538                    Err(error) => {
539                        failure = Some(anyhow!(error).context(format!("read git {command} output")));
540                        break;
541                    }
542                },
543            };
544            watch.mark();
545            write_frame(bridge_input, &buffer[..count]).await?;
546        }
547        // Exactly one end frame per exchange, on every path: the target reads
548        // until it arrives.
549        write_frame(bridge_input, &[]).await?;
550        Ok::<_, anyhow::Error>(failure)
551    };
552    let from_target = async move {
553        loop {
554            match read_frame(bridge_output, MAX_FRAME).await? {
555                // A service that has already exited must not stop the drain:
556                // the frame stream stays in sync only if every frame of this
557                // exchange is read. Writing to the service is local to this
558                // exchange, so the watchdog may abandon a write; reading the
559                // shared frame stream never stops short of the end frame.
560                Frame::Data(frame) => {
561                    watch.mark();
562                    if !watch.is_aborted() {
563                        tokio::select! {
564                            biased;
565                            () = watch.aborted() => {}
566                            result = git_input.write_all(&frame) => {
567                                let _ = result;
568                            }
569                        }
570                    }
571                }
572                Frame::End => break,
573                Frame::Closed => bail!("the target Git bridge closed mid-exchange"),
574            }
575        }
576        // Closing the handle is what ends the service's input: shutting a
577        // child's stdin down leaves the pipe open, and Git would wait on it
578        // forever.
579        drop(git_input);
580        Ok::<_, anyhow::Error>(())
581    };
582    // Both halves always run to completion, so one interrupted transfer can
583    // never leave unread frames in front of the next exchange.
584    let ((service_failure, ()), stalled) =
585        watch_exchange(watch, async { tokio::try_join!(to_target, from_target) }).await?;
586    if let Some(stalled) = stalled {
587        // A service that outlived its exchange would hold the repository and
588        // its pipes for as long as it liked.
589        if let Err(kill_error) = git.kill().await {
590            tracing::warn!(
591                service = command,
592                %kill_error,
593                "could not stop stalled Git service"
594            );
595        }
596        return Ok(Exchange::Failed(
597            stalled.context(format!("git {command} stalled")),
598        ));
599    }
600    if let Some(failure) = service_failure {
601        if let Err(kill_error) = git.kill().await {
602            tracing::warn!(
603                service = command,
604                %kill_error,
605                "could not stop failed Git service"
606            );
607        }
608        return Ok(Exchange::Failed(failure));
609    }
610    match git.wait().await {
611        Ok(status) if status.success() => Ok(Exchange::Completed),
612        Ok(status) => Ok(Exchange::Failed(anyhow!(
613            "git {command} exited with {status}"
614        ))),
615        Err(error) => Ok(Exchange::Failed(
616            anyhow!(error).context("wait for Git service"),
617        )),
618    }
619}
620
621async fn write_frame(writer: &mut (impl AsyncWrite + Unpin), data: &[u8]) -> Result<()> {
622    ensure!(data.len() <= MAX_FRAME, "Git bridge frame is too large");
623    writer.write_u32(data.len() as u32).await?;
624    writer.write_all(data).await?;
625    writer.flush().await?;
626    Ok(())
627}
628
629/// One read from a bridge frame stream.
630enum Frame {
631    /// Payload bytes belonging to the exchange in progress.
632    Data(Vec<u8>),
633    /// The peer finished its half of the exchange in progress.
634    End,
635    /// The peer closed the stream: no further exchange is possible.
636    Closed,
637}
638
639async fn read_frame(reader: &mut (impl AsyncRead + Unpin), maximum: usize) -> Result<Frame> {
640    let length = match reader.read_u32().await {
641        Ok(length) => length as usize,
642        Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => {
643            return Ok(Frame::Closed);
644        }
645        Err(error) => return Err(error.into()),
646    };
647    if length == 0 {
648        return Ok(Frame::End);
649    }
650    ensure!(length <= maximum, "Git bridge frame is too large");
651    let mut data = vec![0; length];
652    reader.read_exact(&mut data).await?;
653    Ok(Frame::Data(data))
654}
655
656#[cfg(unix)]
657pub async fn run_worker_bridge(root: &Path) -> Result<()> {
658    run_worker_bridge_over(
659        root,
660        tokio::io::stdin(),
661        tokio::io::stdout(),
662        HANDSHAKE_DEADLINE,
663        EXCHANGE_IDLE_DEADLINE,
664    )
665    .await
666}
667
668#[cfg(unix)]
669async fn run_worker_bridge_over(
670    root: &Path,
671    mut broker_input: impl AsyncRead + Unpin,
672    mut broker_output: impl AsyncWrite + Unpin,
673    handshake: Duration,
674    idle: Duration,
675) -> Result<()> {
676    use tokio::net::UnixListener;
677
678    std::fs::create_dir_all(root)?;
679    let socket = root.join("git.sock");
680    if socket.exists() {
681        std::fs::remove_file(&socket)
682            .with_context(|| format!("remove stale Git proxy socket {}", socket.display()))?;
683    }
684    let listener = UnixListener::bind(&socket)
685        .with_context(|| format!("bind Git proxy socket {}", socket.display()))?;
686    use std::os::unix::fs::PermissionsExt;
687    std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o600))?;
688    broker_output.write_all(BRIDGE_MAGIC).await?;
689    broker_output.flush().await?;
690    loop {
691        let stream = tokio::select! {
692            accepted = listener.accept() => accepted.context("accept Git proxy client")?.0,
693            // Between exchanges the broker sends nothing, so the only thing
694            // this read can report is the broker going away. Nothing is in
695            // flight to lose when the branch that is not taken is dropped.
696            frame = read_frame(&mut broker_input, MAX_FRAME) => match frame? {
697                Frame::Closed => return Ok(()),
698                _ => bail!("Git broker sent a frame between exchanges"),
699            },
700        };
701        match serve_client(
702            stream,
703            &mut broker_input,
704            &mut broker_output,
705            handshake,
706            idle,
707        )
708        .await?
709        {
710            Exchange::Completed => {}
711            Exchange::Failed(error) => {
712                tracing::warn!(error = format!("{error:#}"), "bridged Git client failed")
713            }
714        }
715    }
716}
717
718/// Bridge one accepted client through the broker's frame stream.
719#[cfg(unix)]
720async fn serve_client(
721    stream: tokio::net::UnixStream,
722    broker_input: &mut (impl AsyncRead + Unpin),
723    broker_output: &mut (impl AsyncWrite + Unpin),
724    handshake: Duration,
725    idle: Duration,
726) -> Result<Exchange> {
727    let (mut read, mut write) = stream.into_split();
728    // Nothing has been framed upstream yet, so a client that dies — or never
729    // speaks — during its handshake costs the bridge nothing but this wait.
730    let open = match tokio::time::timeout(handshake, read_handshake(&mut read)).await {
731        Ok(Ok(open)) => open,
732        Ok(Err(error)) => return Ok(Exchange::Failed(error)),
733        Err(_) => {
734            return Ok(Exchange::Failed(anyhow!(
735                "a Git proxy client sent no handshake within {} seconds",
736                handshake.as_secs_f64()
737            )));
738        }
739    };
740    write_frame(broker_output, &open).await?;
741
742    let watch = ExchangeWatch::new(idle);
743    let watch = &watch;
744    let served = tokio::sync::Notify::new();
745    let to_broker = async {
746        let mut buffer = vec![0; 64 * 1024];
747        let mut failure = None;
748        loop {
749            let count = tokio::select! {
750                biased;
751                // The service finished, so stop reading a client that may
752                // never hang up instead of holding the exchange open.
753                () = served.notified() => break,
754                // The watchdog gave up on this exchange: let go of a client
755                // that has stopped moving and end our half of the stream.
756                () = watch.aborted() => break,
757                // `read` is cancel-safe, and every frame is written outside
758                // the select, so no partial frame can reach the broker.
759                result = read.read(&mut buffer) => match result {
760                    Ok(0) => break,
761                    Ok(count) => count,
762                    Err(error) => {
763                        failure = Some(anyhow!(error).context("read Git proxy client"));
764                        break;
765                    }
766                },
767            };
768            watch.mark();
769            write_frame(broker_output, &buffer[..count]).await?;
770        }
771        // Exactly one end frame per exchange, on every path.
772        write_frame(broker_output, &[]).await?;
773        Ok::<_, anyhow::Error>(failure)
774    };
775    let from_broker = async {
776        loop {
777            match read_frame(broker_input, MAX_FRAME).await? {
778                // A client that has gone away must not stop the drain: the
779                // frame stream stays in sync only if every frame of this
780                // exchange is read. Writing to the client is local to this
781                // exchange, so the watchdog may abandon a write; reading the
782                // shared frame stream never stops short of the end frame.
783                Frame::Data(frame) => {
784                    watch.mark();
785                    if !watch.is_aborted() {
786                        tokio::select! {
787                            biased;
788                            () = watch.aborted() => {}
789                            result = write.write_all(&frame) => {
790                                let _ = result;
791                            }
792                        }
793                    }
794                }
795                Frame::End => break,
796                Frame::Closed => bail!("the Git broker closed the bridge"),
797            }
798        }
799        if let Err(error) = write.shutdown().await {
800            tracing::debug!(%error, "Git proxy client closed before its response was flushed");
801        }
802        served.notify_one();
803        Ok::<_, anyhow::Error>(())
804    };
805    let (client_failure, stalled) = watch_exchange(watch, async {
806        let (failure, ()) = tokio::try_join!(to_broker, from_broker)?;
807        Ok(failure)
808    })
809    .await?;
810    Ok(match stalled.or(client_failure) {
811        Some(error) => Exchange::Failed(error),
812        None => Exchange::Completed,
813    })
814}
815
816#[cfg(unix)]
817pub async fn run_worker_proxy(root: &Path, repository: &str, service: &str) -> Result<()> {
818    use tokio::net::UnixStream;
819
820    crate::hel_config::validate_id("repository", repository)?;
821    ensure!(
822        matches!(service, "git-upload-pack" | "git-receive-pack"),
823        "unsupported Git service"
824    );
825    let mut socket = UnixStream::connect(root.join("git.sock"))
826        .await
827        .with_context(|| format!("connect Git bridge at {}", root.display()))?;
828    let open = serde_json::to_vec(&GitOpen {
829        repository: repository.into(),
830        service: service.into(),
831    })?;
832    socket.write_all(&open).await?;
833    socket.write_all(b"\n").await?;
834    let (mut socket_read, mut socket_write) = socket.into_split();
835    let mut stdin = tokio::io::stdin();
836    let mut stdout = tokio::io::stdout();
837    let from_git = async {
838        tokio::io::copy(&mut stdin, &mut socket_write).await?;
839        socket_write.shutdown().await
840    };
841    let to_git = async {
842        tokio::io::copy(&mut socket_read, &mut stdout).await?;
843        stdout.shutdown().await
844    };
845    tokio::pin!(from_git);
846    tokio::pin!(to_git);
847    tokio::select! {
848        result = &mut to_git => {
849            result?;
850        }
851        result = &mut from_git => {
852            result?;
853            to_git.await?;
854        }
855    }
856    Ok(())
857}
858
859#[cfg(unix)]
860async fn read_handshake(reader: &mut (impl AsyncRead + Unpin)) -> Result<Vec<u8>> {
861    let mut data = Vec::new();
862    loop {
863        ensure!(data.len() < MAX_OPEN, "Git proxy handshake is too large");
864        let byte = reader.read_u8().await?;
865        if byte == b'\n' {
866            break;
867        }
868        data.push(byte);
869    }
870    Ok(data)
871}
872
873#[cfg(not(unix))]
874pub async fn run_worker_bridge(_root: &Path) -> Result<()> {
875    bail!("Git proxy workers require Unix")
876}
877
878#[cfg(not(unix))]
879pub async fn run_worker_proxy(_root: &Path, _repository: &str, _service: &str) -> Result<()> {
880    bail!("Git proxy workers require Unix")
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886
887    /// Read one exchange's frames through the peer's end frame.
888    async fn read_exchange(reader: &mut (impl AsyncRead + Unpin)) -> Vec<u8> {
889        let mut received = Vec::new();
890        loop {
891            match read_frame(reader, MAX_FRAME).await.unwrap() {
892                Frame::Data(frame) => received.extend_from_slice(&frame),
893                Frame::End => return received,
894                Frame::Closed => panic!("the bridge stream closed mid-exchange"),
895            }
896        }
897    }
898
899    async fn read_data_frame(reader: &mut (impl AsyncRead + Unpin)) -> Vec<u8> {
900        match read_frame(reader, MAX_FRAME).await.unwrap() {
901            Frame::Data(frame) => frame,
902            Frame::End => panic!("expected a data frame, not the end of an exchange"),
903            Frame::Closed => panic!("expected a data frame, not a closed stream"),
904        }
905    }
906
907    #[cfg(unix)]
908    async fn write_all_framed(writer: &mut (impl AsyncWrite + Unpin), data: &[u8]) {
909        for chunk in data.chunks(64 * 1024) {
910            write_frame(writer, chunk).await.unwrap();
911        }
912        write_frame(writer, &[]).await.unwrap();
913    }
914
915    /// What a client writes to the proxy socket to open its exchange.
916    #[cfg(unix)]
917    fn handshake_line(repository: &str) -> Vec<u8> {
918        let mut line = open_frame(repository);
919        line.push(b'\n');
920        line
921    }
922
923    fn open_frame(repository: &str) -> Vec<u8> {
924        serde_json::to_vec(&GitOpen {
925            repository: repository.into(),
926            service: "git-upload-pack".into(),
927        })
928        .unwrap()
929    }
930
931    fn git(directory: &Path, args: &[&str]) {
932        let status = std::process::Command::new("git")
933            .args(args)
934            .current_dir(directory)
935            .status()
936            .unwrap();
937        assert!(status.success(), "git {args:?} failed");
938    }
939
940    /// A repository whose ref advertisement is far larger than one pipe
941    /// buffer, so a bridged transfer really has to stream.
942    fn repository_with_large_advertisement(root: &Path) -> PathBuf {
943        let repository = root.join("main");
944        std::fs::create_dir_all(&repository).unwrap();
945        git(&repository, &["init", "-q", "-b", "main"]);
946        git(&repository, &["config", "user.name", "Hel Test"]);
947        git(&repository, &["config", "user.email", "hel@example.test"]);
948        std::fs::write(repository.join("tracked"), "content").unwrap();
949        git(&repository, &["add", "."]);
950        git(&repository, &["commit", "-qm", "base"]);
951        let head = std::process::Command::new("git")
952            .args(["rev-parse", "HEAD"])
953            .current_dir(&repository)
954            .output()
955            .unwrap();
956        let head = String::from_utf8(head.stdout).unwrap().trim().to_owned();
957        let mut packed = String::from("# pack-refs with: peeled fully-peeled sorted \n");
958        packed.push_str(&format!("{head} refs/heads/main\n"));
959        for index in 0..3000 {
960            packed.push_str(&format!("{head} refs/tags/advertised-{index:04}\n"));
961        }
962        std::fs::write(repository.join(".git/packed-refs"), packed).unwrap();
963        repository
964    }
965
966    /// A refused request, a failing Git service, and a transfer the target
967    /// abandons midway are all one exchange's failure: the broker keeps
968    /// serving the exchanges that follow them.
969    #[tokio::test]
970    async fn the_broker_serves_later_exchanges_after_one_fails_mid_transfer() {
971        let directory = tempfile::tempdir().unwrap();
972        let repository = repository_with_large_advertisement(directory.path());
973        let not_a_repository = directory.path().join("not-a-repository");
974        std::fs::create_dir_all(&not_a_repository).unwrap();
975        let repositories = BTreeMap::from([
976            ("main".to_owned(), repository),
977            ("broken".to_owned(), not_a_repository),
978        ]);
979
980        let (broker_end, target_end) = tokio::io::duplex(16 * 1024);
981        let (mut broker_read, mut broker_write) = tokio::io::split(broker_end);
982        let (mut target_read, mut target_write) = tokio::io::split(target_end);
983        let serving = tokio::spawn(async move {
984            serve_bridge(
985                &mut broker_write,
986                &mut broker_read,
987                &repositories,
988                "test",
989                EXCHANGE_IDLE_DEADLINE,
990            )
991            .await
992        });
993
994        // An unknown repository is refused, not fatal.
995        write_frame(&mut target_write, &open_frame("absent"))
996            .await
997            .unwrap();
998        write_frame(&mut target_write, &[]).await.unwrap();
999        assert!(read_exchange(&mut target_read).await.is_empty());
1000
1001        // A Git service that exits non-zero is refused the same way.
1002        write_frame(&mut target_write, &open_frame("broken"))
1003            .await
1004            .unwrap();
1005        write_frame(&mut target_write, &[]).await.unwrap();
1006        assert!(read_exchange(&mut target_read).await.is_empty());
1007
1008        // A client that hangs up midway through a large transfer leaves the
1009        // frame stream in sync for the next exchange.
1010        write_frame(&mut target_write, &open_frame("main"))
1011            .await
1012            .unwrap();
1013        let mut abandoned = read_data_frame(&mut target_read).await.len();
1014        while abandoned <= 64 * 1024 {
1015            abandoned += read_data_frame(&mut target_read).await.len();
1016        }
1017        write_frame(&mut target_write, &[]).await.unwrap();
1018        let remainder = read_exchange(&mut target_read).await;
1019        assert!(abandoned + remainder.len() > 64 * 1024);
1020
1021        // The bridge still serves a complete exchange afterwards.
1022        write_frame(&mut target_write, &open_frame("main"))
1023            .await
1024            .unwrap();
1025        // A client that wants nothing sends a flush packet and hangs up.
1026        write_frame(&mut target_write, b"0000").await.unwrap();
1027        write_frame(&mut target_write, &[]).await.unwrap();
1028        let advertisement = read_exchange(&mut target_read).await;
1029        assert!(
1030            advertisement.len() > 64 * 1024,
1031            "advertisement was {} bytes",
1032            advertisement.len()
1033        );
1034        assert!(String::from_utf8_lossy(&advertisement).contains("refs/heads/main"));
1035
1036        // Both halves have to go for the stream itself to close.
1037        drop((target_read, target_write));
1038        serving.await.unwrap().unwrap();
1039    }
1040
1041    /// A client that dies mid-transfer must cost its own connection only.
1042    #[cfg(unix)]
1043    #[tokio::test]
1044    async fn the_worker_bridge_serves_the_next_client_after_one_dies_mid_transfer() {
1045        let directory = tempfile::tempdir().unwrap();
1046        let root = directory.path().to_path_buf();
1047        let (worker_end, broker_end) = tokio::io::duplex(16 * 1024);
1048        let (worker_read, worker_write) = tokio::io::split(worker_end);
1049        let (mut broker_read, mut broker_write) = tokio::io::split(broker_end);
1050        let serving = tokio::spawn(async move {
1051            run_worker_bridge_over(
1052                &root,
1053                worker_read,
1054                worker_write,
1055                HANDSHAKE_DEADLINE,
1056                EXCHANGE_IDLE_DEADLINE,
1057            )
1058            .await
1059        });
1060
1061        let mut magic = vec![0; BRIDGE_MAGIC.len()];
1062        broker_read.read_exact(&mut magic).await.unwrap();
1063        assert_eq!(magic, BRIDGE_MAGIC);
1064        let socket = directory.path().join("git.sock");
1065        let request = vec![b'q'; 256 * 1024];
1066        let reply = vec![b'r'; 256 * 1024];
1067
1068        // The first client streams a large request and vanishes without ever
1069        // reading its reply.
1070        let mut client = tokio::net::UnixStream::connect(&socket).await.unwrap();
1071        client
1072            .write_all(b"{\"repository\":\"main\",\"service\":\"git-upload-pack\"}\n")
1073            .await
1074            .unwrap();
1075        let open = read_data_frame(&mut broker_read).await;
1076        assert!(String::from_utf8_lossy(&open).contains("git-upload-pack"));
1077        let abandoning = tokio::spawn({
1078            let request = request.clone();
1079            async move {
1080                let _ = client.write_all(&request).await;
1081                drop(client);
1082            }
1083        });
1084        let abandoned = read_exchange(&mut broker_read).await;
1085        abandoning.await.unwrap();
1086        assert!(!abandoned.is_empty());
1087        write_all_framed(&mut broker_write, &reply).await;
1088
1089        // The next client is served in full, both ways.
1090        let (mut client_read, mut client_write) = tokio::net::UnixStream::connect(&socket)
1091            .await
1092            .unwrap()
1093            .into_split();
1094        client_write
1095            .write_all(b"{\"repository\":\"main\",\"service\":\"git-upload-pack\"}\n")
1096            .await
1097            .unwrap();
1098        let open = read_data_frame(&mut broker_read).await;
1099        assert!(String::from_utf8_lossy(&open).contains("main"));
1100        let sending = tokio::spawn(async move {
1101            client_write.write_all(&request).await.unwrap();
1102            client_write.shutdown().await.unwrap();
1103        });
1104        let receiving = tokio::spawn(async move {
1105            let mut received = Vec::new();
1106            client_read.read_to_end(&mut received).await.unwrap();
1107            received
1108        });
1109        let received_request = read_exchange(&mut broker_read).await;
1110        sending.await.unwrap();
1111        write_all_framed(&mut broker_write, &reply).await;
1112        let received_reply = receiving.await.unwrap();
1113
1114        assert_eq!(received_request.len(), 256 * 1024);
1115        assert_eq!(received_reply.len(), 256 * 1024);
1116
1117        // Closing the frame stream stops an idle bridge; both halves have to
1118        // go for the stream itself to close.
1119        drop((broker_read, broker_write));
1120        serving.await.unwrap().unwrap();
1121    }
1122
1123    /// A client that connects and then says nothing must not hold the socket
1124    /// the whole session shares.
1125    #[cfg(unix)]
1126    #[tokio::test]
1127    async fn a_client_that_never_finishes_its_handshake_is_timed_out() {
1128        let directory = tempfile::tempdir().unwrap();
1129        let root = directory.path().to_path_buf();
1130        let handshake = Duration::from_millis(500);
1131        let (worker_end, broker_end) = tokio::io::duplex(16 * 1024);
1132        let (worker_read, worker_write) = tokio::io::split(worker_end);
1133        let (mut broker_read, mut broker_write) = tokio::io::split(broker_end);
1134        let serving = tokio::spawn(async move {
1135            run_worker_bridge_over(
1136                &root,
1137                worker_read,
1138                worker_write,
1139                handshake,
1140                EXCHANGE_IDLE_DEADLINE,
1141            )
1142            .await
1143        });
1144        let mut magic = vec![0; BRIDGE_MAGIC.len()];
1145        broker_read.read_exact(&mut magic).await.unwrap();
1146        let socket = directory.path().join("git.sock");
1147
1148        // A client that connects and stalls before its newline.
1149        let mut silent = tokio::net::UnixStream::connect(&socket).await.unwrap();
1150        let line = handshake_line("silent");
1151        silent.write_all(&line[..line.len() - 1]).await.unwrap();
1152
1153        // The next client is served once the silent one runs out of time, and
1154        // the silent one never reaches the broker at all.
1155        let (mut client_read, mut client_write) = tokio::net::UnixStream::connect(&socket)
1156            .await
1157            .unwrap()
1158            .into_split();
1159        client_write
1160            .write_all(&handshake_line("served"))
1161            .await
1162            .unwrap();
1163        let open = tokio::time::timeout(Duration::from_secs(30), read_data_frame(&mut broker_read))
1164            .await
1165            .expect("a silent client held the bridge");
1166        assert!(
1167            String::from_utf8_lossy(&open).contains("served"),
1168            "first framed request was {}",
1169            String::from_utf8_lossy(&open)
1170        );
1171
1172        // A timed-out client is disconnected rather than left hanging.
1173        let mut byte = [0u8; 1];
1174        assert_eq!(
1175            tokio::time::timeout(Duration::from_secs(30), silent.read(&mut byte))
1176                .await
1177                .expect("a timed-out client was left connected")
1178                .unwrap(),
1179            0
1180        );
1181
1182        // The served exchange still completes normally. Its reply is far
1183        // larger than one pipe buffer, so the client has to be read while the
1184        // broker writes.
1185        let reply = vec![b'r'; 256 * 1024];
1186        let receiving = tokio::spawn(async move {
1187            let mut received = Vec::new();
1188            client_read.read_to_end(&mut received).await.unwrap();
1189            received
1190        });
1191        write_all_framed(&mut broker_write, &reply).await;
1192        assert!(read_exchange(&mut broker_read).await.is_empty());
1193        assert_eq!(receiving.await.unwrap().len(), 256 * 1024);
1194        drop(client_write);
1195
1196        drop((broker_read, broker_write));
1197        serving.await.unwrap().unwrap();
1198    }
1199
1200    /// A client that wedges mid-transfer must lose its own exchange and
1201    /// nothing else: the frame stream stays in sync for the next one.
1202    #[cfg(unix)]
1203    #[tokio::test]
1204    async fn a_client_that_stalls_mid_transfer_loses_only_its_own_exchange() {
1205        let directory = tempfile::tempdir().unwrap();
1206        let root = directory.path().to_path_buf();
1207        let idle = Duration::from_millis(500);
1208        let (worker_end, broker_end) = tokio::io::duplex(16 * 1024);
1209        let (worker_read, worker_write) = tokio::io::split(worker_end);
1210        let (mut broker_read, mut broker_write) = tokio::io::split(broker_end);
1211        let serving = tokio::spawn(async move {
1212            run_worker_bridge_over(&root, worker_read, worker_write, HANDSHAKE_DEADLINE, idle).await
1213        });
1214        let mut magic = vec![0; BRIDGE_MAGIC.len()];
1215        broker_read.read_exact(&mut magic).await.unwrap();
1216        let socket = directory.path().join("git.sock");
1217        let request = vec![b'q'; 256 * 1024];
1218        let reply = vec![b'r'; 256 * 1024];
1219
1220        // This client streams a large request and then wedges: it sends no
1221        // more, reads nothing, and never hangs up.
1222        let mut wedged = tokio::net::UnixStream::connect(&socket).await.unwrap();
1223        wedged.write_all(&handshake_line("wedged")).await.unwrap();
1224        let open = read_data_frame(&mut broker_read).await;
1225        assert!(String::from_utf8_lossy(&open).contains("wedged"));
1226        let started = Instant::now();
1227        let draining = tokio::spawn(async move {
1228            let received =
1229                tokio::time::timeout(Duration::from_secs(30), read_exchange(&mut broker_read))
1230                    .await
1231                    .expect("a wedged client held the bridge");
1232            (broker_read, received)
1233        });
1234        wedged.write_all(&request).await.unwrap();
1235        let (mut broker_read, received) = draining.await.unwrap();
1236        assert_eq!(received.len(), 256 * 1024);
1237        assert!(
1238            started.elapsed() >= idle,
1239            "the exchange ended after {:?}, before its idle window",
1240            started.elapsed()
1241        );
1242
1243        // The broker still owes its half of the aborted exchange, which the
1244        // bridge reads through without touching the client it gave up on.
1245        write_all_framed(&mut broker_write, &reply).await;
1246        let mut byte = [0u8; 1];
1247        assert_eq!(
1248            tokio::time::timeout(Duration::from_secs(30), wedged.read(&mut byte))
1249                .await
1250                .expect("an abandoned client was left connected")
1251                .unwrap(),
1252            0
1253        );
1254
1255        // The next client is served in full, both ways.
1256        let (mut client_read, mut client_write) = tokio::net::UnixStream::connect(&socket)
1257            .await
1258            .unwrap()
1259            .into_split();
1260        client_write
1261            .write_all(&handshake_line("main"))
1262            .await
1263            .unwrap();
1264        let open = read_data_frame(&mut broker_read).await;
1265        assert!(String::from_utf8_lossy(&open).contains("main"));
1266        let sending = tokio::spawn(async move {
1267            client_write.write_all(&request).await.unwrap();
1268            client_write.shutdown().await.unwrap();
1269        });
1270        let receiving = tokio::spawn(async move {
1271            let mut received = Vec::new();
1272            client_read.read_to_end(&mut received).await.unwrap();
1273            received
1274        });
1275        let received_request = read_exchange(&mut broker_read).await;
1276        sending.await.unwrap();
1277        write_all_framed(&mut broker_write, &reply).await;
1278        assert_eq!(received_request.len(), 256 * 1024);
1279        assert_eq!(receiving.await.unwrap().len(), 256 * 1024);
1280
1281        drop((broker_read, broker_write));
1282        serving.await.unwrap().unwrap();
1283    }
1284
1285    /// A Git service that stops moving must not hold the broker either.
1286    #[tokio::test]
1287    async fn the_broker_gives_up_on_a_service_that_stops_moving() {
1288        let directory = tempfile::tempdir().unwrap();
1289        let repository = repository_with_large_advertisement(directory.path());
1290        let repositories = BTreeMap::from([("main".to_owned(), repository)]);
1291        let idle = Duration::from_secs(1);
1292        let (broker_end, target_end) = tokio::io::duplex(16 * 1024);
1293        let (mut broker_read, mut broker_write) = tokio::io::split(broker_end);
1294        let (mut target_read, mut target_write) = tokio::io::split(target_end);
1295        let serving = tokio::spawn(async move {
1296            serve_bridge(
1297                &mut broker_write,
1298                &mut broker_read,
1299                &repositories,
1300                "test",
1301                idle,
1302            )
1303            .await
1304        });
1305
1306        // `upload-pack` advertises its refs and then waits for a client that
1307        // never answers and never hangs up.
1308        write_frame(&mut target_write, &open_frame("main"))
1309            .await
1310            .unwrap();
1311        let started = Instant::now();
1312        let advertisement =
1313            tokio::time::timeout(Duration::from_secs(60), read_exchange(&mut target_read))
1314                .await
1315                .expect("a stalled Git service held the broker");
1316        assert!(
1317            advertisement.len() > 64 * 1024,
1318            "advertisement was {} bytes",
1319            advertisement.len()
1320        );
1321        assert!(
1322            started.elapsed() >= idle,
1323            "the exchange ended after {:?}, before its idle window",
1324            started.elapsed()
1325        );
1326        // The target still owes its half of the aborted exchange.
1327        write_frame(&mut target_write, &[]).await.unwrap();
1328
1329        // The broker serves the next exchange over the same stream.
1330        write_frame(&mut target_write, &open_frame("main"))
1331            .await
1332            .unwrap();
1333        write_frame(&mut target_write, b"0000").await.unwrap();
1334        write_frame(&mut target_write, &[]).await.unwrap();
1335        let advertisement = read_exchange(&mut target_read).await;
1336        assert!(advertisement.len() > 64 * 1024);
1337        assert!(String::from_utf8_lossy(&advertisement).contains("refs/heads/main"));
1338
1339        drop((target_read, target_write));
1340        serving.await.unwrap().unwrap();
1341    }
1342
1343    /// A PID file whose broker is gone must read as dead, however it was
1344    /// left behind.
1345    #[test]
1346    fn broker_liveness_follows_the_lock_and_not_the_written_pid() {
1347        let directory = tempfile::tempdir().unwrap();
1348        let pid_path = directory.path().join("session.pid");
1349
1350        assert!(!broker_is_alive(&pid_path));
1351        assert_eq!(running_broker_pid(&pid_path), None);
1352
1353        // A PID file naming this very much alive process still reads as dead
1354        // while no broker holds its lock, and never names a process to signal.
1355        std::fs::write(&pid_path, std::process::id().to_string()).unwrap();
1356        assert!(!broker_is_alive(&pid_path));
1357        assert_eq!(running_broker_pid(&pid_path), None);
1358
1359        let claimed = claim_broker_pid_file(&pid_path).unwrap();
1360        assert!(broker_is_alive(&pid_path));
1361        assert_eq!(
1362            running_broker_pid(&pid_path),
1363            Some(std::process::id() as i32)
1364        );
1365        assert!(claim_broker_pid_file(&pid_path).is_err());
1366        let written = std::fs::read_to_string(&pid_path).unwrap();
1367        assert_eq!(written.trim(), std::process::id().to_string());
1368
1369        drop(claimed);
1370        // The lock lives on the open file description, so it survives until
1371        // every copy of that description is closed. A sibling thread that
1372        // forks while this one is open hands a copy to its child until the
1373        // child execs, which is why an owner that let go is observed free
1374        // shortly rather than instantly. Measured here: a few hundred
1375        // microseconds at worst.
1376        let released = std::time::Instant::now();
1377        while broker_is_alive(&pid_path) {
1378            assert!(
1379                released.elapsed() < Duration::from_secs(5),
1380                "a released broker lock was never observed free"
1381            );
1382            std::thread::sleep(Duration::from_millis(1));
1383        }
1384        assert_eq!(running_broker_pid(&pid_path), None);
1385    }
1386}