1use super::{
4 SymbolBuildOptions, SymbolParseJob, SymbolParseOutcome, admit_symbol_job_bytes,
5 parse_admitted_symbol_job,
6};
7use crate::CliError;
8use projectatlas_cli::optional_parser_lifecycle::{
9 OptionalParserPackLifecycle, OptionalParserPackLifecycleError,
10 OptionalParserPackProjectSelection, OptionalParserPackSelectionKey,
11 VerifiedOptionalParserPackSelection,
12};
13use projectatlas_cli::parser_supervisor::ParserSupervisorError;
14use projectatlas_core::language::{BROAD_PARSER_PACK_ID, language_capability};
15use projectatlas_core::optional_parser_protocol::{
16 PARSER_MAX_NODE_COUNT, PARSER_MAX_OUTPUT_BYTES, PARSER_MAX_TREE_DEPTH, ParserRequestLimits,
17};
18use projectatlas_core::{IndexWorkControl, IndexWorkFailure, IndexWorkStage};
19use rayon::prelude::*;
20use std::path::Path;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Mutex, MutexGuard, OnceLock, TryLockError};
23use std::thread;
24use std::time::{Duration, Instant};
25
26const OPTIONAL_PARSE_TIMEOUT: Duration = Duration::from_secs(15);
28const OPTIONAL_PARSE_NO_PROGRESS_TIMEOUT: Duration = Duration::from_secs(5);
30const OPTIONAL_RUNTIME_ADMISSION_POLL: Duration = Duration::from_millis(10);
32const BUILT_IN_PARSE_BATCH_SIZE: usize = 64;
34
35static OPTIONAL_PARSER_RUNTIME: OnceLock<Mutex<OptionalParserRuntime>> = OnceLock::new();
37static OPTIONAL_PARSER_DEACTIVATION_REQUESTED: AtomicBool = AtomicBool::new(false);
39static OPTIONAL_PARSER_GROUP_ACTIVE: AtomicBool = AtomicBool::new(false);
41
42enum OptionalParserRuntimeState<T = Box<VerifiedOptionalParserPackSelection>> {
44 Inactive,
46 Resident {
48 verified: T,
50 },
51 UnavailableAfterCleanupFailure {
53 retained: Option<T>,
55 },
56}
57
58impl<T> OptionalParserRuntimeState<T> {
59 fn retain_after_cleanup_failure(&mut self) {
61 let previous = std::mem::replace(
62 self,
63 Self::UnavailableAfterCleanupFailure { retained: None },
64 );
65 let retained = match previous {
66 Self::Resident { verified } => Some(verified),
67 Self::UnavailableAfterCleanupFailure { retained } => retained,
68 Self::Inactive => None,
69 };
70 *self = Self::UnavailableAfterCleanupFailure { retained };
71 }
72}
73
74struct OptionalParserRuntime {
76 state: OptionalParserRuntimeState,
78}
79
80struct OptionalParserGroupLease {
82 active: bool,
84}
85
86impl OptionalParserGroupLease {
87 fn acquire() -> Self {
89 OPTIONAL_PARSER_GROUP_ACTIVE.store(true, Ordering::Release);
90 Self { active: true }
91 }
92
93 fn release(&mut self) {
95 OPTIONAL_PARSER_GROUP_ACTIVE.store(false, Ordering::Release);
96 self.active = false;
97 }
98}
99
100impl Drop for OptionalParserGroupLease {
101 fn drop(&mut self) {
102 if self.active {
103 OPTIONAL_PARSER_GROUP_ACTIVE.store(false, Ordering::Release);
104 }
105 }
106}
107
108impl OptionalParserRuntime {
109 const fn new() -> Self {
111 Self {
112 state: OptionalParserRuntimeState::Inactive,
113 }
114 }
115
116 fn activate(
118 &mut self,
119 verified: VerifiedOptionalParserPackSelection,
120 ) -> Result<(), ParserSupervisorError> {
121 let selection = verified.selection_key().clone();
122 if matches!(
123 &self.state,
124 OptionalParserRuntimeState::Resident {
125 verified: current,
126 } if current.selection_key() == &selection
127 ) {
128 return Ok(());
129 }
130 if matches!(
131 self.state,
132 OptionalParserRuntimeState::UnavailableAfterCleanupFailure { .. }
133 ) {
134 return Err(ParserSupervisorError::Cleanup {
135 message: "optional parser runtime is unavailable after an earlier cleanup failure"
136 .to_owned(),
137 });
138 }
139 let previous = std::mem::replace(&mut self.state, OptionalParserRuntimeState::Inactive);
140 if let OptionalParserRuntimeState::Resident { mut verified } = previous
141 && let Err(error) = verified.supervisor_mut().shutdown()
142 {
143 self.state = OptionalParserRuntimeState::UnavailableAfterCleanupFailure {
144 retained: Some(verified),
145 };
146 return Err(error);
147 }
148 self.state = OptionalParserRuntimeState::Resident {
149 verified: Box::new(verified),
150 };
151 Ok(())
152 }
153
154 fn deactivate(&mut self) -> Result<(), ParserSupervisorError> {
156 let previous = std::mem::replace(&mut self.state, OptionalParserRuntimeState::Inactive);
157 match previous {
158 OptionalParserRuntimeState::Resident { mut verified } => {
159 match verified.supervisor_mut().shutdown() {
160 Ok(()) => Ok(()),
161 Err(error) => {
162 self.state = OptionalParserRuntimeState::UnavailableAfterCleanupFailure {
163 retained: Some(verified),
164 };
165 Err(error)
166 }
167 }
168 }
169 OptionalParserRuntimeState::UnavailableAfterCleanupFailure { retained } => {
170 self.state =
171 OptionalParserRuntimeState::UnavailableAfterCleanupFailure { retained };
172 Ok(())
173 }
174 OptionalParserRuntimeState::Inactive => Ok(()),
175 }
176 }
177
178 fn quarantine(&mut self) -> Result<(), ParserSupervisorError> {
180 let cleanup = self.deactivate();
181 if cleanup.is_ok() {
182 self.state =
183 OptionalParserRuntimeState::UnavailableAfterCleanupFailure { retained: None };
184 }
185 cleanup
186 }
187}
188
189pub(super) fn parse_symbol_jobs_controlled(
191 project_root: &Path,
192 project_selection: &OptionalParserPackProjectSelection,
193 pool: &rayon::ThreadPool,
194 jobs: &[SymbolParseJob],
195 options: &SymbolBuildOptions,
196 control: &IndexWorkControl,
197 verified_text: &std::collections::HashMap<&str, &projectatlas_db::IndexedFileText>,
198) -> Result<Vec<SymbolParseOutcome>, CliError> {
199 if matches!(
200 project_selection,
201 OptionalParserPackProjectSelection::Inactive
202 ) {
203 deactivate_if_initialized(control)?;
204 let built_in = jobs.iter().collect::<Vec<_>>();
205 return parse_built_in_jobs(pool, &built_in, options, control, verified_text);
206 }
207
208 let lifecycle = OptionalParserPackLifecycle::new(project_root, None)?;
209 let verified = lifecycle.resolve_selected_pack()?.ok_or_else(|| {
210 CliError::ParserPack(OptionalParserPackLifecycleError::InvalidData {
211 reason: "optional parser selection disappeared before source staging".to_owned(),
212 })
213 })?;
214 if project_selection.selection_key() != Some(verified.selection_key()) {
215 return Err(CliError::ParserPack(
216 OptionalParserPackLifecycleError::InvalidData {
217 reason: "optional parser selection changed before source staging".to_owned(),
218 },
219 ));
220 }
221 let expected_selection = verified.selection_key().clone();
222
223 let mut built_in = Vec::with_capacity(jobs.len());
224 let mut optional = Vec::new();
225 for job in jobs {
226 match optional_language_id(job) {
227 Some(language) if verified.accepts_language(language) => {
228 optional.push((language, job));
229 }
230 _ => built_in.push(job),
231 }
232 }
233 sort_optional_jobs(&mut optional);
234
235 let mut outcomes = parse_built_in_jobs(pool, &built_in, options, control, verified_text)?;
236 if outcomes.iter().any(terminal_parse_outcome) {
237 return Ok(outcomes);
238 }
239
240 let mut runtime = lock_optional_runtime(control)?;
241 let mut group_lease = OptionalParserGroupLease::acquire();
242 let operation = (|| {
243 service_pending_deactivation(&mut runtime)?;
244 ensure_selection_current(&lifecycle, &expected_selection)?;
245 runtime.activate(verified).map_err(supervisor_error)?;
246 let limits = ParserRequestLimits::new(
247 PARSER_MAX_OUTPUT_BYTES,
248 PARSER_MAX_NODE_COUNT,
249 PARSER_MAX_TREE_DEPTH,
250 )
251 .map_err(ParserSupervisorError::from)
252 .map_err(supervisor_error)?;
253
254 for (language, job) in optional {
255 control.check(IndexWorkStage::SymbolParsing)?;
256 let content = match admit_symbol_job_bytes(job, options, control) {
257 Ok(bytes) => {
258 if let Ok(content) = String::from_utf8(bytes) {
259 content
260 } else {
261 outcomes.push(SymbolParseOutcome::BinaryOrNonUtf8 {
262 path: job.path.clone(),
263 });
264 continue;
265 }
266 }
267 Err(outcome) => {
268 outcomes.push(*outcome);
269 return Ok(outcomes);
270 }
271 };
272 ensure_selection_current(&lifecycle, &expected_selection)?;
273 let deadline = optional_parse_deadline(control)?;
274 let result = match &mut runtime.state {
275 OptionalParserRuntimeState::Resident { verified } => {
276 verified.supervisor_mut().parse(
277 language,
278 content.as_bytes(),
279 limits,
280 deadline,
281 OPTIONAL_PARSE_NO_PROGRESS_TIMEOUT,
282 control.cancellation(),
283 )
284 }
285 OptionalParserRuntimeState::Inactive
286 | OptionalParserRuntimeState::UnavailableAfterCleanupFailure { .. } => {
287 return Err(runtime_unavailable_error());
288 }
289 };
290 if let Err(error) = result {
291 retain_runtime_after_parser_failure(&mut runtime.state, &error);
292 return Err(supervisor_error(error));
293 }
294 let outcome = parse_admitted_symbol_job(
295 job,
296 &content,
297 Some(projectatlas_core::symbols::ParserKind::TreeSitter),
298 options,
299 control,
300 );
301 let terminal = terminal_parse_outcome(&outcome);
302 outcomes.push(outcome);
303 if terminal {
304 break;
305 }
306 }
307 Ok(outcomes)
308 })();
309 group_lease.release();
310 let cleanup = service_pending_deactivation(&mut runtime);
311 combine_optional_operation_and_cleanup(operation, cleanup)
312}
313
314fn retain_runtime_after_parser_failure<T>(
316 state: &mut OptionalParserRuntimeState<T>,
317 error: &ParserSupervisorError,
318) {
319 if error.has_mandatory_cleanup_failure() {
320 state.retain_after_cleanup_failure();
321 }
322}
323
324fn optional_language_id(job: &SymbolParseJob) -> Option<&str> {
326 let capability = language_capability(job.language.as_deref()?)?;
327 (capability.optional_pack == Some(BROAD_PARSER_PACK_ID)).then_some(capability.id)
328}
329
330fn sort_optional_jobs(jobs: &mut [(&str, &SymbolParseJob)]) {
332 jobs.sort_by(|(left_language, left_job), (right_language, right_job)| {
333 left_language
334 .cmp(right_language)
335 .then_with(|| left_job.path.cmp(&right_job.path))
336 });
337}
338
339fn parse_built_in_jobs(
341 pool: &rayon::ThreadPool,
342 jobs: &[&SymbolParseJob],
343 options: &SymbolBuildOptions,
344 control: &IndexWorkControl,
345 verified_text: &std::collections::HashMap<&str, &projectatlas_db::IndexedFileText>,
346) -> Result<Vec<SymbolParseOutcome>, CliError> {
347 let mut outcomes = Vec::with_capacity(jobs.len());
348 for batch in jobs.chunks(BUILT_IN_PARSE_BATCH_SIZE) {
349 control.check(IndexWorkStage::SymbolParsing)?;
350 outcomes.extend(pool.install(|| {
351 batch
352 .par_iter()
353 .map(|job| {
354 super::parse_symbol_job_with_staged_text_controlled(
355 job,
356 options,
357 control,
358 verified_text.get(job.path.as_str()).copied(),
359 )
360 })
361 .collect::<Vec<_>>()
362 }));
363 }
364 Ok(outcomes)
365}
366
367fn terminal_parse_outcome(outcome: &SymbolParseOutcome) -> bool {
369 matches!(
370 outcome,
371 SymbolParseOutcome::SourceChanged { .. }
372 | SymbolParseOutcome::Io { .. }
373 | SymbolParseOutcome::InvalidInput { .. }
374 | SymbolParseOutcome::IndexWork(_)
375 )
376}
377
378fn lock_optional_runtime(
380 control: &IndexWorkControl,
381) -> Result<MutexGuard<'static, OptionalParserRuntime>, CliError> {
382 let runtime = OPTIONAL_PARSER_RUNTIME.get_or_init(|| Mutex::new(OptionalParserRuntime::new()));
383 lock_runtime(runtime, control)
384}
385
386fn lock_runtime<'a>(
388 runtime: &'a Mutex<OptionalParserRuntime>,
389 control: &IndexWorkControl,
390) -> Result<MutexGuard<'a, OptionalParserRuntime>, CliError> {
391 loop {
392 control.check(IndexWorkStage::SymbolParsing)?;
393 match runtime.try_lock() {
394 Ok(guard) => return Ok(guard),
395 Err(TryLockError::WouldBlock) => thread::park_timeout(OPTIONAL_RUNTIME_ADMISSION_POLL),
396 Err(TryLockError::Poisoned(poisoned)) => {
397 let mut guard = poisoned.into_inner();
398 if let Err(error) = guard.quarantine() {
399 return Err(supervisor_error(error));
400 }
401 return Err(runtime_unavailable_error());
402 }
403 }
404 }
405}
406
407fn deactivate_if_initialized(control: &IndexWorkControl) -> Result<(), CliError> {
409 let Some(runtime) = OPTIONAL_PARSER_RUNTIME.get() else {
410 return Ok(());
411 };
412 OPTIONAL_PARSER_DEACTIVATION_REQUESTED.store(true, Ordering::Release);
413 loop {
414 if OPTIONAL_PARSER_GROUP_ACTIVE.load(Ordering::Acquire) {
415 return Ok(());
416 }
417 control.check(IndexWorkStage::SymbolParsing)?;
418 match runtime.try_lock() {
419 Ok(mut guard) => return service_pending_deactivation(&mut guard),
420 Err(TryLockError::WouldBlock) => thread::park_timeout(OPTIONAL_RUNTIME_ADMISSION_POLL),
423 Err(TryLockError::Poisoned(poisoned)) => {
424 let mut guard = poisoned.into_inner();
425 OPTIONAL_PARSER_DEACTIVATION_REQUESTED.store(false, Ordering::Release);
426 return guard.quarantine().map_err(supervisor_error);
427 }
428 }
429 }
430}
431
432fn service_pending_deactivation(runtime: &mut OptionalParserRuntime) -> Result<(), CliError> {
434 if !OPTIONAL_PARSER_DEACTIVATION_REQUESTED.swap(false, Ordering::AcqRel) {
435 return Ok(());
436 }
437 runtime.deactivate().map_err(supervisor_error)
438}
439
440fn ensure_selection_current(
442 lifecycle: &OptionalParserPackLifecycle,
443 expected: &OptionalParserPackSelectionKey,
444) -> Result<(), CliError> {
445 let current = lifecycle.derive_project_selection()?;
446 if current.selection_key() == Some(expected) {
447 return Ok(());
448 }
449 Err(CliError::ParserPack(
450 OptionalParserPackLifecycleError::InvalidData {
451 reason: "optional parser selection changed before source transfer".to_owned(),
452 },
453 ))
454}
455
456fn combine_optional_operation_and_cleanup<T>(
458 operation: Result<T, CliError>,
459 cleanup: Result<(), CliError>,
460) -> Result<T, CliError> {
461 match (operation, cleanup) {
462 (Ok(value), Ok(())) => Ok(value),
463 (Err(operation), Ok(())) => Err(operation),
464 (Ok(_), Err(cleanup)) => Err(cleanup),
465 (Err(operation), Err(cleanup)) => Err(CliError::OptionalParserOperationAndCleanup {
466 operation: Box::new(operation),
467 cleanup: Box::new(cleanup),
468 }),
469 }
470}
471
472fn optional_parse_deadline(control: &IndexWorkControl) -> Result<Instant, CliError> {
474 control.check(IndexWorkStage::SymbolParsing)?;
475 let now = Instant::now();
476 let per_file = now.checked_add(OPTIONAL_PARSE_TIMEOUT).ok_or_else(|| {
477 CliError::IndexWork(IndexWorkFailure::DeadlineExceeded {
478 stage: IndexWorkStage::SymbolParsing,
479 })
480 })?;
481 Ok(control
482 .deadline()
483 .map_or(per_file, |deadline| deadline.min(per_file)))
484}
485
486fn supervisor_error(error: ParserSupervisorError) -> CliError {
488 match error {
489 ParserSupervisorError::Cancelled { .. } => {
490 CliError::IndexWork(IndexWorkFailure::Cancelled {
491 stage: IndexWorkStage::SymbolParsing,
492 })
493 }
494 ParserSupervisorError::DeadlineExceeded { .. } => {
495 CliError::IndexWork(IndexWorkFailure::DeadlineExceeded {
496 stage: IndexWorkStage::SymbolParsing,
497 })
498 }
499 other => CliError::ParserPack(OptionalParserPackLifecycleError::Supervisor(other)),
500 }
501}
502
503fn runtime_unavailable_error() -> CliError {
505 CliError::ParserPack(OptionalParserPackLifecycleError::InvalidData {
506 reason: "optional parser runtime is unavailable until process restart".to_owned(),
507 })
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use std::sync::atomic::AtomicUsize;
514
515 #[test]
516 fn optional_jobs_sort_by_canonical_grammar_then_path() {
517 let job = |path: &str, language: &str| SymbolParseJob {
518 path: path.to_owned(),
519 native_path: path.into(),
520 expected_content_hash: "a".repeat(64),
521 language: Some(language.to_owned()),
522 fallback_summary: None,
523 purpose_needs_suggestion: false,
524 };
525 let jobs = [
526 job("z.zig", "zig"),
527 job("z.awk", "awk"),
528 job("a.zig", "zig"),
529 job("a.awk", "awk"),
530 ];
531 let mut scheduled = [
532 ("zig", &jobs[0]),
533 ("awk", &jobs[1]),
534 ("zig", &jobs[2]),
535 ("awk", &jobs[3]),
536 ];
537 sort_optional_jobs(&mut scheduled);
538 assert_eq!(
539 scheduled
540 .iter()
541 .map(|(language, job)| (*language, job.path.as_str()))
542 .collect::<Vec<_>>(),
543 [
544 ("awk", "a.awk"),
545 ("awk", "z.awk"),
546 ("zig", "a.zig"),
547 ("zig", "z.zig"),
548 ]
549 );
550 }
551
552 #[test]
553 fn cleanup_failure_quarantines_the_concrete_runtime_state() {
554 let mut runtime = OptionalParserRuntime::new();
555 runtime.state =
556 OptionalParserRuntimeState::UnavailableAfterCleanupFailure { retained: None };
557 assert!(runtime.deactivate().is_ok());
558 assert!(matches!(
559 runtime.state,
560 OptionalParserRuntimeState::UnavailableAfterCleanupFailure { .. }
561 ));
562 }
563
564 #[test]
565 fn operation_and_cleanup_failures_remain_distinct_at_the_runtime_boundary() {
566 let operation = CliError::ParserPack(OptionalParserPackLifecycleError::InvalidData {
567 reason: "synthetic operation failure".to_owned(),
568 });
569 let cleanup = CliError::ParserPack(OptionalParserPackLifecycleError::Supervisor(
570 ParserSupervisorError::Cleanup {
571 message: "synthetic cleanup failure".to_owned(),
572 },
573 ));
574
575 let result = combine_optional_operation_and_cleanup::<()>(Err(operation), Err(cleanup));
576 assert!(matches!(
577 result,
578 Err(CliError::OptionalParserOperationAndCleanup { .. })
579 ));
580 }
581
582 #[test]
583 fn cleanup_failure_retains_execution_owner_until_runtime_state_drops() {
584 struct DropProbe<'a>(&'a AtomicUsize);
585
586 impl Drop for DropProbe<'_> {
587 fn drop(&mut self) {
588 self.0.fetch_add(1, Ordering::Relaxed);
589 }
590 }
591
592 let drops = AtomicUsize::new(0);
593 {
594 let mut state = OptionalParserRuntimeState::Resident {
595 verified: DropProbe(&drops),
596 };
597 state.retain_after_cleanup_failure();
598 assert!(matches!(
599 &state,
600 OptionalParserRuntimeState::UnavailableAfterCleanupFailure { retained: Some(_) }
601 ));
602 assert_eq!(drops.load(Ordering::Relaxed), 0);
603 }
604 assert_eq!(drops.load(Ordering::Relaxed), 1);
605 }
606
607 #[test]
608 fn combined_parser_failure_quarantines_and_retains_the_execution_owner() {
609 struct DropProbe<'a>(&'a AtomicUsize);
610
611 impl Drop for DropProbe<'_> {
612 fn drop(&mut self) {
613 self.0.fetch_add(1, Ordering::Relaxed);
614 }
615 }
616
617 let drops = AtomicUsize::new(0);
618 let mut state = OptionalParserRuntimeState::Resident {
619 verified: DropProbe(&drops),
620 };
621 let error = ParserSupervisorError::OperationAndCleanup {
622 operation: Box::new(ParserSupervisorError::Cancelled { phase: "test" }),
623 cleanup: Box::new(ParserSupervisorError::Cleanup {
624 message: "synthetic cleanup failure".to_owned(),
625 }),
626 };
627
628 retain_runtime_after_parser_failure(&mut state, &error);
629 assert!(matches!(
630 &state,
631 OptionalParserRuntimeState::UnavailableAfterCleanupFailure { retained: Some(_) }
632 ));
633 assert_eq!(drops.load(Ordering::Relaxed), 0);
634 drop(state);
635 assert_eq!(drops.load(Ordering::Relaxed), 1);
636 }
637
638 #[test]
639 fn pending_deactivation_is_consumed_under_the_runtime_lease() {
640 OPTIONAL_PARSER_DEACTIVATION_REQUESTED.store(true, Ordering::Release);
641 let mut runtime = OptionalParserRuntime::new();
642 assert!(service_pending_deactivation(&mut runtime).is_ok());
643 assert!(!OPTIONAL_PARSER_DEACTIVATION_REQUESTED.load(Ordering::Acquire));
644 assert!(matches!(
645 runtime.state,
646 OptionalParserRuntimeState::Inactive
647 ));
648 }
649
650 #[test]
651 fn group_activity_is_cleared_when_the_lease_drops() {
652 OPTIONAL_PARSER_GROUP_ACTIVE.store(false, Ordering::Release);
653 {
654 let _lease = OptionalParserGroupLease::acquire();
655 assert!(OPTIONAL_PARSER_GROUP_ACTIVE.load(Ordering::Acquire));
656 }
657 assert!(!OPTIONAL_PARSER_GROUP_ACTIVE.load(Ordering::Acquire));
658 }
659
660 #[test]
661 fn cancellation_interrupts_global_admission_wait() {
662 let runtime = Mutex::new(OptionalParserRuntime::new());
663 let _holder = runtime
664 .lock()
665 .unwrap_or_else(std::sync::PoisonError::into_inner);
666 let control = IndexWorkControl::new(projectatlas_core::IndexCancellation::new(), None);
667 control.cancel();
668 let result = lock_runtime(&runtime, &control);
669 assert!(matches!(
670 result,
671 Err(CliError::IndexWork(IndexWorkFailure::Cancelled {
672 stage: IndexWorkStage::SymbolParsing,
673 }))
674 ));
675 }
676}