Skip to main content

git_zero_rustix/network/
server.rs

1//! Blocking HTTP server socket and request/response framing.
2use super::{SOCKET_FLAGS, SocketWriter, apply_timeouts, close_on_exec, receive, socket};
3use core::{
4    fmt::Write as _,
5    net::{Ipv4Addr, SocketAddr, SocketAddrV4},
6    time::Duration,
7};
8use git_zero::{
9    Error, Result,
10    http::{Request, Response},
11    io::{SliceWriter, Write},
12};
13use rustix::{
14    fd::{AsFd, OwnedFd},
15    net::{self, sockopt},
16};
17
18mod chunked;
19pub use chunked::ChunkedLimits;
20mod deadline;
21use deadline::{DeadlineReader, DeadlineWriter};
22pub use deadline::{RequestDeadlines, ResponseDeadlines};
23mod header;
24use header::{Headers, parse_request, read_header, request_line};
25
26/// Inclusive host admission quotas, separate from caller storage capacity.
27/// `None` leaves that quota unset; storage still bounds all request bytes.
28#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
29pub struct RequestLimits {
30    /// Request line, headers (including their delimiter), and encoded body.
31    pub max_wire_bytes: Option<usize>,
32    /// Decoded body bytes, including identity bodies, without HTTP headers.
33    pub max_body_bytes: Option<usize>,
34}
35
36/// Inclusive HTTP header quotas. Byte counts apply to wire bytes before UTF-8
37/// decoding; values include whitespace after the colon. Unknown and repeated
38/// ordinary fields consume the same budgets as recognized Git/framing fields.
39#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
40pub struct HeaderLimits {
41    /// Request line plus header fields and the final CRLF delimiter.
42    pub max_bytes: Option<usize>,
43    /// Number of fields, excluding the request line and final empty line.
44    pub max_fields: Option<usize>,
45    pub max_name_bytes: Option<usize>,
46    pub max_value_bytes: Option<usize>,
47}
48
49/// Independent whole-request/body and header policies. Unset defaults preserve
50/// the storage-bounded parser. These quotas do not impose a total time deadline.
51#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
52pub struct RequestPolicy {
53    pub request: RequestLimits,
54    pub headers: HeaderLimits,
55}
56
57/// Per-blocking-call timeouts applied only to the accepted socket. These do not
58/// bound accept, a complete request, or a complete response. `None` disables the
59/// corresponding timeout; zero durations are invalid. The OS may round values.
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub struct AcceptedSocketTimeouts {
62    pub read: Option<Duration>,
63    pub write: Option<Duration>,
64}
65
66impl Default for AcceptedSocketTimeouts {
67    fn default() -> Self {
68        Self {
69            read: Some(Duration::from_secs(10)),
70            write: Some(Duration::from_secs(10)),
71        }
72    }
73}
74
75pub fn listen(port: u16) -> Result<OwnedFd> {
76    listen_on(SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, port)))
77}
78
79/// Bind an explicit numeric IPv4 or IPv6 address. IPv6 listeners accept only
80/// IPv6 connections; bind a separate IPv4 listener when both are required.
81/// Port zero is assigned by the OS and can be read with `local_address`.
82pub fn listen_on(address: SocketAddr) -> Result<OwnedFd> {
83    let family = if address.is_ipv4() {
84        net::AddressFamily::INET
85    } else {
86        net::AddressFamily::INET6
87    };
88    let socket = socket(family)?;
89    // OpenBSD IPv6 sockets are always IPv6-only and this option is read-only.
90    #[cfg(not(target_os = "openbsd"))]
91    if address.is_ipv6() {
92        sockopt::set_ipv6_v6only(&socket, true).map_err(|_| Error::Backend)?;
93    }
94    sockopt::set_socket_reuseaddr(&socket, true).map_err(|_| Error::Backend)?;
95    net::bind(&socket, &address).map_err(|_| Error::Backend)?;
96    net::listen(&socket, 128).map_err(|_| Error::Backend)?;
97    Ok(socket)
98}
99
100pub fn local_port(socket: &impl AsFd) -> Result<u16> {
101    Ok(local_address(socket)?.port())
102}
103
104/// Read the actual bound IP address, including an OS-assigned port.
105pub fn local_address(socket: &impl AsFd) -> Result<SocketAddr> {
106    let address = net::getsockname(socket).map_err(|_| Error::Backend)?;
107    SocketAddr::try_from(address).map_err(|_| Error::Backend)
108}
109
110pub fn accept(socket: &impl AsFd) -> Result<OwnedFd> {
111    accept_with_timeouts(socket, AcceptedSocketTimeouts::default())
112}
113
114/// Accept with host-selected child socket timeouts, preserving close-on-exec.
115/// Invalid zero durations are rejected before accepting; configuration failures
116/// close the child before returning `Backend`. The listener is unchanged.
117pub fn accept_with_timeouts(
118    socket: &impl AsFd,
119    timeouts: AcceptedSocketTimeouts,
120) -> Result<OwnedFd> {
121    if [timeouts.read, timeouts.write]
122        .into_iter()
123        .flatten()
124        .any(|value| value.is_zero())
125    {
126        return Err(Error::InvalidRequest);
127    }
128    accept_configured(socket, |client| {
129        apply_timeouts(client, timeouts.read, timeouts.write)
130    })
131}
132
133// Own the accepted descriptor throughout configuration, including failure.
134fn accept_configured(
135    socket: &impl AsFd,
136    configure: impl FnOnce(&OwnedFd) -> Result<()>,
137) -> Result<OwnedFd> {
138    let client =
139        close_on_exec(net::accept_with(socket, SOCKET_FLAGS).map_err(|_| Error::Backend)?)?;
140    configure(&client)?;
141    Ok(client)
142}
143
144/// Read one bounded request. Gzip bodies use the storage remaining after the
145/// compressed wire bytes and a fixed 32 KiB stack history window. Compressed
146/// plus expanded bytes and headers must fit `storage`; chunked input is rejected.
147pub fn read_request<'a>(socket: &impl AsFd, storage: &'a mut [u8]) -> Result<Request<'a>> {
148    read_request_with_limits(socket, storage, RequestLimits::default())
149}
150
151/// Read one request with inclusive logical quotas. Known policy exhaustion
152/// returns `LimitExceeded`; insufficient physical storage returns
153/// `BufferTooSmall`. A tied policy/capacity boundary is a policy failure.
154/// Gzip retains the complete wire body and original physical expansion slack.
155/// All storage is provisional on failure; discard it and close the socket.
156pub fn read_request_with_limits<'a>(
157    socket: &impl AsFd,
158    storage: &'a mut [u8],
159    limits: RequestLimits,
160) -> Result<Request<'a>> {
161    read_request_with_policy(
162        socket,
163        storage,
164        RequestPolicy {
165            request: limits,
166            headers: HeaderLimits::default(),
167        },
168    )
169}
170
171/// Apply header admission during intake and the single metadata-validation pass.
172/// An incomplete header stops at its exact byte quota without reading a byte
173/// beyond it. Field quotas are checked after that field's syntax, before body
174/// intake/inflation and repository preparation. Failure invalidates all storage.
175pub fn read_request_with_policy<'a>(
176    socket: &impl AsFd,
177    storage: &'a mut [u8],
178    policy: RequestPolicy,
179) -> Result<Request<'a>> {
180    read_request_from(&mut |bytes| receive(socket, bytes), storage, policy)
181}
182
183/// Apply monotonic total-intake and idle-progress deadlines in addition to byte
184/// quotas and accepted-socket read timeouts. The deadline starts on this call,
185/// covers header/body intake and the final synchronous parse/inflation check,
186/// and never resets when a peer drips another byte. It excludes accept, Git
187/// preparation and response transmission. Close the socket on every error.
188/// Expired host deadlines return `LimitExceeded`; a shorter socket timeout
189/// retains `Backend`. Socket flags and timeout settings remain unchanged.
190/// With both deadlines unset this is identical to `read_request_with_policy`.
191pub fn read_request_with_deadlines<'a>(
192    socket: &impl AsFd,
193    storage: &'a mut [u8],
194    policy: RequestPolicy,
195    deadlines: RequestDeadlines,
196) -> Result<Request<'a>> {
197    read_socket_request(socket, storage, policy, deadlines, None)
198}
199
200/// Host transport boundary for authenticated TLS or other blocking streams.
201/// The callback fills at most the supplied slice, returns zero on EOF, and owns
202/// its transport deadlines/cancellation. Any error invalidates storage. Rust
203/// owns HTTP/Git parsing; the host owns peer authentication and TLS buffering.
204/// This default entry rejects Transfer-Encoding, as does the socket reader.
205pub fn read_request_from<'a>(
206    receive: &mut impl FnMut(&mut [u8]) -> Result<usize>,
207    storage: &'a mut [u8],
208    policy: RequestPolicy,
209) -> Result<Request<'a>> {
210    read_request_framed(receive, storage, policy, None)
211}
212
213/// Opt-in bounded chunked HTTP/1.1 intake over a host-supplied transport.
214/// Chunk extensions and nonempty trailers are explicitly unsupported. Duplicate
215/// transfer fields, Content-Length conflicts and invalid chunk syntax fail before
216/// returning a request. Responses still close the connection; retain no pipeline.
217pub fn read_request_from_chunked<'a>(
218    receive: &mut impl FnMut(&mut [u8]) -> Result<usize>,
219    storage: &'a mut [u8],
220    policy: RequestPolicy,
221    chunks: ChunkedLimits,
222) -> Result<Request<'a>> {
223    read_request_framed(receive, storage, policy, Some(chunks))
224}
225
226/// Bounded chunked intake with independent monotonic request deadlines.
227pub fn read_request_chunked<'a>(
228    socket: &impl AsFd,
229    storage: &'a mut [u8],
230    policy: RequestPolicy,
231    deadlines: RequestDeadlines,
232    chunks: ChunkedLimits,
233) -> Result<Request<'a>> {
234    read_socket_request(socket, storage, policy, deadlines, Some(chunks))
235}
236
237fn read_socket_request<'a>(
238    socket: &impl AsFd,
239    storage: &'a mut [u8],
240    policy: RequestPolicy,
241    deadlines: RequestDeadlines,
242    chunks: Option<ChunkedLimits>,
243) -> Result<Request<'a>> {
244    if deadlines == RequestDeadlines::default() {
245        return read_request_framed(&mut |bytes| receive(socket, bytes), storage, policy, chunks);
246    }
247    let mut reader = DeadlineReader::new(socket, deadlines)?;
248    let result = read_request_framed(&mut |bytes| reader.receive(bytes), storage, policy, chunks);
249    reader.check()?;
250    result
251}
252
253fn read_request_framed<'a>(
254    receive: &mut impl FnMut(&mut [u8]) -> Result<usize>,
255    storage: &'a mut [u8],
256    policy: RequestPolicy,
257    chunks: Option<ChunkedLimits>,
258) -> Result<Request<'a>> {
259    let limits = policy.request;
260    let (end, length) = read_header(
261        receive,
262        storage,
263        limits.max_wire_bytes,
264        policy.headers.max_bytes,
265    )?;
266    let (header, storage) = storage.split_at_mut(end);
267    let length = length - end;
268    let header = core::str::from_utf8(header).map_err(|_| Error::InvalidRequest)?;
269    let line = request_line(header)?;
270    let headers = if chunks.is_some() {
271        Headers::parse_framed(header, policy.headers, true)?
272    } else {
273        Headers::parse(header, policy.headers)?
274    };
275    let mut request = parse_request(line, &headers)?;
276    let identity = headers
277        .content_encoding
278        .is_none_or(|value| value.eq_ignore_ascii_case("identity"));
279    let (wire_length, body_length) = if headers.chunked {
280        if !line.ends_with(" HTTP/1.1") {
281            return Err(Error::InvalidRequest);
282        }
283        chunked::read(
284            receive,
285            storage,
286            length,
287            limits.max_wire_bytes.map(|limit| limit - end),
288            if identity {
289                limits.max_body_bytes
290            } else {
291                None
292            },
293            chunks.unwrap(),
294        )?
295    } else {
296        let total = headers
297            .content_length
298            .map(super::parse_length)
299            .transpose()?
300            .unwrap_or(0);
301        let total = read_fixed_body(
302            receive,
303            storage,
304            length,
305            total,
306            RequestLimits {
307                max_wire_bytes: limits.max_wire_bytes.map(|limit| limit - end),
308                max_body_bytes: if identity {
309                    limits.max_body_bytes
310                } else {
311                    None
312                },
313            },
314        )?;
315        (total, total)
316    };
317    let (wire, expanded) = storage.split_at_mut(wire_length);
318    request.body = decode_body(
319        &wire[..body_length],
320        expanded,
321        headers.content_encoding,
322        limits.max_body_bytes,
323    )?;
324    Ok(request)
325}
326
327fn read_fixed_body(
328    receive: &mut impl FnMut(&mut [u8]) -> Result<usize>,
329    storage: &mut [u8],
330    mut length: usize,
331    total: usize,
332    limits: RequestLimits,
333) -> Result<usize> {
334    if limits.max_wire_bytes.is_some_and(|limit| total > limit)
335        || limits.max_body_bytes.is_some_and(|limit| total > limit)
336    {
337        return Err(Error::LimitExceeded);
338    }
339    if total > storage.len() {
340        return Err(Error::BufferTooSmall);
341    }
342    while length < total {
343        let count = receive(&mut storage[length..total])?;
344        if count == 0 {
345            return Err(Error::InvalidRequest);
346        }
347        if count > total - length {
348            return Err(Error::Backend);
349        }
350        length += count;
351    }
352    Ok(total)
353}
354
355fn decode_body<'a>(
356    body: &'a [u8],
357    expanded: &'a mut [u8],
358    encoding: Option<&str>,
359    max_body_bytes: Option<usize>,
360) -> Result<&'a [u8]> {
361    match encoding {
362        None => Ok(body),
363        Some(value) if value.eq_ignore_ascii_case("identity") => Ok(body),
364        Some(value) if value.eq_ignore_ascii_case("gzip") => {
365            use git_zero::zlib::{InflateLimits, WINDOW_SIZE, decode_gzip};
366            let length = decode_gzip(
367                body,
368                expanded,
369                &mut [0; WINDOW_SIZE],
370                InflateLimits {
371                    max_output: max_body_bytes
372                        .map_or(expanded.len(), |limit| limit.min(expanded.len()))
373                        as u64,
374                    max_ratio: 128,
375                    ratio_slack: expanded.len() as u64,
376                },
377            )
378            .map_err(|error| {
379                // decode_gzip's only BufferTooSmall path is incomplete output.
380                // Input exhaustion is InvalidZlib because input is complete.
381                if error == Error::BufferTooSmall
382                    && max_body_bytes.is_some_and(|limit| limit <= expanded.len())
383                {
384                    Error::LimitExceeded
385                } else {
386                    error
387                }
388            })?;
389            Ok(&expanded[..length])
390        }
391        _ => Err(Error::Unsupported),
392    }
393}
394
395pub fn respond(socket: &impl AsFd, response: Response, body: &[u8]) -> Result<()> {
396    respond_headers(socket, response, body.len() as u64)?;
397    SocketWriter(socket.as_fd()).write_all(body)
398}
399
400/// Send a prepared pull response with HTTP/1.0 close-delimited framing and
401/// caller-owned staging. Each successful socket write precedes the next pull.
402/// On any failure cancel the cursor and close the socket; never retry headers.
403pub fn respond_stream(
404    socket: &impl AsFd,
405    repository: &impl git_zero::Repository,
406    encoder: &mut git_zero::http::stream::ResponseEncoder<'_>,
407    staging: &mut [u8],
408) -> Result<()> {
409    respond_stream_using(
410        &mut SocketWriter(socket.as_fd()),
411        repository,
412        encoder,
413        staging,
414    )
415}
416
417/// Bound an entire streamed response and gaps in successful socket writes.
418/// Clock checks bracket every synchronous provider pull and EOF; a blocking
419/// provider cannot be preempted. Any expiry cancels the response without a
420/// successful protocol terminator. Close the transport on every error.
421pub fn respond_stream_with_deadlines(
422    socket: &impl AsFd,
423    repository: &impl git_zero::Repository,
424    encoder: &mut git_zero::http::stream::ResponseEncoder<'_>,
425    staging: &mut [u8],
426    deadlines: ResponseDeadlines,
427) -> Result<()> {
428    if deadlines == ResponseDeadlines::default() {
429        return respond_stream(socket, repository, encoder, staging);
430    }
431    let mut writer = match DeadlineWriter::new(socket, deadlines) {
432        Ok(writer) => writer,
433        Err(error) => {
434            encoder.cancel();
435            return Err(error);
436        }
437    };
438    respond_stream_using(&mut writer, repository, encoder, staging)
439}
440
441fn respond_stream_using(
442    writer: &mut impl Write,
443    repository: &impl git_zero::Repository,
444    encoder: &mut git_zero::http::stream::ResponseEncoder<'_>,
445    staging: &mut [u8],
446) -> Result<()> {
447    let result = respond_body(writer, encoder.response(), None, staging, |bytes| {
448        encoder.read(repository, bytes)
449    });
450    if result.is_err() {
451        encoder.cancel();
452    }
453    result
454}
455
456fn respond_body(
457    writer: &mut impl Write,
458    response: Response,
459    mut remaining: Option<u64>,
460    staging: &mut [u8],
461    mut read: impl FnMut(&mut [u8]) -> Result<usize>,
462) -> Result<()> {
463    if staging.is_empty() {
464        return Err(Error::BufferTooSmall);
465    }
466    let mut storage = [0; 512];
467    let mut header = SliceWriter::new(&mut storage);
468    write!(
469        &mut header,
470        "HTTP/1.0 {} Response\r\nContent-Type: {}\r\n",
471        response.status, response.content_type
472    )
473    .map_err(|_| Error::BufferTooSmall)?;
474    if let Some(size) = remaining {
475        write!(&mut header, "Content-Length: {size}\r\n").map_err(|_| Error::BufferTooSmall)?;
476    }
477    header.write_all(b"Cache-Control: no-store\r\nConnection: close\r\n\r\n")?;
478    writer.write_all(header.as_bytes())?;
479    loop {
480        writer.write_all(&[])?; // Deadline check before entering the provider.
481        let count = read(staging)?;
482        if let Some(left) = &mut remaining {
483            *left = left.checked_sub(count as u64).ok_or(Error::Backend)?;
484        }
485        writer.write_all(&staging[..count])?; // Also check EOF/late provider work.
486        if count == 0 {
487            return if remaining.unwrap_or(0) == 0 {
488                Ok(())
489            } else {
490                Err(Error::Backend)
491            };
492        }
493    }
494}
495
496/// Stream a publication response with its known complete length. Each cursor
497/// pins its provider; failed writes cancel only that response. Close the socket
498/// on errors, including a provider that was changed/truncated after publication.
499pub fn respond_published<R: git_zero::Repository>(
500    socket: &impl AsFd,
501    encoder: &mut git_zero::http::dumb::PublishedResponse<'_, R>,
502    staging: &mut [u8],
503) -> Result<()> {
504    let result = respond_known(
505        socket,
506        encoder.response(),
507        encoder.size(),
508        staging,
509        |bytes| encoder.read(bytes),
510    );
511    if result.is_err() {
512        encoder.cancel();
513    }
514    result
515}
516
517/// Known-length publication responses with the same total/idle write budget.
518/// Deadline configuration errors and late provider failures cancel this cursor.
519pub fn respond_published_with_deadlines<R: git_zero::Repository>(
520    socket: &impl AsFd,
521    encoder: &mut git_zero::http::dumb::PublishedResponse<'_, R>,
522    staging: &mut [u8],
523    deadlines: ResponseDeadlines,
524) -> Result<()> {
525    if deadlines == ResponseDeadlines::default() {
526        return respond_published(socket, encoder, staging);
527    }
528    let result = DeadlineWriter::new(socket, deadlines).and_then(|mut writer| {
529        respond_body(
530            &mut writer,
531            encoder.response(),
532            Some(encoder.size()),
533            staging,
534            |bytes| encoder.read(bytes),
535        )
536    });
537    if result.is_err() {
538        encoder.cancel();
539    }
540    result
541}
542
543/// Send a previously verified on-disk publication, with independent file reads
544/// and response-local cancellation on any provider or socket error.
545#[cfg(feature = "storage")]
546pub fn respond_file_published<R: git_zero::Repository>(
547    socket: &impl AsFd,
548    encoder: &mut crate::FilePublishedResponse<'_, R>,
549    staging: &mut [u8],
550) -> Result<()> {
551    let result = respond_known(
552        socket,
553        encoder.response(),
554        encoder.size(),
555        staging,
556        |bytes| encoder.read(bytes),
557    );
558    if result.is_err() {
559        encoder.cancel();
560    }
561    result
562}
563
564fn respond_known(
565    socket: &impl AsFd,
566    response: Response,
567    size: u64,
568    staging: &mut [u8],
569    read: impl FnMut(&mut [u8]) -> Result<usize>,
570) -> Result<()> {
571    respond_body(
572        &mut SocketWriter(socket.as_fd()),
573        response,
574        Some(size),
575        staging,
576        read,
577    )
578}
579
580/// Write framing for a body streamed separately by the host. The host must
581/// then write exactly `body_length` bytes and close the connection on failure.
582pub fn respond_headers(socket: &impl AsFd, response: Response, body_length: u64) -> Result<()> {
583    let mut storage = [0; 512];
584    let mut header = SliceWriter::new(&mut storage);
585    write!(&mut header, "HTTP/1.0 {} Response\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n", response.status, response.content_type, body_length).map_err(|_| Error::BufferTooSmall)?;
586    SocketWriter(socket.as_fd()).write_all(header.as_bytes())
587}
588
589#[cfg(test)]
590mod tests {
591    extern crate std;
592    use super::*;
593
594    #[test]
595    fn explicit_ip_listeners_report_bound_addresses_and_isolate_ipv6() {
596        for value in ["127.0.0.1:0", "[::1]:0"] {
597            let address: SocketAddr = value.parse().unwrap();
598            let listener = listen_on(address).unwrap();
599            let bound = local_address(&listener).unwrap();
600            assert_eq!(bound.ip(), address.ip());
601            assert_ne!(bound.port(), 0);
602            assert_eq!(local_port(&listener).unwrap(), bound.port());
603            assert!(
604                rustix::io::fcntl_getfd(&listener)
605                    .unwrap()
606                    .contains(rustix::io::FdFlags::CLOEXEC)
607            );
608            let family = if address.is_ipv4() {
609                net::AddressFamily::INET
610            } else {
611                assert!(sockopt::ipv6_v6only(&listener).unwrap());
612                net::AddressFamily::INET6
613            };
614            let client = socket(family).unwrap();
615            net::connect(&client, &bound).unwrap();
616            let accepted = accept(&listener).unwrap();
617            assert!(
618                rustix::io::fcntl_getfd(&accepted)
619                    .unwrap()
620                    .contains(rustix::io::FdFlags::CLOEXEC)
621            );
622            for timeout in [sockopt::Timeout::Recv, sockopt::Timeout::Send] {
623                assert_eq!(
624                    sockopt::socket_timeout(&accepted, timeout).unwrap(),
625                    Some(core::time::Duration::from_secs(10))
626                );
627            }
628        }
629    }
630
631    #[test]
632    fn disconnected_stream_cancels_before_any_more_output() {
633        let (socket, peer) = net::socketpair(
634            net::AddressFamily::UNIX,
635            net::SocketType::STREAM,
636            SOCKET_FLAGS,
637            None,
638        )
639        .unwrap();
640        let repository = git_zero::EmptyRepository::new("refs/heads/main").unwrap();
641        let request = Request {
642            method: "GET",
643            path: "HEAD",
644            query: None,
645            content_type: None,
646            git_protocol: None,
647            body: &[],
648        };
649        let mut control = [0; 128];
650        let mut encoder =
651            git_zero::http::stream::ResponseEncoder::new(&repository, request, &mut control)
652                .unwrap();
653        drop(peer);
654        assert_eq!(
655            respond_stream(&socket, &repository, &mut encoder, &mut [0; 3]),
656            Err(Error::Backend)
657        );
658        assert_eq!(
659            encoder.read(&repository, &mut [0; 3]),
660            Err(Error::InvalidRequest)
661        );
662    }
663
664    #[test]
665    fn failed_accepted_configuration_closes_the_child() {
666        use std::io::Read;
667        let listener = listen(0).unwrap();
668        let mut peer = std::net::TcpStream::connect(local_address(&listener).unwrap()).unwrap();
669        peer.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
670        assert!(matches!(
671            accept_configured(&listener, |_| Err(Error::Backend)),
672            Err(Error::Backend)
673        ));
674        assert_eq!(peer.read(&mut [0; 1]).unwrap(), 0);
675        // The listener remains usable after the failed child is discarded.
676        let _peer = std::net::TcpStream::connect(local_address(&listener).unwrap()).unwrap();
677        assert!(accept(&listener).is_ok());
678    }
679}