1use std::collections::HashMap;
8use std::fs;
9use std::path::Path;
10
11use gimli::{BaseAddresses, CfaRule, EhFrame, NativeEndian, Register, RegisterRule, UnwindSection};
12use object::{Object, ObjectSection};
13use procfs::process::{MMapPath, Process};
14use profile_bee_common::{
15 ExecMapping, ProcInfo, UnwindEntry, CFA_REG_DEREF_RSP, CFA_REG_EXPRESSION, CFA_REG_PLT,
16 CFA_REG_RBP, CFA_REG_RSP, MAX_PROC_MAPS, MAX_SHARD_ENTRIES, MAX_UNWIND_SHARDS, REG_RULE_OFFSET,
17 REG_RULE_SAME_VALUE, REG_RULE_UNDEFINED, SHARD_NONE,
18};
19
20pub type BuildId = Vec<u8>;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26struct FileMetadata {
27 dev: u64,
28 ino: u64,
29 size: u64,
30 mtime_sec: i64,
31 mtime_nsec: i64,
32}
33
34impl FileMetadata {
35 fn from_path(path: &Path) -> Result<Self, String> {
37 use std::os::unix::fs::MetadataExt;
38 let metadata = fs::metadata(path)
39 .map_err(|e| format!("Failed to stat {}: {}", path.display(), e))?;
40 Ok(Self {
41 dev: metadata.dev(),
42 ino: metadata.ino(),
43 size: metadata.len(),
44 mtime_sec: metadata.mtime(),
45 mtime_nsec: metadata.mtime_nsec(),
46 })
47 }
48}
49
50const X86_64_RSP: Register = Register(7);
52const X86_64_RBP: Register = Register(6);
53const X86_64_RA: Register = Register(16);
54
55pub fn generate_unwind_table(
57 elf_path: &Path,
58) -> Result<(Vec<UnwindEntry>, Option<BuildId>), String> {
59 let data =
60 fs::read(elf_path).map_err(|e| format!("Failed to read {}: {}", elf_path.display(), e))?;
61 generate_unwind_table_from_bytes(&data)
62}
63
64fn classify_cfa_expression(
73 unwind_expr: &gimli::UnwindExpression<usize>,
74 eh_frame_data: &[u8],
75) -> (u8, i16) {
76 use gimli::Operation;
77
78 let start = unwind_expr.offset;
80 let end = start + unwind_expr.length;
81 if end > eh_frame_data.len() {
82 return (CFA_REG_EXPRESSION, 0);
83 }
84 let expr_bytes = &eh_frame_data[start..end];
85 let expr = gimli::Expression(gimli::EndianSlice::new(expr_bytes, NativeEndian));
86
87 let mut ops = expr.operations(gimli::Encoding {
88 address_size: 8,
89 format: gimli::Format::Dwarf32,
90 version: 4,
91 });
92
93 let Ok(Some(Operation::RegisterOffset {
95 register, offset, ..
96 })) = ops.next()
97 else {
98 return (CFA_REG_EXPRESSION, 0);
99 };
100 if register != X86_64_RSP {
101 return (CFA_REG_EXPRESSION, 0);
102 }
103 let base_offset = offset;
104
105 match ops.next() {
106 Ok(Some(Operation::Deref { .. })) => {
108 let Ok(off) = i16::try_from(base_offset) else {
109 return (CFA_REG_EXPRESSION, 0);
110 };
111 (CFA_REG_DEREF_RSP, off)
112 }
113 Ok(Some(Operation::RegisterOffset {
115 register: reg2,
116 offset: 0,
117 ..
118 })) if reg2 == X86_64_RA => {
119 let Ok(off) = i16::try_from(base_offset) else {
120 return (CFA_REG_EXPRESSION, 0);
121 };
122 (CFA_REG_PLT, off)
123 }
124 _ => (CFA_REG_EXPRESSION, 0),
125 }
126}
127
128fn read_vdso(tgid: u32, start: u64, end: u64) -> Result<Vec<u8>, String> {
129 use std::io::{Read, Seek, SeekFrom};
130 if end <= start {
131 return Err("Invalid vDSO address range".to_string());
132 }
133 let mut f = std::fs::File::open(format!("/proc/{}/mem", tgid))
134 .map_err(|e| format!("Failed to open /proc/{}/mem: {}", tgid, e))?;
135 f.seek(SeekFrom::Start(start))
136 .map_err(|e| format!("Failed to seek to vDSO: {}", e))?;
137 let len = (end - start) as usize;
138 let mut buf = vec![0u8; len];
139 f.read_exact(&mut buf)
140 .map_err(|e| format!("Failed to read vDSO: {}", e))?;
141 Ok(buf)
142}
143
144fn extract_build_id(data: &[u8]) -> Option<BuildId> {
150 let obj = object::File::parse(data).ok()?;
151 let section = obj.section_by_name(".note.gnu.build-id")?;
152 let note_data = section.data().ok()?;
153
154 if note_data.len() < 16 {
164 return None;
165 }
166
167 let namesz =
168 u32::from_ne_bytes([note_data[0], note_data[1], note_data[2], note_data[3]]) as usize;
169 let descsz =
170 u32::from_ne_bytes([note_data[4], note_data[5], note_data[6], note_data[7]]) as usize;
171 let note_type = u32::from_ne_bytes([note_data[8], note_data[9], note_data[10], note_data[11]]);
172
173 if note_type != 3 {
175 return None;
176 }
177
178 if namesz < 4 || note_data.len() < 12 + namesz {
180 return None;
181 }
182
183 let name_aligned = (namesz + 3) & !3;
185 let desc_offset = 12 + name_aligned;
186
187 if note_data.len() < desc_offset + descsz {
188 return None;
189 }
190
191 let build_id = note_data[desc_offset..desc_offset + descsz].to_vec();
192 Some(build_id)
193}
194
195pub fn generate_unwind_table_from_bytes(
196 data: &[u8],
197) -> Result<(Vec<UnwindEntry>, Option<BuildId>), String> {
198 use object::ObjectSegment;
199
200 let obj = object::File::parse(data).map_err(|e| format!("Failed to parse ELF: {}", e))?;
201
202 let build_id = extract_build_id(data);
204
205 let base_vaddr = obj
209 .segments()
210 .find(|s| s.file_range().0 == 0)
211 .map(|s| s.address())
212 .unwrap_or(0);
213
214 let eh_frame_section = obj
215 .section_by_name(".eh_frame")
216 .ok_or_else(|| "No .eh_frame section found".to_string())?;
217
218 let eh_frame_data = eh_frame_section
219 .data()
220 .map_err(|e| format!("Failed to read .eh_frame data: {}", e))?;
221
222 let eh_frame_addr = eh_frame_section.address();
223
224 let eh_frame = EhFrame::new(eh_frame_data, NativeEndian);
225
226 let bases = BaseAddresses::default().set_eh_frame(eh_frame_addr);
227
228 let mut entries = Vec::new();
229 let mut ctx = gimli::UnwindContext::new();
230 let mut cies = HashMap::new();
231
232 let mut iter = eh_frame.entries(&bases);
233 while let Ok(Some(entry)) = iter.next() {
234 match entry {
235 gimli::CieOrFde::Cie(cie) => {
236 let offset = cie.offset();
237 cies.insert(offset, cie);
238 }
239 gimli::CieOrFde::Fde(partial_fde) => {
240 let fde = match partial_fde.parse(|_, bases, offset| {
241 if let Some(cie) = cies.get(&offset.0) {
242 Ok(cie.clone())
243 } else {
244 eh_frame.cie_from_offset(bases, offset)
245 }
246 }) {
247 Ok(fde) => fde,
248 Err(_) => continue,
249 };
250
251 let mut table = match fde.rows(&eh_frame, &bases, &mut ctx) {
252 Ok(table) => table,
253 Err(_) => continue,
254 };
255
256 while let Ok(Some(row)) = table.next_row() {
257 let pc = row.start_address();
258 let cfa = row.cfa();
259
260 let (cfa_type, cfa_offset) = match cfa {
261 CfaRule::RegisterAndOffset { register, offset } => {
262 let reg_type = if *register == X86_64_RSP {
263 CFA_REG_RSP
264 } else if *register == X86_64_RBP {
265 CFA_REG_RBP
266 } else {
267 continue;
269 };
270 let Ok(offset_i16) = i16::try_from(*offset) else {
272 continue;
273 };
274 (reg_type, offset_i16)
275 }
276 CfaRule::Expression(expr) => classify_cfa_expression(expr, eh_frame_data),
277 };
278
279 if cfa_type == CFA_REG_EXPRESSION {
281 continue;
282 }
283
284 let ra_rule = row.register(X86_64_RA);
288 let is_signal_frame = cfa_type == CFA_REG_DEREF_RSP;
289 match ra_rule {
290 RegisterRule::Offset(offset) if offset == -8 => {}
291 RegisterRule::Expression(_) if is_signal_frame => {}
294 RegisterRule::Undefined => continue,
295 _ => continue,
296 };
297
298 let rbp_rule = row.register(X86_64_RBP);
300 let (rbp_type, rbp_offset) = match rbp_rule {
301 RegisterRule::Offset(offset) => {
302 let Ok(offset_i16) = i16::try_from(offset) else {
303 continue;
304 };
305 (REG_RULE_OFFSET, offset_i16)
306 }
307 RegisterRule::SameValue => (REG_RULE_SAME_VALUE, 0i16),
308 RegisterRule::Undefined => (REG_RULE_UNDEFINED, 0i16),
309 _ => (REG_RULE_UNDEFINED, 0i16),
310 };
311
312 let relative_pc = pc - base_vaddr;
313 let Ok(pc32) = u32::try_from(relative_pc) else {
316 continue;
317 };
318
319 entries.push(UnwindEntry {
320 pc: pc32,
321 cfa_offset: cfa_offset as i16,
322 rbp_offset,
323 cfa_type,
324 rbp_type,
325 _pad: [0; 2],
326 });
327 }
328 }
329 }
330 }
331
332 entries.sort_by_key(|e| e.pc);
334
335 let before = entries.len();
339 entries.dedup_by(|b, a| {
340 a.cfa_type == b.cfa_type
341 && a.cfa_offset == b.cfa_offset
342 && a.rbp_type == b.rbp_type
343 && a.rbp_offset == b.rbp_offset
344 });
345 let after = entries.len();
346 if before != after {
347 tracing::debug!(
348 "Dedup: {} -> {} entries ({:.1}% reduction)",
349 before,
350 after,
351 (1.0 - after as f64 / before as f64) * 100.0
352 );
353 }
354
355 Ok((entries, build_id))
356}
357
358pub struct DwarfUnwindManager {
360 pub binary_tables: HashMap<u8, Vec<UnwindEntry>>,
362 pub proc_info: HashMap<u32, ProcInfo>,
364 next_shard_id: u8,
366 metadata_cache: HashMap<FileMetadata, u8>, binary_cache: HashMap<BuildId, u8>, path_cache: HashMap<std::path::PathBuf, u8>, }
374
375impl DwarfUnwindManager {
376 pub fn new() -> Self {
377 Self {
378 binary_tables: HashMap::new(),
379 proc_info: HashMap::new(),
380 next_shard_id: 0,
381 metadata_cache: HashMap::new(),
382 binary_cache: HashMap::new(),
383 path_cache: HashMap::new(),
384 }
385 }
386
387 pub fn load_process(&mut self, tgid: u32) -> Result<(), String> {
390 if self.proc_info.contains_key(&tgid) {
391 return Ok(());
392 }
393 let mut prev_count = 0usize;
397 for _ in 0..20 {
398 let count = Self::count_exec_maps(tgid);
399 if count > 0 && count == prev_count {
400 break;
401 }
402 prev_count = count;
403 std::thread::sleep(std::time::Duration::from_millis(10));
404 }
405 self.scan_and_update(tgid)
406 }
407
408 pub fn refresh_process(&mut self, tgid: u32) -> Result<Vec<u8>, String> {
411 let old_shard_ids: Vec<u8> = self.binary_tables.keys().copied().collect();
412 self.scan_and_update(tgid)?;
413 let new_shard_ids: Vec<u8> = self
414 .binary_tables
415 .keys()
416 .copied()
417 .filter(|id| !old_shard_ids.contains(id))
418 .collect();
419 Ok(new_shard_ids)
420 }
421
422 fn count_exec_maps(tgid: u32) -> usize {
424 let Ok(process) = Process::new(tgid as i32) else {
425 return 0;
426 };
427 let Ok(maps) = process.maps() else {
428 return 0;
429 };
430 use procfs::process::MMPermissions;
431 maps.iter()
432 .filter(|m| {
433 m.perms.contains(MMPermissions::EXECUTE)
434 && m.perms.contains(MMPermissions::READ)
435 && matches!(m.pathname, MMapPath::Path(_) | MMapPath::Vdso)
436 })
437 .count()
438 }
439
440 fn scan_and_update(&mut self, tgid: u32) -> Result<(), String> {
441 let process = Process::new(tgid as i32)
442 .map_err(|e| format!("Failed to open process {}: {}", tgid, e))?;
443
444 let maps = process
445 .maps()
446 .map_err(|e| format!("Failed to read maps for {}: {}", tgid, e))?;
447
448 let existing = self.proc_info.get(&tgid);
450 let existing_ranges: Vec<(u64, u64)> = existing
451 .map(|pi| {
452 (0..pi.mapping_count as usize)
453 .map(|i| (pi.mappings[i].begin, pi.mappings[i].end))
454 .collect()
455 })
456 .unwrap_or_default();
457
458 let mut proc_info = existing.copied().unwrap_or(ProcInfo {
459 mapping_count: 0,
460 _pad: 0,
461 mappings: [ExecMapping {
462 begin: 0,
463 end: 0,
464 load_bias: 0,
465 shard_id: SHARD_NONE,
466 _pad1: [0; 3],
467 table_count: 0,
468 }; MAX_PROC_MAPS],
469 });
470
471 let root_path = format!("/proc/{}/root", tgid);
472
473 for map in maps.iter() {
474 if proc_info.mapping_count as usize >= MAX_PROC_MAPS {
475 break;
476 }
477
478 let perms = &map.perms;
479 use procfs::process::MMPermissions;
480 if !perms.contains(MMPermissions::EXECUTE) || !perms.contains(MMPermissions::READ) {
481 continue;
482 }
483
484 let file_path = match &map.pathname {
485 MMapPath::Path(p) => p.to_path_buf(),
486 MMapPath::Vdso => std::path::PathBuf::from("[vdso]"),
487 _ => continue,
488 };
489
490 let start_addr = map.address.0;
491 let end_addr = map.address.1;
492 let file_offset = map.offset;
493 let is_vdso = matches!(&map.pathname, MMapPath::Vdso);
494
495 if existing_ranges
497 .iter()
498 .any(|&(b, e)| b == start_addr && e == end_addr)
499 {
500 continue;
501 }
502
503 let resolved_path = if is_vdso {
504 file_path.clone()
505 } else {
506 let mut p = std::path::PathBuf::from(&root_path);
507 p.push(file_path.strip_prefix("/").unwrap_or(&file_path));
508 if p.exists() {
509 p
510 } else {
511 file_path.clone()
512 }
513 };
514
515 if !is_vdso && !resolved_path.exists() {
516 continue;
517 }
518
519 let load_bias = start_addr.wrapping_sub(file_offset);
520
521 let (shard_id, table_count) = {
525 let metadata_cache_hit = if !is_vdso {
527 FileMetadata::from_path(&resolved_path)
528 .ok()
529 .and_then(|meta| self.metadata_cache.get(&meta).copied())
530 } else {
531 None
532 };
533
534 if let Some(sid) = metadata_cache_hit {
535 let tc = self
537 .binary_tables
538 .get(&sid)
539 .map(|t| t.len() as u32)
540 .unwrap_or(0);
541 (sid, tc)
542 } else {
543 let binary_data = if is_vdso {
545 read_vdso(tgid, start_addr, end_addr).ok()
546 } else {
547 fs::read(&resolved_path).ok()
548 };
549
550 let cache_hit = if let Some(ref data) = binary_data {
551 if let Some(build_id) = extract_build_id(data) {
553 self.binary_cache.get(&build_id).copied()
554 } else {
555 self.path_cache.get(&resolved_path).copied()
557 }
558 } else {
559 None
560 };
561
562 if let Some(sid) = cache_hit {
563 if !is_vdso {
565 if let Ok(meta) = FileMetadata::from_path(&resolved_path) {
566 self.metadata_cache.insert(meta, sid);
567 }
568 }
569 let tc = self
570 .binary_tables
571 .get(&sid)
572 .map(|t| t.len() as u32)
573 .unwrap_or(0);
574 (sid, tc)
575 } else {
576 let (unwind_entries, build_id_opt) = if let Some(data) = binary_data {
578 match generate_unwind_table_from_bytes(&data) {
579 Ok(result) => result,
580 Err(e) => {
581 let name = if is_vdso {
582 "[vdso]".to_string()
583 } else {
584 resolved_path.display().to_string()
585 };
586 tracing::debug!("Skipping {} for pid {}: {}", name, tgid, e);
587 continue;
588 }
589 }
590 } else {
591 let name = if is_vdso {
592 "[vdso]".to_string()
593 } else {
594 resolved_path.display().to_string()
595 };
596 tracing::debug!("Failed to read binary {} for pid {}", name, tgid);
597 continue;
598 };
599
600 if unwind_entries.is_empty() {
601 continue;
602 }
603
604 let tc = match u32::try_from(unwind_entries.len()) {
605 Ok(v) => v,
606 Err(_) => {
607 let name = if is_vdso {
608 "[vdso]".to_string()
609 } else {
610 resolved_path.display().to_string()
611 };
612 tracing::warn!(
613 "Unwind table too large for {}: {} entries",
614 name,
615 unwind_entries.len(),
616 );
617 continue;
618 }
619 };
620
621 if tc > MAX_SHARD_ENTRIES {
622 tracing::warn!(
623 "Binary unwind table too large: {} entries (max {} per shard), skipping",
624 tc,
625 MAX_SHARD_ENTRIES,
626 );
627 continue;
628 }
629
630 if self.next_shard_id as usize >= MAX_UNWIND_SHARDS {
631 tracing::warn!(
632 "All {} shard slots used, skipping remaining binaries for pid {}",
633 MAX_UNWIND_SHARDS,
634 tgid,
635 );
636 break;
637 }
638
639 let sid = self.next_shard_id;
640 self.next_shard_id += 1;
641
642 self.binary_tables.insert(sid, unwind_entries);
643
644 if let Some(build_id) = build_id_opt {
646 self.binary_cache.insert(build_id, sid);
647 } else {
648 self.path_cache.insert(resolved_path.clone(), sid);
649 }
650
651 if !is_vdso {
653 if let Ok(meta) = FileMetadata::from_path(&resolved_path) {
654 self.metadata_cache.insert(meta, sid);
655 }
656 }
657
658 (sid, tc)
659 }
660 }
661 };
662
663 let idx = proc_info.mapping_count as usize;
664 proc_info.mappings[idx] = ExecMapping {
665 begin: start_addr,
666 end: end_addr,
667 load_bias,
668 shard_id,
669 _pad1: [0; 3],
670 table_count,
671 };
672 proc_info.mapping_count += 1;
673 }
674
675 self.proc_info.insert(tgid, proc_info);
676
677 Ok(())
678 }
679
680 pub fn total_entries(&self) -> usize {
682 self.binary_tables.values().map(|t| t.len()).sum()
683 }
684}
685
686#[cfg(test)]
687mod tests {
688 use super::*;
689
690 #[test]
691 fn test_generate_unwind_table_self() {
692 let exe = std::env::current_exe().unwrap();
694 let (entries, build_id) = generate_unwind_table(&exe).unwrap();
695 assert!(
696 !entries.is_empty(),
697 "Expected non-empty unwind table for test binary"
698 );
699
700 assert!(build_id.is_some(), "Expected build ID for test binary");
702
703 for w in entries.windows(2) {
705 assert!(
706 w[0].pc <= w[1].pc,
707 "Unwind entries not sorted: {} > {}",
708 w[0].pc,
709 w[1].pc
710 );
711 }
712
713 for entry in &entries {
715 assert!(
716 matches!(
717 entry.cfa_type,
718 CFA_REG_RSP | CFA_REG_RBP | CFA_REG_PLT | CFA_REG_DEREF_RSP
719 ),
720 "Unexpected CFA type: {}",
721 entry.cfa_type
722 );
723 }
726 }
727
728 #[test]
729 fn test_generate_unwind_table_missing_file() {
730 let result = generate_unwind_table(Path::new("/nonexistent/binary"));
731 assert!(result.is_err());
732 }
733
734 #[test]
735 fn test_generate_unwind_table_invalid_elf() {
736 let result = generate_unwind_table_from_bytes(b"not an elf file");
737 assert!(result.is_err());
738 }
739
740 #[test]
741 fn test_dwarf_manager_new() {
742 let manager = DwarfUnwindManager::new();
743 assert_eq!(manager.total_entries(), 0);
744 assert!(manager.proc_info.is_empty());
745 }
746
747 #[test]
748 fn test_unwind_entry_sizes() {
749 assert_eq!(std::mem::size_of::<UnwindEntry>(), UnwindEntry::STRUCT_SIZE);
751 assert_eq!(std::mem::size_of::<UnwindEntry>(), 12);
753 }
754
755 #[test]
756 fn test_unwind_table_return_address_convention() {
757 let exe = std::env::current_exe().unwrap();
761 let (entries, _) = generate_unwind_table(&exe).unwrap();
762 assert!(!entries.is_empty(), "Expected non-empty unwind table");
763 }
764
765 #[test]
766 fn test_load_current_process() {
767 let pid = std::process::id();
768 let mut manager = DwarfUnwindManager::new();
769 let result = manager.load_process(pid);
770 assert!(
771 result.is_ok(),
772 "Failed to load current process: {:?}",
773 result
774 );
775 assert!(
776 manager.total_entries() > 0,
777 "Expected non-empty unwind table for current process"
778 );
779 assert!(
780 manager.proc_info.contains_key(&pid),
781 "Expected proc_info entry for current process"
782 );
783 let info = &manager.proc_info[&pid];
784 assert!(
785 info.mapping_count > 0,
786 "Expected at least one executable mapping"
787 );
788 }
789
790 #[test]
791 fn test_libc_unwind_table() {
792 let libc_paths = [
794 "/lib/x86_64-linux-gnu/libc.so.6",
795 "/usr/lib/x86_64-linux-gnu/libc.so.6",
796 "/lib64/libc.so.6",
797 ];
798
799 let libc_path = libc_paths.iter().find(|p| Path::new(p).exists());
800 if let Some(path) = libc_path {
801 let (entries, build_id) = generate_unwind_table(Path::new(path)).unwrap();
802 assert!(
803 !entries.is_empty(),
804 "Expected non-empty unwind table for libc"
805 );
806 assert!(
808 entries.len() > 100,
809 "Expected >100 entries for libc, got {}",
810 entries.len()
811 );
812 assert!(build_id.is_some(), "Expected build ID for libc");
814 }
815 }
816
817 #[test]
818 fn test_build_id_extraction() {
819 let exe = std::env::current_exe().unwrap();
821 let data = fs::read(&exe).unwrap();
822 let build_id = extract_build_id(&data);
823 assert!(build_id.is_some(), "Expected build ID in test binary");
824
825 let id = build_id.unwrap();
827 assert!(!id.is_empty(), "Build ID should not be empty");
828 assert!(id.len() >= 8, "Build ID should be at least 8 bytes");
829 }
830
831 #[test]
832 fn test_build_id_caching() {
833 let mut manager = DwarfUnwindManager::new();
835 let pid = std::process::id();
836
837 let result = manager.load_process(pid);
839 assert!(result.is_ok(), "Failed to load process: {:?}", result);
840
841 let initial_cache_size = manager.binary_cache.len() + manager.path_cache.len();
842 let initial_table_size = manager.total_entries();
843
844 assert!(initial_cache_size > 0, "Expected some cached binaries");
845 assert!(initial_table_size > 0, "Expected non-empty unwind table");
846
847 }
851
852 #[test]
853 fn test_metadata_based_caching() {
854 let mut manager = DwarfUnwindManager::new();
856 let pid = std::process::id();
857
858 let result = manager.load_process(pid);
860 assert!(result.is_ok(), "Failed to load process: {:?}", result);
861
862 let metadata_cache_size = manager.metadata_cache.len();
863 assert!(
864 metadata_cache_size > 0,
865 "Expected metadata cache to be populated"
866 );
867
868 let new_shards = manager.refresh_process(pid).unwrap();
870
871 assert_eq!(new_shards.len(), 0, "Expected no new shards on refresh");
873
874 let new_metadata_cache_size = manager.metadata_cache.len();
876 assert!(
877 new_metadata_cache_size >= metadata_cache_size,
878 "Metadata cache should not shrink"
879 );
880 }
881}