Skip to main content

didbot_serve/
blobs.rs

1//! The three blob methods, and the streaming that keeps them honest.
2//!
3//! `com.atproto.repo.uploadBlob`, `com.atproto.sync.getBlob` and
4//! `com.atproto.sync.listBlobs`. What is here rather than in
5//! [`crate::routes`] is everything to do with a body that does not fit in a
6//! `Bytes`: an upload is consumed a frame at a time and handed straight to
7//! the store, and a download is a stream over an open file. Neither direction
8//! ever holds a whole blob.
9//!
10//! # Which repository an upload is for
11//!
12//! `com.atproto.repo.uploadBlob` takes no parameters. In atproto the account
13//! comes from the request's authentication, and now that this route has one
14//! — see [`Credential::AccountToken`](crate::auth::Credential::AccountToken) —
15//! that is exactly where it comes from: the DID
16//! [`auth::require_agent_token_or_dpop`] resolves *is* the repository, with no
17//! separate field to disagree with it. This used to arrive in a
18//! `didbot-repo` header, back when there was no session to take it from;
19//! `plan/auth-types.md` names that header as the thing that "comes out the
20//! moment a session exists", and this is that moment.
21
22use std::pin::Pin;
23use std::task::{Context, Poll};
24
25use axum::body::{Body, Bytes};
26use axum::extract::rejection::QueryRejection;
27use axum::extract::{Query, State};
28use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
29use axum::response::{IntoResponse, Response};
30use axum::Json;
31use didbot_pds::{BlobError, EstopRefusal, Fetch, ProvisionError, DEFAULT_MIME_TYPE};
32use futures_core::Stream;
33use serde_json::json;
34
35use crate::auth;
36use crate::error::ApiError;
37use crate::routes::AppState;
38use crate::wire::{GetBlobQuery, ListBlobsQuery};
39
40/// How much of a blob is read from the store per chunk on the way out.
41///
42/// Sixty-four kibibytes, matching the verification pass in the store: the
43/// point of both numbers is that neither the syscall nor the buffer is the
44/// cost, and a blob at the size limit never has more than this much of itself
45/// in memory at once.
46const CHUNK: usize = 64 * 1024;
47
48/// How many `com.atproto.sync.getBlob` calls may hold a blocking-pool thread
49/// at once, across every caller.
50///
51/// One read takes a thread twice: once to hash the file against the CID it is
52/// filed under, and once for the task that reads the body out, which
53/// [`BLOB_READAHEAD`]'s back-pressure parks for as long as the client is slow
54/// to take it. That pool is the same one session creation, provisioning,
55/// deletion and firehose publishing draw from, and it holds fewer threads
56/// than [`crate::MAX_CONCURRENT_REQUESTS`] can put in front of it. One permit
57/// covers both threads of one read, from the hash until the body ends.
58///
59/// A caller that finds every permit taken waits for one, and
60/// [`crate::routes::REQUEST_TIMEOUT`]'s layer answers `503` if that wait
61/// outlasts the request.
62static BLOB_READ_CONCURRENCY: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(32);
63
64/// `POST /xrpc/com.atproto.repo.uploadBlob`
65///
66/// The body is streamed into the store frame by frame and never assembled.
67/// The blob's content identifier is the hash of the bytes, so it does not
68/// exist until the last frame has arrived; until then the upload is
69/// somewhere no reader can reach, and it becomes a blob in one step at the
70/// end. A request that stops half way — a client that disconnects, a body
71/// over the size limit — leaves nothing at all, because the store's upload
72/// handle cleans up after itself when it is dropped without being committed.
73///
74/// An app's OAuth token uploads under a `blob:` scope, which names the types
75/// it may upload. The declared type is checked against it before the body is
76/// read, and the type the bytes are filed under is checked again before the
77/// blob is kept. The account's own agent token is not scoped. How large a
78/// blob may be is nobody's grant: the store's own limit bounds the body as it
79/// arrives, and a policy judges the declared length before the body, again
80/// at checkpoints while it arrives, and the size that came in once it is in.
81/// A body whose length differs from its `Content-Length` is refused.
82///
83/// The blob reference in the response names the type the bytes carry a
84/// signature for wherever that contradicts the `Content-Type` the request
85/// declared, which is one of the two answers atproto's blob specification
86/// allows. A client writes that reference into its record, so the record and
87/// what `getBlob` answers say the same thing.
88pub async fn upload_blob(
89    State(state): State<AppState>,
90    headers: HeaderMap,
91    body: Body,
92) -> Response {
93    // The e-stop's write gate, unconditional regardless of credential and
94    // ahead of the credential check — the stop refuses this upload the same
95    // way it refuses every other write, and `docs/write-pipeline.md`'s stage 1
96    // answers before anything is spent finding out who is asking.
97    if let Err(halted) = state.estop.check_use(EstopRefusal::Operation) {
98        return ApiError::from(halted).into_response();
99    }
100    let writer = match auth::require_agent_token_or_dpop(
101        state.registry.as_ref(),
102        &state.oauth,
103        &headers,
104        "POST",
105        "/xrpc/com.atproto.repo.uploadBlob",
106    ) {
107        Ok(writer) => writer,
108        Err(err) => return err.into_response(),
109    };
110    let did = writer.did().to_owned();
111    // The lifecycle's half of the same gate every `com.atproto.repo.*` write
112    // passes, in the same order; see `routes::check_write_not_halted`. A
113    // blob is new data like any other, so an unclaimed server refuses it.
114    if let Err(err) = crate::routes::require_record_writes(&state) {
115        return err.into_response();
116    }
117
118    // `*/*` is what the lexicon declares, so anything is accepted. This is
119    // the caller's claim about the body, and the claim is what policy judges;
120    // the bytes settle it on commit, and the reference that comes back may
121    // name a different type than this one.
122    let mime_type = headers
123        .get(header::CONTENT_TYPE)
124        .and_then(|value| value.to_str().ok())
125        .filter(|value| !value.is_empty())
126        .unwrap_or(DEFAULT_MIME_TYPE)
127        .to_owned();
128    let declared = headers
129        .get(header::CONTENT_LENGTH)
130        .and_then(|value| value.to_str().ok())
131        .and_then(|value| value.parse::<u64>().ok());
132
133    let client_id = match crate::routes::authorize_upload(&state, &writer, &mime_type) {
134        Ok(authorized) => authorized.client_id,
135        Err(err) => return err.into_response(),
136    };
137    let mut upload = match state
138        .registry
139        .begin_blob_from(&did, &mime_type, declared, client_id)
140    {
141        Ok(upload) => upload,
142        Err(err) => return blob_error(&err).into_response(),
143    };
144    // Charged once policy has judged the upload, so an upload it refuses
145    // spends nothing. One operation and no bytes: `[blobs]
146    // account_quota_bytes` already bounds what this account's blobs hold,
147    // and the log gets one entry.
148    if let Err(err) = crate::routes::charge_write(&state, &did, 1, 0) {
149        return err.into_response();
150    }
151
152    // `futures_core::Stream` is polled by hand because that is the only
153    // stream trait this crate depends on; see the `futures-core` note in
154    // Cargo.toml. The alternative is `body.collect()`, which is the defect
155    // this whole route exists not to have.
156    let stream = body.into_data_stream();
157    let mut stream = std::pin::pin!(stream);
158    // The leading bytes, which the type the blob is filed under is read from.
159    let mut head = Vec::new();
160    let mut received: u64 = 0;
161    while let Some(frame) = std::future::poll_fn(|cx| stream.as_mut().poll_next(cx)).await {
162        let chunk = match frame {
163            Ok(chunk) => chunk,
164            Err(err) => {
165                // The upload is dropped here, which discards it. A client
166                // that vanished mid-body has left nothing behind.
167                tracing::info!(did, %err, "a blob upload's body ended early; discarding it");
168                return ApiError::bad_request(format!("the request body ended early: {err}"))
169                    .into_response();
170            }
171        };
172        received += chunk.len() as u64;
173        if let Some(declared) = declared.filter(|declared| received > *declared) {
174            return length_differs(&did, declared, received);
175        }
176        let room = didbot_pds::blobs::SNIFF_BYTES.saturating_sub(head.len());
177        head.extend_from_slice(&chunk[..room.min(chunk.len())]);
178        if let Err(err) = upload.write(&chunk) {
179            return blob_error(&ProvisionError::Blob(err)).into_response();
180        }
181    }
182    if let Some(declared) = declared.filter(|declared| received != *declared) {
183        return length_differs(&did, declared, received);
184    }
185
186    // The scope names types, and the bytes can overrule the declared one. A
187    // refusal here drops the upload, which discards it.
188    let filed = didbot_pds::blobs::filed_type(&mime_type, didbot_pds::blobs::sniff(&head));
189    if filed != mime_type {
190        if let Err(err) = crate::routes::authorize_upload(&state, &writer, filed) {
191            return err.into_response();
192        }
193    }
194
195    match upload.commit() {
196        Ok(reference) => {
197            tracing::info!(
198                did,
199                client = client_id.map(didbot_pds::policy::ClientId::as_str),
200                cid = %reference.cid,
201                size = reference.size,
202                mime_type = %reference.mime_type,
203                "uploaded a blob"
204            );
205            Json(json!({ "blob": reference.to_json() })).into_response()
206        }
207        Err(err) => blob_error(&ProvisionError::Blob(err)).into_response(),
208    }
209}
210
211/// The answer to an upload body that is not the length its `Content-Length`
212/// declared. atproto's blob specification: "If the actual blob upload size
213/// differs from the `Content-Length` header, the server should reject the
214/// upload." A body that runs past its length is refused at the frame that
215/// runs past it, and no later frame is read.
216fn length_differs(did: &str, declared: u64, received: u64) -> Response {
217    tracing::info!(
218        did,
219        declared,
220        received,
221        "a blob upload's body is not the length it declared; discarding it"
222    );
223    let message = if received > declared {
224        format!("the request body is longer than its Content-Length of {declared} bytes")
225    } else {
226        format!(
227            "the request body is {received} bytes, shorter than its Content-Length of {declared}"
228        )
229    };
230    ApiError::bad_request(message).into_response()
231}
232
233/// The content security policy every `com.atproto.sync.getBlob` answer
234/// carries.
235///
236/// A blob is bytes an account uploaded, served from the same origin as
237/// `/dashboard`. An HTML or SVG blob opened in a browser would otherwise run
238/// its script as this origin, and a `fetch` from there carries the
239/// dashboard's session cookie. `sandbox` gives the page an origin of its
240/// own, and `default-src 'none'` lets it load nothing. An `<img>` on another
241/// page is not bound by it, so an avatar still shows.
242const BLOB_CONTENT_SECURITY_POLICY: &str = "default-src 'none'; sandbox";
243
244/// `GET /xrpc/com.atproto.sync.getBlob`
245///
246/// The bytes as they were uploaded, having first been checked against the
247/// name they are filed under. A store that finds the wrong bytes answers
248/// `BlobCorrupt` and serves none of them: the CID is a hash, so a reader has
249/// every reason to trust what comes back, and that is exactly why a server
250/// that can tell must not serve it.
251///
252/// A store backed by object storage may answer with a redirect instead, and
253/// this handler passes one through. See [`Fetch`].
254///
255/// Every answer carries [`BLOB_CONTENT_SECURITY_POLICY`].
256pub async fn get_blob(
257    State(state): State<AppState>,
258    query: Result<Query<GetBlobQuery>, QueryRejection>,
259) -> Response {
260    let mut response = serve_blob(state, query).await;
261    response.headers_mut().insert(
262        header::CONTENT_SECURITY_POLICY,
263        HeaderValue::from_static(BLOB_CONTENT_SECURITY_POLICY),
264    );
265    response
266}
267
268/// [`get_blob`]'s answer, before its content security policy.
269async fn serve_blob(
270    state: AppState,
271    query: Result<Query<GetBlobQuery>, QueryRejection>,
272) -> Response {
273    let Query(query) = match query {
274        Ok(query) => query,
275        Err(rejection) => return ApiError::bad_request(rejection.body_text()).into_response(),
276    };
277    // The permit is acquired before `spawn_blocking`, not inside it: what it
278    // bounds is blocking-pool threads (see `BLOB_READ_CONCURRENCY`), and
279    // claiming one of those first would just hold it idle while this request
280    // waits its turn. It is released at the end of this function on every
281    // branch that does not stream, and moved into `BlobStream` on the one
282    // that does.
283    let permit = BLOB_READ_CONCURRENCY
284        .acquire()
285        .await
286        .expect("this semaphore is never closed");
287    // Off the async worker: `FileBlobStore::fetch` hashes the whole file
288    // against the CID it is filed under before it hands back a single byte,
289    // so this call is as long as the blob is.
290    let registry = state.registry.clone();
291    let (did, cid) = (query.did.clone(), query.cid.clone());
292    let fetched = tokio::task::spawn_blocking(move || registry.fetch_blob(&did, &cid)).await;
293    let fetched = match fetched {
294        Ok(fetched) => fetched,
295        Err(join_err) => {
296            return crate::routes::blocking_task_failed("com.atproto.sync.getBlob", join_err)
297                .into_response()
298        }
299    };
300    match fetched {
301        Ok(Fetch::Stream {
302            mime_type,
303            size,
304            body,
305        }) => (
306            [
307                (header::CONTENT_TYPE, mime_type),
308                (header::CONTENT_LENGTH, size.to_string()),
309                // A blob never changes under its own name, so a cache may
310                // keep it as long as it likes. This is the one answer this
311                // server gives where that is true by construction.
312                (
313                    header::CACHE_CONTROL,
314                    "public, max-age=31536000, immutable".to_owned(),
315                ),
316            ],
317            Body::from_stream(BlobStream::new(body, permit)),
318        )
319            .into_response(),
320        Ok(Fetch::Redirect { url, expires_in }) => (
321            StatusCode::FOUND,
322            [
323                (header::LOCATION, url),
324                (
325                    header::CACHE_CONTROL,
326                    format!("private, max-age={}", expires_in.as_secs()),
327                ),
328            ],
329        )
330            .into_response(),
331        Err(ProvisionError::UnknownAccount { did }) => {
332            crate::routes::repo_not_served(&state, &did).into_response()
333        }
334        Err(err) => blob_error(&err).into_response(),
335    }
336}
337
338/// `GET /xrpc/com.atproto.sync.listBlobs`
339///
340/// Every blob CID one account holds, in CID order, paged on the last CID of
341/// the previous page. `since` narrows that to the blobs referenced by
342/// records that changed after the named revision — see
343/// [`Registry::list_blobs`](didbot_pds::Registry::list_blobs) for how, and for
344/// what happens when this deployment cannot place the revision.
345///
346/// It takes no credential, and a `since` walks every record changed after
347/// it, so it spends the caller's sync budget and runs on the blocking pool
348/// like the other `com.atproto.sync` reads.
349pub async fn list_blobs(
350    State(state): State<AppState>,
351    peer: crate::rate_limit::Peer,
352    headers: HeaderMap,
353    query: Result<Query<ListBlobsQuery>, QueryRejection>,
354) -> Response {
355    if let Err(refusal) = crate::routes::require_sync_budget(&state, &headers, peer) {
356        return refusal.into_response();
357    }
358    let Query(query) = match query {
359        Ok(query) => query,
360        Err(rejection) => return ApiError::bad_request(rejection.body_text()).into_response(),
361    };
362    let listed = crate::routes::sync_read(&state, "com.atproto.sync.listBlobs", move |registry| {
363        registry.list_blobs(
364            &query.did,
365            query.effective_limit(),
366            query.cursor.as_deref(),
367            query.since.as_deref(),
368        )
369    })
370    .await;
371    match listed {
372        Ok((cids, Some(cursor))) => Json(json!({ "cids": cids, "cursor": cursor })).into_response(),
373        Ok((cids, None)) => Json(json!({ "cids": cids })).into_response(),
374        Err(response) => response,
375    }
376}
377
378/// Maps a blob failure onto the error name a client switches on.
379///
380/// `BlobNotFound` and `RepoNotFound` are the lexicons' own names for
381/// `getBlob`. The others are this server's, because the lexicons declare
382/// nothing for them: a size limit and the quotas are a deployment's policy
383/// rather than the protocol's, and corruption is not a thing the protocol
384/// expects a server to be able to notice.
385pub fn blob_error(err: &ProvisionError) -> ApiError {
386    let ProvisionError::Blob(blob) = err else {
387        return match err {
388            ProvisionError::UnknownAccount { did } => ApiError::new(
389                StatusCode::NOT_FOUND,
390                "RepoNotFound",
391                format!("no repository here for `{did}`"),
392            ),
393            other => ApiError::from(other),
394        };
395    };
396    match blob {
397        BlobError::NotFound { .. } => {
398            ApiError::new(StatusCode::NOT_FOUND, "BlobNotFound", blob.to_string())
399        }
400        BlobError::MalformedCid { .. } => ApiError::bad_request(blob.to_string()),
401        // 413 rather than 400, because the request is well formed and the
402        // only thing wrong with it is its size — which is what that status
403        // says and what a client backing off needs to be able to tell.
404        BlobError::TooLarge { .. } => ApiError::new(
405            StatusCode::PAYLOAD_TOO_LARGE,
406            "BlobTooLarge",
407            blob.to_string(),
408        ),
409        // 403: nothing about the request would make it succeed later without
410        // the account first giving something back.
411        BlobError::QuotaExceeded { .. } => ApiError::new(
412            StatusCode::FORBIDDEN,
413            "AccountQuotaExceeded",
414            blob.to_string(),
415        ),
416        // 507, and the name a full log answers with: the server is out of
417        // the room it set aside, and the same upload works once blobs are
418        // collected or deleted somewhere on it.
419        BlobError::TotalQuotaExceeded { .. } => ApiError::new(
420            StatusCode::INSUFFICIENT_STORAGE,
421            "StorageFull",
422            blob.to_string(),
423        ),
424        BlobError::Corrupt { .. } => ApiError::new(
425            StatusCode::INTERNAL_SERVER_ERROR,
426            "BlobCorrupt",
427            blob.to_string(),
428        ),
429        BlobError::Backend(_) => ApiError::new(
430            StatusCode::INTERNAL_SERVER_ERROR,
431            "InternalServerError",
432            blob.to_string(),
433        ),
434        // The same answers a record write refused by policy gets.
435        BlobError::PolicyRejected { reason } => ApiError::from(&ProvisionError::PolicyRejected {
436            reason: reason.clone(),
437        }),
438        BlobError::PolicyFrozen { reason } => ApiError::from(&ProvisionError::PolicyFrozen {
439            reason: reason.clone(),
440        }),
441    }
442}
443
444/// How many chunks the reading task may run ahead of the socket.
445///
446/// The back-pressure: the reader fills this and then waits, so a slow client
447/// costs one task and [`BLOB_READAHEAD`] × [`CHUNK`] of buffer rather than
448/// the whole blob in memory.
449const BLOB_READAHEAD: usize = 4;
450
451/// A blob on its way out, a chunk at a time.
452///
453/// The store hands back a plain [`std::io::Read`], and this turns it into the
454/// stream `axum::body::Body` wants. The read is blocking, which is the same
455/// posture the rest of this server takes towards its disk — the write-ahead
456/// log is written from inside async handlers too — and it is what keeps
457/// the store's interface one trait rather than one per runtime.
458/// [`BLOB_READ_CONCURRENCY`] is what bounds how many of those reads run at
459/// once.
460struct BlobStream {
461    chunks: tokio::sync::mpsc::Receiver<Result<Bytes, std::io::Error>>,
462}
463
464impl BlobStream {
465    /// Reads `reader` on the blocking pool, handing chunks over a bounded
466    /// channel.
467    ///
468    /// `std::io::Read` on a file blocks the thread that calls it, and before
469    /// this that thread was an async worker: a deployment with two of them
470    /// (the `t3.small` `infra/pds` deploys onto) had every route, the
471    /// firehose included, waiting behind two blob reads. The read happens on
472    /// a blocking thread now, and the channel is what keeps it from running
473    /// ahead of the socket.
474    ///
475    /// `permit` is the caller's [`BLOB_READ_CONCURRENCY`] permit, held for as
476    /// long as the reading task holds its thread and given back the moment
477    /// that task ends — whether the blob ran out, the read failed, or the
478    /// client went away.
479    fn new(
480        mut reader: Box<dyn std::io::Read + Send>,
481        permit: tokio::sync::SemaphorePermit<'static>,
482    ) -> Self {
483        let (chunks, rx) = tokio::sync::mpsc::channel(BLOB_READAHEAD);
484        tokio::task::spawn_blocking(move || {
485            let _permit = permit;
486            let mut buffer = vec![0u8; CHUNK];
487            loop {
488                let chunk = match reader.read(&mut buffer) {
489                    Ok(0) => return,
490                    Ok(read) => Ok(Bytes::copy_from_slice(&buffer[..read])),
491                    // The stream ends here rather than repeating the failure:
492                    // a body that has already begun cannot become an error
493                    // response, so the honest thing left is a short one that
494                    // the declared `Content-Length` makes visible as
495                    // truncated.
496                    Err(err) => {
497                        let _ = chunks.blocking_send(Err(err));
498                        return;
499                    }
500                };
501                // A closed channel is a client that went away mid-body, and
502                // the read stops with it rather than pulling the rest of the
503                // blob off the disk for nobody.
504                if chunks.blocking_send(chunk).is_err() {
505                    return;
506                }
507            }
508        });
509        Self { chunks: rx }
510    }
511}
512
513impl Stream for BlobStream {
514    type Item = Result<Bytes, std::io::Error>;
515
516    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
517        self.get_mut().chunks.poll_recv(cx)
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use std::sync::Arc;
525
526    /// A reader whose first `read` parks until it is released, standing in
527    /// for a disk that is slow — or, for `FileBlobStore`, for the whole-file
528    /// re-hash it does before it hands back a byte.
529    struct ParkedReader {
530        go: Option<std::sync::mpsc::Receiver<()>>,
531    }
532
533    /// A one-permit semaphore of this test's own, standing in for
534    /// [`BLOB_READ_CONCURRENCY`] so that what one test counts cannot be
535    /// another test's blob read.
536    fn own_semaphore() -> &'static tokio::sync::Semaphore {
537        Box::leak(Box::new(tokio::sync::Semaphore::new(1)))
538    }
539
540    impl std::io::Read for ParkedReader {
541        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
542            match self.go.take() {
543                Some(go) => {
544                    let _ = go.recv();
545                    buf[..3].copy_from_slice(b"hi!");
546                    Ok(3)
547                }
548                None => Ok(0),
549            }
550        }
551    }
552
553    /// **What a blob read used to cost.** `std::io::Read` blocks the thread
554    /// that calls it, and `poll_next` was calling it on an async worker — so
555    /// on the two-worker host `infra/pds` deploys onto, two slow blob reads
556    /// were every route waiting, the firehose included.
557    ///
558    /// One worker here, so the property is exact rather than probabilistic:
559    /// while the read is parked, another task still runs.
560    #[tokio::test(flavor = "current_thread")]
561    async fn a_blob_body_is_read_off_the_async_worker() {
562        let (release, go) = std::sync::mpsc::channel();
563        let semaphore = own_semaphore();
564        let permit = semaphore.try_acquire().expect("a free permit");
565        let mut stream = Box::pin(BlobStream::new(
566            Box::new(ParkedReader { go: Some(go) }),
567            permit,
568        ));
569
570        // The read is parked inside `ParkedReader::read`. The worker is free
571        // only if that call is not on it.
572        let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
573        let flag = ran.clone();
574        tokio::spawn(async move {
575            flag.store(true, std::sync::atomic::Ordering::SeqCst);
576        });
577        let polled = std::future::poll_fn(|cx| {
578            let _ = stream.as_mut().poll_next(cx);
579            Poll::Ready(())
580        });
581        tokio::time::timeout(std::time::Duration::from_secs(5), polled)
582            .await
583            .expect("polling a parked blob body must not block the worker");
584        tokio::task::yield_now().await;
585        assert!(
586            ran.load(std::sync::atomic::Ordering::SeqCst),
587            "the parked read held the only async worker"
588        );
589
590        let _ = release.send(());
591        let chunk = tokio::time::timeout(
592            std::time::Duration::from_secs(5),
593            std::future::poll_fn(|cx| stream.as_mut().poll_next(cx)),
594        )
595        .await
596        .expect("the released read answers")
597        .expect("a chunk")
598        .expect("read without error");
599        assert_eq!(chunk.as_ref(), b"hi!");
600    }
601
602    /// **A download that ends early still gives its thread back.** The permit
603    /// bounds blocking-pool threads and is held for the whole body, so the
604    /// case to get right is the body that never finishes: a client that
605    /// disconnects mid-download leaves the reading task parked in a read it
606    /// only abandons on its next send.
607    #[tokio::test]
608    async fn a_blob_body_gives_its_permit_back_when_the_client_goes_away() {
609        let (release, go) = std::sync::mpsc::channel();
610        let semaphore = own_semaphore();
611        let permit = semaphore.try_acquire().expect("a free permit");
612        let stream = BlobStream::new(Box::new(ParkedReader { go: Some(go) }), permit);
613        assert_eq!(
614            semaphore.available_permits(),
615            0,
616            "a blob read in flight holds its permit"
617        );
618
619        drop(stream);
620        let _ = release.send(());
621
622        let returned = tokio::time::timeout(std::time::Duration::from_secs(5), async {
623            while semaphore.available_permits() == 0 {
624                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
625            }
626        })
627        .await;
628        assert!(
629            returned.is_ok(),
630            "the permit outlived the blob read it bounds"
631        );
632    }
633}