Merge track r3-t06

# Conflicts:
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
aiongg
2026-10-31 02:30:12 +00:00
26 changed files with 1544 additions and 127 deletions
+2 -1
View File
@@ -259,7 +259,8 @@ lightcraft --control 7980 ~/Pictures/trip
- **Command registry.** `engine.commands` lists the available commands; `develop.controls` lists every slider's
range, default and current value.
- **MCP server.** `lightcraft-cli mcp` exposes the command registry to Claude (or any MCP client), alongside
helpers for import, query, develop, mask, render (returned as an image) and export. It runs headless, or attached
helpers for import, query, develop, render (returned as an image) and export. Masks have no helper of their own:
create and edit them through `run_command` (e.g. `mask.add`). It runs headless, or attached
to the running app with screenshots, clicks and gestures. See [docs/mcp.md](docs/mcp.md).
```sh
+1 -1
View File
@@ -241,7 +241,7 @@ lightcraft --control 7980 ~/Pictures/trip
- **命令注册表。** `engine.commands` 列出可用命令;`develop.controls` 列出每个滑块的范围、
默认值和当前值。
- **MCP 服务器。** `lightcraft-cli mcp` 把命令注册表开放给 Claude(或任何 MCP 客户端),
同时提供导入、查询、显影、蒙版、渲染(以图像返回)和导出的辅助工具。它既能无界面运行,也能附着
同时提供导入、查询、显影、渲染(以图像返回)和导出的辅助工具。蒙版没有专门的辅助工具,请通过 `run_command` 使用(例如 `mask.add`)。它既能无界面运行,也能附着
到正在运行的应用上,支持截图、点击和手势。见 [docs/mcp.md](docs/mcp.md)。
```sh
+1 -1
View File
@@ -240,7 +240,7 @@ lightcraft --control 7980 ~/Pictures/trip
- **指令註冊表。** `engine.commands` 列出可用的指令;`develop.controls` 列出每個滑桿的範圍、
預設值與目前值。
- **MCP 伺服器。** `lightcraft-cli mcp` 把指令註冊表開放給 Claude(或任何 MCP 用戶端),
同時提供匯入、查詢、顯影、遮色片、算繪(以影像回傳)與匯出的輔助工具。它可以無介面執行,也可以附掛
同時提供匯入、查詢、顯影、算繪(以影像回傳)與匯出的輔助工具。遮色片沒有專屬的輔助工具,請透過 `run_command` 使用(例如 `mask.add`)。它可以無介面執行,也可以附掛
到正在執行的應用程式上,支援截圖、點擊與手勢。詳見 [docs/mcp.md](docs/mcp.md)。
```sh
+102 -11
View File
@@ -155,7 +155,16 @@ fn main() -> ExitCode {
// Warnings (a GPU render redone on the CPU, an unknown backend name) on stderr; LIGHTCRAFT_LOG
// or RUST_LOG picks another level (#168).
lightcraft_engine::logging::install("lightcraft-cli");
let args: Vec<String> = std::env::args().skip(1).collect();
let mut args: Vec<String> = Vec::new();
for (index, arg) in std::env::args_os().skip(1).enumerate() {
match arg.into_string() {
Ok(arg) => args.push(arg),
Err(_) => {
eprintln!("lightcraft-cli: argument {} is not valid Unicode", index + 1);
return ExitCode::FAILURE;
}
}
}
let r = match args.first().map(String::as_str) {
Some("run") => run(&args[1..]),
Some("mcp") => mcp(&args[1..]),
@@ -557,6 +566,8 @@ fn run(args: &[String]) -> Result<(), String> {
} else {
std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?
};
// Windows PowerShell 5.1 writes a UTF-8 BOM; it is not part of the first JSON line.
let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty() && !l.trim_start().starts_with('#')) {
let v: Value = serde_json::from_str(line).map_err(|e| format!("{path}:{}: {e}", n + 1))?;
// `{"command": id, "params": …}` or a control request `{"method": m, "params": …}`
@@ -565,7 +576,11 @@ fn run(args: &[String]) -> Result<(), String> {
.or(v.get("method"))
.and_then(Value::as_str)
.ok_or_else(|| format!("{path}:{}: needs `command` or `method`", n + 1))?;
let params = v.get("params").cloned().filter(|p| p.is_object()).unwrap_or_else(|| json!({}));
let params = match v.get("params") {
None | Some(Value::Null) => json!({}),
Some(Value::Object(params)) => Value::Object(params.clone()),
Some(_) => return Err(format!("{path}:{}: `params` must be a JSON object", n + 1)),
};
steps.push(Step { id: id.to_string(), params });
}
}
@@ -594,14 +609,7 @@ fn run(args: &[String]) -> Result<(), String> {
None => Headless::default(),
};
if !imports.is_empty() {
let paths = expand_paths(&imports);
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
// what follows acts on the imported photos (already-known files are reported as duplicates)
let mut ids: Vec<Value> = r["imported"].as_array().cloned().unwrap_or_default();
ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
if let Some(first) = ids.first().cloned() {
h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
}
import_and_select(&mut h.session, &imports)?;
}
Box::new(h)
}
@@ -626,6 +634,34 @@ fn run(args: &[String]) -> Result<(), String> {
if failed > 0 { Err(format!("{failed} command(s) failed")) } else { Ok(()) }
}
fn import_and_select(session: &mut Session, imports: &[String]) -> Result<(), String> {
let selection = session.selection.clone();
let paths = expand_paths(imports);
let result = (|| {
let r = session.execute("library.import", &json!({"paths": paths})).map_err(|e| format!("import failed: {e}; no commands were run"))?;
let ids = import_targets(&r)?;
let first = ids.first().cloned().ok_or("import found no photos to select; no commands were run")?;
session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
Ok::<(), String>(())
})();
if result.is_err() {
session.selection = selection;
}
result
}
fn import_targets(report: &Value) -> Result<Vec<Value>, String> {
if let Some(failed) = report["failed"].as_array().filter(|failed| !failed.is_empty()) {
return Err(format!("import failed for {} item(s): {}; no commands were run", failed.len(), json!(failed)));
}
let mut ids: Vec<Value> = report["imported"].as_array().cloned().unwrap_or_default();
ids.extend(report["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
ids.extend(report["restored"].as_array().into_iter().flatten().filter(|id| id.is_u64()).cloned());
let mut seen = std::collections::HashSet::new();
ids.retain(|id| id.as_u64().is_none_or(|id| seen.insert(id)));
if ids.is_empty() { Err("import found no photos to select; no commands were run".into()) } else { Ok(ids) }
}
fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a str, String> {
*i += 1;
args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value"))
@@ -683,7 +719,9 @@ fn render(args: &[String]) -> Result<(), String> {
"--set" => {
let kv = take_value(args, &mut i, "--set")?;
let (k, v) = kv.split_once('=').ok_or_else(|| format!("--set expects control=value, got `{kv}`"))?;
let v: f64 = v.trim().parse().map_err(|_| format!("--set {k}: `{v}` is not a number"))?;
// `inf` / `NaN` parse as f64 but become JSON null, which the control would silently ignore.
let v: f64 =
v.trim().parse().ok().filter(|n: &f64| n.is_finite()).ok_or_else(|| format!("--set {k}: `{v}` is not a finite number"))?;
values.insert(k.trim().to_string(), json!(v));
}
"--settings" => settings = Some(read_settings(take_value(args, &mut i, "--settings")?)?),
@@ -852,6 +890,8 @@ fn snapshot(args: &[String]) -> Result<(), String> {
let mut failed = 0usize;
if let Some(script) = &script {
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
// Windows PowerShell 5.1 writes a UTF-8 BOM; it is not part of the first JSON line.
let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
let mut out = std::io::stdout().lock();
for (n, line) in text.lines().enumerate() {
let line = line.trim();
@@ -1028,3 +1068,54 @@ fn denoise_serve(args: &[String]) -> Result<(), String> {
fn denoise_serve(_: &[String]) -> Result<(), String> {
Err("denoise-serve: this build has no denoise support (build with the `denoise` feature)".into())
}
#[cfg(test)]
mod import_target_tests {
use super::{import_and_select, import_targets, json};
#[test]
fn empty_reports_and_duplicates_without_ids_have_no_target() {
assert!(import_targets(&json!({})).is_err());
assert!(import_targets(&json!({"imported": [], "duplicates": [{"existing": null}]})).is_err());
}
#[test]
fn restored_only_and_mixed_reports_keep_target_order() {
assert_eq!(import_targets(&json!({"restored": [9]})).unwrap(), vec![json!(9)]);
assert_eq!(
import_targets(&json!({"imported": [1], "duplicates": [{"existing": null}, {"existing": 2}], "restored": [3]})).unwrap(),
vec![json!(1), json!(2), json!(3)]
);
}
#[test]
fn repeated_targets_are_selected_once_in_first_occurrence_order() {
let report = json!({"imported": [5, 7], "duplicates": [{"existing": 5}, {"existing": 8}, {"existing": 8}, {"existing": 7}, {"existing": null}], "restored": [8, 9, 5]});
assert_eq!(import_targets(&report).unwrap(), vec![json!(5), json!(7), json!(8), json!(9)]);
}
#[test]
fn failed_items_reject_even_when_targets_exist() {
let error = import_targets(&json!({"imported": [1], "failed": [["broken.png", "invalid"]]})).unwrap_err();
assert!(error.contains("1") && error.contains("broken.png") && error.contains("no commands were run"), "{error}");
}
#[test]
fn failed_partial_import_restores_empty_selection() {
let dir = std::env::temp_dir().join(format!("lightcraft-import-selection-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let input = dir.join("valid.png");
let broken = dir.join("broken.png");
let img = lightcraft_raster::Rgba8::from_fn(2, 2, |_, _| [20, 40, 60, 255]);
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &Default::default()).unwrap();
std::fs::write(&input, png).unwrap();
std::fs::write(&broken, b"not a PNG").unwrap();
let mut headless = lightcraft_mcp::Headless::default();
let session = &mut headless.session;
let selection = session.selection.clone();
assert!(selection.ids.is_empty() && selection.active.is_none());
assert!(import_and_select(session, &[input.to_string_lossy().into_owned(), broken.to_string_lossy().into_owned()]).is_err());
assert_eq!(session.selection, selection);
let _ = std::fs::remove_dir_all(dir);
}
}
+305 -9
View File
@@ -162,6 +162,43 @@ fn mcp_over_stdio_sets_exposure_and_renders() {
assert!(b > a + 10.0, "{a} → {b}");
}
#[cfg(unix)]
#[test]
fn non_utf8_argument_fails_without_a_panic() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let out = tmp("non-utf8-output.png");
let _ = std::fs::remove_file(&out);
let output = Command::new(binary()).arg("render").arg(OsString::from_vec(b"bad-\xff.png".to_vec())).arg("-o").arg(&out).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{stderr}");
assert!(stderr.contains("argument 2"), "{stderr}");
assert!(stderr.contains("is not valid Unicode"), "{stderr}");
assert!(!stderr.contains("panicked"), "{stderr}");
assert!(!out.exists());
assert!(Command::new(binary()).arg("--version").status().unwrap().success());
}
#[cfg(windows)]
#[test]
fn non_unicode_argument_fails_without_a_panic_on_windows() {
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
let out = tmp("non-unicode-output.png");
let _ = std::fs::remove_file(&out);
let name: Vec<u16> = "bad-".encode_utf16().chain([0xD800]).chain(".png".encode_utf16()).collect();
let output = Command::new(binary()).arg("render").arg(OsString::from_wide(&name)).arg("-o").arg(&out).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{stderr}");
assert!(stderr.contains("argument 2"), "{stderr}");
assert!(stderr.contains("is not valid Unicode"), "{stderr}");
assert!(!stderr.contains("panicked"), "{stderr}");
assert!(!out.exists());
assert!(Command::new(binary()).arg("--version").status().unwrap().success());
}
#[test]
fn render_subcommand() {
let input = tmp("r-in.png");
@@ -184,6 +221,12 @@ fn render_subcommand() {
let o = Command::new(binary()).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "nope=1"]).output().unwrap();
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control"));
// Values that parse as f64 but are not finite fail cleanly too.
for bad in ["light.exposure=inf", "light.exposure=-inf", "light.exposure=NaN", "light.exposure=1e999"] {
let o = Command::new(binary()).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", bad]).output().unwrap();
assert!(!o.status.success(), "{bad} must be refused");
assert!(String::from_utf8_lossy(&o.stderr).contains("not a finite number"), "{bad}");
}
}
/// Issue #1730: `render --settings` took a `.lcpreset` from `preset.export`, ignored its wrapper
@@ -502,6 +545,29 @@ fn snapshot_starts_without_a_gpu() {
}
}
/// A loopback port nothing listens on, for tests that need `--connect` to be refused (#3070).
/// A fixed port is not safe: agents give their apps' control servers ports in 18000–19999 (and
/// 7980 is the default), so a test could reach someone's running app and drive it. The OS hands
/// out a free port for `:0`; the listener is dropped so connecting to it is refused. Another
/// process could bind the port between the drop and the connect. That window is a few
/// milliseconds, and `:0` draws from the OS's ephemeral range (from 32768 on Linux, 49152 on
/// macOS and Windows), where nobody puts a control server (7980, 18000–19999): the standard
/// trade-off for a test that needs a refused connection.
///
/// Within this binary the window is closed: the tests that take an ephemeral port and let it go
/// hold [`ephemeral_ports`], so `denoise-serve` can't start on a port a refused-connection test
/// picked while it runs.
fn unused_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}
/// Held by each test that picks an ephemeral port and releases it before use, for the whole test.
fn ephemeral_ports() -> std::sync::MutexGuard<'static, ()> {
static PORTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
// a test that failed while holding it doesn't fail the others
PORTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn run_cli(args: &[&str], stdin: Option<&str>) -> (bool, Vec<Value>, String) {
let mut child = Command::new(binary()).arg("run").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
if let Some(text) = stdin {
@@ -513,6 +579,230 @@ fn run_cli(args: &[&str], stdin: Option<&str>) -> (bool, Vec<Value>, String) {
(o.status.success(), lines, String::from_utf8_lossy(&o.stderr).to_string())
}
/// Windows PowerShell 5.1 writes a UTF-8 BOM; `--script` (file and stdin) must accept it.
#[test]
fn run_script_accepts_a_utf8_bom() {
let text = "\u{feff}{\"command\": \"library.info\", \"params\": {}}\n";
let script = tmp("bom-steps.jsonl");
std::fs::write(&script, text).unwrap();
let (ok, lines, err) = run_cli(&["--demo", "--script", script.to_str().unwrap()], None);
assert!(ok, "file: {err}");
assert_eq!(lines.len(), 1, "{lines:?}");
let (ok, lines, err) = run_cli(&["--demo", "--script", "-"], Some(text));
assert!(ok, "stdin: {err}");
assert_eq!(lines.len(), 1, "{lines:?}");
}
#[test]
fn run_script_params_validate_before_any_commands() {
let path = tmp("run-invalid-params.jsonl");
let path_str = path.to_str().unwrap();
for params in [json!([]), json!("invalid"), json!(42), json!(true)] {
let script = format!("{}\n{}\n", json!({"command": "library.info"}), json!({"command": "develop.reset", "params": params}));
std::fs::write(&path, &script).unwrap();
for (source, stdin) in [("-", Some(script.as_str())), (path_str, None)] {
let (ok, lines, err) = run_cli(&["--script", source], stdin);
assert!(!ok, "{source}: {params}: {lines:?}");
assert!(lines.is_empty(), "{source}: {params}: {lines:?}");
assert!(err.contains(&format!("{source}:2")), "{err}");
assert!(err.contains("`params` must be a JSON object"), "{err}");
}
}
for script in
["{\"command\":\"library.info\"}\n", "{\"command\":\"library.info\",\"params\":null}\n", "{\"command\":\"library.info\",\"params\":{}}\n"]
{
let (ok, lines, err) = run_cli(&["--script", "-"], Some(script));
assert!(ok, "{script}: {err}");
assert_eq!(lines.len(), 1, "{script}: {lines:?}");
assert_eq!(lines[0]["ok"], true, "{script}: {lines:?}");
}
std::fs::remove_file(path).unwrap();
}
#[test]
fn run_failed_import_never_edits_existing_selection() {
let dir = tmp("failed-import");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let lib = dir.join("library");
let missing = dir.join("missing.png");
let empty = dir.join("empty");
std::fs::create_dir(&empty).unwrap();
assert!(!missing.exists());
let lib = lib.to_str().unwrap();
let (ok, lines, err) = run_cli(&["--demo", "--library", lib, "library.state"], None);
assert!(ok, "{err}");
let id = lines[0]["result"]["selection"]["active"].as_u64().unwrap();
let (ok, _, err) = run_cli(&["--library", lib, "photo.rate", &format!("ids=[{id}]"), "rating=0"], None);
assert!(ok, "{err}");
for path in [&missing, &empty] {
let (ok, lines, err) = run_cli(&["--library", lib, "--import", path.to_str().unwrap(), "photo.rate", "rating=5"], None);
assert!(!ok, "import unexpectedly succeeded for {}: {lines:?}", path.display());
assert!(lines.is_empty(), "commands ran: {lines:?}");
assert!(err.contains("import") && err.contains("no commands were run"), "{err}");
if path == &missing {
assert!(err.contains("failed") && err.contains("missing.png"), "{err}");
}
let (ok, lines, err) = run_cli(&["--library", lib, "photo.inspect", &format!("id={id}")], None);
assert!(ok, "{err}");
assert_eq!(lines[0]["result"]["rating"], 0);
}
}
#[test]
fn run_partial_import_stops_commands_and_import_targets_remain_usable() {
let dir = tmp("partial-import");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let input = dir.join("valid.png");
let broken = dir.join("broken.png");
gradient_png(&input);
std::fs::write(&broken, b"not a PNG").unwrap();
let mixed_lib = dir.join("mixed");
let mixed_lib = mixed_lib.to_str().unwrap();
let input = input.to_str().unwrap();
let (ok, lines, err) = run_cli(&["--demo", "--library", mixed_lib, "catalog.query"], None);
assert!(ok, "{err}");
let photos = lines[0]["result"]["photos"].as_array().unwrap();
let a = photos[0]["id"].as_u64().unwrap();
let b = photos[1]["id"].as_u64().unwrap();
let (ok, lines, err) = run_cli(
&[
"--library",
mixed_lib,
"library.select",
&format!("ids=[{a},{b}]"),
&format!("active={a}"),
"photo.rate",
&format!("ids=[{a}]"),
"rating=0",
"library.state",
],
None,
);
assert!(ok, "{err}");
let selection = lines[2]["result"]["selection"].clone();
assert_eq!(selection["ids"], json!([a, b]));
assert_eq!(selection["active"], a);
let (ok, lines, err) =
run_cli(&["--library", mixed_lib, "--import", input, "--import", broken.to_str().unwrap(), "photo.rate", "rating=5"], None);
let (query_ok, after, query_err) =
run_cli(&["--library", mixed_lib, "library.state", "photo.inspect", &format!("id={a}"), "catalog.query"], None);
assert!(query_ok, "{query_err}");
assert_eq!(after[0]["result"]["selection"], selection, "selection changed after partial import");
assert!(!ok, "partial import unexpectedly succeeded: {lines:?}");
assert!(lines.is_empty() && err.contains("failed") && err.contains("no commands were run"), "{err}: {lines:?}");
assert_eq!(after[1]["result"]["rating"], 0);
assert!(after[2]["result"]["photos"].as_array().unwrap().iter().any(|p| p["fileName"] == "valid.png"));
let lib = dir.join("duplicates");
let lib = lib.to_str().unwrap();
let (ok, lines, err) = run_cli(&["--demo", "--library", lib, "library.state"], None);
assert!(ok, "{err}");
let previous = lines[0]["result"]["selection"]["active"].as_u64().unwrap();
let (ok, lines, err) = run_cli(&["--library", lib, "--import", input, "photo.rate", "rating=4", "library.state"], None);
assert!(ok, "{err}");
let id = lines[1]["result"]["selection"]["active"].as_u64().unwrap();
assert_ne!(id, previous);
let (ok, _, err) = run_cli(&["--library", lib, "library.select", &format!("ids=[{previous}]"), "photo.rate", "rating=0"], None);
assert!(ok, "{err}");
let (ok, _, err) = run_cli(&["--library", lib, "--import", input, "photo.rate", "rating=3"], None);
assert!(ok, "{err}");
let (ok, lines, err) = run_cli(&["--library", lib, "photo.inspect", &format!("id={id}"), "photo.inspect", &format!("id={previous}")], None);
assert!(ok, "{err}");
assert_eq!(lines[0]["result"]["rating"], 3);
assert_eq!(lines[1]["result"]["rating"], 0);
}
#[test]
fn run_restored_import_is_selected() {
let dir = tmp("restored-import");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let input = dir.join("valid.png");
gradient_png(&input);
let lib = dir.join("library");
let (lib, input) = (lib.to_str().unwrap(), input.to_str().unwrap());
let (ok, lines, err) = run_cli(&["--library", lib, "--import", input, "library.state"], None);
assert!(ok, "{err}");
let id = lines[0]["result"]["selection"]["active"].as_u64().unwrap();
let (ok, _, err) = run_cli(&["--library", lib, "photo.delete"], None);
assert!(ok, "{err}");
let (ok, _, err) = run_cli(&["--library", lib, "--import", input, "photo.rate", "rating=4"], None);
assert!(ok, "{err}");
let (ok, lines, err) = run_cli(&["--library", lib, "catalog.query"], None);
assert!(ok, "{err}");
assert_eq!(lines[0]["result"]["photos"].as_array().unwrap().len(), 1);
assert_eq!(lines[0]["result"]["photos"][0]["id"], id);
assert_eq!(lines[0]["result"]["photos"][0]["rating"], 4);
}
#[test]
fn run_same_batch_duplicates_selects_photo_once() {
let dir = tmp("same-batch-duplicates");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let first = dir.join("first.png");
let second = dir.join("second.png");
gradient_png(&first);
std::fs::copy(&first, &second).unwrap();
let library = dir.join("library");
let (ok, lines, err) = run_cli(
&[
"--library",
library.to_str().unwrap(),
"--import",
first.to_str().unwrap(),
"--import",
second.to_str().unwrap(),
"photo.rate",
"rating=4",
"library.state",
"catalog.query",
],
None,
);
assert!(ok, "{err}");
let selection = &lines[1]["result"]["selection"];
let ids = selection["ids"].as_array().unwrap();
assert_eq!(ids.len(), 1, "same photo selected more than once: {selection}");
assert_eq!(selection["active"], ids[0]);
let photos = lines[2]["result"]["photos"].as_array().unwrap();
assert_eq!(photos.len(), 1);
assert_eq!(photos[0]["id"], ids[0]);
assert_eq!(photos[0]["rating"], 4);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn run_mixed_new_and_restored_import_selects_both() {
let dir = tmp("mixed-restored-import");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let restored = dir.join("restored.png");
let new = dir.join("new.png");
gradient_png(&restored);
let image = lightcraft_raster::Rgba8::from_fn(120, 80, |x, y| [(x * 2) as u8, (y * 3) as u8, 200, 255]);
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &Default::default()).unwrap();
std::fs::write(&new, png).unwrap();
let lib = dir.join("library");
let (lib, restored, new) = (lib.to_str().unwrap(), restored.to_str().unwrap(), new.to_str().unwrap());
let (ok, lines, err) = run_cli(&["--library", lib, "--import", restored, "library.state"], None);
assert!(ok, "{err}");
let restored_id = lines[0]["result"]["selection"]["active"].as_u64().unwrap();
let (ok, _, err) = run_cli(&["--library", lib, "photo.delete"], None);
assert!(ok, "{err}");
let (ok, lines, err) = run_cli(&["--library", lib, "--import", new, "--import", restored, "photo.rate", "rating=4", "library.state"], None);
assert!(ok, "{err}");
assert_eq!(lines[1]["result"]["selection"]["ids"].as_array().unwrap().len(), 2);
let (ok, lines, err) = run_cli(&["--library", lib, "catalog.query"], None);
assert!(ok, "{err}");
let photos = lines[0]["result"]["photos"].as_array().unwrap();
assert_eq!(photos.len(), 2);
assert!(photos.iter().any(|p| p["id"] == restored_id && p["rating"] == 4));
assert!(photos.iter().any(|p| p["fileName"] == "new.png" && p["id"] != restored_id && p["rating"] == 4));
}
#[test]
fn run_subcommand_chains_commands_and_persists_a_library() {
let input = tmp("run-in.png");
@@ -614,9 +904,11 @@ fn a_library_open_in_another_process_is_refused() {
/// ADDR actually names — following the old fixed "7980" left the retry connecting elsewhere.
#[test]
fn connect_failure_hint_names_the_attempted_port() {
let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None);
let _ports = ephemeral_ports();
let port = unused_port();
let (ok, _lines, err) = run_cli(&["--connect", &format!("127.0.0.1:{port}"), "ui.inspect"], None);
assert!(!ok);
assert!(err.contains("--control 18437"), "the hint must name the port it tried: {err}");
assert!(err.contains(&format!("--control {port}")), "the hint must name the port it tried: {err}");
assert!(!err.contains("--control 7980"), "{err}");
}
@@ -626,20 +918,23 @@ fn connect_failure_hint_names_the_attempted_port() {
/// works; non-numeric, non-`ADDR`-shaped tokens (e.g. a real command id) keep falling through.
#[test]
fn connect_bare_port_is_treated_as_loopback_port() {
let _ports = ephemeral_ports();
let port = unused_port();
let hint = format!("--control {port}");
// Bare port: the failure hint names that port.
let (ok, _lines, err) = run_cli(&["--connect", "18437", "ui.inspect"], None);
let (ok, _lines, err) = run_cli(&["--connect", &port.to_string(), "ui.inspect"], None);
assert!(!ok);
assert!(err.contains("--control 18437"), "bare port must reach the hint: {err}");
assert!(err.contains(&hint), "bare port must reach the hint: {err}");
assert!(!err.contains("--control 7980"), "{err}");
// `<host>:<port>` still works (issue #167 case).
let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None);
let (ok, _lines, err) = run_cli(&["--connect", &format!("127.0.0.1:{port}"), "ui.inspect"], None);
assert!(!ok);
assert!(err.contains("--control 18437"), "{err}");
assert!(err.contains(&hint), "{err}");
// `--connect=<bare-port>` already worked (the `a.starts_with("--connect=")` arm); spot-check
// the hint matches its port too.
let (ok, _lines, err) = run_cli(&["--connect=18437", "ui.inspect"], None);
let (ok, _lines, err) = run_cli(&[&format!("--connect={port}"), "ui.inspect"], None);
assert!(!ok);
assert!(err.contains("--control 18437"), "{err}");
assert!(err.contains(&hint), "{err}");
}
/// The CLI logs warnings on stderr (#168): with `RUST_LOG=warn` an unknown GPU backend name is
@@ -785,7 +1080,8 @@ fn denoise_serve_runs_tiles_for_another_computer() {
Command::new(binary()).args(["denoise-serve", "--models", models.to_str().unwrap(), "--token-env", "LC_TEST_NO_SUCH_VAR"]).output().unwrap();
assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("LC_TEST_NO_SUCH_VAR"));
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let _ports = ephemeral_ports();
let port = unused_port();
let addr = format!("127.0.0.1:{port}");
let token = "cli-test-token-0123456789";
let mut child = Command::new(binary())
+115 -19
View File
@@ -87,7 +87,7 @@ fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context, id
log::warn!("control connection closed: could not set idle read timeout: {e}");
return;
}
serve_lines(&stream, &stream, |method, params| {
serve_stream(stream, |method, params| {
let (req, rrx) = ControlRequest::new(method, params);
let state = Arc::clone(&req.state);
tx.send(req).ok()?;
@@ -96,6 +96,40 @@ fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context, id
});
}
/// The longest a closing connection is drained, however slowly its peer keeps sending.
const DRAIN_TIME: Duration = Duration::from_secs(1);
/// [`serve_lines`] on a socket, then close it so the peer reads every reply.
fn serve_stream(stream: TcpStream, handle: impl FnMut(String, Value) -> Option<Value>) {
serve_lines(&stream, &stream, handle);
close_gracefully(&stream);
}
/// Close a connection the server is done with without destroying its last reply. A socket closed
/// while the peer's later lines are unread, or arrive after it, answers them with a reset, and a
/// reset can discard the replies the peer has not read yet (on Windows it does): the client sees
/// "connection reset" instead of why. Send FIN, then read and drop what is left (at most
/// [`MAX_LINE`] bytes, 500 ms per read and [`DRAIN_TIME`] in all) before closing. From #1970
/// (LochLogic).
fn close_gracefully(stream: &TcpStream) {
let _ = stream.shutdown(std::net::Shutdown::Write);
let start = std::time::Instant::now();
let mut sink = [0u8; 8192];
let mut left = MAX_LINE;
let mut reader = stream;
while left > 0 {
// a peer that keeps trickling bytes must not hold the connection slot past DRAIN_TIME
let wait = DRAIN_TIME.saturating_sub(start.elapsed()).min(Duration::from_millis(500));
if wait.is_zero() || stream.set_read_timeout(Some(wait)).is_err() {
break;
}
match reader.read(&mut sink) {
Ok(0) | Err(_) => break,
Ok(n) => left = left.saturating_sub(n),
}
}
}
/// Wait for a request's reply. While the app has not picked the request up and `frames` (its
/// frame counter) stands still for `no_frames`, the request is abandoned (it will not run late)
/// and the reply says why; a request the app is running gets the whole `timeout`.
@@ -170,15 +204,40 @@ fn parse_line(bytes: &[u8]) -> Line {
}
}
/// The error of the notice sent when an idle connection is closed. Clients match the reply's
/// `"closed": "idle"` marker; `crates/mcp/src/backend.rs` also matches this text, for apps from
/// before the marker, so keep the two in step.
const IDLE_CLOSE_ERROR: &str = "control connection idle read timed out; closing the connection";
/// Why the server closes a connection; its error reply says so.
#[derive(Debug, PartialEq)]
enum Closing {
/// Nothing complete arrived within the idle timeout. Nothing more is read, so a request that
/// crosses the notice is never run: the reply carries `"closed": "idle"` to tell clients it is
/// safe to send that request again on a new connection.
Idle,
/// Junk, an overlong line or a failed read.
Error(String),
}
impl Closing {
fn reply(&self) -> Value {
match self {
Self::Idle => json!({"ok": false, "error": IDLE_CLOSE_ERROR, "closed": "idle"}),
Self::Error(e) => json!({"ok": false, "error": e}),
}
}
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, Closing> {
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| match e.kind() {
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => "control connection idle read timed out; closing the connection".to_string(),
_ => e.to_string(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => Closing::Idle,
_ => Closing::Error(e.to_string()),
})?;
if n == 0 {
return Ok(false);
@@ -191,7 +250,7 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
return Ok(true);
}
if buf.len() > max {
return Err(format!("request line longer than {max} bytes; closing the connection"));
return Err(Closing::Error(format!("request line longer than {max} bytes; closing the connection")));
}
// Last line without a newline, then end of stream.
Ok(true)
@@ -204,12 +263,12 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
let closing = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(false) => return,
Err(e) => e,
Err(closing) => closing,
Ok(true) => match parse_line(&buf) {
Line::Blank => continue,
Line::Reject(e) => e,
Line::Reject(e) => Closing::Error(e),
Line::Request { id, method, params } => {
let Some(mut r) = handle(method, params) else { return };
if let Some(o) = r.as_object_mut() {
@@ -222,7 +281,7 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
}
},
};
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
let _ = writeln!(out, "{}", closing.reply()).and_then(|()| out.flush());
return;
}
}
@@ -287,7 +346,9 @@ mod tests {
assert!(connection.requests.try_recv().is_err(), "incomplete input never runs");
let reply: Value = serde_json::from_str(&reply).unwrap();
assert_eq!(reply["ok"], false);
assert!(reply["error"].as_str().unwrap().contains("idle read timed out"));
assert_eq!(reply["error"], IDLE_CLOSE_ERROR);
assert_eq!(reply["closed"], "idle", "clients may resend a request that crossed this notice");
assert!(reply.get("id").is_none());
// A subsequent connection is admitted through the same accounting path.
let mut next = Connection::new(&open, Duration::from_secs(1), |c| c.shutdown(std::net::Shutdown::Write).unwrap());
@@ -413,6 +474,7 @@ mod tests {
assert_eq!(r.len(), 1);
assert_eq!(r[0]["ok"], false);
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
assert!(r[0].get("closed").is_none(), "only an idle close invites a resend: {}", r[0]);
}
#[test]
@@ -502,18 +564,52 @@ mod tests {
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
let read = s.try_clone().unwrap();
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
serve_stream(s, |_, _| Some(json!({"ok": true})));
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
let mut text = String::new();
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n").unwrap();
let mut replies = BufReader::new(c.try_clone().unwrap());
let (mut first, mut second) = (String::new(), String::new());
replies.read_line(&mut first).unwrap();
replies.read_line(&mut second).unwrap();
assert!(first.contains("\"id\":7"), "{first}");
assert!(second.contains("closing the connection"), "{second}");
// A client that keeps sending after the junk: its next line reaches the server after the
// rejection. A socket closed by then answers that line with a reset, which the client reads
// as "connection reset" (os error 10053 / 10054 on Windows) instead of the end of the
// replies. The pause lets a reset arrive before the read.
c.write_all(b"{\"method\":\"y\"}\n").unwrap();
std::thread::sleep(Duration::from_millis(100));
let mut rest = String::new();
let read = replies.read_to_string(&mut rest);
server.join().unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines[0].contains("\"id\":7"));
assert!(lines[1].contains("closing the connection"));
assert!(read.is_ok(), "the close must be clean, not a reset: {read:?}");
assert_eq!(rest, "", "nothing follows the rejection");
}
#[test]
fn a_closing_connection_is_not_held_open_by_a_trickling_peer() {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
let server_done = Arc::clone(&done);
let server = std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
let t0 = std::time::Instant::now();
close_gracefully(&s);
server_done.store(true, Ordering::SeqCst);
t0.elapsed()
});
let mut c = TcpStream::connect(addr).unwrap();
// one byte every 100 ms: each read returns well within its 500 ms timeout
for _ in 0..100 {
if done.load(Ordering::SeqCst) || c.write_all(b"x").is_err() {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let drained = server.join().unwrap();
assert!(drained < DRAIN_TIME + Duration::from_secs(1), "drained for {drained:?}");
}
}
+13 -1
View File
@@ -104,9 +104,14 @@ fn base64_encode(bytes: &[u8]) -> String {
fn base64_decode(s: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(s.len() / 4 * 3);
let (mut acc, mut bits) = (0u32, 0u32);
let mut padded = false;
for c in s.bytes().filter(|c| !c.is_ascii_whitespace()) {
if c == b'=' {
break;
padded = true;
continue;
}
if padded {
return None;
}
let v = B64.iter().position(|b| *b == c)? as u32;
acc = (acc << 6) | v;
@@ -117,6 +122,9 @@ fn base64_decode(s: &str) -> Option<Vec<u8>> {
acc &= (1 << bits) - 1;
}
}
if bits == 6 {
return None;
}
Some(out)
}
@@ -133,6 +141,10 @@ mod tests {
assert_eq!(base64_encode(b"Man"), "TWFu");
assert_eq!(base64_encode(b"Ma"), "TWE=");
assert!(base64_decode("TW!u").is_none());
assert!(base64_decode("Z").is_none());
assert!(base64_decode("Zm9vY").is_none());
assert!(base64_decode("Zg==!!!not-base64").is_none());
assert_eq!(base64_decode("Zg==\n").unwrap(), b"f");
}
#[test]
+73 -3
View File
@@ -41,7 +41,77 @@ pub fn missing(s: &Session) -> Vec<(PhotoId, String)> {
if cfg!(target_arch = "wasm32") {
return Vec::new();
}
missing_with(&s.catalog, |f| Path::new(f).exists())
let exists = s.media.availability.probe();
missing_with(&s.catalog, |f| exists(f))
}
/// Every in-scope photo with its file ([`checked_path`]), without touching the disk: what a
/// sweep ([`sweep`]) goes through, so a caller can take the list on its own thread and check
/// the files on another.
pub fn checked(cat: &Catalog) -> Vec<(PhotoId, String)> {
cat.photos().filter_map(|p| checked_path(p).map(|f| (p.id, f.to_string()))).collect()
}
fn fingerprint_of<'a>(entries: impl Iterator<Item = (PhotoId, &'a str)>) -> u64 {
use std::hash::{DefaultHasher, Hash, Hasher};
entries.fold(0u64, |sum, entry| {
let mut h = DefaultHasher::new();
entry.hash(&mut h);
sum.wrapping_add(h.finish())
})
}
/// What [`checked`] lists, as one number (the photos in scope and the file each one points at,
/// whatever their order), without building the list. A caller that took `checked` and checked its
/// files on another thread compares it with [`fingerprint_of_checked`] to know whether the photos
/// changed meanwhile.
pub fn fingerprint(cat: &Catalog) -> u64 {
fingerprint_of(cat.photos().filter_map(|p| checked_path(p).map(|f| (p.id, f))))
}
/// [`fingerprint`] of the list [`checked`] gave.
pub fn fingerprint_of_checked(files: &[(PhotoId, String)]) -> u64 {
fingerprint_of(files.iter().map(|(id, f)| (*id, f.as_str())))
}
/// What of `gone` (a sweep's answer, of the catalog as it was) still holds in `cat` as it is now: the
/// photos still in scope that still point at the file that was not found. A photo removed, moved to
/// Recently Deleted or relinked since is not reported as missing; one added since was not checked.
pub fn still_missing(cat: &Catalog, gone: Vec<(PhotoId, String)>) -> Vec<(PhotoId, String)> {
gone.into_iter().filter(|(id, file)| cat.photo(*id).and_then(|p| checked_path(p)) == Some(file.as_str())).collect()
}
/// How many files [`sweep`] checks between two reports of its progress.
const PROGRESS_EVERY: usize = 64;
/// The files among `files` that `exists` doesn't find, in order: [`missing`] for a caller that
/// checks on a thread of its own. `stop` is asked before every check (`None`: it said yes, and
/// the rest was not looked at); `progress` is told how many files are done every few dozen.
pub fn sweep(
files: Vec<(PhotoId, String)>,
exists: impl Fn(&str) -> bool,
stop: impl Fn() -> bool,
progress: impl Fn(usize),
) -> Option<Vec<(PhotoId, String)>> {
let mut gone = Vec::new();
for (done, (id, file)) in files.into_iter().enumerate() {
if stop() {
return None;
}
if done % PROGRESS_EVERY == 0 {
progress(done);
}
if !exists(&file) {
gone.push((id, file));
}
}
// (a stop that came during the last check is honoured too)
(!stop()).then_some(gone)
}
/// `library.missing`'s answer: `[{id, path}]`.
pub fn missing_json(gone: Vec<(PhotoId, String)>) -> Value {
Value::Array(gone.into_iter().map(|(id, path)| json!({"id": id.0, "path": path})).collect())
}
/// Whether photo `id` is in scope and its file is gone (the Missing Photos view checks only
@@ -418,8 +488,8 @@ fn find_missing(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "library.missing", "Missing Photos", [], None, "{} → [{id, path}] — photos whose original file isn't where the library expects it", always, |s, _| {
Ok(Value::Array(missing(s).into_iter().map(|(id, path)| json!({"id": id.0, "path": path})).collect()))
cmd!(query "library.missing", "Missing Photos", [], None, "{} → [{id, path}] — photos whose original file isn't where the library expects it (every file is looked at: on a network share that takes minutes, so the desktop app, asked over its control channel, checks on a worker thread, with a row in the activity stack that can stop it, and answers when it is done)", always, |s, _| {
Ok(missing_json(missing(s)))
}),
cmd!(
"photo.relink",
+70
View File
@@ -1473,6 +1473,76 @@ fn missing_photos_skip_local_browse_records() {
assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts");
}
/// `library.missing` asks the session's existence check (the one the availability cache and
/// the tests replace), and a sweep on another thread answers what the command does: the same
/// files in the same order, progress on the way, and a stop that leaves the rest unchecked.
#[test]
fn missing_sweep_answers_what_the_command_does_and_can_stop() {
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use std::sync::atomic::{AtomicUsize, Ordering};
let mut s = Session::new();
let mut ops = Vec::new();
for i in 0..200u64 {
let path = format!("/share/IMG_{i:04}.jpg");
let p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:04}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
s.commit("Add", Op::Batch { ops }).unwrap();
// the first hundred files are gone
s.media.availability.set_probe(std::sync::Arc::new(|f| !f.contains("_00")));
let asked = s.execute("library.missing", &json!({})).unwrap();
assert_eq!(asked.as_array().map(Vec::len), Some(100), "the command used the session's check: {asked}");
let files = crate::cmd::missing::checked(&s.catalog);
assert_eq!(files.len(), 200);
let reports = AtomicUsize::new(0);
let probe = s.media.availability.probe();
let swept = crate::cmd::missing::sweep(
files.clone(),
|f| probe(f),
|| false,
|_| {
reports.fetch_add(1, Ordering::Relaxed);
},
);
assert_eq!(crate::cmd::missing::missing_json(swept.unwrap()), asked);
assert!(reports.load(Ordering::Relaxed) >= 2, "progress is reported on the way");
// stopped after 10 files: nothing is answered, and the rest is not looked at
let looked = AtomicUsize::new(0);
let stopped = crate::cmd::missing::sweep(
files,
|_| {
looked.fetch_add(1, Ordering::Relaxed);
true
},
|| looked.load(Ordering::Relaxed) >= 10,
|_| {},
);
assert_eq!((stopped, looked.load(Ordering::Relaxed)), (None, 10));
}
/// A stop that arrives while the last file is being checked is honoured: no answer.
#[test]
fn missing_sweep_honours_a_stop_during_the_last_check() {
use lightcraft_catalog::PhotoId;
use std::sync::atomic::{AtomicBool, Ordering};
let stop = AtomicBool::new(false);
let files = vec![(PhotoId(1), "/a".to_string()), (PhotoId(2), "/b".to_string())];
let swept = crate::cmd::missing::sweep(
files,
|f| {
if f == "/b" {
stop.store(true, Ordering::Relaxed);
}
false
},
|| stop.load(Ordering::Relaxed),
|_| {},
);
assert_eq!(swept, None);
}
/// `library.browse` follows `library.source`'s rule for the active photo (issue #1447): a library
/// photo the folder doesn't show gives way to the folder's first photo; one it shows stays.
#[test]
+176 -55
View File
@@ -136,39 +136,58 @@ fn peer_closed(stream: &TcpStream) -> bool {
stream.set_nonblocking(false).is_err() || !open
}
/// The app's idle-close notice (`apps/lightcraft/src/control_server.rs`), matched when a reply lacks
/// the `closed` marker (an app from before it). Keep the two in step.
const IDLE_CLOSE_ERROR: &str = "control connection idle read timed out; closing the connection";
/// True for the app's idle-close notice: an id-less error the app writes when it closes a connection
/// on which it read nothing for its idle timeout. Read in reply to a request, it means the app closed
/// the connection without reading the request (the request crossed the notice), so nothing ran.
fn is_idle_close(reply: &Value) -> bool {
reply.get("id").is_none()
&& reply.get("ok").and_then(Value::as_bool) == Some(false)
&& (reply.get("closed").and_then(Value::as_str) == Some("idle") || reply.get("error").and_then(Value::as_str) == Some(IDLE_CLOSE_ERROR))
}
impl Backend for Remote {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next_id;
self.next_id += 1;
let line = json!({"id": id, "method": method, "params": params}).to_string();
// Retry once with a fresh connection (the app may have restarted), but only when the request
// never reached the app. After a full write the command may have run, so resending it could
// apply a mutation twice (toggle, undo, ...): report the unknown outcome instead.
let reply = match self.roundtrip(&line) {
Ok(r) => r,
Err((_, false)) => {
self.conn = None;
self.roundtrip(&line).map_err(|(e, sent)| {
// Send again once, on a fresh connection, but only when the request never reached the app: it
// could not be written (the app restarted), or the app's idle-close notice crossed it (the app
// stopped reading before it arrived). After a full write anything else may mean the command
// ran, so resending it could apply a mutation twice (toggle, undo, ...): report the unknown
// outcome instead. One resend at most, whatever the reason, so this can't loop.
let mut resent = false;
let v = loop {
let reply = match self.roundtrip(&line) {
Ok(r) => r,
Err((e, sent)) => {
self.conn = None;
if sent {
self.unknown_outcome(method, &e)
} else {
format!("LightCraft app at {} is not reachable: {e} (start it with `lightcraft --control PORT`)", self.addr)
return Err(self.unknown_outcome(method, &e));
}
})?
}
Err((e, true)) => {
if resent {
return Err(format!("LightCraft app at {} is not reachable: {e} (start it with `lightcraft --control PORT`)", self.addr));
}
resent = true;
continue;
}
};
let v = parse_reply(&reply, id).map_err(|e| {
self.conn = None;
return Err(self.unknown_outcome(method, &e));
self.unknown_outcome(method, &e)
})?;
if v.get("id").is_none() {
self.conn = None;
if !resent && is_idle_close(&v) {
resent = true;
continue;
}
}
break v;
};
let v = parse_reply(&reply, id).map_err(|e| {
self.conn = None;
self.unknown_outcome(method, &e)
})?;
if v.get("id").is_none() {
self.conn = None;
}
if v.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
@@ -275,6 +294,11 @@ mod tests {
("{\"id\":1,\"ok\":true,\"result\":null}\r\n", Ok(Value::Null)),
("{\"id\":1,\"ok\":false,\"error\":\"unknown command\"}\n", Err("unknown command".to_string())),
("{\"ok\":false,\"error\":\"too many control connections\"}\n", Err("too many control connections".to_string())),
// The idle-close text in reply to the request (it has the id) is the command's own error.
(
"{\"id\":1,\"ok\":false,\"error\":\"control connection idle read timed out; closing the connection\"}\n",
Err(IDLE_CLOSE_ERROR.to_string()),
),
] {
with_reply(reply.as_bytes(), |remote, result| {
assert_eq!(result, expected);
@@ -380,51 +404,148 @@ mod tests {
assert_eq!(seen.load(Ordering::SeqCst), 2);
}
/// Accept the next connection within 5 s, as a blocking stream with a 3 s read timeout.
fn accept(listener: &TcpListener) -> (BufReader<TcpStream>, TcpStream) {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock && std::time::Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(5));
}
Err(e) => panic!("accept: {e}"),
}
};
// BSD kernels (macOS, FreeBSD) let an accepted socket inherit the listener's non-blocking flag.
stream.set_nonblocking(false).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
(BufReader::new(stream.try_clone().unwrap()), stream)
}
fn read_request(reader: &mut BufReader<TcpStream>) -> Value {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
serde_json::from_str(&line).unwrap()
}
/// The app's idle-close notice, as `apps/lightcraft/src/control_server.rs` writes it.
fn idle_notice() -> Value {
json!({"ok": false, "error": IDLE_CLOSE_ERROR, "closed": "idle"})
}
/// Issue #3069. The app closes an idle connection with an id-less notice. Either the notice
/// arrives before the next request (the client sees the close and reconnects before sending),
/// or the request crosses it (sent just as the app stopped reading): the notice then comes
/// back as the request's reply although the app never ran it, so the client sends it once more
/// on a new connection. Both orders are forced here, so the test does not depend on timing.
#[test]
fn idle_close_reconnects_without_replaying_a_command() {
let _serial = crate::serial_sockets_and_children();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let addr = listener.local_addr().unwrap().to_string();
let (closed, expired) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || {
let mut ids = Vec::new();
for _ in 0..2 {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock && std::time::Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(5));
}
Err(e) => panic!("accept: {e}"),
}
};
stream.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut writer = stream;
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let request: Value = serde_json::from_str(&line).unwrap();
ids.push(request["id"].as_u64().unwrap());
writeln!(writer, "{}", json!({"id": request["id"], "ok": true, "result": ids.len()})).unwrap();
writer.set_read_timeout(Some(Duration::from_millis(50))).unwrap();
line.clear();
let error = reader.read_line(&mut line).unwrap_err();
assert!(matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
assert!(line.is_empty(), "no command is resent while idle");
writeln!(writer, "{}", json!({"ok": false, "error": "control connection idle read timed out; closing the connection"})).unwrap();
drop(reader);
drop(writer);
closed.send(()).unwrap();
}
ids
let mut ran = Vec::new();
let mut run = |request: &Value, writer: &mut TcpStream| {
ran.push(request["id"].as_u64().unwrap());
writeln!(writer, "{}", json!({"id": request["id"], "ok": true, "result": ran.len()})).unwrap();
};
// 1. Idle with nothing sent: the notice (in an older app's form, without `closed`) and
// the close reach the client before its next request.
let (mut reader, mut writer) = accept(&listener);
run(&read_request(&mut reader), &mut writer);
writer.set_read_timeout(Some(Duration::from_millis(50))).unwrap();
let mut line = String::new();
let error = reader.read_line(&mut line).unwrap_err();
assert!(matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
assert!(line.is_empty(), "no command is resent while idle");
writeln!(writer, "{}", json!({"ok": false, "error": IDLE_CLOSE_ERROR})).unwrap();
drop((reader, writer));
closed.send(()).unwrap();
// 2. The client reconnected before sending. Its next request crosses the notice: the
// app's read timed out just before the request arrived, so it is not run. (Reading
// it here only shows that it was sent on this connection and keeps the close clean.)
let (mut reader, mut writer) = accept(&listener);
run(&read_request(&mut reader), &mut writer);
let crossed = read_request(&mut reader);
writeln!(writer, "{}", idle_notice()).unwrap();
drop((reader, writer));
// 3. The crossed request, sent once more on a new connection, and nothing after it.
let (mut reader, mut writer) = accept(&listener);
let resent = read_request(&mut reader);
assert_eq!(resent, crossed, "the same request is sent again");
run(&resent, &mut writer);
line.clear();
assert_eq!(reader.read_line(&mut line).unwrap(), 0, "nothing else arrives: {line}");
ran
});
let mut remote = Remote::lazy(&addr);
assert_eq!(remote.call("engine.execute", json!({"command": "edit.undo"})).unwrap(), json!(1));
expired.recv_timeout(Duration::from_secs(5)).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !remote.conn.as_ref().is_some_and(|(_, writer)| peer_closed(writer)) {
assert!(std::time::Instant::now() < deadline, "the idle close never reached the client");
std::thread::sleep(Duration::from_millis(1));
}
assert_eq!(remote.call("engine.execute", json!({"command": "edit.undo"})).unwrap(), json!(2));
expired.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(server.join().unwrap(), [1, 2], "each command ran once on a separate connection");
assert_eq!(remote.call("engine.execute", json!({"command": "edit.undo"})).unwrap(), json!(3));
drop(remote);
assert_eq!(server.join().unwrap(), [1, 2, 3], "each command ran once");
}
/// The resend after an idle close happens once: a second notice is the call's error.
#[test]
fn idle_close_is_resent_only_once() {
let _serial = crate::serial_sockets_and_children();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let addr = listener.local_addr().unwrap().to_string();
let server = std::thread::spawn(move || {
for _ in 0..2 {
let (mut reader, mut writer) = accept(&listener);
assert_eq!(read_request(&mut reader)["id"], 1);
writeln!(writer, "{}", idle_notice()).unwrap();
}
listener
});
let mut remote = Remote::lazy(&addr);
assert_eq!(remote.call("engine.execute", json!({"command": "edit.undo"})), Err(IDLE_CLOSE_ERROR.to_string()));
assert!(remote.conn.is_none(), "the closed connection is not reused");
drop(remote);
let listener = server.join().unwrap();
assert_eq!(listener.accept().unwrap_err().kind(), std::io::ErrorKind::WouldBlock, "a third connection was opened");
}
#[test]
fn only_the_idle_close_notice_counts_as_undelivered() {
let idle = [
json!({"ok": false, "error": IDLE_CLOSE_ERROR, "closed": "idle"}),
// an app from before the `closed` marker
json!({"ok": false, "error": IDLE_CLOSE_ERROR}),
// the marker decides when present, whatever the wording
json!({"ok": false, "error": "idle, closing", "closed": "idle"}),
];
for reply in idle {
assert!(is_idle_close(&reply), "{reply}");
}
let delivered = [
// a reply to the request itself
json!({"id": 1, "ok": false, "error": IDLE_CLOSE_ERROR}),
json!({"id": 1, "ok": false, "error": IDLE_CLOSE_ERROR, "closed": "idle"}),
// other refusals and closes
json!({"ok": false, "error": "too many control connections"}),
json!({"ok": false, "error": "bad JSON; closing the connection"}),
json!({"ok": false, "error": "bad JSON; closing the connection", "closed": "junk"}),
json!({"ok": false, "error": format!("{IDLE_CLOSE_ERROR}.")}),
json!({"ok": true, "result": IDLE_CLOSE_ERROR, "closed": "idle"}),
];
for reply in delivered {
assert!(!is_idle_close(&reply), "{reply}");
}
}
#[test]
+17 -1
View File
@@ -20,9 +20,14 @@ pub fn base64_encode(bytes: &[u8]) -> String {
pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(text.len() / 4 * 3);
let (mut acc, mut bits) = (0u32, 0u32);
let mut padded = false;
for c in text.bytes().filter(|c| !c.is_ascii_whitespace()) {
if c == b'=' {
break;
padded = true;
continue;
}
if padded {
return None;
}
let v = ALPHABET.iter().position(|&a| a == c)? as u32;
acc = (acc << 6) | v;
@@ -33,6 +38,9 @@ pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
acc &= (1 << bits) - 1;
}
}
if bits == 6 {
return None;
}
Some(out)
}
@@ -57,4 +65,12 @@ mod tests {
}
assert!(base64_decode("not base64!").is_none());
}
#[test]
fn rejects_malformed() {
assert!(base64_decode("Z").is_none());
assert!(base64_decode("Zm9vY").is_none());
assert!(base64_decode("Zg==!!!not-base64").is_none());
assert_eq!(base64_decode("Zg==\n").unwrap(), b"f");
}
}
+63
View File
@@ -539,3 +539,66 @@ fn the_clipboard_tool_cuts_copies_and_pastes_in_the_app() {
assert!(!r.is_error, "{r:?}");
assert_eq!(b.0, vec![("ui.clipboard".to_string(), json!({"action": "paste", "text": "travel"}))]);
}
/// A running app that writes the PNG `ui.render` / `ui.screenshot` ask for. It notes each path and
/// what its folder looked like then; with `dies_mid_write` it leaves a half-written temp file and
/// fails instead, like an app that quits part-way through.
struct PngWriter {
seen: Vec<(std::path::PathBuf, Option<std::fs::Metadata>)>,
dies_mid_write: bool,
}
impl Backend for PngWriter {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
assert!(matches!(method, "ui.render" | "ui.screenshot"), "{method}");
let path = std::path::PathBuf::from(params["path"].as_str().unwrap());
self.seen.push((path.clone(), path.parent().and_then(|d| std::fs::symlink_metadata(d).ok())));
if self.dies_mid_write {
std::fs::write(path.with_file_name(".lightcraft-mcp-test.tmp"), b"half a png").unwrap();
return Err("connection closed".into());
}
crate::write_image(&path, &lightcraft_raster::Rgba8::new(1, 1), 92)?;
Ok(json!({"width": 1, "height": 1}))
}
fn has_ui(&self) -> bool {
true
}
fn describe(&self) -> String {
"png writer".into()
}
}
/// Renders and screenshots reach the agent through a PNG the app writes. In a shared /tmp other
/// users could read it or take its predictable name first and make the render fail, so it goes in
/// a new folder of its own, private on Unix, that is gone afterwards even when the write fails
/// (#2573).
#[test]
fn render_and_screenshot_pngs_go_through_a_private_folder() {
let mut b = PngWriter { seen: vec![], dies_mid_write: false };
for (tool, args) in [("render_photo", json!({"id": 1})), ("render_preview", json!({"id": 1})), ("screenshot", json!({}))] {
let r = call_tool(&mut b, tool, &args);
assert!(!r.is_error, "{tool}: {r:?}");
assert_eq!(r.image().map(|(_, mime)| mime), Some("image/png"), "{tool}: {r:?}");
}
b.dies_mid_write = true;
assert!(call_tool(&mut b, "screenshot", &json!({})).is_error);
assert_eq!(b.seen.len(), 4);
let temp = std::env::temp_dir();
let mut folders = vec![];
for (file, folder) in &b.seen {
let dir = file.parent().unwrap();
let meta = folder.as_ref().unwrap();
assert!(meta.is_dir() && !meta.file_type().is_symlink(), "{}", dir.display());
assert_ne!(dir, temp, "{} is right in the shared temp dir", file.display());
assert_eq!(dir.parent(), Some(temp.as_path()), "{}", file.display());
assert!(dir.file_name().unwrap().to_string_lossy().starts_with("lightcraft-mcp-"), "{}", dir.display());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(meta.permissions().mode() & 0o777, 0o700, "{}", dir.display());
}
assert!(!dir.exists(), "{} was left behind", dir.display());
assert!(!folders.contains(&dir), "{} was used twice", dir.display());
folders.push(dir);
}
}
+55 -9
View File
@@ -2,6 +2,7 @@
//! per command in the engine's command registry (`cmd_<id with . → _>`), all forwarded to a
//! [`Backend`] as control-channel methods.
use std::hash::{BuildHasher, Hasher, RandomState};
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::{Map, Value, json};
@@ -437,9 +438,48 @@ fn activate(b: &mut dyn Backend, args: &Value) -> Result<(), String> {
Ok(())
}
fn temp_path(tag: &str) -> std::path::PathBuf {
static N: AtomicU64 = AtomicU64::new(0);
std::env::temp_dir().join(format!("lightcraft-mcp-{}-{}-{tag}.png", std::process::id(), N.fetch_add(1, Ordering::Relaxed)))
/// Where the backend writes a render or screenshot for us to read back: `<tag>.png` in a new
/// `lightcraft-mcp-<pid>-<random>` folder in the temp dir, private to this user on Unix (0700).
/// Straight in a shared /tmp, other users could read the PNG, and a name they took first made the
/// render fail (#2573). Dropping this removes the folder and whatever is in it.
struct TempPng {
dir: std::path::PathBuf,
file: std::path::PathBuf,
}
impl TempPng {
fn new(tag: &str) -> Result<Self, String> {
static N: AtomicU64 = AtomicU64::new(0);
let temp = std::env::temp_dir();
for _ in 0..100 {
let mut h = RandomState::new().build_hasher();
h.write_u64(N.fetch_add(1, Ordering::Relaxed));
let dir = temp.join(format!("lightcraft-mcp-{}-{:016x}", std::process::id(), h.finish()));
#[cfg(unix)]
let builder = {
use std::os::unix::fs::DirBuilderExt;
let mut b = std::fs::DirBuilder::new();
b.mode(0o700);
b
};
#[cfg(not(unix))]
let builder = std::fs::DirBuilder::new();
// Never recursive: a name someone else made first fails and is not used.
match builder.create(&dir) {
Ok(()) => return Ok(Self { file: dir.join(format!("{tag}.png")), dir }),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(format!("cannot make a private temporary folder in {}: {e}", temp.display())),
}
}
Err(format!("cannot make a private temporary folder in {}: every name tried is taken", temp.display()))
}
}
impl Drop for TempPng {
fn drop(&mut self) {
// Also removes the temp file a backend that died mid-write leaves next to the PNG.
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// Read a PNG the backend wrote, optionally downscale / re-encode as JPEG, and wrap it as image
@@ -512,26 +552,32 @@ fn render_photo(b: &mut dyn Backend, args: &Value) -> ToolResult {
},
};
let Some(id) = id else { return ToolResult::error("no active photo: import or select one, or pass `id`") };
let file = temp_path("render");
let path = file.to_string_lossy().to_string();
let tmp = match TempPng::new("render") {
Ok(t) => t,
Err(e) => return ToolResult::error(e),
};
let path = tmp.file.to_string_lossy().to_string();
let meta = match b.call("ui.render", json!({"id": id, "size": size, "path": path})) {
Ok(m) => m,
Err(e) => return ToolResult::error(e),
};
let mut meta = meta;
meta["id"] = json!(id);
image_result(&file, None, args.get("format").and_then(Value::as_str).unwrap_or("png"), args.get("path").and_then(Value::as_str), meta)
image_result(&tmp.file, None, args.get("format").and_then(Value::as_str).unwrap_or("png"), args.get("path").and_then(Value::as_str), meta)
}
fn screenshot(b: &mut dyn Backend, args: &Value) -> ToolResult {
if let Err(e) = check_save_path(b, args) {
return ToolResult::error(e);
}
let file = temp_path("screenshot");
let path = file.to_string_lossy().to_string();
let tmp = match TempPng::new("screenshot") {
Ok(t) => t,
Err(e) => return ToolResult::error(e),
};
let path = tmp.file.to_string_lossy().to_string();
match b.call("ui.screenshot", json!({"path": path})) {
Ok(meta) => image_result(
&file,
&tmp.file,
Some(args.get("maxSize").and_then(Value::as_u64).unwrap_or(1600).clamp(64, 8192) as u32),
args.get("format").and_then(Value::as_str).unwrap_or("png"),
args.get("path").and_then(Value::as_str),
+1
View File
@@ -73,6 +73,7 @@ impl LightcraftApp {
drop_pending: vec![],
copied: None,
pending_picks: vec![],
missing: Default::default(),
synthetic_mods: egui::Modifiers::NONE,
synthetic_mods_release: false,
host_mods: egui::Modifiers::NONE,
+3
View File
@@ -18,6 +18,7 @@ impl LightcraftApp {
control::Outcome::Done(v) => {
let _ = reply.send(v);
}
control::Outcome::Later => {}
control::Outcome::Screenshot { target, headless } => {
if let Err(error) = self.admit_shot(ctx, target, headless, reply.clone()) {
let _ = reply.send(serde_json::json!({"ok": false, "error": error}));
@@ -53,6 +54,8 @@ impl LightcraftApp {
if self.pending_gestures.len() >= MAX_PENDING_GESTURES {
return Err(format!("{}: {MAX_PENDING_GESTURES} control gestures are already pending", method));
}
// refused at once, without waiting for a layout or taking a pending slot
crate::control::check_gesture_numbers(&method, &params)?;
let now = self.tick_now_ms();
self.pending_gestures.push(PendingGesture {
method,
+43 -2
View File
@@ -59,6 +59,8 @@ impl ControlRequest {
pub enum Outcome {
Done(Value),
/// Answered later, by whoever holds the request's `reply` (a task that is still running).
Later,
/// A capture was admitted: `target` pins the destination identity until the write.
Screenshot {
target: Option<lightcraft_engine::originals::WriteTarget>,
@@ -72,11 +74,19 @@ pub enum Outcome {
},
}
/// A reply that says it worked: `result`.
pub(crate) fn success(v: Value) -> Value {
json!({"ok": true, "result": v})
}
/// A reply that says it failed: `error`.
pub(crate) fn failure(e: impl std::fmt::Display) -> Value {
json!({"ok": false, "error": e.to_string()})
}
fn ok(v: Value) -> Outcome {
Outcome::Done(json!({"ok": true, "result": v}))
Outcome::Done(success(v))
}
fn err(e: impl std::fmt::Display) -> Outcome {
Outcome::Done(json!({"ok": false, "error": e.to_string()}))
Outcome::Done(failure(e))
}
fn wrap(r: Result<Value, String>) -> Outcome {
match r {
@@ -329,6 +339,12 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
"engine.execute" | "ui.menu.invoke" | "command" => {
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
if id == "library.missing" {
// looks at every photo's file: on a worker thread, answered when it is done
let started = crate::missing_sweep::ask(app, req.reply.clone());
ctx.request_repaint();
return started;
}
let r = app.run(id, params);
ctx.request_repaint();
wrap(r)
@@ -603,6 +619,30 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String
Ok(out)
}
/// The largest coordinate or offset a gesture takes, in points (far past any screen).
const MAX_GESTURE_NUMBER: f64 = 1e6;
/// Every number of a gesture's parameters (and of `ui.pointer`'s events) is finite and within
/// ±[`MAX_GESTURE_NUMBER`]. A larger one turns into an infinite or NaN `f32` position or scroll
/// delta: egui asserts on NaN positions, and a release build would hand them to every widget.
/// `count` and `steps` are clamped where they are read instead, and `ui.zoom`'s `factor` (a
/// multiplier, not a position) is checked and clamped by its own branch. Checked when a request
/// is admitted and again here, for callers that resolve a gesture directly.
pub(crate) fn check_gesture_numbers(method: &str, p: &Value) -> Result<(), String> {
fn check(method: &str, key: &str, v: &Value) -> Result<(), String> {
match v {
Value::Number(n) if !matches!(key, "count" | "steps" | "factor") => match n.as_f64() {
Some(x) if x.abs() <= MAX_GESTURE_NUMBER => Ok(()),
_ => Err(format!("{method}: `{key}` must be a number between -1000000 and 1000000")),
},
Value::Array(a) => a.iter().try_for_each(|v| check(method, key, v)),
Value::Object(o) => o.iter().try_for_each(|(k, v)| check(method, k, v)),
_ => Ok(()),
}
}
check(method, "", p)
}
/// Resolve one queued synthetic-input control request against the **current** layout.
///
/// Called by the app only after a completed UI pass newer than admission (plan §4.5). Widget rects
@@ -610,6 +650,7 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String
/// has pushed the events onto `app.synthetic`; the app answers the request once the UI consumed
/// them.
pub(crate) fn gesture_events(app: &mut LightcraftApp, ctx: &egui::Context, method: &str, p: &Value) -> Result<GestureOutcome, String> {
check_gesture_numbers(method, p)?;
let s = |k: &str| p.get(k).and_then(Value::as_str);
let f = |k: &str| p.get(k).and_then(Value::as_f64);
match method {
+12 -4
View File
@@ -466,13 +466,21 @@ impl Headless {
}
}
/// Queue a control-protocol request without running a frame: the answer comes on the returned
/// channel once a frame ([`Self::step`]) has taken the request up, and the work it starts is done.
pub(crate) fn send(&mut self, method: &str, params: Value) -> Result<std::sync::mpsc::Receiver<Value>, Value> {
let (req, rx) = ControlRequest::new(method, params);
self.control.send(req).map_err(|_| json!({"ok": false, "error": "control channel closed"}))?;
Ok(rx)
}
/// Send a control-protocol request (see [`crate::control`]) and run frames until it is
/// answered, then until its input has been consumed. Returns `{"ok": …, "result"|"error": …}`.
pub fn request(&mut self, method: &str, params: Value, timeout: Duration) -> Value {
let (req, rx) = ControlRequest::new(method, params);
if self.control.send(req).is_err() {
return json!({"ok": false, "error": "control channel closed"});
}
let rx = match self.send(method, params) {
Ok(rx) => rx,
Err(e) => return e,
};
let t0 = Instant::now();
let reply = loop {
self.step();
+4
View File
@@ -32,6 +32,7 @@ pub mod icons;
pub mod lightroom_import;
pub mod links;
pub mod menus;
mod missing_sweep;
mod model_setup;
pub mod multi_select;
pub mod panels;
@@ -243,6 +244,9 @@ pub struct LightcraftApp {
pub copied: Option<String>,
/// Native file dialogs up for commands (`pick`): each command runs again when its closes.
pub(crate) pending_picks: Vec<pick::Pending>,
/// `library.missing` requests from the control channel and the check that answers them on a
/// worker thread ([`missing_sweep`]).
pub(crate) missing: missing_sweep::State,
/// Modifiers announced for synthetic input (held from a button down to its release). This is
/// only the app's synthetic ownership; the real host state is tracked separately in
/// `host_mods`, so a canceled synthetic gesture never clears a physically held key.
+120
View File
@@ -0,0 +1,120 @@
//! `library.missing` in the app. The engine's command looks at every photo's file on the calling
//! thread, which here is the UI's: on a share with hundreds of thousands of photos that froze the
//! window for minutes. A request from the control channel (and so an agent, `mcp --connect`) goes
//! the way of the person's own long jobs instead: the files are checked on a worker thread, under
//! a row in the activity stack that counts them and can stop the check, and the request is
//! answered with the same `[{id, path}]` once the check ends. A second request meanwhile joins the
//! check that is running and gets its answer, rather than starting another walk of the share.
//!
//! A check on a big share can outlast what a caller waits for (the control server gives a request
//! 60 s). An answer that nobody was waiting for any more is kept for a few minutes and given to
//! the next request, so that asking again after a timeout is not another walk of the share that
//! times out again.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use std::time::Duration;
use web_time::Instant;
use lightcraft_catalog::PhotoId;
use lightcraft_engine::cmd::missing;
use serde_json::Value;
use crate::LightcraftApp;
use crate::control::{self, Outcome};
/// The activity stack's label (the command's name, already translated).
pub const LABEL: &str = "Missing Photos";
/// The activity row's kind.
const KIND: &str = "missing";
/// How long an answer nobody was waiting for is kept.
const KEEP: Duration = Duration::from_secs(10 * 60);
/// The requests waiting for a check, and the answer kept for the next one.
#[derive(Default)]
pub struct State {
/// Each request with the library ([`lightcraft_engine::Session::library_generation`]) it asked about.
pub(crate) waiting: Vec<(u64, Sender<Value>)>,
/// A finished check's answer that no request received.
kept: Option<Kept>,
}
/// An answer nobody received: its library, the photos it was of ([`missing::fingerprint`]), when it was kept.
struct Kept {
library: u64,
snapshot: u64,
at: Instant,
answer: Value,
}
impl State {
/// Send `answer` to every request waiting on `library`; `true` if any caller was still there.
/// (a request whose caller gave up has no one to send to)
fn answer(&mut self, library: u64, answer: &Value) -> bool {
let (theirs, others): (Vec<_>, Vec<_>) = std::mem::take(&mut self.waiting).into_iter().partition(|(l, _)| *l == library);
self.waiting = others;
theirs.into_iter().fold(false, |received, (_, waiting)| waiting.send(answer.clone()).is_ok() || received)
}
/// A check for `library` could not start: fail its requests, and only those (another library's
/// check may be running for the others).
pub(crate) fn fail(&mut self, library: u64, error: &str) {
self.answer(library, &control::failure(error));
}
}
/// Answer `library.missing` from the control channel: give the answer kept from a check nobody
/// waited for, or start the check, or join the one running for this library. The answer goes to
/// `reply` when the check ends.
pub fn ask(app: &mut LightcraftApp, reply: Sender<Value>) -> Outcome {
let library = app.session.library_generation();
if let Some(kept) = app.missing.kept.take()
&& kept.library == library
&& kept.at.elapsed() < KEEP
&& kept.snapshot == missing::fingerprint(&app.session.catalog)
{
return Outcome::Done(kept.answer);
}
app.missing.waiting.push((library, reply));
if app.tasks.is_running(LABEL) && app.missing.waiting.iter().filter(|(l, _)| *l == library).count() > 1 {
return Outcome::Later;
}
let files = missing::checked(&app.session.catalog);
let snapshot = missing::fingerprint_of_checked(&files);
let total = files.len() as u64;
let exists = app.session.media.availability.probe();
let cancel = Arc::new(AtomicBool::new(false));
let flag = cancel.clone();
let work = move |task: lightcraft_engine::activity::TaskHandle| {
task.progress(0, total);
// (a panic is this check's failure, answered to the callers: they would wait for it in vain)
lightcraft_engine::guard::catch("Missing Photos", || {
missing::sweep(files, |f| exists(f), || flag.load(Ordering::Relaxed), |done| task.progress(done as u64, total))
})
};
let done = move |app: &mut LightcraftApp, _ctx: &egui::Context, checked: Result<Option<Vec<(PhotoId, String)>>, String>| {
let (answer, complete) = match checked {
_ if app.session.library_generation() != library => {
(control::failure("Missing Photos: another library was opened before the check ended"), false)
}
Err(e) => (control::failure(e), false),
Ok(None) => (control::failure("Missing Photos: the check was stopped"), false),
Ok(Some(gone)) if missing::fingerprint(&app.session.catalog) == snapshot => (control::success(missing::missing_json(gone)), true),
// (photos changed while the files were checked, as an import does: the answer is what of the
// check still holds, and is not kept, for the next request must see the photos as they are then)
Ok(Some(gone)) => (control::success(missing::missing_json(missing::still_missing(&app.session.catalog, gone))), false),
};
let received = app.missing.answer(library, &answer);
if complete && !received {
app.missing.kept = Some(Kept { library, snapshot, at: Instant::now(), answer });
}
};
if let Err(e) = crate::tasks::spawn_tracked(app, LABEL, KIND, cancel, work, done) {
app.missing.fail(library, &e);
}
Outcome::Later
}
+1
View File
@@ -55,6 +55,7 @@ mod masking;
mod masking_layout;
mod menubar;
mod merge_preview;
mod missing_sweep;
mod model_setup;
mod move_folder;
mod offline;
+43 -1
View File
@@ -564,7 +564,7 @@ fn one_synthetic_batch_until_a_completed_pass_and_drops_wait_for_the_ui_phase()
#[test]
fn first_headless_frame_runs_logic_and_uses_its_simulated_clock() {
let mut h = Headless::new(app(), [800.0, 600.0], 1.0);
let mut h = Headless::new(self::app(), [800.0, 600.0], 1.0);
assert_eq!(h.time(), 0.0);
h.step();
assert!(h.app.styled, "the first frame at t=0 runs logic and installs fonts");
@@ -903,6 +903,48 @@ fn ui_scroll_carries_validated_unit_phase_and_source() {
assert!(app.synthetic.is_empty());
}
/// A coordinate or delta too large for an `f32` became an infinite or NaN pointer position or
/// scroll delta: egui panicked on the NaN in debug builds ("Comparing NaN vectors"), and a release
/// build handed it to every widget. Such a gesture is refused and queues nothing.
#[test]
fn gestures_refuse_numbers_that_are_not_screen_positions() {
let (mut app, ctx) = (app(), egui::Context::default());
app.widgets.push(("finite:test".into(), egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(20.0, 20.0))));
app.image_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(100.0, 100.0)));
let refused = [
("ui.click", serde_json::json!({"x": 1e308})),
("ui.click", serde_json::json!({"y": -1e308})),
("ui.move", serde_json::json!({"x": 1e300, "y": 1.0})),
("ui.drag", serde_json::json!({"x": 1.0, "toY": 1e308})),
("ui.dragWidget", serde_json::json!({"id": "finite:test", "dx": -1e308})),
("ui.scroll", serde_json::json!({"dy": 1e308})),
("ui.pointer", serde_json::json!({"events": [{"kind": "down", "x": 0.5}, {"kind": "move", "x": 1e308}]})),
];
for (method, p) in refused {
let e = crate::control::gesture_events(&mut app, &ctx, method, &p).err().unwrap_or_else(|| panic!("{method} {p} was accepted"));
assert!(e.starts_with(method) && e.contains("between"), "{e}");
assert!(app.synthetic.is_empty(), "{method} {p} queued events");
}
// ordinary values still work, and count / steps are still clamped rather than refused
crate::control::gesture_events(&mut app, &ctx, "ui.click", &serde_json::json!({"x": 10.0, "y": -5.0, "count": u64::MAX})).unwrap();
crate::control::gesture_events(&mut app, &ctx, "ui.drag", &serde_json::json!({"x": 1.0, "toX": 999_999.0, "steps": 1e12})).unwrap();
assert!(!app.synthetic.is_empty());
// refused when the request is admitted: no pending slot, no wait for a layout
let (tx, rx) = reply_channel();
let e = app.admit_gesture(&ctx, "ui.click".into(), serde_json::json!({"x": 1e308}), tx).expect_err("refused at admission");
assert!(e.contains("between"), "{e}");
assert!(app.pending_gestures.is_empty());
drop(rx);
// through the whole control channel, as an agent sends it: an error, not a panic
let mut h = Headless::new(self::app(), [800.0, 600.0], 1.0);
h.step();
let r = h.request("ui.click", serde_json::json!({"x": 1e308}), Duration::from_secs(5));
assert_eq!(r["ok"], false, "{r}");
h.step();
}
#[test]
fn synthetic_pointer_requests_bound_clicks_and_drag_steps() {
let (mut app, ctx) = (app(), egui::Context::default());
+285
View File
@@ -0,0 +1,285 @@
//! `library.missing` from the control channel (an agent, `mcp --connect`) looks at every photo's
//! file, which on a share with hundreds of thousands of photos took minutes on the UI thread and
//! froze the window: the files are checked on a worker thread under a row of the activity stack.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant};
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
use serde_json::{Value, json};
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
const T: Duration = Duration::from_secs(60);
const PHOTOS: u64 = 40;
/// Photos `IMG_0000` … on a share; the first ten are gone. The existence check waits for `open`
/// (a share that answers slowly), at most `guard`, and counts its calls, and those made on the UI thread.
struct Share {
open: Arc<AtomicBool>,
on_ui: Arc<AtomicUsize>,
calls: Arc<AtomicUsize>,
}
fn app_on_a_slow_share(guard: Duration) -> (Headless, Share) {
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() });
for i in 0..PHOTOS {
let path = format!("/share/IMG_{i:04}.jpg");
let p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:04}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
app.session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
}
let share = Share { open: Arc::new(AtomicBool::new(false)), on_ui: Arc::new(AtomicUsize::new(0)), calls: Arc::new(AtomicUsize::new(0)) };
let (ui, open, on_ui, calls) = (std::thread::current().id(), share.open.clone(), share.on_ui.clone(), share.calls.clone());
let deadline = Instant::now() + guard;
app.session.media.availability.set_probe(Arc::new(move |f| {
calls.fetch_add(1, Ordering::SeqCst);
if std::thread::current().id() == ui {
on_ui.fetch_add(1, Ordering::SeqCst);
}
// (a hang guard: the check goes on after it, so a regression fails the test instead of hanging it)
while !open.load(Ordering::SeqCst) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(1));
}
!f.contains("_000")
}));
(Headless::new(app, [1200.0, 800.0], 1.0), share)
}
fn ask(h: &mut Headless) -> Receiver<Value> {
h.send("engine.execute", json!({"command": "library.missing"})).unwrap()
}
/// Frames until `rx` has an answer.
fn answer(h: &mut Headless, rx: &Receiver<Value>) -> Value {
let t0 = Instant::now();
loop {
h.step();
if let Ok(v) = rx.try_recv() {
return v;
}
assert!(t0.elapsed() < T, "never answered");
std::thread::sleep(Duration::from_millis(1));
}
}
fn missing_rows(h: &Headless) -> Vec<lightcraft_engine::activity::TaskInfo> {
h.app.session.activity.list().into_iter().filter(|r| r.kind == "missing").collect()
}
#[test]
fn library_missing_is_checked_off_the_ui_thread_and_answered_when_done() {
let (mut h, share) = app_on_a_slow_share(Duration::from_secs(3));
let t0 = Instant::now();
let rx = ask(&mut h);
for _ in 0..3 {
h.step();
}
// the share is still answering nothing: the window took its frames all the same
assert!(t0.elapsed() < Duration::from_secs(2), "the frames waited for the files: {:?}", t0.elapsed());
assert!(rx.try_recv().is_err(), "no answer before the files are checked");
let rows = missing_rows(&h);
assert_eq!(rows.len(), 1, "{rows:?}");
assert!(rows[0].cancellable, "the row can stop the check");
assert_eq!(rows[0].label, crate::missing_sweep::LABEL);
share.open.store(true, Ordering::SeqCst);
let reply = answer(&mut h, &rx);
assert_eq!(share.on_ui.load(Ordering::SeqCst), 0, "a file was looked at on the UI thread");
assert_eq!(reply["ok"], true, "{reply}");
let ids: Vec<u64> = reply["result"].as_array().unwrap().iter().map(|r| r["id"].as_u64().unwrap()).collect();
assert_eq!(ids, (1..=10).collect::<Vec<_>>(), "the same [{{id, path}}] the command gives");
assert_eq!(reply["result"][0]["path"], "/share/IMG_0000.jpg");
assert!(h.step_until(T, |h| h.app.tasks.is_empty()));
assert!(missing_rows(&h).is_empty(), "the row goes with the check");
}
/// A request that comes while a check runs gets that check's answer: the share isn't walked twice.
#[test]
fn a_second_request_joins_the_check_that_is_running() {
let (mut h, share) = app_on_a_slow_share(Duration::from_secs(3));
let (first, second) = (ask(&mut h), ask(&mut h));
for _ in 0..3 {
h.step();
}
assert_eq!(missing_rows(&h).len(), 1, "one check for both");
share.open.store(true, Ordering::SeqCst);
let (a, b) = (answer(&mut h, &first), answer(&mut h, &second));
assert_eq!(a["ok"], true, "{a}");
assert_eq!(a, b);
assert_eq!(share.on_ui.load(Ordering::SeqCst), 0);
}
/// ✕ on the row (or `activity.cancel`) stops the check; the request is told so.
#[test]
fn stopping_the_check_answers_the_request_with_an_error() {
let (mut h, share) = app_on_a_slow_share(Duration::from_secs(3));
let rx = ask(&mut h);
for _ in 0..3 {
h.step();
}
let id = missing_rows(&h)[0].id;
let r = h.request("engine.execute", json!({"command": "activity.cancel", "params": {"id": id}}), T);
assert_eq!(r["ok"], true, "{r}");
share.open.store(true, Ordering::SeqCst);
let reply = answer(&mut h, &rx);
assert_eq!(reply["ok"], false, "{reply}");
assert!(reply["error"].as_str().is_some_and(|e| e.contains("stopped")), "{reply}");
assert!(h.step_until(T, |h| h.app.tasks.is_empty()));
assert!(missing_rows(&h).is_empty());
}
/// The control server gives a request 60 s, a check on a big share takes longer: the caller asks
/// again after its timeout and is given the answer the check ended with, not another walk.
#[test]
fn an_answer_nobody_waited_for_is_given_to_the_next_request() {
let (mut h, share) = app_on_a_slow_share(Duration::from_secs(3));
let gave_up = ask(&mut h);
for _ in 0..3 {
h.step();
}
drop(gave_up);
share.open.store(true, Ordering::SeqCst);
assert!(h.step_until(T, |h| h.app.tasks.is_empty()), "the check ends without a caller");
assert_eq!(share.calls.load(Ordering::SeqCst), PHOTOS as usize);
let again = ask(&mut h);
let reply = answer(&mut h, &again);
assert_eq!(reply["ok"], true, "{reply}");
assert_eq!(reply["result"].as_array().map(Vec::len), Some(10), "{reply}");
assert_eq!(share.calls.load(Ordering::SeqCst), PHOTOS as usize, "no second walk of the share");
// given once: the next request checks again
let third = ask(&mut h);
assert_eq!(answer(&mut h, &third)["ok"], true);
assert_eq!(share.calls.load(Ordering::SeqCst), 2 * PHOTOS as usize);
}
/// A check that panics is a failure told to the caller, not a request left waiting for its timeout.
#[test]
fn a_check_that_panics_is_answered_with_an_error() {
let (mut h, _share) = app_on_a_slow_share(Duration::from_secs(3));
h.app.session.media.availability.set_probe(Arc::new(|_| panic!("a file system that panics")));
let rx = ask(&mut h);
let reply = answer(&mut h, &rx);
assert_eq!(reply["ok"], false, "{reply}");
assert!(reply["error"].as_str().is_some_and(|e| e.contains("failed unexpectedly")), "{reply}");
assert!(h.step_until(T, |h| h.app.tasks.is_empty()));
// and the next request is checked afresh
h.app.session.media.availability.set_probe(Arc::new(|_| true));
let rx = ask(&mut h);
assert_eq!(answer(&mut h, &rx)["result"], json!([]));
}
fn new_photo(id: u64, path: &str) -> Box<Photo> {
Box::new(Photo::new(PhotoId(id), Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T00:00:00"))
}
/// Photos change while the files are being checked (auto-import adds some every few seconds, a share check
/// takes minutes): the answer is the check's, kept to the photos that are still where it looked, so asking
/// again never has to be the answer. A photo that was missing and has been removed, moved to Recently
/// Deleted or relinked since is not reported; one added since was not checked and is not either.
#[test]
fn a_check_over_changing_photos_answers_what_still_holds() {
let (mut h, share) = app_on_a_slow_share(Duration::from_secs(3));
let rx = ask(&mut h);
for _ in 0..3 {
h.step();
}
let cat = &mut h.app.session.catalog;
cat.apply(Op::RemovePhoto { id: PhotoId(1) }).unwrap();
cat.apply(Op::SetDeleted { id: PhotoId(2), deleted: true }).unwrap();
let relinked =
Op::Relink { id: PhotoId(3), file_name: "y.jpg".into(), source: Source::File { path: "/share/IMG_9998.jpg".into() }, format: None };
cat.apply(relinked).unwrap();
cat.apply(Op::AddPhoto { photo: new_photo(PHOTOS + 1, "/share/IMG_0000x.jpg") }).unwrap();
share.open.store(true, Ordering::SeqCst);
let reply = answer(&mut h, &rx);
assert_eq!(reply["ok"], true, "{reply}");
let ids: Vec<u64> = reply["result"].as_array().unwrap().iter().map(|r| r["id"].as_u64().unwrap()).collect();
assert_eq!(ids, (4..=10).collect::<Vec<_>>(), "{reply}");
}
/// A stop is a stop, whatever the photos did meanwhile.
#[test]
fn a_stopped_check_says_stopped_even_if_the_photos_changed() {
let (mut h, share) = app_on_a_slow_share(Duration::from_secs(3));
let rx = ask(&mut h);
for _ in 0..3 {
h.step();
}
h.app.session.catalog.apply(Op::AddPhoto { photo: new_photo(PHOTOS + 1, "/share/IMG_9999.jpg") }).unwrap();
let id = missing_rows(&h)[0].id;
let r = h.request("engine.execute", json!({"command": "activity.cancel", "params": {"id": id}}), T);
assert_eq!(r["ok"], true, "{r}");
share.open.store(true, Ordering::SeqCst);
let reply = answer(&mut h, &rx);
assert!(reply["error"].as_str().is_some_and(|e| e.contains("stopped")), "{reply}");
}
/// A check nobody waited for, over photos that changed while it ran, is not kept: the next request
/// checks afresh (and then sees the photo that came meanwhile).
#[test]
fn a_check_over_changed_photos_is_not_kept_for_a_later_request() {
let (mut h, share) = app_on_a_slow_share(Duration::from_secs(3));
drop(ask(&mut h));
for _ in 0..3 {
h.step();
}
h.app.session.catalog.apply(Op::AddPhoto { photo: new_photo(PHOTOS + 1, "/share/IMG_0000x.jpg") }).unwrap();
share.open.store(true, Ordering::SeqCst);
assert!(h.step_until(T, |h| h.app.tasks.is_empty()));
let calls = share.calls.load(Ordering::SeqCst);
let again = ask(&mut h);
let reply = answer(&mut h, &again);
assert_eq!(reply["result"].as_array().map(Vec::len), Some(11), "{reply}");
assert!(share.calls.load(Ordering::SeqCst) > calls, "checked afresh");
}
/// An edit that leaves the photos' files alone (a rating) does not spoil the check.
#[test]
fn an_edit_that_leaves_the_files_alone_does_not_spoil_the_check() {
let (mut h, share) = app_on_a_slow_share(Duration::from_secs(3));
let rx = ask(&mut h);
for _ in 0..3 {
h.step();
}
h.app.session.catalog.apply(Op::SetRating { id: PhotoId(1), rating: 3 }).unwrap();
share.open.store(true, Ordering::SeqCst);
assert_eq!(answer(&mut h, &rx)["ok"], true);
}
/// A check that can't start for one library fails only the requests for that library.
#[test]
fn a_failed_start_fails_only_that_librarys_requests() {
let mut state = crate::missing_sweep::State::default();
let (a_tx, a_rx) = std::sync::mpsc::channel();
let (b_tx, b_rx) = std::sync::mpsc::channel();
state.waiting.push((1, a_tx));
state.waiting.push((2, b_tx));
state.fail(2, "Missing Photos: could not start");
assert!(b_rx.try_recv().is_ok_and(|v| v["ok"] == false));
assert!(a_rx.try_recv().is_err(), "the other library's request keeps waiting");
assert_eq!(state.waiting.len(), 1);
}
/// An answer kept for a request that never came is not given once the photos have changed.
#[test]
fn a_kept_answer_is_not_given_after_the_photos_changed() {
let (mut h, share) = app_on_a_slow_share(Duration::from_secs(3));
drop(ask(&mut h));
for _ in 0..3 {
h.step();
}
share.open.store(true, Ordering::SeqCst);
assert!(h.step_until(T, |h| h.app.tasks.is_empty()));
let p =
Photo::new(PhotoId(PHOTOS + 1), Source::File { path: "/share/IMG_0000x.jpg".into() }, "IMG_0000x.jpg", "JPEG", 60, 40, "2026-01-01T00:00:00");
h.app.session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
let calls = share.calls.load(Ordering::SeqCst);
let again = ask(&mut h);
let reply = answer(&mut h, &again);
assert_eq!(reply["result"].as_array().map(Vec::len), Some(11), "{reply}");
assert!(share.calls.load(Ordering::SeqCst) > calls);
}
+16 -6
View File
@@ -21,15 +21,25 @@ headless snapshots or `lightcraft-cli mcp` without `--connect`. A request that h
Connections close after five minutes without incoming bytes while the server is waiting for
input, including in the middle of an unfinished request line. This allows pauses between
automation commands while releasing abandoned connection slots. An idle read timeout gets an
`ok: false` error saying the connection is closing; incomplete input is never dispatched.
automation commands while releasing abandoned connection slots. An idle read timeout gets one
reply without an `id`, then the connection closes; incomplete input is never dispatched:
```text
← {"ok": false, "error": "control connection idle read timed out; closing the connection", "closed": "idle"}
```
This is a socket read inactivity limit, not a total connection lifetime: a peer that keeps
sending bytes can keep it open. Waiting for an already-dispatched command's reply does not
count as idle reading and retains the request timeout above.
Reconnect before sending another request after an idle close. MCP connect mode detects closed
connections before sending and reconnects; if a connection is lost after sending a request,
its outcome remains unknown and the client must not blindly replay it.
Reconnect before sending another request after an idle close. A request sent just as the
connection expires crosses the notice: the client reads the notice where it expects the reply.
The server reads nothing after its idle timeout, so that request never ran, and `"closed": "idle"`
(only on this notice) says it is safe to send it again, once, on a new connection. Apps from
before the marker send the same notice without it; match its exact `error` text for them. MCP
connect mode does both: it detects closed connections before sending and reconnects, and
resends a request that crossed the idle notice once. Any other lost connection after sending a
request leaves its outcome unknown, and the client must not blindly replay it.
**Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params` are
optional; blank lines are skipped). Anything else — text that isn't JSON, a JSON array or number, an object without
@@ -82,7 +92,7 @@ filmstrip is visible.
| `ui.inspect` | — | UI state, window, `loupe` (`source`, `pending`, `failed` = why its render failed, else `null`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:<id>`, ✕ = `activity:cancel:<id>`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) |
| `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) |
| `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks; `count` is capped at 16) / `{id, toX?, toY?, dx?, dy?, steps?}` (steps are capped at 1000) / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews). `fx`/`fy` are fractions of the registered rect and may lie outside `[0,1]`; the resulting point must still be inside egui's clipped interaction rect, and a non-finite fraction or resolved point is rejected before any input is queued. Geometry comes from the latest completed layout; if the widget moves before the first event or before a press, or its latest clipped rect no longer contains the point, the request fails with an error instead of clicking another widget or retargeting. A target below an ordinary modal dialog may succeed as a no-op or a backdrop click, because egui routes that input to the dialog; a target covered by a non-modal overlay fails. The blocking library-problem/launch-list window is explicit: its dim takes the modal layer, but a background target fails instead of reporting a no-op success, and only that window's own controls (`button:library…`) pass. Its creation frame is shielded and redrawn with empty input like a prompt's, so background text/pointer input cannot act while the new modal areas settle. A quit prompt or settings notice is a real egui modal scope with a foreground backdrop. Only its own buttons (`button:quit…`, `button:noticeOk`) accept a `ui.clickWidget`; background targets fail, and background keystrokes, text/clipboard input, wheel/zoom input and AccessKit actions are dropped before panels see them. The prompt itself takes Tab/Shift+Tab, Enter/Space on the focused button, and Escape/Enter for the safe default Cancel; AccessKit Focus/Click works when targeted at a prompt button. Its first creation frame (including a Tasks→Unsaved or notice→next-notice transition) is shielded and redrawn with empty input, so a press/release in that frame, or a held press from before it, cannot act underneath it; a synthetic drag already in progress is canceled. Once settled, raw `ui.click`/`ui.drag`/`ui.pointer` and backdrop clicks are still delivered to the prompt backdrop as no-ops, and the prompt's own button clicks work normally. The area/layer check cannot see a different widget covering the point in the same layer. A `ui.clickWidget` whose widget disappears before its release also fails, after canceling the pointer press without a click or drag (the same rules apply when a click spans several host frames) |
| `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points (the same click and step caps apply) |
| `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points (the same click and step caps apply). In every pointer and scroll gesture a number other than `count` and `steps` must lie between −1,000,000 and 1,000,000, or the request is refused and queues nothing |
| `ui.pointer` | `{events: [{kind: down\|drag\|up, x, y}], alt?, shift?, cmd?}` | Gesture in normalized image coordinates (Detail view) |
| `ui.key` | `{key, action?: press\|down\|up, cmd?, shift?, alt?, ctrl?}` | Key press (default), or the key going `down` and staying held until an `up` (Space held over a `ui.pointer` drag); while a prompt is up only its Tab/Shift+Tab, Enter, Space and Escape are delivered, every other key is dropped |
| `ui.text` | `{text}` | Text input |
+3 -1
View File
@@ -22,7 +22,9 @@ it open, `--library` on the same folder fails with "This library is already open
`--demo` starts the headless session with the procedurally generated demo library;
`--compact` lists only the helper tools (see below). In connect mode the server starts even when
the app is not running yet and connects on the first call (and reconnects if the app restarts).
A request is sent again only when it never reached the app. If the connection drops after a request
A request is sent again (once) only when it never reached the app: it could not be sent, or the app's
idle-close notice came back in its place (the app closed the idle connection without reading it; see
[control-protocol.md](control-protocol.md)). If the connection drops after a request
was sent but before its reply, the tool fails with "the outcome is unknown" and nothing is resent, so a
command such as undo can't run twice: check the app's state before retrying. Replies must be
newline-terminated, at most 64 MiB before the newline (including encoded previews/screenshots),
File diff suppressed because one or more lines are too long
+17
View File
@@ -25,6 +25,11 @@
- Export ▸ Image Sizing kept pixel sizes only in steps of 16, so a typed 1080 became 1088 and 3000 became 3008. The
long edge, short edge, width and height fields now keep any whole number of pixels.
### The control channel refuses pointer positions that aren't on any screen
- `ui.click`, `ui.move`, `ui.drag`, `ui.dragWidget`, `ui.pointer` and `ui.scroll` with a coordinate or delta far past
any screen (such as `1e308`) used to queue an infinite or NaN position. A debug build panicked on it and a release
build handed it to every widget. Numbers outside ±1,000,000 are now an error, and nothing is queued.
## 2026-10-29
### A person's page opens in seconds in a large library
@@ -250,6 +255,18 @@
that no longer existed. The library's lock is now released as soon as everything is saved, as it already was on
the other systems.
### Asking for the missing photos no longer freezes the window
- `library.missing` (the control channel and `mcp --connect`) looked at every photo's file on the thread that draws
the window, so on a library of hundreds of thousands of photos on a network share the app stopped answering for
minutes. The files are now checked in the background: a "Missing Photos" row in the activity stack counts them and
its ✕ stops the check, and the request is answered with the same list when the check ends. A second request meanwhile
shares the running check. A caller that stops waiting (the control server gives a request 60 s) gets the answer of
that check when it asks again within ten minutes, instead of another walk of the share, as long as the photos have
not changed since; otherwise the files are checked again. If photos change while the files are being checked (an
import adds some, others are removed, moved to Recently Deleted or relinked), the answer leaves out the photos that
no longer apply, a photo added meanwhile is not part of that check, and the answer is not kept for a later request.
### Choose where a Lightroom catalog goes
- File ▸ Import Lightroom Catalog… now asks first: merge the catalog's photos, collections and edits into the
library open now, or create a new library for them, named after the catalog and placed next to the open library