Skip to content

Commit afd11d4

Browse files
committed
review: fix critical bugs, add tests, best-practice improvements
- Fix MoldClient::generate — now returns raw bytes (was trying to parse image/png as JSON) - Fix generate route — spawn_blocking + catch_unwind converts panics to 500 with body - Add request validation — empty prompt, zero dims, non-multiple-of-16, >1024, zero steps → 422 - Move load() onto InferenceEngine trait; AppState holds Box<dyn InferenceEngine> for testability - Fix CLIP truncation to 77 tokens (hard CLIP limit) - Fix T5 config — typed struct literal instead of raw JSON string - Fix is_schnell — also checks transformer filename for GGUF models - Add path existence checks before unsafe mmap calls - Fix CLI default width/height 1024→768 (1024 OOMs on VAE decode) - Wire mold-cli serve to actually call mold_server::run_server() - Fix config TOML parse errors — now logged instead of silently swallowed - Remove unused hf-hub dependency from mold-inference - Add 14 unit tests in mold-core (types, config, env var resolution) - Add 10 server route tests with MockEngine (health, status, models, validation, success, error) - Remove unnecessary tensor clones in non-quantized path - Add REVIEW.md
1 parent d508b7a commit afd11d4

15 files changed

Lines changed: 821 additions & 69 deletions

File tree

REVIEW.md

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# mold — Peer Review
2+
3+
**Reviewer:** Bender
4+
**Date:** 2026-03-12
5+
**Commit:** d508b7a
6+
7+
---
8+
9+
## Summary
10+
11+
Core inference pipeline is working (GGUF loading, device split, VAE decode). The scaffolding is solid. However there are several critical correctness bugs, zero tests, and a handful of medium-priority issues that need addressing before this is production-ready.
12+
13+
---
14+
15+
## Critical
16+
17+
### 1. `MoldClient::generate` is completely broken
18+
**File:** `crates/mold-core/src/client.rs`
19+
20+
The client calls `.json::<GenerateResponse>()` to parse the response, but the server returns raw `image/png` bytes with `Content-Type: image/png`. They are fundamentally incompatible. The CLI (`mold generate`) will always fail with a JSON parse error.
21+
22+
**Fix:** `generate()` must return `Vec<u8>` (raw bytes) and the caller reconstructs `GenerateResponse`.
23+
24+
### 2. Panics produce empty 500s with no diagnostics
25+
**File:** `crates/mold-server/src/routes.rs`
26+
27+
The generate handler uses `map_err(|e| ...)` which only catches `Err`. CUDA OOM and other runtime panics (e.g., VAE decode at 1024×1024) cause tokio to catch the panic and return `500 ""` with no body and no log. Already bit us multiple times.
28+
29+
**Fix:** Wrap the blocking inference call in `tokio::task::spawn_blocking` + `std::panic::catch_unwind` and map panics to proper error responses. Also add `tracing::error!` in the map_err closure.
30+
31+
### 3. Generate handler holds the mutex for the entire inference duration
32+
**File:** `crates/mold-server/src/routes.rs`
33+
34+
`state.engine.lock().await` is held for the full 8–30s generation. Any concurrent request will queue behind it (tokio::sync::Mutex is fair). This is arguably intentional (GPU is single-threaded), but it means a hung generation blocks all other requests including `/health`. The lock should be released before blocking I/O where possible, and the inference should run on `spawn_blocking`.
35+
36+
### 4. No request validation
37+
**File:** `crates/mold-core/src/types.rs`, `crates/mold-server/src/routes.rs`
38+
39+
`GenerateRequest` has no bounds checking. A request with `width: 0` or `steps: 0` produces cryptic candle panics instead of a 422. Minimum viable validation:
40+
- `width` and `height`: must be > 0, must be multiples of 16 (FLUX requirement), and capped at 768 (current OOM limit)
41+
- `steps`: must be >= 1
42+
- `prompt`: must not be empty
43+
44+
---
45+
46+
## High Priority
47+
48+
### 5. CLIP prompt not truncated to 77 tokens
49+
**File:** `crates/mold-inference/src/engine.rs`
50+
51+
CLIP has a hard maximum of 77 tokens. The code passes raw token IDs without truncation. Long prompts will silently produce incorrect output or panic inside CLIP's attention mechanism.
52+
53+
```rust
54+
// Add before building input_ids:
55+
tokens.truncate(77);
56+
```
57+
58+
### 6. `is_schnell` detected by string match on model name
59+
**File:** `crates/mold-inference/src/engine.rs`
60+
61+
`self.model_name.contains("schnell")` is fragile. A model named `"my-schnell-dev"` would incorrectly use the schnell schedule; a GGUF named `"flux1-schnell-Q8_0.gguf"` would only match if the model_name also contains "schnell". Should use an explicit enum `ModelFamily::Schnell | ModelFamily::Dev` resolved at load time.
62+
63+
### 7. T5 config hardcoded as raw JSON string
64+
**File:** `crates/mold-inference/src/engine.rs`
65+
66+
The T5-XXL config is embedded as a 400-char JSON string literal. If candle's `t5::Config` gains or removes fields this silently breaks (or panics on deserialization). Should be a typed `const` or built from a struct literal, not deserialized from a string that can't be checked at compile time.
67+
68+
### 8. `unsafe` mmap with no path existence check
69+
**File:** `crates/mold-inference/src/engine.rs`
70+
71+
All four `VarBuilder::from_mmaped_safetensors` calls are `unsafe` and produce unreadable errors if the path doesn't exist. Add a simple pre-check:
72+
73+
```rust
74+
if !path.exists() {
75+
bail!("model file not found: {}", path.display());
76+
}
77+
```
78+
79+
### 9. Default CLI width/height is 1024 but 1024 causes OOM
80+
**File:** `crates/mold-cli/src/main.rs`
81+
82+
`--width` and `--height` default to 1024 in the CLI, but 1024×1024 panics (VAE OOM) on the current setup. The default should match the safe operating range: **768**.
83+
84+
### 10. `candle` pinned to `branch = "main"`
85+
**File:** `crates/mold-inference/Cargo.toml`
86+
87+
`branch = "main"` is non-reproducible — a `cargo update` on a different day can silently pull in a breaking candle commit. Pin to a specific `rev = "..."`.
88+
89+
---
90+
91+
## Medium Priority
92+
93+
### 11. Dead error types: `MoldError` and `InferenceError` are unused
94+
**Files:** `crates/mold-core/src/error.rs`, `crates/mold-inference/src/error.rs`
95+
96+
Both error enums are defined but the actual code uses `anyhow::Error` throughout. Either use them (makes error handling more structured and allows `match` on error variants) or remove them to reduce confusion.
97+
98+
### 12. Dead API client methods hit non-existent endpoints
99+
**File:** `crates/mold-core/src/client.rs`
100+
101+
`MoldClient::load_model()` and `unload_model()` call `/api/models/load` and `/api/models/{model}` which don't exist in the router. They'll always return 404. Either implement the routes or remove the methods.
102+
103+
### 13. `mold serve` CLI command is a silent stub
104+
**File:** `crates/mold-cli/src/commands/serve.rs`
105+
106+
Prints a warning and exits with Ok(()). Users who run `mold serve` get no actual server. Should either call `mold_server::run_server()` directly (it's a library crate, this is already possible) or return an explicit error.
107+
108+
### 14. `mold pull` simulates a download
109+
**File:** `crates/mold-cli/src/commands/pull.rs`
110+
111+
Runs a fake progress bar and exits. Fine as a placeholder, but the fake progress bar is confusing — it implies the model was downloaded when nothing happened.
112+
113+
### 15. Config TOML parse errors swallowed silently
114+
**File:** `crates/mold-core/src/config.rs`
115+
116+
```rust
117+
Err(_) => Config::default(), // silently ignores parse error
118+
```
119+
120+
Should `tracing::warn!` or at minimum `eprintln!` so users know their config is being ignored.
121+
122+
### 16. `hf-hub` and `uuid` are unused dependencies
123+
**Files:** `crates/mold-inference/Cargo.toml`, `crates/mold-core/Cargo.toml`
124+
125+
`hf-hub` is pulled into mold-inference but never called (no download code exists). `uuid` is in mold-core but not used in any visible code. Both increase compile time and binary size for no benefit.
126+
127+
### 17. GPU info always null
128+
**File:** `crates/mold-server/src/routes.rs`
129+
130+
`ServerStatus.gpu_info` is always `None`. `nvidia-smi` or candle's device API can provide this. Low priority but `mold ps` showing "GPU: not detected" is misleading.
131+
132+
---
133+
134+
## Minor / Style
135+
136+
### 18. Redundant `.map_err(anyhow::Error::from)` in `FluxTransformer::denoise`
137+
`flux::sampling::denoise` returns `candle_core::Result<Tensor>` which auto-converts via `?`. The explicit `.map_err(anyhow::Error::from)` is unnecessary.
138+
139+
### 19. `t5_emb.clone()` in the non-quantized path wastes a copy
140+
In the non-quantized branch: `(t5_emb.clone(), clip_emb.clone(), img.clone())` — the originals are never used again, so move instead of clone.
141+
142+
### 20. `LoadedFlux.cpu` device stored unnecessarily
143+
`Device::Cpu` is a zero-cost value (`Device::Cpu` is a unit variant). Storing it in `LoadedFlux` adds no value — just use `Device::Cpu` inline during generate.
144+
145+
### 21. `model_registry::find_model` is dead code
146+
147+
### 22. `LoadModelRequest` in types.rs is orphaned
148+
Only used by dead client methods.
149+
150+
### 23. No README.md at repo root
151+
CLAUDE.md exists but GitHub shows no README on the repo landing page.
152+
153+
---
154+
155+
## Test Coverage: Zero
156+
157+
No tests exist anywhere. Minimum required:
158+
159+
| What | Where | Priority |
160+
|------|-------|----------|
161+
| `OutputFormat` parsing (png, jpeg, jpg, invalid) | `mold-core` | High |
162+
| `GenerateRequest` validation bounds | `mold-core` | High |
163+
| `Config::load_or_default` with missing/corrupt file | `mold-core` | High |
164+
| `ModelPaths::resolve` env var precedence over config | `mold-core` | Medium |
165+
| API route unit tests (mock engine, check HTTP codes) | `mold-server` | High |
166+
| `model_registry::known_models` returns expected entries | `mold-inference` | Low |
167+
168+
---
169+
170+
## Fixes Implemented
171+
172+
See commits following this review.

crates/mold-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ path = "src/main.rs"
1212

1313
[dependencies]
1414
mold-core = { path = "../mold-core" }
15+
mold-server = { path = "../mold-server" }
1516
anyhow = "1"
1617
clap = { version = "4", features = ["derive"] }
1718
colored = "3"

crates/mold-cli/src/commands/serve.rs

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,5 @@ pub async fn run(port: u16, bind: &str, models_dir: Option<String>) -> Result<()
2121
models_path.display(),
2222
);
2323

24-
// This will block until the server shuts down
25-
// mold-server is a library crate, so we call it directly
26-
// For now, just use a simple axum setup inline since mold-server
27-
// exposes run_server()
28-
println!(
29-
"{} Server requires mold-server (use with full build)",
30-
"!".yellow()
31-
);
32-
println!(
33-
"{} For development, run: cargo run -p mold-server",
34-
"●".green()
35-
);
36-
37-
Ok(())
24+
mold_server::run_server(bind, port, models_path).await
3825
}

crates/mold-cli/src/main.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ enum Commands {
2929
#[arg(short, long)]
3030
output: Option<String>,
3131

32-
/// Image width
33-
#[arg(long, default_value = "1024")]
32+
/// Image width (max 768 — 1024+ causes VAE OOM on RTX 4090 with current GGUF models)
33+
#[arg(long, default_value = "768")]
3434
width: u32,
3535

36-
/// Image height
37-
#[arg(long, default_value = "1024")]
36+
/// Image height (max 768 — 1024+ causes VAE OOM on RTX 4090 with current GGUF models)
37+
#[arg(long, default_value = "768")]
3838
height: u32,
3939

4040
/// Number of inference steps

crates/mold-core/src/client.rs

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use anyhow::Result;
22
use reqwest::Client;
33

4-
use crate::types::{GenerateRequest, GenerateResponse, LoadModelRequest, ModelInfo, ServerStatus};
4+
use crate::types::{
5+
GenerateRequest, GenerateResponse, ImageData, LoadModelRequest, ModelInfo, ServerStatus,
6+
};
57

68
pub struct MoldClient {
79
base_url: String,
@@ -22,17 +24,47 @@ impl MoldClient {
2224
Self::new(&base_url)
2325
}
2426

25-
pub async fn generate(&self, req: GenerateRequest) -> Result<GenerateResponse> {
26-
let resp = self
27+
/// Generate an image. Returns raw image bytes (PNG or JPEG).
28+
/// The server returns raw bytes, not JSON — callers are responsible for
29+
/// writing the bytes to disk or further processing.
30+
pub async fn generate_raw(&self, req: &GenerateRequest) -> Result<Vec<u8>> {
31+
let bytes = self
2732
.client
2833
.post(format!("{}/api/generate", self.base_url))
29-
.json(&req)
34+
.json(req)
3035
.send()
3136
.await?
3237
.error_for_status()?
33-
.json::<GenerateResponse>()
34-
.await?;
35-
Ok(resp)
38+
.bytes()
39+
.await?
40+
.to_vec();
41+
Ok(bytes)
42+
}
43+
44+
/// Generate an image and return a minimal response wrapping the raw bytes.
45+
pub async fn generate(&self, req: GenerateRequest) -> Result<GenerateResponse> {
46+
let seed = req.seed.unwrap_or(0);
47+
let width = req.width;
48+
let height = req.height;
49+
let model = req.model.clone();
50+
let format = req.output_format;
51+
52+
let start = std::time::Instant::now();
53+
let data = self.generate_raw(&req).await?;
54+
let generation_time_ms = start.elapsed().as_millis() as u64;
55+
56+
Ok(GenerateResponse {
57+
images: vec![ImageData {
58+
data,
59+
format,
60+
width,
61+
height,
62+
index: 0,
63+
}],
64+
generation_time_ms,
65+
model,
66+
seed_used: seed,
67+
})
3668
}
3769

3870
pub async fn list_models(&self) -> Result<Vec<ModelInfo>> {

crates/mold-core/src/config.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,23 @@ impl Config {
122122
let config_path = Self::config_path();
123123
if config_path.exists() {
124124
match std::fs::read_to_string(&config_path) {
125-
Ok(contents) => toml::from_str(&contents).unwrap_or_default(),
126-
Err(_) => Config::default(),
125+
Ok(contents) => match toml::from_str(&contents) {
126+
Ok(cfg) => cfg,
127+
Err(e) => {
128+
eprintln!(
129+
"warning: failed to parse config at {}: {e} — using defaults",
130+
config_path.display()
131+
);
132+
Config::default()
133+
}
134+
},
135+
Err(e) => {
136+
eprintln!(
137+
"warning: failed to read config at {}: {e} — using defaults",
138+
config_path.display()
139+
);
140+
Config::default()
141+
}
127142
}
128143
} else {
129144
Config::default()

0 commit comments

Comments
 (0)