1#[cfg(feature = "simd")]
6use super::phase2::Phase2AlwaysActiveGpuEvidence;
7use super::scan_filters::*;
8use super::*;
9
10#[cfg(feature = "simd")]
11use std::cell::RefCell;
12
13#[cfg(feature = "simd")]
17thread_local! {
18 static TRIGGER_POOL: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
21}
22
23#[cfg(feature = "simd")]
24#[inline]
25fn with_trigger_buffer<R>(words_needed: usize, f: impl FnOnce(&mut [u64]) -> R) -> R {
26 TRIGGER_POOL.with(|cell| {
27 let mut buf = cell.borrow_mut();
28 if buf.len() < words_needed {
29 buf.resize(words_needed, 0);
30 }
31 let slice = &mut buf[..words_needed];
32 slice.fill(0);
33 f(slice)
34 })
35}
36
37#[cfg(feature = "simd")]
38#[inline]
39fn mark_hs_trigger(
40 scratch: &mut [u64],
41 prefilter: &super::SimdPhase1Prefilter,
42 ac_len: usize,
43 hs_id: usize,
44) {
45 if let Some(orig) = prefilter.original_indices(hs_id) {
46 for &idx in orig {
47 let idx = idx as usize;
48 if idx < ac_len {
49 scratch[idx / 64] |= 1u64 << (idx % 64);
50 }
51 }
52 }
53}
54
55impl CompiledScanner {
56 #[cfg(feature = "simd")]
57 #[inline]
58 fn post_process_coalesced_matches(
59 &self,
60 chunk: &keyhog_core::Chunk,
61 matches: &mut Vec<keyhog_core::RawMatch>,
62 route: crate::ScanExecutionRoute,
63 ) {
64 if self.chunk_needs_decode_postprocess(chunk) {
65 self.post_process_matches(chunk, matches, None, route);
66 } else {
67 self.scan_cross_chunk_fragments(chunk, matches, None, route);
68 }
69 }
70
71 #[cfg(feature = "simd")]
72 #[inline]
73 fn decode_only_coalesced_matches(
74 &self,
75 chunk: &keyhog_core::Chunk,
76 route: crate::ScanExecutionRoute,
77 ) -> Option<Vec<keyhog_core::RawMatch>> {
78 if !self.chunk_needs_decode_postprocess(chunk) {
79 return None;
80 }
81 let mut matches = Vec::new();
82 self.post_process_matches(chunk, &mut matches, None, route);
83 Some(matches)
84 }
85
86 pub fn scan_coalesced_with_backend(
93 &self,
94 chunks: &[keyhog_core::Chunk],
95 backend: crate::hw_probe::ScanBackend,
96 ) -> Vec<Vec<keyhog_core::RawMatch>> {
97 self.scan_coalesced_with_backend_and_admission(chunks, backend, None)
98 }
99
100 pub fn scan_coalesced_with_backend_and_admission(
104 &self,
105 chunks: &[keyhog_core::Chunk],
106 backend: crate::hw_probe::ScanBackend,
107 plan: Option<&super::Phase1AdmissionPlan>,
108 ) -> Vec<Vec<keyhog_core::RawMatch>> {
109 self.scan_coalesced_with_backend_admission_and_route(
110 chunks,
111 backend,
112 plan,
113 self.execution_route_for_backend(backend),
114 )
115 }
116
117 pub fn scan_coalesced_with_backend_admission_and_route(
119 &self,
120 chunks: &[keyhog_core::Chunk],
121 backend: crate::hw_probe::ScanBackend,
122 plan: Option<&super::Phase1AdmissionPlan>,
123 route: crate::ScanExecutionRoute,
124 ) -> Vec<Vec<keyhog_core::RawMatch>> {
125 match self.try_scan_coalesced_with_backend_admission_and_route(chunks, backend, plan, route)
126 {
127 Ok(matches) => matches,
128 Err(crate::error::ScanError::Gpu(reason)) => {
129 super::gpu_forced::fail_selected_gpu_dispatch(self, &reason)
130 }
131 Err(error) => crate::process_exit::backend_unavailable(format!(
132 "selected scanner backend failed: {error}"
133 )),
134 }
135 }
136
137 pub fn try_scan_coalesced_with_backend_and_admission(
141 &self,
142 chunks: &[keyhog_core::Chunk],
143 backend: crate::hw_probe::ScanBackend,
144 plan: Option<&super::Phase1AdmissionPlan>,
145 ) -> crate::error::Result<Vec<Vec<keyhog_core::RawMatch>>> {
146 self.try_scan_coalesced_with_backend_admission_and_route(
147 chunks,
148 backend,
149 plan,
150 self.execution_route_for_backend(backend),
151 )
152 }
153
154 pub fn try_scan_coalesced_with_backend_admission_and_route(
156 &self,
157 chunks: &[keyhog_core::Chunk],
158 backend: crate::hw_probe::ScanBackend,
159 plan: Option<&super::Phase1AdmissionPlan>,
160 route: crate::ScanExecutionRoute,
161 ) -> crate::error::Result<Vec<Vec<keyhog_core::RawMatch>>> {
162 self.try_scan_coalesced_with_backend_admission_route_and_recovery(
163 chunks, backend, plan, route, false,
164 )
165 .map(|outcome| outcome.matches)
166 }
167
168 pub fn try_scan_coalesced_with_backend_admission_route_and_recovery(
171 &self,
172 chunks: &[keyhog_core::Chunk],
173 backend: crate::hw_probe::ScanBackend,
174 plan: Option<&super::Phase1AdmissionPlan>,
175 route: crate::ScanExecutionRoute,
176 #[cfg_attr(not(feature = "gpu"), allow(unused_variables))]
177 recover_gpu_dispatch_faults: bool,
178 ) -> crate::error::Result<super::CoalescedScanOutcome> {
179 let expected_residual_backend = if backend.is_gpu() {
180 crate::hw_probe::ScanBackend::CpuFallback
181 } else {
182 backend
183 };
184 if route.decode_backend != expected_residual_backend {
185 return Err(crate::error::ScanError::Config(format!(
186 "{} route declares {} residual execution, expected {}. Rebuild the execution route from the selected backend",
187 backend.label(),
188 route.decode_backend.label(),
189 expected_residual_backend.label(),
190 )));
191 }
192 let result = if backend == crate::hw_probe::ScanBackend::SimdCpu {
193 self.try_initialize_simd_backend().map_err(|error| {
194 crate::error::ScanError::Simd(format!(
195 "selected Hyperscan backend initialization failed: {error}"
196 ))
197 })?;
198 Ok(super::CoalescedScanOutcome {
199 matches: self.scan_coalesced_simd(
200 chunks,
201 plan.filter(|plan| plan.matches_chunks(chunks)),
202 route,
203 )?,
204 recovery: None,
205 })
206 } else if backend.is_gpu() {
207 #[cfg(feature = "gpu")]
208 {
209 self.try_scan_coalesced_gpu_region_presence_recovering(
210 chunks,
211 backend,
212 route,
213 recover_gpu_dispatch_faults,
214 )
215 .map_err(|error| {
216 self.record_gpu_runtime_fault(error.reason());
217 crate::error::ScanError::Gpu(error.to_string())
218 })
219 }
220 #[cfg(not(feature = "gpu"))]
221 {
222 Err(crate::error::ScanError::Gpu(format!(
223 "{} selected but this scanner build has no GPU support",
224 backend.label()
225 )))
226 }
227 } else {
228 Ok(super::CoalescedScanOutcome {
229 matches: self.scan_chunks_with_backend_internal_admission_and_route(
230 chunks,
231 backend,
232 plan.filter(|plan| plan.matches_chunks(chunks)),
233 route,
234 ),
235 recovery: None,
236 })
237 };
238 if result.is_ok() {
242 profile::add_bytes(chunks.iter().map(|chunk| chunk.data.len() as u64).sum());
243 profile::add_files(chunks.len() as u64);
244 }
245 result
246 }
247
248 pub fn scan_coalesced(&self, chunks: &[keyhog_core::Chunk]) -> Vec<Vec<keyhog_core::RawMatch>> {
254 self.scan_chunks_with_backend(chunks, crate::hw_probe::ScanBackend::CpuFallback)
255 }
256
257 #[allow(clippy::needless_return)] fn scan_coalesced_simd(
261 &self,
262 chunks: &[keyhog_core::Chunk],
263 admission_plan: Option<&super::Phase1AdmissionPlan>,
264 route: crate::ScanExecutionRoute,
265 ) -> crate::error::Result<Vec<Vec<keyhog_core::RawMatch>>> {
266 #[cfg(not(feature = "simd"))]
267 {
268 let _ = (chunks, admission_plan, route);
270 return Err(crate::error::ScanError::Simd(
271 "selected SimdCpu/Hyperscan backend but this binary was built without the `simd` feature; rebuild with simd or choose --backend cpu".to_string(),
272 ));
273 }
274
275 #[cfg(feature = "simd")]
276 {
277 let prefilter = self.try_simd_prefilter().map_err(|error| {
278 crate::error::ScanError::Simd(format!(
279 "selected Hyperscan backend was not initialized: {error}"
280 ))
281 })?;
282
283 for chunk in chunks {
287 crate::telemetry::record_file_scanned(chunk.data.len());
288 }
289 let triggers = {
290 let _g = profile::span(profile::P::Phase1Triggers);
291 self.compute_coalesced_triggers(chunks, prefilter, admission_plan)
292 .map_err(crate::error::ScanError::Simd)?
293 };
294 return Ok(self.scan_coalesced_phase2(chunks, triggers, route));
295 }
296 }
297
298 #[cfg(feature = "simd")]
303 pub(crate) fn compute_coalesced_triggers(
304 &self,
305 chunks: &[keyhog_core::Chunk],
306 prefilter: &super::SimdPhase1Prefilter,
307 admission_plan: Option<&super::Phase1AdmissionPlan>,
308 ) -> Result<Vec<Option<Vec<u64>>>, String> {
309 use rayon::prelude::*;
310 let ac_len = self.ac_map.len();
311 let words_needed = super::trigger_bitmap::words_for(ac_len);
312 let triggers: Result<Vec<Option<Vec<u64>>>, String> = chunks
313 .par_iter()
314 .enumerate()
315 .map(|(chunk_index, chunk)| {
316 let data = chunk.data.as_bytes();
317 let admission =
318 match admission_plan.and_then(|plan| plan.admission_for(chunk_index)) {
319 Some(admission) => admission,
320 None => self.phase1_admission(data),
321 };
322 if admission != super::Phase1Admission::Admitted {
323 return Ok(None);
324 }
325 with_trigger_buffer(words_needed, |scratch| {
326 let scanner = prefilter.scanner();
327 scanner.scan_each_result(data, |hs_id| {
328 mark_hs_trigger(scratch, prefilter, ac_len, hs_id);
329 })?;
330 prefilter.for_each_recovery_match(data, |pattern_index| {
331 self.mark_triggered_pattern(scratch, pattern_index);
332 });
333 if scratch.iter().any(|&w| w != 0) {
334 Ok(Some(scratch.to_vec()))
335 } else {
336 Ok(None)
337 }
338 })
339 })
340 .collect();
341 let triggers = triggers?;
342
343 if tracing::enabled!(tracing::Level::INFO) {
344 let hit_count = triggers.iter().filter(|t| t.is_some()).count();
345 let total_hs_matches: usize = triggers
346 .iter()
347 .filter_map(|t| t.as_ref())
348 .map(|t| t.iter().map(|w| w.count_ones() as usize).sum::<usize>())
349 .sum();
350 tracing::info!(
351 files = chunks.len(),
352 hits = hit_count,
353 triggered_patterns = total_hs_matches,
354 "coalesced scan phase 1 complete"
355 );
356 }
357 Ok(triggers)
358 }
359
360 pub(crate) fn should_scan_no_hit_chunk(
363 &self,
364 chunk: &keyhog_core::Chunk,
365 route: crate::ScanExecutionRoute,
366 ) -> bool {
367 self.should_scan_no_hit_chunk_with_phase2_absence_proof(chunk, false, route)
368 }
369
370 fn should_scan_no_hit_chunk_with_phase2_absence_proof(
371 &self,
372 chunk: &keyhog_core::Chunk,
373 raw_phase2_absence_proven: bool,
374 route: crate::ScanExecutionRoute,
375 ) -> bool {
376 let raw_text = chunk.data.as_ref();
377 if self.no_hit_text_admits(chunk, raw_text, raw_phase2_absence_proven, route) {
378 return true;
379 }
380
381 if !self.config.unicode_normalization
382 || !crate::unicode_hardening::contains_evasion(raw_text)
383 {
384 return false;
385 }
386
387 let prepared = self.prepare_chunk(chunk);
388 let normalized = prepared.preprocessed.text.as_ref();
389 if normalized.as_bytes() == raw_text.as_bytes() {
390 return false;
391 }
392 let normalized_triggers =
393 self.collect_triggered_patterns_for_backend(normalized, route.decode_backend);
394 normalized_triggers.iter().any(|&word| word != 0)
395 || self.no_hit_text_admits(chunk, normalized, false, route)
396 }
397
398 fn no_hit_text_admits(
399 &self,
400 _chunk: &keyhog_core::Chunk,
401 text: &str,
402 phase2_absence_proven: bool,
403 route: crate::ScanExecutionRoute,
404 ) -> bool {
405 if !phase2_absence_proven && self.has_active_phase2_patterns_for_chunk(text, route) {
406 return true;
407 }
408 let data = text.as_bytes();
409 let keyword_admits = self
410 .detector_plans
411 .generic_assignment()
412 .is_some_and(|plan| plan.stems().is_match(data))
413 || has_secret_keyword_fast(data);
414 if keyword_admits {
415 return true;
416 }
417 #[cfg(feature = "entropy")]
418 let isolated_bare_owner_index = self
419 .detector_plans
420 .generic_ownership()
421 .isolated_bare_owner_index();
422 #[cfg(feature = "entropy")]
423 let isolated_bare_policy = isolated_bare_owner_index
424 .and_then(|index| self.detector_plans.get(index).entropy.as_ref())
425 .copied();
426 #[cfg(feature = "entropy")]
427 let keyword_free_min_len = self
428 .detector_plans
429 .generic_ownership()
430 .keyword_free_owner_index()
431 .and_then(|index| self.detector_plans.get(index).entropy.as_ref())
432 .and_then(|policy| {
433 let sensitive_path = _chunk
434 .metadata
435 .path
436 .as_deref()
437 .is_some_and(crate::confidence::is_sensitive_path);
438 policy.keyword_free_admission_run_min_len(
439 self.config.entropy_threshold,
440 sensitive_path,
441 )
442 });
443 #[cfg(feature = "multiline")]
444 if crate::multiline::has_concatenation_indicators(text) {
445 #[cfg(feature = "entropy")]
446 if let Some(policy) = isolated_bare_policy.filter(|_| self.config.entropy_enabled) {
447 if crate::entropy::scanner::has_isolated_bare_secret_candidate_with_policy(
448 text,
449 self.config.entropy_threshold,
450 &self.config.placeholder_keywords,
451 policy.keyword_free_min_len,
452 &policy,
453 ) {
454 return true;
455 }
456 }
457 }
458 #[cfg(feature = "entropy")]
459 let entropy_admits = self.config.entropy_enabled
460 && ((keyword_free_min_len
461 .is_some_and(|minimum| has_high_entropy_run_at_least(data, minimum))
462 && crate::entropy::is_entropy_appropriate_with_content(
463 _chunk.metadata.path.as_deref(),
464 self.config.entropy_in_source_files,
465 text,
466 &self.config.secret_keywords,
467 ))
468 || isolated_bare_policy.is_some_and(|policy| {
469 crate::entropy::scanner::has_isolated_bare_secret_candidate_with_policy(
470 text,
471 self.config.entropy_threshold,
472 &self.config.placeholder_keywords,
473 policy.keyword_free_min_len,
474 &policy,
475 )
476 }));
477 #[cfg(feature = "entropy")]
478 {
479 entropy_admits
480 }
481 #[cfg(not(feature = "entropy"))]
482 {
483 false
484 }
485 }
486
487 #[cfg(feature = "simd")]
491 pub(crate) fn scan_coalesced_phase2(
492 &self,
493 chunks: &[keyhog_core::Chunk],
494 triggers: Vec<Option<Vec<u64>>>,
495 route: crate::ScanExecutionRoute,
496 ) -> Vec<Vec<keyhog_core::RawMatch>> {
497 self.scan_coalesced_phase2_with_admission(
498 chunks, triggers, None, None, None, None, None, None, None, route,
499 )
500 }
501
502 #[cfg(feature = "simd")]
503 fn normalize_coalesced_phase2_triggers(
504 &self,
505 chunks: &[keyhog_core::Chunk],
506 triggers: Vec<Option<Vec<u64>>>,
507 route: crate::ScanExecutionRoute,
508 ) -> Vec<Option<Vec<u64>>> {
509 let chunk_count = chunks.len();
510 let trigger_count = triggers.len();
511 if trigger_count == chunk_count {
512 return triggers;
513 }
514
515 eprintln!(
520 "keyhog: ERROR coalesced phase-2 trigger row count mismatch \
521 (chunks={chunk_count}, trigger_rows={trigger_count}); recomputing \
522 all trigger rows from chunk bytes (KH-1431)"
523 );
524 tracing::error!(
525 chunks = chunk_count,
526 trigger_rows = trigger_count,
527 "coalesced phase-2 trigger row count mismatch; recomputing all rows (fail closed)"
528 );
529 crate::telemetry::record_boundary_result_cardinality_mismatch();
530 drop(triggers);
531 let mut recomputed = Vec::with_capacity(chunk_count);
532 for chunk in chunks {
533 let triggered =
534 self.collect_triggered_patterns_for_backend(&chunk.data, route.decode_backend);
535 if triggered.iter().any(|&word| word != 0) {
536 recomputed.push(Some(triggered));
537 } else {
538 recomputed.push(None);
539 }
540 }
541 recomputed
542 }
543
544 #[cfg(feature = "simd")]
551 pub(crate) fn scan_coalesced_phase2_with_admission(
552 &self,
553 chunks: &[keyhog_core::Chunk],
554 triggers: Vec<Option<Vec<u64>>>,
555 phase2_admission: Option<&[bool]>,
556 phase2_admission_complete: Option<&[bool]>,
557 phase2_keyword_hints: Option<&[Vec<u32>]>,
558 phase2_always_anchor_presence: Option<&[bool]>,
559 phase2_always_anchor_literal_matches: Option<&[Vec<(u32, u32)>]>,
560 confirmed_anchor_literal_matches: Option<&[Vec<(u32, u32)>]>,
561 generic_keyword_positions: Option<&[Vec<u32>]>,
562 route: crate::ScanExecutionRoute,
563 ) -> Vec<Vec<keyhog_core::RawMatch>> {
564 use rayon::prelude::*;
565
566 let triggers = self.normalize_coalesced_phase2_triggers(chunks, triggers, route);
567 let perf_trace = super::profile::perf_trace_enabled();
568 let phase2_start = perf_trace.then(std::time::Instant::now);
569 let telemetry = crate::telemetry::capture_scan_telemetry();
570 struct CoalescedChunkOutput {
571 state: Option<crate::types::ScanState>,
572 matches: Vec<keyhog_core::RawMatch>,
573 needs_postprocess: bool,
574 }
575
576 let mut outputs: Vec<CoalescedChunkOutput> = chunks
577 .par_iter()
578 .zip(triggers.into_par_iter())
579 .enumerate()
580 .map(|(chunk_index, (chunk, triggered_opt))| {
581 crate::telemetry::with_captured_scan_telemetry(telemetry.as_ref(), || {
582 let keyword_hints = phase2_keyword_hints
583 .and_then(|rows| rows.get(chunk_index))
584 .map(Vec::as_slice);
585 let always_anchor_present = phase2_always_anchor_presence
586 .and_then(|rows| rows.get(chunk_index).copied());
587 let always_anchor_literal_matches = phase2_always_anchor_literal_matches
588 .and_then(|rows| rows.get(chunk_index))
589 .map(Vec::as_slice);
590 let admitted_by_phase2_gpu = match phase2_admission
591 .and_then(|admission| admission.get(chunk_index))
592 .copied()
593 {
594 Some(admitted) => admitted,
595 None => false,
596 };
597 let phase2_gpu_complete = match phase2_admission_complete
598 .and_then(|complete| complete.get(chunk_index))
599 .copied()
600 {
601 Some(complete) => complete,
602 None => false,
603 };
604 let phase2_always_active_gpu_evidence =
605 always_anchor_present.map(|anchor_present| Phase2AlwaysActiveGpuEvidence {
606 prefixless_admitted: admitted_by_phase2_gpu,
607 prefixless_complete: phase2_gpu_complete,
608 anchor_present,
609 anchor_literal_matches: always_anchor_literal_matches,
610 });
611 let confirmed_anchor_matches = confirmed_anchor_literal_matches
612 .and_then(|rows| rows.get(chunk_index))
613 .map(Vec::as_slice);
614 let generic_keyword_positions = generic_keyword_positions
615 .and_then(|rows| rows.get(chunk_index))
616 .map(Vec::as_slice);
617 if let Some(triggered) = triggered_opt {
618 if chunk.data.len() > MAX_SCAN_CHUNK_BYTES {
619 let matches = self.scan_windowed_with_triggered(
620 chunk,
621 &triggered,
622 None,
623 keyword_hints,
624 phase2_always_active_gpu_evidence,
625 confirmed_anchor_matches,
626 generic_keyword_positions,
627 route,
628 );
629 return CoalescedChunkOutput {
630 state: None,
631 matches,
632 needs_postprocess: true,
633 };
634 } else {
635 let prepared = self.prepare_chunk(chunk);
636 let state = self.scan_prepared_state_with_triggered(
637 prepared,
638 &triggered,
639 None,
640 keyword_hints,
641 phase2_always_active_gpu_evidence,
642 confirmed_anchor_matches,
643 generic_keyword_positions,
644 route,
645 );
646 return CoalescedChunkOutput {
647 state: Some(state),
648 matches: Vec::new(),
649 needs_postprocess: true,
650 };
651 }
652 }
653 let raw_phase2_absence_proven = phase2_always_active_gpu_evidence
654 .is_some_and(|evidence| evidence.absence_proven())
655 && phase2_keyword_hints
656 .and_then(|rows| rows.get(chunk_index))
657 .is_some();
658 let admitted_by_phase2_keyword_hint =
659 keyword_hints.is_some_and(|hints| !hints.is_empty());
660 let admitted_by_phase2_always_anchor = match always_anchor_present {
661 Some(present) => present,
662 None => false,
663 };
664 let admitted_by_generic_keyword_hint =
665 generic_keyword_positions.is_some_and(|positions| !positions.is_empty());
666 let generic_assignment_absence_proven =
672 self.detector_plans.generic_assignment().is_none()
673 || generic_keyword_positions.is_some();
674 if !admitted_by_phase2_gpu
675 && !admitted_by_phase2_keyword_hint
676 && !admitted_by_phase2_always_anchor
677 && !admitted_by_generic_keyword_hint
678 && generic_assignment_absence_proven
679 && !self.should_scan_no_hit_chunk_with_phase2_absence_proof(
680 chunk,
681 raw_phase2_absence_proven,
682 route,
683 )
684 {
685 if let Some(matches) = self.decode_only_coalesced_matches(chunk, route) {
686 return CoalescedChunkOutput {
687 state: None,
688 matches,
689 needs_postprocess: false,
690 };
691 }
692 return CoalescedChunkOutput {
693 state: None,
694 matches: Vec::new(),
695 needs_postprocess: false,
696 };
697 }
698
699 let prepared = self.prepare_chunk(chunk);
700 let state = self.scan_prepared_state_with_triggered(
701 prepared,
702 &[],
703 None,
704 keyword_hints,
705 phase2_always_active_gpu_evidence,
706 confirmed_anchor_matches,
707 generic_keyword_positions,
708 route,
709 );
710 CoalescedChunkOutput {
711 state: Some(state),
712 matches: Vec::new(),
713 needs_postprocess: true,
714 }
715 })
716 })
717 .collect();
718
719 #[cfg(feature = "ml")]
720 {
721 let mut output_indices = Vec::new();
722 let mut scan_states = Vec::new();
723 for (output_index, output) in outputs.iter_mut().enumerate() {
724 if let Some(state) = output.state.take() {
725 output_indices.push(output_index);
726 scan_states.push(state);
727 }
728 }
729 let _g = profile::span(profile::P::Ml);
730 self.apply_ml_batch_scores_across(&mut scan_states);
731 for (output_index, state) in output_indices.into_iter().zip(scan_states) {
732 outputs[output_index].matches = state.into_matches();
733 }
734 }
735 #[cfg(not(feature = "ml"))]
736 for output in &mut outputs {
737 if let Some(state) = output.state.take() {
738 output.matches = state.into_matches();
739 }
740 }
741
742 let mut results: Vec<Vec<keyhog_core::RawMatch>> = outputs
743 .into_par_iter()
744 .zip(chunks.par_iter())
745 .map(|(mut output, chunk)| {
746 crate::telemetry::with_captured_scan_telemetry(telemetry.as_ref(), || {
747 if output.needs_postprocess {
748 self.post_process_coalesced_matches(chunk, &mut output.matches, route);
749 }
750 output.matches
751 })
752 })
753 .collect();
754
755 let phase2_elapsed = phase2_start.map(|t| t.elapsed());
756 let boundary_start = perf_trace.then(std::time::Instant::now);
757 super::boundary::scan_chunk_boundaries_with_route(self, chunks, &mut results, route);
758 if perf_trace {
759 eprintln!(
760 "perf-trace scan_coalesced_phase2: chunks={} p2={:.3}s boundary={:.3}s",
761 chunks.len(),
762 phase2_elapsed.map_or(0.0, |d| d.as_secs_f64()),
763 boundary_start.map_or(0.0, |t| t.elapsed().as_secs_f64())
764 );
765 }
766 results
767 }
768}