Execute start_print, and never await it on the uplink task
The gateway can now run a cloud-dispatched print: download the job file from the presigned URL in the command, then hand it to the existing `upload_project_file`, which derives the filament mapping from the archive, transfers it over FTPS and starts it. ## The download refuses to be a weapon `src/uplink/download.rs` checks the HTTP status before reading a byte, because an expired presigned URL answers 403 with an XML error document and writing *that* to a printer as a project is the worst outcome available. It rejects a `Content-Length` over 256 MiB before reading, and enforces the cap while streaming as well, so a server that lies or omits the length cannot make a small gateway box buffer until the OOM killer arrives. A byte count that disagrees with `Content-Length` is an error: a `.3mf` is a zip, and a truncated one loses the central directory the printer needs. HTTPS only, no redirects, and the URL is stripped from error messages. `PrinterCommand` no longer derives `Debug` for the same reason - `dispatch` logs every command, and a presigned URL is a bearer credential until it expires. It prints as `<redacted>`. ## Why it is spawned `start_print` is the one command that cannot be answered inline, and the reason is not latency. Everything else here is an MQTT publish returning in milliseconds. This downloads tens of megabytes and then transfers them at a measured 0.2 MiB/s - about four minutes for a 50 MB project - and awaiting that inside the uplink's `select!` loop stops heartbeats and, far worse, stops telemetry for the duration. No telemetry is what makes spawning mandatory rather than tidy. The cloud now derives a job's state from the telemetry that follows a start, so a gateway that goes silent for four minutes during every job start withholds exactly the information the job depends on - while the backend times the command out after ten seconds and concludes the start failed. The job would be requeued onto a printer that was busy printing it. So the `ok` means "accepted, working on it", which is all a start reply was ever trusted to mean. `runs_for_minutes` is an exhaustive match rather than a `matches!`, so a new variant has to make that choice instead of defaulting to the stall. The gap this leaves is honest and recorded in the code: a failure after the ack has no `CommandResult` to travel on, because answering later needs an outbound channel this loop does not have. Until it does, the gateway's log is where the reason lives and the backend reaps the job. reqwest 0.12 with `rustls-tls-native-roots`, not 0.13: 0.13 pulls `webpki-root-certs` under CDLA-Permissive-2.0, which is not on the allow list, and `aws-lc` brings OpenSSL-licensed C. `cargo deny check licenses` passes. 54 tests.
This commit is contained in:
+52
-11
@@ -98,18 +98,59 @@ fn a_vendor_specific_control_verb_is_rejected() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The variant that is deliberately absent - still, but no longer for the
|
||||
/// original reason. The print-start path *has* now been confirmed on a real
|
||||
/// P1P (2026-10-11); what is missing is the path around it, since nothing
|
||||
/// uploads a sliced file to a gateway and nothing decides which printer runs
|
||||
/// which job. Delete this test when there is something to start.
|
||||
/// The schema's `StartPrint` variant, in the shape the backend dispatches.
|
||||
/// Every field has to arrive intact: `url` is what gets downloaded and
|
||||
/// `file_name` is what the printer is told to call it, so a swapped or
|
||||
/// dropped one prints the wrong thing or nothing.
|
||||
#[test]
|
||||
fn there_is_no_start_print_command() {
|
||||
let raw = r#"{"type":"command","command_id":"x","printer_id":"p1p","command":{"action":"start_print","file_name":"plate_1.3mf"}}"#;
|
||||
assert!(
|
||||
serde_json::from_str::<ServerMessage>(raw).is_err(),
|
||||
"a start-print command parsed - is there a job path to drive it yet?"
|
||||
);
|
||||
fn a_start_print_command_arrives_with_its_fields_intact() {
|
||||
let raw = r#"{"type":"command","command_id":"c","printer_id":"p1p","command":{"action":"start_print","job_id":"5b0e7c1a-0000-4000-8000-000000000000","file_name":"plate_1.3mf","url":"https://objects.example.com/jobs/abc.3mf?X-Amz-Signature=deadbeef&X-Amz-Expires=300"}}"#;
|
||||
match serde_json::from_str::<ServerMessage>(raw).expect("should parse") {
|
||||
ServerMessage::Command { command: PrinterCommand::StartPrint { job_id, file_name, url }, .. } => {
|
||||
assert_eq!(job_id, "5b0e7c1a-0000-4000-8000-000000000000");
|
||||
assert_eq!(file_name, "plate_1.3mf");
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://objects.example.com/jobs/abc.3mf?X-Amz-Signature=deadbeef&X-Amz-Expires=300"
|
||||
);
|
||||
}
|
||||
other => panic!("wrong variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL is a bearer credential until it expires, and `dispatch` logs
|
||||
/// every command with `{:?}`.
|
||||
#[test]
|
||||
fn a_start_print_url_does_not_appear_in_debug_output() {
|
||||
let raw = r#"{"type":"command","command_id":"c","printer_id":"p1p","command":{"action":"start_print","job_id":"j","file_name":"plate_1.3mf","url":"https://objects.example.com/x?X-Amz-Signature=deadbeef"}}"#;
|
||||
let parsed = serde_json::from_str::<ServerMessage>(raw).expect("should parse");
|
||||
let shown = format!("{parsed:?}");
|
||||
assert!(!shown.contains("deadbeef"), "signature leaked into a log line: {shown}");
|
||||
assert!(shown.contains("plate_1.3mf"), "the file name should still be logged: {shown}");
|
||||
}
|
||||
|
||||
/// Each field is load-bearing and none has a sensible default: a command
|
||||
/// missing its URL must be rejected here, not discovered at the printer.
|
||||
#[test]
|
||||
fn a_start_print_missing_any_required_field_is_rejected() {
|
||||
let full = [
|
||||
("job_id", r#""job_id":"5b0e7c1a-0000-4000-8000-000000000000""#),
|
||||
("file_name", r#""file_name":"plate_1.3mf""#),
|
||||
("url", r#""url":"https://objects.example.com/jobs/abc.3mf""#),
|
||||
];
|
||||
for missing in 0..full.len() {
|
||||
let fields: Vec<&str> =
|
||||
full.iter().enumerate().filter(|(i, _)| *i != missing).map(|(_, (_, f))| *f).collect();
|
||||
let raw = format!(
|
||||
r#"{{"type":"command","command_id":"c","printer_id":"p1p","command":{{"action":"start_print",{}}}}}"#,
|
||||
fields.join(",")
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<ServerMessage>(&raw).is_err(),
|
||||
"start_print without '{}' parsed and should not have:\n {raw}",
|
||||
full[missing].0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user