Skip to content

Commit 0ff2c76

Browse files
authored
Merge pull request #6 from socrates8300/codex/auth-crypto-ci-hygiene
v0.1-rc: graceful shutdown, Docker deploy, coverage gate; align fixes
2 parents e815341 + a2863ff commit 0ff2c76

15 files changed

Lines changed: 2077 additions & 43 deletions

File tree

.claude/settings.local.json

Lines changed: 0 additions & 7 deletions
This file was deleted.

.dockerignore

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Build artifacts
2+
target/
3+
4+
# Version control
5+
.git/
6+
.gitignore
7+
8+
# Editor / IDE
9+
.vscode/
10+
.idea/
11+
*.swp
12+
*.swo
13+
14+
# OS
15+
.DS_Store
16+
Thumbs.db
17+
18+
# Documentation (not needed in build)
19+
docs/
20+
*.md
21+
LICENSE
22+
CODE_OF_CONDUCT.md
23+
24+
# Dev tooling
25+
.devcontainer/
26+
.github/
27+
28+
# Environment files
29+
.env
30+
.env.*
31+
32+
# Temporary files
33+
*.tmp
34+
*.bak
35+
*.log

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,4 @@ jobs:
9292
- name: Install cargo-llvm-cov
9393
run: cargo install cargo-llvm-cov --version 0.6.16 --locked
9494
- name: Coverage Gate
95-
run: cargo llvm-cov --workspace --summary-only --fail-under-lines 55
95+
run: cargo llvm-cov --workspace --summary-only --fail-under-lines 60

.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,15 @@ Thumbs.db
2222
# Temporary files
2323
*.tmp
2424
*.bak
25+
26+
# SDD agent context database WAL sidecars (the .db itself is committed)
27+
docs/agent_context.db-shm
28+
docs/agent_context.db-wal
29+
30+
# SDD CLI scratch/derived state — DB is the source of truth
31+
.sdd-iter
32+
.sdd-coverage-ledger.json
33+
docs/agent_context_schema.sql
34+
35+
# Claude Code local-only settings (per-user permission overrides)
36+
.claude/settings.local.json

crates/mx20022-channels/mx20022-channel-http/src/lib.rs

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: AGPL-3.0-only
33

44
use std::sync::Arc;
5+
use std::time::Duration;
56

67
use async_trait::async_trait;
78
use axum::body::Bytes;
@@ -19,7 +20,7 @@ use mx20022_channels::{
1920
ChannelError, ChannelHealth, DeliveryReceipt, InboundChannel, InboundMessage, OutboundChannel,
2021
OutboundMessage,
2122
};
22-
use tokio::sync::{mpsc, RwLock};
23+
use tokio::sync::{mpsc, watch, RwLock};
2324
use tower_http::cors::CorsLayer;
2425

2526
#[derive(Debug, Clone)]
@@ -44,13 +45,18 @@ struct InboundState {
4445
pub struct HttpInboundChannel {
4546
config: HttpInboundConfig,
4647
paused: Arc<RwLock<bool>>,
48+
shutdown_tx: Arc<watch::Sender<bool>>,
49+
shutdown_rx: watch::Receiver<bool>,
4750
}
4851

4952
impl HttpInboundChannel {
5053
pub fn new(config: HttpInboundConfig) -> Self {
54+
let (shutdown_tx, shutdown_rx) = watch::channel(false);
5155
Self {
5256
config,
5357
paused: Arc::new(RwLock::new(false)),
58+
shutdown_tx: Arc::new(shutdown_tx),
59+
shutdown_rx,
5460
}
5561
}
5662
}
@@ -92,7 +98,16 @@ impl InboundChannel for HttpInboundChannel {
9298
))
9399
})?;
94100
tracing::info!(channel = %self.config.name, bind = %self.config.bind, "http inbound channel starting with TLS");
101+
let handle = axum_server::Handle::new();
102+
let shutdown_handle = handle.clone();
103+
let mut shutdown_rx = self.shutdown_rx.clone();
104+
tokio::spawn(async move {
105+
let _ = shutdown_rx.changed().await;
106+
tracing::info!("TLS graceful shutdown triggered");
107+
shutdown_handle.graceful_shutdown(Some(Duration::from_secs(30)));
108+
});
95109
axum_server::bind_rustls(socket, tls)
110+
.handle(handle)
96111
.serve(app.into_make_service())
97112
.await
98113
.map_err(|e| ChannelError::new(format!("inbound channel serve failed: {e}")))
@@ -104,7 +119,13 @@ impl InboundChannel for HttpInboundChannel {
104119
ChannelError::new(format!("failed to bind inbound channel: {e}"))
105120
})?;
106121
tracing::warn!(channel = %self.config.name, bind = %self.config.bind, "http inbound channel starting without TLS");
122+
let mut shutdown_rx = self.shutdown_rx.clone();
123+
let shutdown_signal = async move {
124+
let _ = shutdown_rx.changed().await;
125+
tracing::info!("graceful shutdown triggered");
126+
};
107127
axum::serve(listener, app)
128+
.with_graceful_shutdown(shutdown_signal)
108129
.await
109130
.map_err(|e| ChannelError::new(format!("inbound channel serve failed: {e}")))
110131
}
@@ -115,6 +136,8 @@ impl InboundChannel for HttpInboundChannel {
115136
}
116137

117138
async fn shutdown(&self) -> Result<(), ChannelError> {
139+
tracing::info!(channel = %self.config.name, "http inbound channel shutting down");
140+
let _ = self.shutdown_tx.send(true);
118141
Ok(())
119142
}
120143

@@ -307,16 +330,20 @@ fn now_millis() -> u128 {
307330
#[cfg(test)]
308331
mod tests {
309332
use std::sync::Arc;
333+
use std::time::Duration;
310334

311335
use axum::http::HeaderMap;
312336
use axum::http::StatusCode;
313337
use axum::routing::post;
314338
use axum::{extract::State, Router};
315339
use mx20022_channels::auth::InboundAuthConfig;
316-
use mx20022_channels::{OutboundChannel, OutboundMessage};
340+
use mx20022_channels::{InboundChannel, OutboundChannel, OutboundMessage};
317341
use tokio::sync::{mpsc, RwLock};
318342

319-
use super::{handle_post, HttpOutboundChannel, HttpOutboundConfig, InboundState};
343+
use super::{
344+
handle_post, HttpInboundChannel, HttpInboundConfig, HttpOutboundChannel,
345+
HttpOutboundConfig, InboundState,
346+
};
320347

321348
#[tokio::test]
322349
async fn inbound_handler_enqueues_message() {
@@ -336,6 +363,72 @@ mod tests {
336363
assert_eq!(queued.content_type, "application/xml");
337364
}
338365

366+
#[tokio::test]
367+
async fn shutdown_drain() {
368+
let (tx, mut rx) = mpsc::channel(10);
369+
370+
// Bind to a free port by creating a temporary listener.
371+
let temp_listener = tokio::net::TcpListener::bind("127.0.0.1:0")
372+
.await
373+
.expect("bind temp listener");
374+
let addr = temp_listener.local_addr().expect("resolve addr");
375+
drop(temp_listener);
376+
377+
let channel = Arc::new(HttpInboundChannel::new(HttpInboundConfig {
378+
name: "test-shutdown".to_string(),
379+
bind: addr.to_string(),
380+
content_type: "application/xml".to_string(),
381+
auth: InboundAuthConfig::default(),
382+
cors_allowed_origins: vec![],
383+
tls_cert_path: None,
384+
tls_key_path: None,
385+
}));
386+
387+
let run_channel = Arc::clone(&channel);
388+
let handle = tokio::spawn(async move { run_channel.run(tx).await });
389+
390+
// Wait for the server to be ready.
391+
tokio::time::sleep(Duration::from_millis(100)).await;
392+
393+
// Send a message before shutdown — should succeed.
394+
let client = reqwest::Client::new();
395+
let resp = client
396+
.post(format!("http://{addr}/"))
397+
.header("content-type", "application/xml")
398+
.body("<Document/>")
399+
.send()
400+
.await
401+
.expect("pre-shutdown request");
402+
assert_eq!(resp.status(), StatusCode::ACCEPTED);
403+
404+
// Verify the message was queued.
405+
let msg = rx
406+
.recv()
407+
.await
408+
.expect("should receive pre-shutdown message");
409+
assert_eq!(msg.raw, "<Document/>");
410+
411+
// Trigger graceful shutdown.
412+
channel.shutdown().await.expect("shutdown");
413+
414+
// Wait for the server to drain.
415+
let result = tokio::time::timeout(Duration::from_secs(5), handle).await;
416+
assert!(result.is_ok(), "server should shut down within timeout");
417+
assert!(result.unwrap().is_ok(), "server task should not error");
418+
419+
// After shutdown, new requests should be rejected.
420+
let err = client
421+
.post(format!("http://{addr}/"))
422+
.header("content-type", "application/xml")
423+
.body("<AfterShutdown/>")
424+
.send()
425+
.await;
426+
assert!(
427+
err.is_err(),
428+
"post-shutdown request should fail (connection refused)"
429+
);
430+
}
431+
339432
#[tokio::test]
340433
async fn outbound_channel_posts_payload() {
341434
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")

crates/mx20022-runtime/benches/runtime_hot_paths.rs

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,5 +105,103 @@ fn bench_store_query(c: &mut Criterion) {
105105
});
106106
}
107107

108-
criterion_group!(benches, bench_runtime_process, bench_store_query);
108+
fn build_3p_config(sqlite_path: &str) -> RuntimeConfig {
109+
let raw = format!(
110+
r#"
111+
[runtime]
112+
name = "bench-runtime"
113+
instance_id = "bench-01"
114+
115+
[store]
116+
backend = "sqlite"
117+
url = "{sqlite_path}"
118+
119+
[channels.http-in]
120+
type = "http"
121+
mode = "server"
122+
bind = "127.0.0.1:0"
123+
124+
[[pipeline]]
125+
name = "bench-3p"
126+
channel_in = "http-in"
127+
participants = [
128+
{{ name = "message-logger" }},
129+
{{ name = "duplicate-checker" }},
130+
{{ name = "circuit-breaker" }}
131+
]
132+
"#
133+
);
134+
RuntimeConfig::parse(&raw).expect("benchmark 3p runtime config should parse")
135+
}
136+
137+
fn build_3p_runtime(rt: &tokio::runtime::Runtime) -> (TempDir, Arc<RuntimeApp>) {
138+
let temp_dir = TempDir::new().expect("create temp dir");
139+
let sqlite_path = temp_dir.path().join("bench-3p.db");
140+
let cfg = build_3p_config(sqlite_path.to_str().expect("utf8 sqlite path"));
141+
let app = rt
142+
.block_on(RuntimeApp::from_config(&cfg))
143+
.expect("build 3p runtime app");
144+
(temp_dir, Arc::new(app))
145+
}
146+
147+
fn bench_3p_process(c: &mut Criterion) {
148+
let rt = tokio::runtime::Runtime::new().expect("create tokio runtime");
149+
let (_temp_dir, app) = build_3p_runtime(&rt);
150+
let counter = AtomicU64::new(1);
151+
152+
c.bench_function("runtime_app_process_3_participants", |b| {
153+
b.to_async(&rt).iter(|| async {
154+
let id = counter.fetch_add(1, Ordering::Relaxed);
155+
let report = app
156+
.process(
157+
"bench-3p",
158+
format!("TX-BENCH-3P-{id}"),
159+
"http-in",
160+
"pacs.008.001.08",
161+
"<Document/>",
162+
)
163+
.await
164+
.expect("process 3p benchmark transaction");
165+
black_box(report);
166+
});
167+
});
168+
}
169+
170+
fn bench_concurrent_process(c: &mut Criterion) {
171+
let rt = tokio::runtime::Runtime::new().expect("create tokio runtime");
172+
let (_temp_dir, app) = build_runtime(&rt);
173+
let counter = AtomicU64::new(1);
174+
175+
c.bench_function("runtime_app_process_concurrent_10", |b| {
176+
b.to_async(&rt).iter(|| async {
177+
let mut tasks = Vec::with_capacity(10);
178+
for _ in 0..10 {
179+
let app = app.clone();
180+
let id = counter.fetch_add(1, Ordering::Relaxed);
181+
tasks.push(tokio::spawn(async move {
182+
app.process(
183+
"bench",
184+
format!("TX-CONC-{id}"),
185+
"http-in",
186+
"pacs.008.001.08",
187+
"<Document/>",
188+
)
189+
.await
190+
}));
191+
}
192+
for task in tasks {
193+
let result = task.await.expect("spawn should not panic");
194+
black_box(result.expect("concurrent process should succeed"));
195+
}
196+
});
197+
});
198+
}
199+
200+
criterion_group!(
201+
benches,
202+
bench_runtime_process,
203+
bench_store_query,
204+
bench_3p_process,
205+
bench_concurrent_process
206+
);
109207
criterion_main!(benches);

0 commit comments

Comments
 (0)