add request inspect command
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
e3c451ce8ffdcc04ee6f43a9e420efa24368d9c7- Parents
-
c35eb64 - Tree
3c7e46d
e3c451c
e3c451ce8ffdcc04ee6f43a9e420efa24368d9c7c35eb64
3c7e46d| Status | File | + | - |
|---|---|---|---|
| M |
README.md
|
1 | 0 |
| M |
garwarp-ipc/src/lib.rs
|
85 | 2 |
| M |
garwarp/src/daemon.rs
|
94 | 0 |
| M |
garwarp/src/request.rs
|
42 | 0 |
| M |
garwarpctl/src/main.rs
|
46 | 0 |
README.mdmodified@@ -23,6 +23,7 @@ Current scaffold includes: | ||
| 23 | 23 | 4. Verify D-Bus activation: `./scripts/test-dbus-activation.sh` |
| 24 | 24 | 5. Create mock request: `cargo run -p garwarpctl -- begin req-1 :1.2 - x11:0x2a` |
| 25 | 25 | 6. Transition mock request: `cargo run -p garwarpctl -- transition req-1 :1.2 awaiting_user` |
| 26 | +7. Inspect request snapshot: `cargo run -p garwarpctl -- inspect req-1` | |
| 26 | 27 | |
| 27 | 28 | ## Runtime Tuning |
| 28 | 29 | 1. `GARWARP_REQUEST_TIMEOUT_MS`: timeout before in-flight requests are marked `expired`. |
garwarp-ipc/src/lib.rsmodified@@ -38,6 +38,9 @@ impl HealthStatus { | ||
| 38 | 38 | pub enum ControlRequest { |
| 39 | 39 | Status, |
| 40 | 40 | Stop, |
| 41 | + InspectRequest { | |
| 42 | + id: String, | |
| 43 | + }, | |
| 41 | 44 | BeginRequest { |
| 42 | 45 | id: String, |
| 43 | 46 | sender: String, |
@@ -88,6 +91,7 @@ impl ControlRequest { | ||
| 88 | 91 | match self { |
| 89 | 92 | Self::Status => "status".to_string(), |
| 90 | 93 | Self::Stop => "stop".to_string(), |
| 94 | + Self::InspectRequest { id } => format!("inspect id={id}"), | |
| 91 | 95 | Self::BeginRequest { |
| 92 | 96 | id, |
| 93 | 97 | sender, |
@@ -139,6 +143,11 @@ impl ControlRequest { | ||
| 139 | 143 | |
| 140 | 144 | let mut parts = trimmed.split_whitespace(); |
| 141 | 145 | match parts.next() { |
| 146 | + Some("inspect") => { | |
| 147 | + let fields = parse_fields(parts)?; | |
| 148 | + let id = fields.get("id")?.clone(); | |
| 149 | + Some(Self::InspectRequest { id }) | |
| 150 | + } | |
| 142 | 151 | Some("begin") => { |
| 143 | 152 | let fields = parse_fields(parts)?; |
| 144 | 153 | let id = fields.get("id")?.clone(); |
@@ -192,8 +201,21 @@ impl StatusResponse { | ||
| 192 | 201 | pub enum ControlResponse { |
| 193 | 202 | Status(StatusResponse), |
| 194 | 203 | AckStopping, |
| 195 | - AckRequest { id: String, state: String }, | |
| 196 | - Error { code: u32, reason: String }, | |
| 204 | + AckRequest { | |
| 205 | + id: String, | |
| 206 | + state: String, | |
| 207 | + }, | |
| 208 | + RequestSnapshot { | |
| 209 | + id: String, | |
| 210 | + state: String, | |
| 211 | + sender: String, | |
| 212 | + app_id: Option<String>, | |
| 213 | + parent_window: Option<String>, | |
| 214 | + }, | |
| 215 | + Error { | |
| 216 | + code: u32, | |
| 217 | + reason: String, | |
| 218 | + }, | |
| 197 | 219 | } |
| 198 | 220 | |
| 199 | 221 | impl ControlResponse { |
@@ -210,6 +232,20 @@ impl ControlResponse { | ||
| 210 | 232 | Self::AckRequest { id, state } => { |
| 211 | 233 | format!("ack request id={} state={}\n", id, state) |
| 212 | 234 | } |
| 235 | + Self::RequestSnapshot { | |
| 236 | + id, | |
| 237 | + state, | |
| 238 | + sender, | |
| 239 | + app_id, | |
| 240 | + parent_window, | |
| 241 | + } => { | |
| 242 | + let app_id = app_id.as_deref().unwrap_or("-"); | |
| 243 | + let parent_window = parent_window.as_deref().unwrap_or("-"); | |
| 244 | + format!( | |
| 245 | + "snapshot id={} state={} sender={} app_id={} parent={}\n", | |
| 246 | + id, state, sender, app_id, parent_window | |
| 247 | + ) | |
| 248 | + } | |
| 213 | 249 | Self::Error { code, reason } => format!("error code={} reason={}\n", code, reason), |
| 214 | 250 | } |
| 215 | 251 | } |
@@ -285,6 +321,43 @@ impl ControlResponse { | ||
| 285 | 321 | Some(other) => Err(ParseError::UnknownToken(other.to_string())), |
| 286 | 322 | None => Err(ParseError::MissingField("ack")), |
| 287 | 323 | }, |
| 324 | + Some("snapshot") => { | |
| 325 | + let mut id = None; | |
| 326 | + let mut state = None; | |
| 327 | + let mut sender = None; | |
| 328 | + let mut app_id = None; | |
| 329 | + let mut parent_window = None; | |
| 330 | + | |
| 331 | + for part in parts { | |
| 332 | + let (key, value) = part | |
| 333 | + .split_once('=') | |
| 334 | + .ok_or(ParseError::InvalidField(part.to_string()))?; | |
| 335 | + match key { | |
| 336 | + "id" => id = Some(value.to_string()), | |
| 337 | + "state" => state = Some(value.to_string()), | |
| 338 | + "sender" => sender = Some(value.to_string()), | |
| 339 | + "app_id" => { | |
| 340 | + if value != "-" { | |
| 341 | + app_id = Some(value.to_string()); | |
| 342 | + } | |
| 343 | + } | |
| 344 | + "parent" => { | |
| 345 | + if value != "-" { | |
| 346 | + parent_window = Some(value.to_string()); | |
| 347 | + } | |
| 348 | + } | |
| 349 | + _ => return Err(ParseError::InvalidField(part.to_string())), | |
| 350 | + } | |
| 351 | + } | |
| 352 | + | |
| 353 | + Ok(Self::RequestSnapshot { | |
| 354 | + id: id.ok_or(ParseError::MissingField("id"))?, | |
| 355 | + state: state.ok_or(ParseError::MissingField("state"))?, | |
| 356 | + sender: sender.ok_or(ParseError::MissingField("sender"))?, | |
| 357 | + app_id, | |
| 358 | + parent_window, | |
| 359 | + }) | |
| 360 | + } | |
| 288 | 361 | Some("error") => match parts.next() { |
| 289 | 362 | Some(first_field) => { |
| 290 | 363 | let mut code = None; |
@@ -365,6 +438,9 @@ mod tests { | ||
| 365 | 438 | for request in [ |
| 366 | 439 | ControlRequest::Status, |
| 367 | 440 | ControlRequest::Stop, |
| 441 | + ControlRequest::InspectRequest { | |
| 442 | + id: "req-1".to_string(), | |
| 443 | + }, | |
| 368 | 444 | ControlRequest::BeginRequest { |
| 369 | 445 | id: "req-1".to_string(), |
| 370 | 446 | sender: ":1.2".to_string(), |
@@ -404,6 +480,13 @@ mod tests { | ||
| 404 | 480 | id: "req-1".to_string(), |
| 405 | 481 | state: "pending".to_string(), |
| 406 | 482 | }, |
| 483 | + ControlResponse::RequestSnapshot { | |
| 484 | + id: "req-1".to_string(), | |
| 485 | + state: "awaiting_user".to_string(), | |
| 486 | + sender: ":1.2".to_string(), | |
| 487 | + app_id: Some("org.test.App".to_string()), | |
| 488 | + parent_window: Some("x11:0x2a".to_string()), | |
| 489 | + }, | |
| 407 | 490 | ControlResponse::Error { |
| 408 | 491 | code: 2, |
| 409 | 492 | reason: "invalid_request".to_string(), |
garwarp/src/daemon.rsmodified@@ -123,6 +123,22 @@ fn handle_connection(stream: UnixStream, state: &mut DaemonState) -> io::Result< | ||
| 123 | 123 | state.running = false; |
| 124 | 124 | ControlResponse::AckStopping |
| 125 | 125 | } |
| 126 | + Some(ControlRequest::InspectRequest { id }) => match state.requests.record(&id) { | |
| 127 | + Some(record) => ControlResponse::RequestSnapshot { | |
| 128 | + id: record.id, | |
| 129 | + state: record.state.as_str().to_string(), | |
| 130 | + sender: record.owner.sender, | |
| 131 | + app_id: record.owner.app_id, | |
| 132 | + parent_window: record.parent_window.map(|parent| parent.as_str()), | |
| 133 | + }, | |
| 134 | + None => { | |
| 135 | + let mapping = map_portal_error(&PortalError::RequestNotFound); | |
| 136 | + ControlResponse::Error { | |
| 137 | + code: mapping.code as u32, | |
| 138 | + reason: mapping.reason.to_string(), | |
| 139 | + } | |
| 140 | + } | |
| 141 | + }, | |
| 126 | 142 | Some(ControlRequest::BeginRequest { |
| 127 | 143 | id, |
| 128 | 144 | sender, |
@@ -589,6 +605,84 @@ mod tests { | ||
| 589 | 605 | assert_eq!(state.requests.state("req-1"), Some(RequestState::Cancelled)); |
| 590 | 606 | } |
| 591 | 607 | |
| 608 | + #[test] | |
| 609 | + fn inspect_returns_request_snapshot() { | |
| 610 | + let (mut client, server) = UnixStream::pair().expect("pair should be created"); | |
| 611 | + client | |
| 612 | + .write_all(b"inspect id=req-1\n") | |
| 613 | + .expect("inspect request should be written"); | |
| 614 | + | |
| 615 | + let mut state = DaemonState { | |
| 616 | + health: HealthStatus::Healthy, | |
| 617 | + requests: RequestRegistry::new(Duration::from_secs(5)), | |
| 618 | + running: true, | |
| 619 | + }; | |
| 620 | + state | |
| 621 | + .requests | |
| 622 | + .begin_at( | |
| 623 | + "req-1", | |
| 624 | + RequestOwner::new(":1.2", Some("org.test.App".to_string())), | |
| 625 | + Some(ParentWindowContext::X11 { window_id: 42 }), | |
| 626 | + Instant::now(), | |
| 627 | + ) | |
| 628 | + .expect("request should be created"); | |
| 629 | + state | |
| 630 | + .requests | |
| 631 | + .transition( | |
| 632 | + "req-1", | |
| 633 | + &RequestOwner::new(":1.2", Some("org.test.App".to_string())), | |
| 634 | + RequestState::AwaitingUser, | |
| 635 | + ) | |
| 636 | + .expect("request should transition"); | |
| 637 | + handle_connection(server, &mut state).expect("inspect should be handled"); | |
| 638 | + | |
| 639 | + let mut response_line = String::new(); | |
| 640 | + let mut reader = BufReader::new(client); | |
| 641 | + reader | |
| 642 | + .read_line(&mut response_line) | |
| 643 | + .expect("response should be readable"); | |
| 644 | + let response = ControlResponse::parse_line(&response_line).expect("response should parse"); | |
| 645 | + assert_eq!( | |
| 646 | + response, | |
| 647 | + ControlResponse::RequestSnapshot { | |
| 648 | + id: "req-1".to_string(), | |
| 649 | + state: "awaiting_user".to_string(), | |
| 650 | + sender: ":1.2".to_string(), | |
| 651 | + app_id: Some("org.test.App".to_string()), | |
| 652 | + parent_window: Some("x11:0x2a".to_string()), | |
| 653 | + } | |
| 654 | + ); | |
| 655 | + } | |
| 656 | + | |
| 657 | + #[test] | |
| 658 | + fn inspect_missing_request_maps_to_not_found() { | |
| 659 | + let (mut client, server) = UnixStream::pair().expect("pair should be created"); | |
| 660 | + client | |
| 661 | + .write_all(b"inspect id=req-missing\n") | |
| 662 | + .expect("inspect request should be written"); | |
| 663 | + | |
| 664 | + let mut state = DaemonState { | |
| 665 | + health: HealthStatus::Healthy, | |
| 666 | + requests: RequestRegistry::new(Duration::from_secs(5)), | |
| 667 | + running: true, | |
| 668 | + }; | |
| 669 | + handle_connection(server, &mut state).expect("inspect should be handled"); | |
| 670 | + | |
| 671 | + let mut response_line = String::new(); | |
| 672 | + let mut reader = BufReader::new(client); | |
| 673 | + reader | |
| 674 | + .read_line(&mut response_line) | |
| 675 | + .expect("response should be readable"); | |
| 676 | + let response = ControlResponse::parse_line(&response_line).expect("response should parse"); | |
| 677 | + assert_eq!( | |
| 678 | + response, | |
| 679 | + ControlResponse::Error { | |
| 680 | + code: 2, | |
| 681 | + reason: "request_not_found".to_string(), | |
| 682 | + } | |
| 683 | + ); | |
| 684 | + } | |
| 685 | + | |
| 592 | 686 | #[test] |
| 593 | 687 | fn startup_recovery_expires_non_terminal_requests() { |
| 594 | 688 | let path = unique_temp_file(); |
garwarp/src/request.rsmodified@@ -279,6 +279,16 @@ impl RequestRegistry { | ||
| 279 | 279 | self.entries.get(id).map(|entry| entry.owner.clone()) |
| 280 | 280 | } |
| 281 | 281 | |
| 282 | + #[must_use] | |
| 283 | + pub fn record(&self, id: &str) -> Option<RequestRecord> { | |
| 284 | + self.entries.get(id).map(|entry| RequestRecord { | |
| 285 | + id: entry.id.clone(), | |
| 286 | + owner: entry.owner.clone(), | |
| 287 | + parent_window: entry.parent_window, | |
| 288 | + state: entry.state, | |
| 289 | + }) | |
| 290 | + } | |
| 291 | + | |
| 282 | 292 | #[must_use] |
| 283 | 293 | pub fn records(&self) -> Vec<RequestRecord> { |
| 284 | 294 | let mut records = self |
@@ -573,4 +583,36 @@ mod tests { | ||
| 573 | 583 | .expect("duplicate cancel should be idempotent"); |
| 574 | 584 | assert_eq!(registry.state("req-1"), Some(RequestState::Cancelled)); |
| 575 | 585 | } |
| 586 | + | |
| 587 | + #[test] | |
| 588 | + fn record_returns_current_snapshot() { | |
| 589 | + let now = Instant::now(); | |
| 590 | + let request_owner = owner(":1.2"); | |
| 591 | + let mut registry = RequestRegistry::new(Duration::from_secs(5)); | |
| 592 | + registry | |
| 593 | + .begin_at( | |
| 594 | + "req-1", | |
| 595 | + request_owner.clone(), | |
| 596 | + Some(ParentWindowContext::X11 { window_id: 42 }), | |
| 597 | + now, | |
| 598 | + ) | |
| 599 | + .expect("request should be created"); | |
| 600 | + registry | |
| 601 | + .transition_at( | |
| 602 | + "req-1", | |
| 603 | + &request_owner, | |
| 604 | + RequestState::AwaitingUser, | |
| 605 | + now + Duration::from_millis(1), | |
| 606 | + ) | |
| 607 | + .expect("request should transition"); | |
| 608 | + | |
| 609 | + let snapshot = registry.record("req-1").expect("record should exist"); | |
| 610 | + assert_eq!(snapshot.id, "req-1"); | |
| 611 | + assert_eq!(snapshot.owner, request_owner); | |
| 612 | + assert_eq!(snapshot.state, RequestState::AwaitingUser); | |
| 613 | + assert_eq!( | |
| 614 | + snapshot.parent_window, | |
| 615 | + Some(ParentWindowContext::X11 { window_id: 42 }) | |
| 616 | + ); | |
| 617 | + } | |
| 576 | 618 | } |
garwarpctl/src/main.rsmodified@@ -29,6 +29,9 @@ fn main() { | ||
| 29 | 29 | enum Command { |
| 30 | 30 | Status, |
| 31 | 31 | Stop, |
| 32 | + Inspect { | |
| 33 | + id: String, | |
| 34 | + }, | |
| 32 | 35 | Version, |
| 33 | 36 | Help, |
| 34 | 37 | Begin { |
@@ -50,6 +53,7 @@ fn parse_command(args: &[String]) -> Result<Command, String> { | ||
| 50 | 53 | [] => Ok(Command::Status), |
| 51 | 54 | [command] if command == "status" => Ok(Command::Status), |
| 52 | 55 | [command] if command == "stop" => Ok(Command::Stop), |
| 56 | + [command, id] if command == "inspect" => Ok(Command::Inspect { id: id.clone() }), | |
| 53 | 57 | [command] if command == "version" || command == "--version" || command == "-V" => { |
| 54 | 58 | Ok(Command::Version) |
| 55 | 59 | } |
@@ -194,6 +198,35 @@ fn run(command: Command) -> io::Result<()> { | ||
| 194 | 198 | )), |
| 195 | 199 | } |
| 196 | 200 | } |
| 201 | + Command::Inspect { id } => { | |
| 202 | + let response = send_request(ControlRequest::InspectRequest { id })?; | |
| 203 | + match response { | |
| 204 | + ControlResponse::RequestSnapshot { | |
| 205 | + id, | |
| 206 | + state, | |
| 207 | + sender, | |
| 208 | + app_id, | |
| 209 | + parent_window, | |
| 210 | + } => { | |
| 211 | + println!("id={id}"); | |
| 212 | + println!("state={state}"); | |
| 213 | + println!("sender={sender}"); | |
| 214 | + println!("app_id={}", app_id.unwrap_or_else(|| "-".to_string())); | |
| 215 | + println!( | |
| 216 | + "parent_window={}", | |
| 217 | + parent_window.unwrap_or_else(|| "-".to_string()) | |
| 218 | + ); | |
| 219 | + Ok(()) | |
| 220 | + } | |
| 221 | + ControlResponse::Error { code, reason } => Err(io::Error::other(format!( | |
| 222 | + "daemon error: code={code} reason={reason}" | |
| 223 | + ))), | |
| 224 | + other => Err(io::Error::new( | |
| 225 | + io::ErrorKind::InvalidData, | |
| 226 | + format!("unexpected response: {other:?}"), | |
| 227 | + )), | |
| 228 | + } | |
| 229 | + } | |
| 197 | 230 | Command::Begin { |
| 198 | 231 | id, |
| 199 | 232 | sender, |
@@ -290,6 +323,7 @@ fn print_help() { | ||
| 290 | 323 | println!("commands:"); |
| 291 | 324 | println!(" status (default)"); |
| 292 | 325 | println!(" stop"); |
| 326 | + println!(" inspect <id>"); | |
| 293 | 327 | println!(" begin <id> <sender> [app_id|-] [parent_window|-]"); |
| 294 | 328 | println!(" transition <id> <sender> <awaiting_user|fulfilled|cancelled|failed> [app_id|-]"); |
| 295 | 329 | println!(" await|fulfill|cancel|fail <id> <sender> [app_id|-]"); |
@@ -351,6 +385,18 @@ mod tests { | ||
| 351 | 385 | ); |
| 352 | 386 | } |
| 353 | 387 | |
| 388 | + #[test] | |
| 389 | + fn parse_inspect_command() { | |
| 390 | + let args = vec!["inspect".to_string(), "req-1".to_string()]; | |
| 391 | + let command = parse_command(&args).expect("inspect command should parse"); | |
| 392 | + assert_eq!( | |
| 393 | + command, | |
| 394 | + Command::Inspect { | |
| 395 | + id: "req-1".to_string() | |
| 396 | + } | |
| 397 | + ); | |
| 398 | + } | |
| 399 | + | |
| 354 | 400 | #[test] |
| 355 | 401 | fn parse_transition_target_rejects_unknown_state() { |
| 356 | 402 | let parsed = parse_transition_target("bogus"); |