Skip to content

Commit 59cc8bc

Browse files
committed
fix(render): keep a seal's place until its preparation ends
run_seal held the seal's place in flight in the handler future, while Seal::seal prepares the field on a blocking task. When a client disconnected, the future and its place dropped, but the blocking work ran on, so it went uncounted by the seal limit and the shutdown drain. Seal::seal now takes the place and moves it into the blocking task, which hands it back when it ends: a dropped seal keeps its place until the preparation finishes, and the drain waits for it. Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
1 parent 807735c commit 59cc8bc

3 files changed

Lines changed: 76 additions & 13 deletions

File tree

‎crates/registry-render/src/seal.rs‎

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -878,27 +878,34 @@ impl Seal {
878878
/// must be a single-revision PDF; one that carries an update or bytes
879879
/// after its end marker is refused before anything is signed. Reading
880880
/// the PDF is blocking work, so it runs off the async workers; the
881-
/// caller bounds `first_revision` by the output limit.
881+
/// caller bounds `first_revision` by the output limit. `in_flight` is
882+
/// the caller's place for this seal, held until the seal ends or, when
883+
/// this future is dropped first, until the blocking work does.
882884
pub async fn seal(
883885
&self,
884886
first_revision: Vec<u8>,
885887
signing_time: OffsetDateTime,
888+
in_flight: tokio::sync::OwnedSemaphorePermit,
886889
) -> Result<SealedDocument, RenderProblem> {
887890
let internal = |detail: String| RenderProblem::new(ProblemKind::Internal, detail);
888891
let field = self.signature_field(signing_time)?;
889-
let (document_digest, prepared) = tokio::task::spawn_blocking(move || {
892+
// The place travels with the blocking work and back: if this future
893+
// is dropped, the work runs on and the place is released only when
894+
// it ends.
895+
let (_in_flight, prepared) = tokio::task::spawn_blocking(move || {
890896
let document_digest = sha256_digest(&first_revision);
891-
crate::pdf_signature::prepare(&first_revision, &field)
892-
.map(|prepared| (document_digest, prepared))
897+
let prepared = crate::pdf_signature::prepare(&first_revision, &field)
898+
.map(|prepared| (document_digest, prepared));
899+
(in_flight, prepared)
893900
})
894901
.await
895902
.map_err(|error| {
896903
internal(format!(
897904
"seal {}: preparing the signature field stopped: {error}",
898905
self.id
899906
))
900-
})?
901-
.map_err(|error| {
907+
})?;
908+
let (document_digest, prepared) = prepared.map_err(|error| {
902909
internal(format!(
903910
"seal {}: the rendered document cannot be sealed: {error}",
904911
self.id
@@ -1296,6 +1303,58 @@ mod tests {
12961303

12971304
use crate::worker::seal_tests::sealed_receipt;
12981305

1306+
/// A place for one seal in flight, from a service of one.
1307+
fn place() -> tokio::sync::OwnedSemaphorePermit {
1308+
Arc::new(tokio::sync::Semaphore::new(1))
1309+
.try_acquire_owned()
1310+
.expect("a free place")
1311+
}
1312+
1313+
/// A client that disconnects drops its seal's future, but preparing the
1314+
/// field is blocking work that runs on. The seal's place goes with that
1315+
/// work and comes back only when it ends, so the drain, which waits for
1316+
/// every place, waits for it too. With the one blocking thread busy, the
1317+
/// preparation waits in the queue, holding the place, until it is free.
1318+
#[test]
1319+
fn a_dropped_seal_keeps_its_place_until_its_blocking_work_ends() {
1320+
use std::future::Future as _;
1321+
use std::task::{Context, Waker};
1322+
1323+
let pki = TestPki::generate().expect("test PKI");
1324+
let test_seal = pki.issue_seal(SigningAlgorithm::Es256).expect("test seal");
1325+
let seal = Seal::for_test(
1326+
"ministry",
1327+
&test_seal,
1328+
Arc::new(test_seal.signer().expect("test signer")),
1329+
);
1330+
let first_revision = sealed_receipt(seal.worker_seal());
1331+
let places = Arc::new(tokio::sync::Semaphore::new(1));
1332+
let runtime = tokio::runtime::Builder::new_current_thread()
1333+
.max_blocking_threads(1)
1334+
.enable_all()
1335+
.build()
1336+
.expect("runtime");
1337+
runtime.block_on(async {
1338+
let (release, wait) = std::sync::mpsc::channel::<()>();
1339+
let busy = tokio::task::spawn_blocking(move || wait.recv());
1340+
let held = Arc::clone(&places).try_acquire_owned().expect("a place");
1341+
let mut sealing = Box::pin(seal.seal(first_revision, OffsetDateTime::now_utc(), held));
1342+
assert!(sealing
1343+
.as_mut()
1344+
.poll(&mut Context::from_waker(Waker::noop()))
1345+
.is_pending());
1346+
drop(sealing);
1347+
assert_eq!(
1348+
places.available_permits(),
1349+
0,
1350+
"the queued preparation holds the place"
1351+
);
1352+
release.send(()).expect("the busy task waits");
1353+
busy.await.expect("busy task").expect("released");
1354+
let _place = places.acquire().await.expect("the place comes back");
1355+
});
1356+
}
1357+
12991358
/// A signer that counts its signatures, so a test can show that a
13001359
/// refused answer was never signed.
13011360
struct CountingSigner {
@@ -1358,7 +1417,7 @@ mod tests {
13581417
("bytes after the end marker", smuggled),
13591418
("an update of the worker's own", own_update),
13601419
] {
1361-
let Err(problem) = seal.seal(answer, now).await else {
1420+
let Err(problem) = seal.seal(answer, now, place()).await else {
13621421
panic!("{label}: sealed");
13631422
};
13641423
assert_eq!(problem.kind, ProblemKind::Internal, "{label}");
@@ -1369,7 +1428,10 @@ mod tests {
13691428
"nothing signed"
13701429
);
13711430

1372-
let sealed = seal.seal(honest.clone(), now).await.expect("seals");
1431+
let sealed = seal
1432+
.seal(honest.clone(), now, place())
1433+
.await
1434+
.expect("seals");
13731435
assert_eq!(signer.signatures.load(Ordering::SeqCst), 1);
13741436
assert_eq!(sealed.document_digest, sha256_digest(&honest));
13751437
let expected = crate::pdf_signature::prepare(&honest, &field).expect("the parent's update");
@@ -1475,7 +1537,7 @@ mod tests {
14751537

14761538
let first_revision = sealed_receipt(large_seal.worker_seal());
14771539
large_seal
1478-
.seal(first_revision, OffsetDateTime::now_utc())
1540+
.seal(first_revision, OffsetDateTime::now_utc(), place())
14791541
.await
14801542
.expect("the signature fits the reserved space");
14811543
}

‎crates/registry-render/src/server.rs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,7 +1219,8 @@ struct SealedRender {
12191219
/// free, release the slot, then prepare the field, sign, timestamp and embed
12201220
/// in this process, signed at `signing_time`. Preparing, signing and the
12211221
/// timestamp exchange never hold a render slot; the whole call holds one of
1222-
/// the service's places for seals in flight.
1222+
/// the service's places for seals in flight, and the preparation keeps it
1223+
/// until it ends even when the client has gone.
12231224
async fn run_seal(
12241225
service: &Service,
12251226
seal: &Seal,
@@ -1228,7 +1229,7 @@ async fn run_seal(
12281229
) -> Result<(SealedRender, SealedDocument), RenderProblem> {
12291230
// A seal beyond the places is refused, not queued: the queue behind a
12301231
// slow authority would otherwise grow without a bound.
1231-
let _in_flight = service
1232+
let in_flight = service
12321233
.seals_in_flight
12331234
.clone()
12341235
.try_acquire_owned()
@@ -1260,7 +1261,7 @@ async fn run_seal(
12601261
data_sha256: answer.data_sha256,
12611262
warnings: answer.warnings,
12621263
};
1263-
let sealed = seal.seal(first_revision, signing_time).await?;
1264+
let sealed = seal.seal(first_revision, signing_time, in_flight).await?;
12641265
Ok((rendered, sealed))
12651266
}
12661267

‎products/render/SECURITY-MATRIX.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ conditions, follow the table.
1616
| 7 | Request data values or the API key leak into the audit log or logs | Audit events carry a closed, value-free field set; lifecycle logs use fixed dimensions (method class, route template, status, latency, trace id); the seal route's `seal refused` warning carries only the seal id and the problem and `reason` names | `serve::audit_events_are_value_free` (canary + key scans over the audit file) |
1717
| 8 | A render starts, or a document leaves, with no accepted audit record of it | The shared audit writer accepts a `file` entry only after `fsync` (owner-only file, single-writer lock) and a `stdout` entry only after it is written and flushed, then stops accepting after any failure. The `request` entry is accepted before the worker starts, else `503 audit-failed` and no render; the `response` entry is accepted before the document leaves, else `503 audit-failed` and no PDF or hash headers; `/ready` reports a stopped writer. Each call pairs its entries under a correlation the server draws, never the caller's `Idempotency-Key` (echoed only as `correlationId`), so calls sharing a key cannot be confused, and a call that ends before its outcome is written writes an `unfinished` response. Host-level tampering with the log is not defended here: the log carries no hash chain or signature (residual below) | `server::tests::the_request_entry_is_accepted_before_the_render_starts`; `server::tests::a_refused_request_entry_prevents_the_render`; `serve::a_refused_request_entry_prevents_the_render`; `serve::a_refused_response_entry_withholds_the_pdf`; `serve::a_render_writes_a_request_entry_then_a_response_entry_sharing_correlation` (file mode 0600); `server::tests::concurrent_calls_sharing_an_idempotency_key_pair_their_own_entries` |
1818
| 9 | A render succeeds but is wrong on paper (missing glyphs) | Check-time label-script coverage over the actual label characters; package validates before it writes any output; render-time warnings; `--strict` fails on warnings | `scaffold::package_refuses_a_broken_bundle_before_writing_output` (CJK label, no font, so the package directory is never created) |
19-
| 10 | Pathological template data exhausts CPU or memory | Serves render in a supervised worker process: killed at the configured timeout, which also bounds its exit after answering, `RLIMIT_AS` on Linux, recycled on panic; bounded concurrency, and as many seals in flight as render slots once a seal releases its slot; request body and output caps with hard ceilings | `serve::pathological_renders_are_bounded_and_the_service_recovers` (timeout or memory wall is audited; a fresh worker immediately serves a healthy render); `worker::tests::a_worker_that_lingers_after_answering_is_cut_off_at_the_deadline`; `serve_seal::seals_in_flight_beyond_the_render_concurrency_are_refused_as_rate_limited` |
19+
| 10 | Pathological template data exhausts CPU or memory | Serves render in a supervised worker process: killed at the configured timeout, which also bounds its exit after answering, `RLIMIT_AS` on Linux, recycled on panic; bounded concurrency, and as many seals in flight as render slots once a seal releases its slot, a seal counted until its preparation ends even after its client has gone; request body and output caps with hard ceilings | `serve::pathological_renders_are_bounded_and_the_service_recovers` (timeout or memory wall is audited; a fresh worker immediately serves a healthy render); `worker::tests::a_worker_that_lingers_after_answering_is_cut_off_at_the_deadline`; `serve_seal::seals_in_flight_beyond_the_render_concurrency_are_refused_as_rate_limited`; `seal::tests::a_dropped_seal_keeps_its_place_until_its_blocking_work_ends` |
2020
| 11 | Assets smuggle executables or balloons | base64-decoded, JPEG/PNG magic sniffed, per-asset 2 MiB / per-request 8 MiB caps, exact bytes covered by `dataDigest` | `golden::wrong_media_type_and_oversize_assets_are_refused` |
2121
| 12 | Deeply nested JSON parse DoS | `serde_json` default 128-depth recursion limit on top of the tower body-limit layer | platform-owned; body limit exercised by config ceiling (`runtime.rs` 64 MiB hard cap) |
2222
| 13 | Response header injection via echoed `Idempotency-Key` | `HeaderValue::from_str` rejects CR/LF/NUL; 128-char bound at read | code-reviewed; echo asserted in `serve::render_returns_pdf_with_hash_headers_matching_golden` |

0 commit comments

Comments
 (0)