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}