File
Blob: firmware/vendor/str0m/tests/sdp-m-recycle.rs
| 1 | use std::time::Instant; |
| 2 | |
| 3 | mod common; |
| 4 | use common::TestRtc; |
| 5 | use common::init_crypto_default; |
| 6 | use common::init_log; |
| 7 | use common::negotiate; |
| 8 | use str0m::Rtc; |
| 9 | use str0m::change::SdpOffer; |
| 10 | use str0m::format::Codec; |
| 11 | use str0m::format::CodecSpec; |
| 12 | use str0m::format::FormatParams; |
| 13 | use str0m::format::PayloadParams; |
| 14 | use str0m::media::Direction; |
| 15 | use str0m::media::Frequency; |
| 16 | use str0m::media::MediaKind; |
| 17 | use str0m::media::Mid; |
| 18 | use str0m::media::Pt; |
| 19 | use tracing::Span; |
| 20 | use tracing::info_span; |
| 21 | |
| 22 | #[test] |
| 23 | pub fn stop_media_then_remote_recycles_slot() { |
| 24 | init_log(); |
| 25 | init_crypto_default(); |
| 26 | |
| 27 | // L stops its video m-line, then receives an offer that recycles the |
| 28 | // resulting port=0 slot with a new mid (RFC 8829 §5.2.2). |
| 29 | let (mut l, mut r) = with_params(info_span!("L"), &[vp8(100)], info_span!("R"), &[vp8(100)]); |
| 30 | |
| 31 | let old_mid = l._mids()[0]; |
| 32 | |
| 33 | negotiate(&mut l, &mut r, |change| { |
| 34 | change.stop_media(old_mid); |
| 35 | }); |
| 36 | |
| 37 | assert!(l.media(old_mid).unwrap().stopped()); |
| 38 | assert!(r.media(old_mid).unwrap().stopped()); |
| 39 | |
| 40 | // str0m's own SdpApi doesn't produce recycling offers, so take an |
| 41 | // offer from R (which appends a new m-line) and rewrite it so the |
| 42 | // new m-line lives at the index of the stopped one - what a browser |
| 43 | // would emit per RFC 8829 §5.2.2. |
| 44 | let offer_from_r = r |
| 45 | .span |
| 46 | .in_scope(|| { |
| 47 | let mut change = r.rtc.sdp_api(); |
| 48 | change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None); |
| 49 | change.apply() |
| 50 | }) |
| 51 | .expect("R has a change to apply"); |
| 52 | |
| 53 | let offer_sdp = offer_from_r.0.to_sdp_string(); |
| 54 | let recycled_offer_sdp = rewrite_offer_to_recycle_stopped_slot(&offer_sdp, old_mid); |
| 55 | |
| 56 | let recycled_offer = |
| 57 | SdpOffer::from_sdp_string(&recycled_offer_sdp).expect("recycled offer parses"); |
| 58 | |
| 59 | let answer = l.span.in_scope(|| { |
| 60 | l.rtc |
| 61 | .sdp_api() |
| 62 | .accept_offer(recycled_offer) |
| 63 | .expect("accept") |
| 64 | }); |
| 65 | |
| 66 | let answer_sdp = answer.to_sdp_string(); |
| 67 | |
| 68 | // RFC 3264 §6: the answer must have the same m-line count as the |
| 69 | // offer. Without recycling, str0m appends a second m-line and the |
| 70 | // answer is invalid. |
| 71 | let count_m_lines = |s: &str| s.lines().filter(|l| l.starts_with("m=")).count(); |
| 72 | let offer_m_lines = count_m_lines(&recycled_offer_sdp); |
| 73 | let answer_m_lines = count_m_lines(&answer_sdp); |
| 74 | assert_eq!( |
| 75 | answer_m_lines, offer_m_lines, |
| 76 | "answer m-line count ({answer_m_lines}) differs from offer m-line count ({offer_m_lines})\n\n\ |
| 77 | rewritten offer:\n{recycled_offer_sdp}\n\nanswer:\n{answer_sdp}", |
| 78 | ); |
| 79 | } |
| 80 | |
| 81 | #[test] |
| 82 | pub fn recycling_rejected_for_non_stopped_slot() { |
| 83 | init_log(); |
| 84 | init_crypto_default(); |
| 85 | |
| 86 | // L holds an active video m-line. An offer arrives that tries to |
| 87 | // recycle that active slot with a new mid (no prior stop). str0m must |
| 88 | // refuse rather than install a second Media at the same index. |
| 89 | let (mut l, mut r) = with_params(info_span!("L"), &[vp8(100)], info_span!("R"), &[vp8(100)]); |
| 90 | |
| 91 | let active_mid = l._mids()[0]; |
| 92 | |
| 93 | // Sanity: the mid is active, not stopped. |
| 94 | assert!(!l.media(active_mid).unwrap().stopped()); |
| 95 | assert!(!l.media(active_mid).unwrap().disabled()); |
| 96 | |
| 97 | // Take an ordinary offer from R adding a new m-line, then rewrite it |
| 98 | // so the new m-line squats the slot of L's active mid. |
| 99 | let offer_from_r = r |
| 100 | .span |
| 101 | .in_scope(|| { |
| 102 | let mut change = r.rtc.sdp_api(); |
| 103 | change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None); |
| 104 | change.apply() |
| 105 | }) |
| 106 | .expect("R has a change to apply"); |
| 107 | |
| 108 | let offer_sdp = offer_from_r.0.to_sdp_string(); |
| 109 | let bad_offer_sdp = rewrite_offer_to_recycle_stopped_slot(&offer_sdp, active_mid); |
| 110 | let bad_offer = SdpOffer::from_sdp_string(&bad_offer_sdp).expect("offer parses"); |
| 111 | |
| 112 | let result = l.span.in_scope(|| l.rtc.sdp_api().accept_offer(bad_offer)); |
| 113 | |
| 114 | assert!( |
| 115 | result.is_err(), |
| 116 | "accept_offer must reject a recycling attempt against a non-stopped slot, got Ok:\n{}", |
| 117 | bad_offer_sdp |
| 118 | ); |
| 119 | } |
| 120 | |
| 121 | #[test] |
| 122 | pub fn stop_media_recycled_slot_state() { |
| 123 | init_log(); |
| 124 | init_crypto_default(); |
| 125 | |
| 126 | // After a recycled offer is accepted, verify the post-recycle state |
| 127 | // of the surviving Media objects. The happy-path test only asserts |
| 128 | // answer m-line count; this pins down the full state. |
| 129 | let (mut l, mut r) = with_params(info_span!("L"), &[vp8(100)], info_span!("R"), &[vp8(100)]); |
| 130 | |
| 131 | let old_mid = l._mids()[0]; |
| 132 | |
| 133 | negotiate(&mut l, &mut r, |change| { |
| 134 | change.stop_media(old_mid); |
| 135 | }); |
| 136 | |
| 137 | let offer_from_r = r |
| 138 | .span |
| 139 | .in_scope(|| { |
| 140 | let mut change = r.rtc.sdp_api(); |
| 141 | change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None); |
| 142 | change.apply() |
| 143 | }) |
| 144 | .expect("R has a change to apply"); |
| 145 | |
| 146 | let recycled_offer_sdp = |
| 147 | rewrite_offer_to_recycle_stopped_slot(&offer_from_r.0.to_sdp_string(), old_mid); |
| 148 | let recycled_offer = SdpOffer::from_sdp_string(&recycled_offer_sdp).expect("parses"); |
| 149 | |
| 150 | // The new mid is whichever mid in the recycled SDP isn't old_mid. |
| 151 | let new_mid = new_mid_in_sdp(&recycled_offer_sdp, old_mid); |
| 152 | |
| 153 | l.span.in_scope(|| { |
| 154 | l.rtc |
| 155 | .sdp_api() |
| 156 | .accept_offer(recycled_offer) |
| 157 | .expect("accept"); |
| 158 | }); |
| 159 | |
| 160 | // L side: old mid is retired, new mid is live and not stopped. |
| 161 | assert!( |
| 162 | !l._mids().contains(&old_mid), |
| 163 | "retired mid {old_mid} must not appear in L's medias after recycle, got {:?}", |
| 164 | l._mids() |
| 165 | ); |
| 166 | assert!( |
| 167 | l._mids().contains(&new_mid), |
| 168 | "recycled mid {new_mid} must appear in L's medias, got {:?}", |
| 169 | l._mids() |
| 170 | ); |
| 171 | assert!(l.media(old_mid).is_none(), "old Media must be gone on L"); |
| 172 | |
| 173 | let new_media_l = l.media(new_mid).expect("new Media on L"); |
| 174 | assert!(!new_media_l.stopped(), "recycled Media must not be stopped"); |
| 175 | assert!( |
| 176 | !new_media_l.disabled(), |
| 177 | "recycled Media must not be disabled" |
| 178 | ); |
| 179 | assert_eq!( |
| 180 | new_media_l.kind(), |
| 181 | MediaKind::Video, |
| 182 | "recycled Media kind must match the offer" |
| 183 | ); |
| 184 | // R offered sendonly, so L's inverted direction is recvonly. |
| 185 | assert_eq!(new_media_l.direction(), Direction::RecvOnly); |
| 186 | assert_eq!( |
| 187 | new_media_l.remote_pts(), |
| 188 | &[Pt::new_with_value(100)], |
| 189 | "recycled Media must have remote_pts populated" |
| 190 | ); |
| 191 | } |
| 192 | |
| 193 | #[test] |
| 194 | pub fn stop_media_and_recycle_then_negotiate_again() { |
| 195 | init_log(); |
| 196 | init_crypto_default(); |
| 197 | |
| 198 | // After accepting a recycled offer, L must still be able to generate |
| 199 | // a valid subsequent offer of its own. This exercises that session |
| 200 | // state (medias, streams, index bookkeeping) is consistent for the |
| 201 | // side that processed the recycling. |
| 202 | // |
| 203 | // We don't round-trip R here: R sent a non-recycled offer that was |
| 204 | // rewritten out-of-band, so from R's own perspective the resulting |
| 205 | // answer is malformed. That's a synthetic-test artifact, not a |
| 206 | // real-world scenario (browsers always answer in-shape). |
| 207 | let (mut l, mut r) = with_params(info_span!("L"), &[vp8(100)], info_span!("R"), &[vp8(100)]); |
| 208 | |
| 209 | let old_mid = l._mids()[0]; |
| 210 | |
| 211 | negotiate(&mut l, &mut r, |change| { |
| 212 | change.stop_media(old_mid); |
| 213 | }); |
| 214 | |
| 215 | let offer_from_r = r |
| 216 | .span |
| 217 | .in_scope(|| { |
| 218 | let mut change = r.rtc.sdp_api(); |
| 219 | change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None); |
| 220 | change.apply() |
| 221 | }) |
| 222 | .expect("R has a change to apply"); |
| 223 | |
| 224 | let recycled_offer_sdp = |
| 225 | rewrite_offer_to_recycle_stopped_slot(&offer_from_r.0.to_sdp_string(), old_mid); |
| 226 | let recycled_offer = SdpOffer::from_sdp_string(&recycled_offer_sdp).expect("parses"); |
| 227 | |
| 228 | l.span.in_scope(|| { |
| 229 | l.rtc |
| 230 | .sdp_api() |
| 231 | .accept_offer(recycled_offer) |
| 232 | .expect("accept") |
| 233 | }); |
| 234 | |
| 235 | // L must be able to create another offer cleanly. This would fail if |
| 236 | // `as_media_lines` disagrees with the post-recycle `medias` list. |
| 237 | let follow_up = l |
| 238 | .span |
| 239 | .in_scope(|| { |
| 240 | let mut change = l.rtc.sdp_api(); |
| 241 | change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None); |
| 242 | change.apply() |
| 243 | }) |
| 244 | .expect("L can produce a follow-up offer"); |
| 245 | |
| 246 | let follow_up_sdp = follow_up.0.to_sdp_string(); |
| 247 | |
| 248 | // Retired mid must not reappear in L's offer. |
| 249 | let old_mid_str = old_mid.to_string(); |
| 250 | assert!( |
| 251 | !follow_up_sdp |
| 252 | .lines() |
| 253 | .any(|l| l.strip_prefix("a=mid:").map(str::trim) == Some(old_mid_str.as_str())), |
| 254 | "retired mid resurfaced in L's follow-up offer:\n{}", |
| 255 | follow_up_sdp |
| 256 | ); |
| 257 | // The follow-up SDP must have exactly 2 m-lines (recycled + new). |
| 258 | let m_line_count = follow_up_sdp |
| 259 | .lines() |
| 260 | .filter(|l| l.starts_with("m=")) |
| 261 | .count(); |
| 262 | assert_eq!( |
| 263 | m_line_count, 2, |
| 264 | "expected 2 m-lines in follow-up offer, got:\n{}", |
| 265 | follow_up_sdp |
| 266 | ); |
| 267 | assert!(!l._mids().contains(&old_mid)); |
| 268 | } |
| 269 | |
| 270 | #[test] |
| 271 | pub fn recycling_with_media_type_change() { |
| 272 | init_log(); |
| 273 | init_crypto_default(); |
| 274 | |
| 275 | // JSEP §5.2.2 allows a recycled slot to change media type. Stop an |
| 276 | // audio m-line and have the remote reoffer with a video m-line at |
| 277 | // the same slot. Both peers need audio *and* video codecs, so we |
| 278 | // construct them manually rather than via with_params. |
| 279 | let build = |span: Span| { |
| 280 | let mut b = Rtc::builder().clear_codecs(); |
| 281 | b.codec_config().add_config( |
| 282 | Pt::new_with_value(111), |
| 283 | None, |
| 284 | Codec::Opus, |
| 285 | Frequency::FORTY_EIGHT_KHZ, |
| 286 | Some(2), |
| 287 | FormatParams::default(), |
| 288 | ); |
| 289 | b.codec_config().add_config( |
| 290 | Pt::new_with_value(100), |
| 291 | None, |
| 292 | Codec::Vp8, |
| 293 | Frequency::NINETY_KHZ, |
| 294 | None, |
| 295 | FormatParams::default(), |
| 296 | ); |
| 297 | TestRtc::new_with_rtc(span, b.build(Instant::now())) |
| 298 | }; |
| 299 | let mut l = build(info_span!("L")); |
| 300 | let mut r = build(info_span!("R")); |
| 301 | |
| 302 | negotiate(&mut l, &mut r, |change| { |
| 303 | change.add_media(MediaKind::Audio, Direction::SendRecv, None, None, None); |
| 304 | }); |
| 305 | |
| 306 | let old_mid = l._mids()[0]; |
| 307 | assert_eq!(l.media(old_mid).unwrap().kind(), MediaKind::Audio); |
| 308 | |
| 309 | negotiate(&mut l, &mut r, |change| { |
| 310 | change.stop_media(old_mid); |
| 311 | }); |
| 312 | |
| 313 | let offer_from_r = r |
| 314 | .span |
| 315 | .in_scope(|| { |
| 316 | let mut change = r.rtc.sdp_api(); |
| 317 | change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None); |
| 318 | change.apply() |
| 319 | }) |
| 320 | .expect("R has a change to apply"); |
| 321 | |
| 322 | let recycled_sdp = |
| 323 | rewrite_offer_to_recycle_stopped_slot(&offer_from_r.0.to_sdp_string(), old_mid); |
| 324 | let recycled_offer = SdpOffer::from_sdp_string(&recycled_sdp).expect("parses"); |
| 325 | let new_mid = new_mid_in_sdp(&recycled_sdp, old_mid); |
| 326 | |
| 327 | let result = l |
| 328 | .span |
| 329 | .in_scope(|| l.rtc.sdp_api().accept_offer(recycled_offer)); |
| 330 | assert!( |
| 331 | result.is_ok(), |
| 332 | "accepting a recycle that changes media type (audio->video) should succeed per JSEP §5.2.2: {:?}", |
| 333 | result.err() |
| 334 | ); |
| 335 | |
| 336 | let recycled = l.media(new_mid).expect("new Media present"); |
| 337 | assert_eq!( |
| 338 | recycled.kind(), |
| 339 | MediaKind::Video, |
| 340 | "recycled Media kind must reflect the new m-line type" |
| 341 | ); |
| 342 | assert!(!recycled.stopped()); |
| 343 | } |
| 344 | |
| 345 | #[test] |
| 346 | pub fn recycling_in_answer_produces_error() { |
| 347 | init_log(); |
| 348 | init_crypto_default(); |
| 349 | |
| 350 | // JSEP §5.2.2 only permits recycling in offers. An answer that |
| 351 | // introduces a new mid at the index of a stopped slot is malformed. |
| 352 | // str0m's `ensure_correct_answer` should reject it rather than |
| 353 | // silently retiring our Media. |
| 354 | let (mut l, mut r) = with_params(info_span!("L"), &[vp8(100)], info_span!("R"), &[vp8(100)]); |
| 355 | |
| 356 | let old_mid = l._mids()[0]; |
| 357 | |
| 358 | negotiate(&mut l, &mut r, |change| { |
| 359 | change.stop_media(old_mid); |
| 360 | }); |
| 361 | |
| 362 | // L initiates a fresh offer adding a new video m-line; R will answer. |
| 363 | let (offer_from_l, pending_l) = l |
| 364 | .span |
| 365 | .in_scope(|| { |
| 366 | let mut change = l.rtc.sdp_api(); |
| 367 | change.add_media(MediaKind::Video, Direction::SendOnly, None, None, None); |
| 368 | change.apply() |
| 369 | }) |
| 370 | .expect("L has a change to apply"); |
| 371 | |
| 372 | let answer_from_r = r |
| 373 | .span |
| 374 | .in_scope(|| r.rtc.sdp_api().accept_offer(offer_from_l).expect("accept")); |
| 375 | |
| 376 | // Rewrite R's answer so the new mid's m-line squats the old stopped |
| 377 | // slot. This is the malformed shape we want L to reject. |
| 378 | let malformed_answer_sdp = |
| 379 | rewrite_offer_to_recycle_stopped_slot(&answer_from_r.to_sdp_string(), old_mid); |
| 380 | let malformed_answer = |
| 381 | str0m::change::SdpAnswer::from_sdp_string(&malformed_answer_sdp).expect("parses"); |
| 382 | |
| 383 | let result = l |
| 384 | .span |
| 385 | .in_scope(|| l.rtc.sdp_api().accept_answer(pending_l, malformed_answer)); |
| 386 | |
| 387 | assert!( |
| 388 | result.is_err(), |
| 389 | "accept_answer must reject a recycled answer, got Ok:\n{}", |
| 390 | malformed_answer_sdp |
| 391 | ); |
| 392 | } |
| 393 | |
| 394 | // ------------------------------------------------------------------------- |
| 395 | // Helpers |
| 396 | // ------------------------------------------------------------------------- |
| 397 | |
| 398 | /// Moves the trailing (newly added) m-line into the slot occupied by the |
| 399 | /// m-line for `old_mid`, and updates the BUNDLE group accordingly. When |
| 400 | /// `old_mid` belongs to a stopped m-line (port=0) this produces the offer |
| 401 | /// shape a browser emits when recycling the slot per RFC 8829 §5.2.2. |
| 402 | fn rewrite_offer_to_recycle_stopped_slot(sdp: &str, old_mid: Mid) -> String { |
| 403 | // Split into sections, each starting with "m=". The first section is |
| 404 | // the session-level block before any m-line. |
| 405 | let mut sections: Vec<String> = Vec::new(); |
| 406 | let mut current = String::new(); |
| 407 | for line in sdp.split_inclusive("\r\n") { |
| 408 | if line.starts_with("m=") && !current.is_empty() { |
| 409 | sections.push(std::mem::take(&mut current)); |
| 410 | } |
| 411 | current.push_str(line); |
| 412 | } |
| 413 | if !current.is_empty() { |
| 414 | sections.push(current); |
| 415 | } |
| 416 | |
| 417 | let session_block = sections.remove(0); |
| 418 | |
| 419 | // Find the stopped section and the trailing new section. |
| 420 | let old_mid_attr = format!("a=mid:{old_mid}\r\n"); |
| 421 | let stopped_idx = sections |
| 422 | .iter() |
| 423 | .position(|s| s.contains(&old_mid_attr)) |
| 424 | .expect("stopped section present"); |
| 425 | let new_section = sections.pop().expect("new section present"); |
| 426 | |
| 427 | sections[stopped_idx] = new_section; |
| 428 | |
| 429 | // Parse the new mid so we can rewrite the BUNDLE group. |
| 430 | let new_mid = sections[stopped_idx] |
| 431 | .lines() |
| 432 | .find_map(|l| l.strip_prefix("a=mid:")) |
| 433 | .expect("new section has a=mid:") |
| 434 | .trim() |
| 435 | .to_string(); |
| 436 | |
| 437 | let old_mid_str = old_mid.to_string(); |
| 438 | let session_block = session_block |
| 439 | .lines() |
| 440 | .map(|l| { |
| 441 | if let Some(rest) = l.strip_prefix("a=group:BUNDLE ") { |
| 442 | let mut mids: Vec<&str> = rest |
| 443 | .split_whitespace() |
| 444 | .filter(|m| *m != old_mid_str) |
| 445 | .collect(); |
| 446 | if !mids.contains(&new_mid.as_str()) { |
| 447 | mids.push(new_mid.as_str()); |
| 448 | } |
| 449 | format!("a=group:BUNDLE {}", mids.join(" ")) |
| 450 | } else { |
| 451 | l.to_string() |
| 452 | } |
| 453 | }) |
| 454 | .collect::<Vec<_>>() |
| 455 | .join("\r\n") |
| 456 | + "\r\n"; |
| 457 | |
| 458 | let mut out = session_block; |
| 459 | for s in sections { |
| 460 | out.push_str(&s); |
| 461 | } |
| 462 | out |
| 463 | } |
| 464 | |
| 465 | /// Find the mid in `sdp` that isn't `old_mid`. Panics if zero or more than one match. |
| 466 | fn new_mid_in_sdp(sdp: &str, old_mid: Mid) -> Mid { |
| 467 | let old_str = old_mid.to_string(); |
| 468 | let mut found: Option<Mid> = None; |
| 469 | for line in sdp.lines() { |
| 470 | if let Some(rest) = line.strip_prefix("a=mid:") { |
| 471 | let m = rest.trim(); |
| 472 | if m == old_str { |
| 473 | continue; |
| 474 | } |
| 475 | assert!( |
| 476 | found.is_none(), |
| 477 | "more than one non-old mid in sdp:\n{}", |
| 478 | sdp |
| 479 | ); |
| 480 | found = Some(m.into()); |
| 481 | } |
| 482 | } |
| 483 | found.expect("a new mid must be present in the recycled sdp") |
| 484 | } |
| 485 | |
| 486 | fn with_params( |
| 487 | span_l: Span, |
| 488 | params_l: &[PayloadParams], |
| 489 | span_r: Span, |
| 490 | params_r: &[PayloadParams], |
| 491 | ) -> (TestRtc, TestRtc) { |
| 492 | let mut l = build_params(span_l, params_l); |
| 493 | let mut r = build_params(span_r, params_r); |
| 494 | |
| 495 | let kind = params_l |
| 496 | .first() |
| 497 | .map(|p| p.spec().codec.kind()) |
| 498 | .unwrap_or(MediaKind::Audio); |
| 499 | |
| 500 | negotiate(&mut l, &mut r, |change| { |
| 501 | change.add_media(kind, Direction::SendRecv, None, None, None); |
| 502 | }); |
| 503 | |
| 504 | (l, r) |
| 505 | } |
| 506 | |
| 507 | fn build_params(span: Span, params: &[PayloadParams]) -> TestRtc { |
| 508 | let mut b = Rtc::builder().clear_codecs(); |
| 509 | let config = b.codec_config(); |
| 510 | for p in params { |
| 511 | config.add_config( |
| 512 | p.pt(), |
| 513 | p.resend(), |
| 514 | p.spec().codec, |
| 515 | p.spec().clock_rate, |
| 516 | p.spec().channels, |
| 517 | p.spec().format, |
| 518 | ); |
| 519 | } |
| 520 | TestRtc::new_with_rtc(span, b.build(Instant::now())) |
| 521 | } |
| 522 | |
| 523 | fn vp8(pt: u8) -> PayloadParams { |
| 524 | PayloadParams::new( |
| 525 | pt.into(), |
| 526 | None, |
| 527 | CodecSpec { |
| 528 | codec: Codec::Vp8, |
| 529 | channels: None, |
| 530 | clock_rate: Frequency::NINETY_KHZ, |
| 531 | format: FormatParams::default(), |
| 532 | }, |
| 533 | ) |
| 534 | } |